openvibers/openvibe.rent Commits
Rent: search every rental site at once (the aggregator the owner asked for) (#53)
Changes
@@@ -1,20 +1,47 @@# OpenVibe.Rent--> Find a place, or rent yours out.++> Every rental site, one search.--**Status:** v1 works end to end: people post listings, anyone searches them, three reports hide one until staff look, and--"Affordable housing near you" shows HUD's public-domain housing data (public housing, LIHTC, HUD-assisted multifamily,++**Status:** a rental aggregator. **Search everywhere** (`/everywhere`) opens every major rental site's own search for one++place and set of filters. People post listings here too, anyone searches them, and three reports hide one until staff++look. "Affordable housing near you" shows HUD's public-domain housing data (public housing, LIHTC, HUD-assisted multifamily,public housing authorities and housing counselling agencies) near a place a visitor types, with FHFA home-price context beside it.**Domain:** `openvibe.rent` · **Port:** 5010 · **Service id:** `rent` · **Env prefix:** `RENT`**License:** AGPL-3.0 (same as every OpenVibe service).## What it is--One place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and--equipment. In this first version the listings are **posted by people on this site** — there are no scraped listings--and no partner feeds. Other sources come later, and only when their terms are verified; each listing will then carry--its provenance (the source, a link back to it, and when it was read). Until then every listing here was written by--the person who posted it, and the page says so.++The rental aggregator the owner asked for (2026-09-28): one consumer-friendly place that combines every rental site,++from apartments to commercial space and much more.++++**Search everywhere** (`/everywhere`, `GET /api/v1/everywhere`, [server/aggregate/everywhere.js](server/aggregate/everywhere.js))++takes one place (a city and state, or a ZIP code) and what you are looking for, and opens each site's own search there,++with the price and bedroom filters where the site takes them:++++- **Apartments and houses:** Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com, RentCafe,++ Apartment List, PadMapper, HotPads, Trulia and Realtor.com.++- **Rooms:** Craigslist, Facebook, Zumper, Roomies and Furnished Finder.++- **Furnished and monthly stays:** Craigslist sublets, Furnished Finder and Airbnb monthly.++- **Commercial space:** Craigslist and LoopNet.++- **Parking:** Craigslist and SpotHero.++- **Storage:** Craigslist and SpareFoot.++- **Cars:** Turo.++++Every URL form was opened in a real browser for several cities, including one-word and multi-word names, on++2026-10-15 (`test/everywhere.test.js` pins them). The listings, messages and payments stay on each site; the person++sees them in their own browser and account.++++This is the permitted way to aggregate sites whose terms forbid automated collection, as the owner decided on++2026-09-28 (deep links out, a browser extension inside the person's own sessions, licensed APIs, people posting).++Next:++- a board that saves listings from any site into one OpenVibe list;++- the OpenVibe Companion extension;++- licensed listing feeds through OpenVibe.Sources.++++The United States comes first. A place elsewhere gets the sites that work worldwide.++++People also **post listings here** (apartments, rooms, houses, commercial space, parking and storage, equipment), and++each one is written by the person who posted it.Separately, the site is useful before anyone has posted anything: **"Affordable housing near you"** (`/housing`) showspublic housing, tax-credit (LIHTC) and HUD-assisted multifamily properties, public housing authorities and housing@@@ -90,3 +90,6 @@ form.inline { display: inline; }.page .sc-ic .ov-icon { --ovi-size: 28px !important; }button.sc-btn { background: transparent; }button.sc-btn.sc-primary { border: 0; }++/* The sites the search-everywhere form opens (home); the results page has css/everywhere.css. */++.ew-chips { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 12px 0 6px; }++.ew-chips li { padding: 5px 11px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); font-size: .9rem; }nnew file mode 100644@@@ -0,0 +1,15 @@++/* OpenVibe.Rent — /everywhere only (every rental site's own search for one place), so app.css stays inside its budget. */++.everywhere-kinds { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 4px; }++.ew-kind { display: inline-block; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .9rem; }++a.ew-kind:hover { border-color: var(--accent, #3b82f6); }++.ew-kind.on { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 18%, var(--bg-secondary, #0f1729)); font-weight: 650; }++.everywhere-sites { list-style: none; padding: 0; margin: 0 0 10px; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }++.ew-site { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); transition: border-color .15s ease, transform .15s ease; }++.ew-site:hover { border-color: var(--accent, #3b82f6); transform: translateY(-1px); }++.ew-open { display: flex; flex-direction: column; gap: 2px; text-decoration: none; color: var(--text-primary, #e6edf7); }++.ew-open:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 3px; border-radius: 6px; }++.ew-name { font-weight: 700; font-size: 1.05rem; }++.ew-host { font-size: .82rem; color: var(--accent-text, var(--accent, #3b82f6)); overflow-wrap: anywhere; }++.ew-best { margin: 0; font-size: .9rem; color: var(--text-secondary, #96a7c2); }++.everywhere-here { margin: 0; padding-left: 18px; display: grid; gap: 6px; }++@media (prefers-reduced-motion: reduce) { .ew-site, .ew-site:hover { transition: none; transform: none; } }nnew file mode 100644@@@ -0,0 +1,185 @@++'use strict';++++/**++ * Search everywhere: the rental aggregator's front door. One place and one set of filters open every major rental++ * site's own search for that place — Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com,++ * RentCafe, Apartment List, PadMapper, HotPads, Trulia, Realtor.com, Roomies, Furnished Finder, Airbnb, LoopNet,++ * SpotHero, SpareFoot and Turo — next to what OpenVibe.Rent has itself (listings posted here, affordable housing from++ * HUD). The listings stay on each site: a link opens that site's own results, in the person's own browser and account.++ * This is the permitted way to aggregate sites whose terms forbid automated collection (the owner's 2026-09-28 Q8:++ * deep links out, a browser extension inside the person's own sessions, licensed APIs, people posting).++ *++ * Every URL pattern here was opened in a real browser for several cities, one-word and multi-word, on 2026-10-15++ * (tools/rent-links-check.js). Sites that answer automated checks with a bot wall (Zillow, Apartments.com, HotPads,++ * Trulia, Realtor.com, LoopNet) use their public, long-standing URL forms and open normally for a person.++ *++ * US places first (the owner's Q9); a place outside the US gets the sites that work worldwide.++ */++++const KINDS = ['apartment', 'house', 'room', 'stay', 'commercial', 'parking', 'storage', 'vehicle'];++const KIND_TEXT = {++ apartment: 'Apartments', house: 'Houses', room: 'Rooms and shares', stay: 'Furnished and monthly stays',++ commercial: 'Commercial and office space', parking: 'Parking', storage: 'Storage', vehicle: 'Cars and vehicles',++};++++const US_STATE_NAMES = {++ AL: 'alabama', AK: 'alaska', AZ: 'arizona', AR: 'arkansas', CA: 'california', CO: 'colorado', CT: 'connecticut', DE: 'delaware',++ DC: 'district-of-columbia', FL: 'florida', GA: 'georgia', HI: 'hawaii', ID: 'idaho', IL: 'illinois', IN: 'indiana', IA: 'iowa',++ KS: 'kansas', KY: 'kentucky', LA: 'louisiana', ME: 'maine', MD: 'maryland', MA: 'massachusetts', MI: 'michigan', MN: 'minnesota',++ MS: 'mississippi', MO: 'missouri', MT: 'montana', NE: 'nebraska', NV: 'nevada', NH: 'new-hampshire', NJ: 'new-jersey',++ NM: 'new-mexico', NY: 'new-york', NC: 'north-carolina', ND: 'north-dakota', OH: 'ohio', OK: 'oklahoma', OR: 'oregon',++ PA: 'pennsylvania', RI: 'rhode-island', SC: 'south-carolina', SD: 'south-dakota', TN: 'tennessee', TX: 'texas', UT: 'utah',++ VT: 'vermont', VA: 'virginia', WA: 'washington', WV: 'west-virginia', WI: 'wisconsin', WY: 'wyoming', PR: 'puerto-rico',++};++++/** "Salt Lake City" → "salt-lake-city"; accents and punctuation dropped. */++const slug = (v) => String(v || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()++ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');++/** "Salt Lake City" → "Salt-Lake-City" (SpareFoot, Airbnb keep the capitals). */++const titleSlug = (v) => slug(v).split('-').filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('-');++const q = (params) => {++ const out = new URLSearchParams();++ for (const [k, v] of Object.entries(params)) if (v != null && v !== '') out.set(k, String(v));++ const s = out.toString();++ return s ? `?${s}` : '';++};++++/**++ * The sites. `kinds` says which searches each one answers; `build(place, f)` returns the URL for a place++ * ({ city, state, country, postcode, lat, lon }) and filters ({ kind, maxPrice, minBeds }), or null when it cannot++ * serve that place. `us: true` sites need a US city and state.++ */++const SITES = [++ {++ id: 'craigslist', name: 'Craigslist', us: true,++ kinds: ['apartment', 'house', 'room', 'stay', 'commercial', 'parking', 'storage'],++ best: 'Local listings from owners and small landlords, often not anywhere else.',++ build: (p, f) => {++ const cat = { apartment: 'apa', house: 'apa', room: 'roo', stay: 'sub', commercial: 'off', parking: 'prk', storage: 'prk' }[f.kind];++ const beds = ['apartment', 'house'].includes(f.kind) ? f.minBeds : null;++ return `https://www.craigslist.org/search/city/${slug(p.city)}-${p.state.toLowerCase()}${q({ cat, max_price: f.maxPrice, min_bedrooms: beds, postal: p.postcode, radius: p.postcode ? 10 : null })}`;++ },++ },++ {++ id: 'facebook', name: 'Facebook Marketplace', us: false,++ kinds: ['apartment', 'house', 'room'],++ best: 'Rooms, sublets and homes from people nearby; sign in to Facebook to see them all.',++ build: (p) => `https://www.facebook.com/marketplace/${slug(p.city).replace(/-/g, '')}/propertyrentals`,++ },++ {++ id: 'zillow', name: 'Zillow', us: true, kinds: ['apartment', 'house'],++ best: 'The largest US listing database, with a map and price history.',++ build: (p, f) => `https://www.zillow.com/${slug(p.city)}-${p.state.toLowerCase()}/${f.kind === 'house' ? 'rent-houses' : 'rentals'}/`,++ },++ {++ id: 'apartments', name: 'Apartments.com', us: true, kinds: ['apartment'],++ best: 'Managed apartment buildings with floor plans and availability.',++ build: (p, f) => {++ const parts = [f.minBeds ? `${f.minBeds}-bedrooms` : null, f.maxPrice ? `under-${f.maxPrice}` : null].filter(Boolean).join('-');++ return `https://www.apartments.com/${slug(p.city)}-${p.state.toLowerCase()}/${parts ? `${parts}/` : ''}`;++ },++ },++ {++ id: 'zumper', name: 'Zumper', us: true, kinds: ['apartment', 'house', 'room'],++ best: 'Fresh listings updated daily, including rooms and houses.',++ build: (p, f) => `https://www.zumper.com/${{ house: 'houses-for-rent', room: 'rooms-for-rent' }[f.kind] || 'apartments-for-rent'}/${slug(p.city)}-${p.state.toLowerCase()}`,++ },++ {++ id: 'rentcom', name: 'Rent.com', us: true, kinds: ['apartment', 'house'],++ best: 'Apartments and houses with verified photos and pricing.',++ build: (p, f) => (US_STATE_NAMES[p.state] ? `https://www.rent.com/${US_STATE_NAMES[p.state]}/${slug(p.city)}-${f.kind === 'house' ? 'houses' : 'apartments'}` : null),++ },++ {++ id: 'rentcafe', name: 'RentCafe', us: true, kinds: ['apartment'],++ best: 'Apartment communities with online applications.',++ build: (p) => `https://www.rentcafe.com/apartments-for-rent/${slug(p.city)}-${p.state.toLowerCase()}/`,++ },++ {++ id: 'apartmentlist', name: 'Apartment List', us: true, kinds: ['apartment'],++ best: 'Matches you to buildings by budget and commute.',++ build: (p) => `https://www.apartmentlist.com/${p.state.toLowerCase()}/${slug(p.city)}`,++ },++ {++ id: 'padmapper', name: 'PadMapper', us: true, kinds: ['apartment', 'house'],++ best: 'A map view across many listing sources.',++ build: (p) => `https://www.padmapper.com/apartments/${slug(p.city)}-${p.state.toLowerCase()}`,++ },++ {++ id: 'hotpads', name: 'HotPads', us: true, kinds: ['apartment', 'house'],++ best: 'Map-first search with many houses and condos.',++ build: (p, f) => `https://hotpads.com/${slug(p.city)}-${p.state.toLowerCase()}/${f.kind === 'house' ? 'houses-for-rent' : 'apartments-for-rent'}`,++ },++ {++ id: 'trulia', name: 'Trulia', us: true, kinds: ['apartment', 'house'],++ best: 'Neighbourhood details: commute, schools, crime.',++ build: (p) => `https://www.trulia.com/for_rent/${titleSlug(p.city).replace(/-/g, '_')},${p.state}/`,++ },++ {++ id: 'realtor', name: 'Realtor.com', us: true, kinds: ['apartment', 'house'],++ best: 'Rentals listed by agents.',++ build: (p) => `https://www.realtor.com/apartments/${titleSlug(p.city)}_${p.state}`,++ },++ {++ id: 'roomies', name: 'Roomies', us: true, kinds: ['room'],++ best: 'Rooms and roommates.',++ build: (p) => `https://www.roomies.com/rooms/${slug(p.city)}-${p.state.toLowerCase()}`,++ },++ {++ id: 'furnishedfinder', name: 'Furnished Finder', us: true, kinds: ['stay', 'room'],++ best: 'Furnished monthly rentals (made for travel nurses, open to all).',++ build: (p) => `https://www.furnishedfinder.com/housing/us--${p.state.toLowerCase()}--${slug(p.city)}`,++ },++ {++ id: 'airbnb', name: 'Airbnb (monthly)', us: false, kinds: ['stay'],++ best: 'Furnished stays of a month or more, worldwide.',++ build: (p) => `https://www.airbnb.com/s/${titleSlug(p.city)}--${p.state || titleSlug(p.countryName || '')}/homes?monthly_stay=true`,++ },++ {++ id: 'loopnet', name: 'LoopNet', us: true, kinds: ['commercial'],++ best: 'Office, retail and industrial space for lease.',++ build: (p) => `https://www.loopnet.com/search/commercial-real-estate/${slug(p.city)}-${p.state.toLowerCase()}/for-lease/`,++ },++ {++ id: 'spothero', name: 'SpotHero', us: true, kinds: ['parking'],++ best: 'Monthly and daily parking in garages and lots.',++ build: (p) => (p.lat != null && p.lon != null ? `https://spothero.com/search${q({ latitude: Number(p.lat).toFixed(4), longitude: Number(p.lon).toFixed(4), kind: 'monthly' })}` : null),++ },++ {++ id: 'sparefoot', name: 'SpareFoot', us: true, kinds: ['storage'],++ best: 'Self-storage units compared by price and size.',++ build: (p) => `https://www.sparefoot.com/${titleSlug(p.city)}-${p.state}-self-storage.html`,++ },++ {++ id: 'turo', name: 'Turo', us: true, kinds: ['vehicle'],++ best: 'Cars rented from their owners.',++ build: (p) => `https://turo.com/us/en/search${q({ country: 'US', location: `${p.city}, ${p.state}` })}`,++ },++];++++/**++ * The links for one place and filter set, in the catalog's order. `place.state` is a two-letter US state code (or++ * null), `place.country` a two-letter country code. A site that cannot serve the place is left out, never guessed.++ */++function linksFor(place, { kind = 'apartment', maxPrice = null, minBeds = null } = {}) {++ if (!place || !place.city) return [];++ const k = KINDS.includes(kind) ? kind : 'apartment';++ const inUs = (place.country || 'us') === 'us' && /^[A-Z]{2}$/.test(String(place.state || ''));++ const f = { kind: k, maxPrice: positiveInt(maxPrice, 100000), minBeds: positiveInt(minBeds, 10) };++ const out = [];++ for (const site of SITES) {++ if (!site.kinds.includes(k)) continue;++ if (site.us && !inUs) continue;++ let url = null;++ try { url = site.build(place, f); } catch { url = null; }++ if (!url || !/^https:\/\//.test(url)) continue;++ out.push({ id: site.id, name: site.name, url, best: site.best });++ }++ return out;++}++++function positiveInt(v, max) {++ const n = Number(v);++ return Number.isInteger(n) && n > 0 && n <= max ? n : null;++}++++module.exports = { KINDS, KIND_TEXT, SITES, US_STATE_NAMES, linksFor, slug, titleSlug };@@@ -314,6 +314,30 @@ function elementsToResults(features, def, origin, limit) {return limit ? out.slice(0, limit) : out;}++/**++ * A place name → one geocoded place: name, coordinates, country code, city and two-letter US state code, the postcode++ * and the country's name (Nominatim, cached by the upstream). Shared by the affordable-housing search and the++ * search-everywhere page. Throws housing.place.not_found when nothing matches.++ */++async function geocodePlace(upstream, q) {++ const g = await upstream.geocode(q, { limit: 1 });++ const hit = Array.isArray(g.body) ? g.body[0] : null;++ if (!hit) throw new UpstreamError('housing.place.not_found', `no place called “${String(q).slice(0, 80)}”; try a city and state, or a ZIP code`, { status: 404 });++ const la = Number(hit.lat);++ const lo = Number(hit.lon);++ if (!Number.isFinite(la) || !Number.isFinite(lo)) throw new UpstreamError('housing.place.upstream', 'could not reach the place service right now (no coordinates for that place)');++ const country = hit.address && (hit.address.country_code || hit.address.country)++ ? String(hit.address.country_code || hit.address.country).toLowerCase() : null;++ // The two pieces the FHFA House Price Index lookup matches on (server/housing/hpi.js): a city and the++ // two-letter state code. Nominatim (addressdetails=1) gives a US state as "ISO3166-2-lvl4": "US-CO" and++ // "state": "Colorado"; there is no state_code field (Rent#30 read one, so production never matched).++ const addr = hit.address || {};++ const city = str(addr.city) || str(addr.town) || str(addr.village) || str(addr.hamlet) || str(addr.municipality)++ || str(addr.county) || str(addr.suburb) || str(addr.city_district);++ const state = usStateCode(addr);++ return { name: hit.display_name || String(q), lat: la, lon: lo, from: 'geocode', geocoded_at: g.fetched_at, country_code: country, bbox: parseBbox(hit.boundingbox), city, state, postcode: str(addr.postcode), country_name: str(addr.country) };++}++/*** The place search behind both the API and the pages.*@@@ -333,22 +357,7 @@ async function searchPlaces(upstream, { q = null, kinds = null } = {}, config) {let place = null;if (q && String(q).trim()) {-- const g = await upstream.geocode(q, { limit: 1 });-- const hit = Array.isArray(g.body) ? g.body[0] : null;-- if (!hit) throw new UpstreamError('housing.place.not_found', `no place called “${String(q).slice(0, 80)}”; try a city and state, or a ZIP code`, { status: 404 });-- const la = Number(hit.lat);-- const lo = Number(hit.lon);-- if (!Number.isFinite(la) || !Number.isFinite(lo)) throw new UpstreamError('housing.place.upstream', 'could not reach the place service right now (no coordinates for that place)');-- const country = hit.address && (hit.address.country_code || hit.address.country)-- ? String(hit.address.country_code || hit.address.country).toLowerCase() : null;-- // The two pieces the FHFA House Price Index lookup matches on (server/housing/hpi.js): a city and the-- // two-letter state code. Nominatim (addressdetails=1) gives a US state as "ISO3166-2-lvl4": "US-CO" and-- // "state": "Colorado"; there is no state_code field (Rent#30 read one, so production never matched).-- const addr = hit.address || {};-- const city = str(addr.city) || str(addr.town) || str(addr.village) || str(addr.hamlet) || str(addr.municipality)-- || str(addr.county) || str(addr.suburb) || str(addr.city_district);-- const state = usStateCode(addr);-- place = { name: hit.display_name || String(q), lat: la, lon: lo, from: 'geocode', geocoded_at: g.fetched_at, country_code: country, bbox: parseBbox(hit.boundingbox), city, state };++ place = await geocodePlace(upstream, q);} else {throw new UpstreamError('housing.place.invalid', 'q: a place to search for', { status: 422 });}@@@ -392,6 +401,6 @@ async function searchPlaces(upstream, { q = null, kinds = null } = {}, config) {}module.exports = {-- searchPlaces, haversineKm, envelopeFor, parseBbox, normalizeFeature, toResult, elementsToResults, safeUrl,++ searchPlaces, geocodePlace, haversineKm, envelopeFor, parseBbox, normalizeFeature, toResult, elementsToResults, safeUrl,LAYERS, KINDS, KIND_TEXT, KIND_DESCRIPTIONS, ATTRIBUTION, SOURCE, UpstreamError,};@@@ -38,6 +38,7 @@ const service = require('../listings/service');const { isStaff } = require('../listings/staff');const housingPlaces = require('../housing/places');const housingHpi = require('../housing/hpi');++const everywhere = require('../aggregate/everywhere');/** The listing columns a caller may set, as the validate() input names. */const INPUT_KEYS = ['kind', 'title', 'description', 'price', 'currency', 'period', 'city', 'region', 'country', 'neighbourhood', 'bedrooms'];@@@ -57,6 +58,27 @@ function createApi(ctx) {r.get('/ping', limits.reads('rent.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));// ── Affordable housing near you (public) ─────────────────++ // Search everywhere (server/aggregate/everywhere.js): one place and filters → every rental site's own search for it.++ // No token; it geocodes, so it takes the same per-address budget as /housing.++ r.get('/everywhere', limits.budget('rent.housing.search'), async (req, res) => {++ const one = (v) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, 200) : null);++ const kind = one(req.query.kind) || 'apartment';++ if (!everywhere.KINDS.includes(kind)) return problem(req, res, 422, 'rent.everywhere.invalid', `kind: one of ${everywhere.KINDS.join(', ')}`);++ if (!one(req.query.q)) return problem(req, res, 422, 'rent.everywhere.invalid', 'q: a place to search, such as a city and state or a ZIP code');++ const place = await housingPlaces.geocodePlace(ctx.upstream, one(req.query.q)).catch((err) => {++ if (err && err.code) { problem(req, res, err.status || 502, err.code, err.message); return null; }++ throw err;++ });++ if (!place) return undefined;++ const sites = everywhere.linksFor({ city: place.city, state: place.state, country: place.country_code, postcode: place.postcode, lat: place.lat, lon: place.lon, countryName: place.country_name },++ { kind, maxPrice: one(req.query.max_price), minBeds: one(req.query.min_beds) });++ res.set('Cache-Control', 'public, max-age=300');++ return res.json({++ place: { name: place.name, city: place.city, state: place.state, country: place.country_code, lat: place.lat, lon: place.lon },++ kind, sites,++ });++ });++// No token: HUD's open data is the same for everyone. It spends Nominatim's shared one-request-a-second budget, so// it takes the per-address budget in ./caller-limits.js rather than the default read numbers.r.get('/housing', limits.budget('rent.housing.search'), async (req, res) => {@@@ -48,6 +48,7 @@ const listingText = (row) => `${listings.KIND_TEXT[row.kind] || row.kind} in ${[async function publicPages(s) {const static_ = [{ path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Rent home', text: 'One place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. Listings are posted by people; OpenVibe handles no payments and no deposits.' },++ { path: '/everywhere', changefreq: 'weekly', priority: 0.9, title: 'Search every rental site', text: 'One search across Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com and more: apartments, houses, rooms, furnished stays, commercial space, parking, storage and cars.' },{ path: '/housing', changefreq: 'daily', priority: 0.8, title: 'Affordable housing near you', text: 'Public housing, tax-credit (LIHTC) and HUD-assisted properties, and the authorities and counselling agencies to apply through, near a place — from U.S. Department of Housing and Urban Development open data (public domain).' },{ path: '/listings', changefreq: 'hourly', priority: 0.9, title: 'Listings for rent', text: 'Search every active listing by kind, city, country, price and period, newest first.' },{ path: '/safety', changefreq: 'monthly', priority: 0.6, title: 'Renting safely', text: 'Never pay or send a deposit before you have seen the place and signed an agreement. OpenVibe does not handle payments for listings.' },@@@ -83,6 +84,7 @@ function createDiscoveryRoutes(ctx) {sections: [{ title: 'Start here', links: [{ title: 'OpenVibe.Rent', url: abs('/'), note: 'find something to rent, or offer your own' },++ { title: 'Search every rental site', url: abs('/everywhere'), note: 'one place and filters open Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper and more at once' },{ title: 'Affordable housing', url: abs('/housing'), note: 'public housing, LIHTC and HUD-assisted properties, and where to apply, near you' },{ title: 'Listings', url: abs('/listings'), note: 'search by kind, city, country, price and period' },{ title: 'Renting safely', url: abs('/safety'), note: 'the rules, including the one about deposits' },@@@ -90,6 +92,7 @@ function createDiscoveryRoutes(ctx) {] },{ title: 'Machine-readable', links: [{ title: 'Listings (JSON)', url: abs('/api/v1/listings'), note: 'active listings, newest first, with facets' },++ { title: 'Search every rental site (JSON)', url: abs('/api/v1/everywhere?q=salt+lake+city&kind=apartment'), note: 'the geocoded place and the search link on every rental site' },{ title: 'Affordable housing (JSON)', url: abs('/api/v1/housing?q=washington'), note: 'the geocoded place and normalised HUD housing results near it' },{ title: 'Sitemap', url: abs('/sitemap.xml') },{ title: 'Full text for language models', url: abs('/llms-full.txt') },@@@ -28,9 +28,10 @@ const service = require('../listings/service');const { isStaff } = require('../listings/staff');const housingPlaces = require('../housing/places');const housingHpi = require('../housing/hpi');++const everywhere = require('../aggregate/everywhere');const SITE_NAME = 'OpenVibe.Rent';--const TAGLINE = 'Find a place, or rent yours out.';++const TAGLINE = 'Every rental site, one search.';const HOME_LATEST = 6;const STATE_KIND = { active: 'ok', hidden: 'warn', expired: '', removed: 'bad' };@@@ -154,52 +155,125 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}const latest = await store.search(s, listings.normalizeFilters({}), { limit: HOME_LATEST });const hero = showcase.hero({eyebrow: `${SITE_NAME} · ${TAGLINE}`,-- title: 'Find somewhere to live, work or park.',-- accent: 'Posted by people.',-- lede: `${SITE_NAME} is one place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. In this first version people post the listings here themselves — nothing is scraped from another site, and nothing is paid for here.`,-- actions: signedIn(req)-- ? [{ label: 'Post a listing', href: '/post', primary: true }, { label: 'Your listings', href: '/mine' }]-- : [{ label: 'Browse listings', href: '/listings', primary: true }, { label: 'Sign in to post', href: '/auth/login?next=%2Fpost' }],-- note: 'No payments, no deposits, no messaging: a listing carries a way to make contact and nothing more.',++ title: 'Every rental site.',++ accent: 'One search.',++ lede: `${SITE_NAME} searches Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com and a dozen more at once: apartments, houses, rooms, furnished stays, commercial space, parking, storage and cars. Next to them, affordable housing from HUD and listings people post here, all under one OpenVibe account.`,++ actions: [++ { label: 'Search every site', href: '/everywhere', primary: true },++ { label: 'Affordable housing', href: '/housing' },++ signedIn(req) ? { label: 'Post a listing', href: '/post' } : { label: 'Sign in to post', href: '/auth/login?next=%2Fpost' },++ ],++ note: 'Open source (AGPL-3.0). Listings, messages and payments stay on each site; OpenVibe never takes a deposit.',++ aside: { html: String(everywhereForm({}, { big: true })) },});page(req, res, {index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,-- description: 'Find somewhere to live, work or park, posted by people, and affordable housing near you from U.S. HUD open data. No payments, no scraping.',++ description: 'Search Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper and more at once: apartments, houses, rooms, stays, commercial space, parking, storage and cars, plus affordable housing from HUD.',jsonLd: homeJsonLd(config),styles: [showcase.STYLESHEET],body: html`${raw(hero)}++<section class="sc-sec" aria-labelledby="h-sites"><h2 id="h-sites">The sites it searches</h2>++<p class="sc-lede">Type a place once. Each site opens its own search there, with your price and bedrooms where it takes them, so you see every site's newest listings without repeating yourself.</p>++<ul class="ew-chips">${everywhere.SITES.map((site) => html`<li title="${site.best}">${site.name}</li>`)}</ul>++<p class="small muted">The United States first; more countries next.</p></section>++<section class="sc-sec" aria-labelledby="h-housing"><h2 id="h-housing">Affordable housing near you</h2>--<p class="sc-lede">Public housing, tax-credit (LIHTC) and HUD-assisted properties, and the authorities and counselling agencies to apply through — from U.S. HUD open data, useful before anyone has posted here.</p>++<p class="sc-lede">Public housing, tax-credit (LIHTC) and HUD-assisted properties, and the authorities and counselling agencies to apply through, from U.S. HUD open data.</p><p><a class="sc-btn sc-primary" href="/housing">Find housing near you</a></p></section>--<section class="sc-sec" aria-labelledby="h-search"><h2 id="h-search">Search the listings</h2>--<p class="sc-lede">Straight to it, or <a href="/listings">browse everything</a>.</p>--${searchForm({}, { big: true })}</section>----<section class="sc-sec" aria-labelledby="h-latest"><h2 id="h-latest">Latest listings</h2>--<p class="sc-lede">The newest active listings, posted by people on ${SITE_NAME}.</p>--${listingList(latest.rows, { empty: 'No listings yet — yours could be the first.' })}--<p><a class="sc-btn" href="/listings">Every listing</a> <a class="sc-btn" href="/post">Post a listing</a></p></section>++<section class="sc-sec" aria-labelledby="h-latest"><h2 id="h-latest">Posted on ${SITE_NAME}</h2>++<p class="sc-lede">Listings people post here themselves: apartments, rooms, houses, commercial space, parking and storage, and equipment. <a href="/listings">Browse them</a> or search them by place.</p>++${listingList(latest.rows, { empty: 'Nobody has posted here yet: yours could be the first.' })}++<p><a class="sc-btn" href="/listings">Every listing posted here</a> <a class="sc-btn" href="/post">Post a listing</a></p></section>${raw(showcase.features({title: 'How it works',-- lede: 'A listing here is somebody offering something, and the rules are the same for everyone.',++ lede: 'One account for every rental search you make, wherever the listing lives.',items: [-- { icon: 'ov:account', title: 'Posted by people', text: 'Every listing is posted by a signed-in person, who can edit, renew, hide or delete it. Nothing here is scraped from another site.' },-- { icon: 'ov:gauge', title: 'Safety first', text: 'Never pay or send a deposit before you have seen the place and signed an agreement. OpenVibe does not handle payments for listings.' },-- { icon: 'ov:page', title: 'Reported listings are checked', text: 'Three distinct reports hide a listing until OpenVibe staff look at it and restore or remove it.' },-- { icon: 'ov:db', title: 'Searches you can keep', text: 'Save your filters and see how many listings are new since you last looked. Alerts come later through OpenVibe.Watch.' },-- { icon: 'ov:deploy', title: 'No addresses', text: 'A listing names a city and a neighbourhood, never a street address: a house number with a street is refused.' },++ { icon: 'ov:search', title: 'One search, every site', text: 'Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com, RentCafe, Apartment List, PadMapper, HotPads, Trulia, Realtor.com, Roomies, Furnished Finder, Airbnb, LoopNet, SpotHero, SpareFoot and Turo, opened at your place.', href: '/everywhere' },++ { icon: 'ov:map', title: 'Affordable housing', text: 'Public housing, tax-credit and HUD-assisted homes near any U.S. place, with the agencies to apply through.', href: '/housing' },++ { icon: 'ov:account', title: 'Post your own', text: 'Anyone signed in can list a place, a room, a parking spot or equipment here, and edit, renew or hide it at any time.', href: '/post' },++ { icon: 'ov:bell', title: 'Alerts that keep looking', text: 'Save a search and get alerts through OpenVibe.Watch when something new matches.' },++ { icon: 'ov:gauge', title: 'Safety first', text: 'Never pay or send a deposit before you have seen the place and signed an agreement. Reported listings are hidden until staff look.', href: '/safety' },],}))}<section class="sc-sec" aria-labelledby="h-sources"><h2 id="h-sources">Where the listings come from</h2>--<p class="sc-prose">Today: people, posting here themselves. There are no scraped listings and no partner feeds — other sources arrive only when their terms are verified, and each listing will then say where it came from and link back to it. <a href="/safety">The safety rules</a> are on every listing page.</p></section>++<p class="sc-prose">From every major rental site, opened at your place and filters: the listings, the landlord and the payment stay on that site. From U.S. HUD open data, for affordable housing. And from people posting here. Partner feeds and licensed listing data join as they are signed, each listing labelled with its source. <a href="/safety">How to rent safely</a>.</p></section>--${raw(showcase.cta({ title: 'Have something to rent out?', text: 'Post it in a minute: a description, a price, a period and where it is. You can edit, renew or hide it at any time.', actions: [{ label: 'Post a listing', href: '/post', primary: true }, { label: 'Safety rules', href: '/safety' }] }))}`,++${raw(showcase.cta({ title: 'Have something to rent out?', text: 'Post it in a minute: a description, a price, a period and where it is. You can edit, renew or hide it at any time.', actions: [{ label: 'Post a listing', href: '/post', primary: true }, { label: 'Search every site', href: '/everywhere' }] }))}`,});});++ // ── Search everywhere: every rental site's own search for one place ──++ // What each search-everywhere kind is called on this site's own listings (null: no listing kind matches).++ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, 200) : null);++ const OWN_KIND = { apartment: 'apartment', house: 'house', room: 'room', stay: null, commercial: 'commercial', parking: 'parking', storage: 'parking', vehicle: 'equipment' };++ const HUD_KINDS = new Set(['apartment', 'house', 'room']);++ const everywhereForm = (v = {}, { big = false } = {}) => html`<form class="search-form everywhere-form${big ? ' everywhere-big' : ''}" method="get" action="/everywhere" role="search">++<div class="field"><label for="ew-q">Where</label><input id="ew-q" name="q" type="search" required maxlength="200" placeholder="A city and state, or a ZIP code" value="${v.q || ''}" autocomplete="address-level2"></div>++<div class="field-row">++<div class="field"><label for="ew-kind">Looking for</label><select id="ew-kind" name="kind">${everywhere.KINDS.map((k) => html`<option value="${k}"${k === (v.kind || 'apartment') ? raw(' selected') : ''}>${everywhere.KIND_TEXT[k]}</option>`)}</select></div>++<div class="field"><label for="ew-max">Up to, a month</label><input id="ew-max" name="max" type="number" min="1" max="100000" step="1" inputmode="numeric" placeholder="Any price" value="${v.max || ''}"></div>++<div class="field"><label for="ew-beds">Bedrooms</label><select id="ew-beds" name="beds"><option value="">Any</option>${[1, 2, 3, 4].map((n) => html`<option value="${n}"${String(n) === String(v.beds || '') ? raw(' selected') : ''}>${n}+</option>`)}</select></div>++</div>++<p><button class="sc-btn sc-primary" type="submit">Search every site</button></p>++</form>`;++++ async function everywherePage(req, res, { limited = false } = {}) {++ const v = { q: str(req.query.q), kind: everywhere.KINDS.includes(String(req.query.kind)) ? String(req.query.kind) : 'apartment', max: str(req.query.max), beds: str(req.query.beds) };++ let place = null;++ let problem = null;++ if (limited) problem = 'Too many searches from you just now. Try again in a minute.';++ else if (v.q) {++ try { place = await housingPlaces.geocodePlace(ctx.upstream, v.q); } catch (err) {++ problem = err && err.code === 'housing.place.not_found' ? err.message : 'The place search is unavailable right now. Try again in a few minutes.';++ }++ }++ const label = place ? [place.city, place.state || (place.country_name || '')].filter(Boolean).join(', ') : null;++ const links = place ? everywhere.linksFor({ city: place.city, state: place.state, country: place.country_code, postcode: place.postcode, lat: place.lat, lon: place.lon, countryName: place.country_name }, { kind: v.kind, maxPrice: v.max, minBeds: v.beds }) : [];++ const own = place && OWN_KIND[v.kind] ? `/listings?${listings.filtersToQuery({ kind: OWN_KIND[v.kind], city: place.city, max: v.max ? Number(v.max) || null : null })}` : null;++ const tab = (k) => `/everywhere?${new URLSearchParams({ q: v.q || '', kind: k, ...(v.max ? { max: v.max } : {}), ...(v.beds ? { beds: v.beds } : {}) })}`;++ const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } };++ page(req, res, {++ title: label ? `${everywhere.KIND_TEXT[v.kind]} near ${label}` : 'Search every rental site',++ description: 'One search across Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com and more: apartments, houses, rooms, furnished stays, commercial space, parking, storage and cars.',++ index: !v.q, cache: v.q ? null : PUBLIC_CACHE,++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Search everywhere' }],++ styles: [showcase.STYLESHEET], localStyles: ['css/everywhere.css'],++ body: html`<h1>${label ? html`${everywhere.KIND_TEXT[v.kind]} near ${label}` : 'Search every rental site'}</h1>++<p class="sc-lede">One search, every site: ${SITE_NAME} opens Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper and more at the place and filters you choose, next to the listings people post here and affordable housing from HUD.</p>++${everywhereForm(v)}++${problem ? notice(problem, 'warn') : ''}++${place ? html`<nav class="everywhere-kinds" aria-label="What to look for">${everywhere.KINDS.map((k) => (k === v.kind ? html`<span class="ew-kind on" aria-current="page">${everywhere.KIND_TEXT[k]}</span>` : html`<a class="ew-kind" href="${tab(k)}">${everywhere.KIND_TEXT[k]}</a>`))}</nav>++<section class="sc-sec" aria-labelledby="h-sites"><h2 id="h-sites">On every site</h2>++${links.length ? html`<ul class="everywhere-sites">${links.map((l) => html`<li class="ew-site"><a class="ew-open" href="${l.url}" target="_blank" rel="noopener nofollow"><span class="ew-name">${l.name}</span><span class="ew-host">${hostOf(l.url)} ↗</span></a><p class="ew-best">${l.best}</p></li>`)}</ul>++<p class="small muted">Each link opens that site's own search for this place in a new tab, with your filters where the site takes them. Listings, messages and payments stay on that site: never pay a deposit before you have seen a place (<a href="/safety">the safety rules</a>).</p>`++ : html`<p class="muted">No site in the list covers this place and kind yet. The list starts with the United States; more countries come next.</p>`}++</section>++<section class="sc-sec" aria-labelledby="h-here"><h2 id="h-here">On ${SITE_NAME}</h2><ul class="everywhere-here">++${own ? html`<li><a href="${own}">Listings people posted here near ${place.city}</a></li>` : ''}++${HUD_KINDS.has(v.kind) && (place.country_code || 'us') === 'us' ? html`<li><a href="/housing?${new URLSearchParams({ q: v.q })}">Affordable housing near ${place.city}</a>: public housing, tax-credit and HUD-assisted homes</li>` : ''}++<li><a href="/post">Post a listing</a> of your own</li></ul></section>` : ''}`,++ }, limited ? 429 : 200);++ }++++ // The geocode spends the same per-caller budget as /housing and the API; over it, the page says so (429).++ const everywhereSearch = ctx.limits.budget('rent.housing.search');++ r.get('/everywhere', (req, res) => {++ if (!str(req.query.q)) return everywherePage(req, res);++ let settled = false;++ let retryAfter = '60';++ const allowed = () => { if (!settled) { settled = true; everywherePage(req, res); } };++ const refused = () => {++ if (settled) return;++ settled = true;++ res.set('Retry-After', retryAfter);++ everywherePage(req, res, { limited: true });++ };++ everywhereSearch(req, { statusCode: 200, setHeader: (k, val) => { if (String(k).toLowerCase() === 'retry-after') retryAfter = String(val); }, end: refused }, allowed);++ });++// ── Affordable housing near you ──────────────────────────// HUD's data is public domain, but the licence still asks for credit; every /housing page carries it, linked back.const hudNote = (fetchedAt) => html`<p class="attribution small muted"><a href="${housingPlaces.ATTRIBUTION.url}" rel="noopener">${housingPlaces.ATTRIBUTION.text}</a> · <a href="${housingHpi.ATTRIBUTION.url}" rel="noopener">${housingHpi.ATTRIBUTION.text}</a>. The housing data covers the United States, and availability must be confirmed with the property or authority.${fetchedAt ? html` Fetched ${time(fetchedAt)}.` : ''}</p>`;nnew file mode 100644@@@ -0,0 +1,134 @@++'use strict';++/**++ * Search everywhere (server/aggregate/everywhere.js, /everywhere, /api/v1/everywhere): one place and filters open every++ * rental site's own search there, with the URL forms checked in a real browser on 2026-10-15; a site that cannot serve++ * the place is left out; the page and the API answer with the geocoded place; the home is the aggregator the owner++ * asked for and no longer says nothing is scraped.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const everywhere = require('../server/aggregate/everywhere');++++const SLC = { city: 'Salt Lake City', state: 'UT', country: 'us', postcode: '84111', lat: 40.7608, lon: -111.891, countryName: 'United States' };++const urls = (links) => Object.fromEntries(links.map((l) => [l.id, l.url]));++const ids = (links) => links.map((l) => l.id);++++const nominatimSlc = [{++ lat: '40.7596198', lon: '-111.8867975', display_name: 'Salt Lake City, Salt Lake County, Utah, 84111, United States',++ boundingbox: ['40.6996', '40.8533', '-112.1015', '-111.7393'],++ address: { city: 'Salt Lake City', county: 'Salt Lake County', state: 'Utah', 'ISO3166-2-lvl4': 'US-UT', postcode: '84111', country: 'United States', country_code: 'us' },++}];++const nominatimToronto = [{++ lat: '43.6534817', lon: '-79.3839347', display_name: 'Toronto, Ontario, Canada', boundingbox: ['43.58', '43.86', '-79.64', '-79.11'],++ address: { city: 'Toronto', state: 'Ontario', 'ISO3166-2-lvl4': 'CA-ON', country: 'Canada', country_code: 'ca' },++}];++const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'Content-Type': 'application/json' } });++const housingFetchImpl = async (url) => {++ const u = new URL(String(url));++ if (u.pathname.endsWith('/search')) return json(/toronto/i.test(u.searchParams.get('q') || '') ? nominatimToronto : nominatimSlc);++ return json({ features: [] });++};++++(async () => {++ await check('apartments in a multi-word US city: every site, in order, at the place and with the filters each takes', () => {++ const links = everywhere.linksFor(SLC, { kind: 'apartment', maxPrice: 1500, minBeds: 2 });++ assert.deepStrictEqual(ids(links), ['craigslist', 'facebook', 'zillow', 'apartments', 'zumper', 'rentcom', 'rentcafe', 'apartmentlist', 'padmapper', 'hotpads', 'trulia', 'realtor']);++ const u = urls(links);++ assert.strictEqual(u.craigslist, 'https://www.craigslist.org/search/city/salt-lake-city-ut?cat=apa&max_price=1500&min_bedrooms=2&postal=84111&radius=10');++ assert.strictEqual(u.facebook, 'https://www.facebook.com/marketplace/saltlakecity/propertyrentals');++ assert.strictEqual(u.zillow, 'https://www.zillow.com/salt-lake-city-ut/rentals/');++ assert.strictEqual(u.apartments, 'https://www.apartments.com/salt-lake-city-ut/2-bedrooms-under-1500/');++ assert.strictEqual(u.zumper, 'https://www.zumper.com/apartments-for-rent/salt-lake-city-ut');++ assert.strictEqual(u.rentcom, 'https://www.rent.com/utah/salt-lake-city-apartments');++ assert.strictEqual(u.rentcafe, 'https://www.rentcafe.com/apartments-for-rent/salt-lake-city-ut/');++ assert.strictEqual(u.apartmentlist, 'https://www.apartmentlist.com/ut/salt-lake-city');++ assert.strictEqual(u.padmapper, 'https://www.padmapper.com/apartments/salt-lake-city-ut');++ assert.strictEqual(u.hotpads, 'https://hotpads.com/salt-lake-city-ut/apartments-for-rent');++ assert.strictEqual(u.trulia, 'https://www.trulia.com/for_rent/Salt_Lake_City,UT/');++ assert.strictEqual(u.realtor, 'https://www.realtor.com/apartments/Salt-Lake-City_UT');++ assert.ok(links.every((l) => l.best && l.name), 'every link says what the site is best for');++ });++++ await check('each kind opens the sites that carry it', () => {++ const u = (kind) => urls(everywhere.linksFor(SLC, { kind }));++ assert.deepStrictEqual(Object.keys(u('house')), ['craigslist', 'facebook', 'zillow', 'zumper', 'rentcom', 'padmapper', 'hotpads', 'trulia', 'realtor']);++ assert.strictEqual(u('house').zillow, 'https://www.zillow.com/salt-lake-city-ut/rent-houses/');++ assert.strictEqual(u('house').zumper, 'https://www.zumper.com/houses-for-rent/salt-lake-city-ut');++ assert.strictEqual(u('house').rentcom, 'https://www.rent.com/utah/salt-lake-city-houses');++ assert.deepStrictEqual(Object.keys(u('room')), ['craigslist', 'facebook', 'zumper', 'roomies', 'furnishedfinder']);++ assert.match(u('room').craigslist, /\?cat=roo&postal=84111&radius=10$/);++ assert.strictEqual(u('room').roomies, 'https://www.roomies.com/rooms/salt-lake-city-ut');++ assert.deepStrictEqual(Object.keys(u('stay')), ['craigslist', 'furnishedfinder', 'airbnb']);++ assert.match(u('stay').craigslist, /\?cat=sub&/);++ assert.strictEqual(u('stay').airbnb, 'https://www.airbnb.com/s/Salt-Lake-City--UT/homes?monthly_stay=true');++ assert.strictEqual(u('stay').furnishedfinder, 'https://www.furnishedfinder.com/housing/us--ut--salt-lake-city');++ assert.deepStrictEqual(Object.keys(u('commercial')), ['craigslist', 'loopnet']);++ assert.strictEqual(u('commercial').loopnet, 'https://www.loopnet.com/search/commercial-real-estate/salt-lake-city-ut/for-lease/');++ assert.deepStrictEqual(Object.keys(u('parking')), ['craigslist', 'spothero']);++ assert.strictEqual(u('parking').spothero, 'https://spothero.com/search?latitude=40.7608&longitude=-111.8910&kind=monthly');++ assert.deepStrictEqual(Object.keys(u('storage')), ['craigslist', 'sparefoot']);++ assert.strictEqual(u('storage').sparefoot, 'https://www.sparefoot.com/Salt-Lake-City-UT-self-storage.html');++ assert.deepStrictEqual(Object.keys(u('vehicle')), ['turo']);++ assert.strictEqual(u('vehicle').turo, 'https://turo.com/us/en/search?country=US&location=Salt+Lake+City%2C+UT');++ });++++ await check('outside the US only the sites that work worldwide; nothing is guessed', () => {++ const toronto = { city: 'Toronto', state: null, country: 'ca', countryName: 'Canada' };++ assert.deepStrictEqual(ids(everywhere.linksFor(toronto, { kind: 'apartment' })), ['facebook']);++ assert.deepStrictEqual(urls(everywhere.linksFor(toronto, { kind: 'stay' })), { airbnb: 'https://www.airbnb.com/s/Toronto--Canada/homes?monthly_stay=true' });++ assert.deepStrictEqual(everywhere.linksFor({ city: null, state: 'UT' }, { kind: 'apartment' }), [], 'no city, no links');++ });++++ await check('filters are bounded, an unknown kind is apartments, and every link is https', () => {++ const u = urls(everywhere.linksFor(SLC, { kind: 'boats', maxPrice: 'abc', minBeds: 99 }));++ assert.strictEqual(u.craigslist, 'https://www.craigslist.org/search/city/salt-lake-city-ut?cat=apa&postal=84111&radius=10');++ assert.strictEqual(u.apartments, 'https://www.apartments.com/salt-lake-city-ut/');++ for (const kind of everywhere.KINDS) for (const l of everywhere.linksFor(SLC, { kind })) assert.match(l.url, /^https:\/\/[a-z0-9.-]+\//, l.url);++ });++++ const t = await boot({ housingFetchImpl });++ try {++ await check('/everywhere: the place, every site for it, the kinds, and what OpenVibe.Rent has there', async () => {++ const r = await t.get('/everywhere?q=Salt+Lake+City&kind=apartment&max=1500&beds=2');++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.ok(r.text.includes('Apartments near Salt Lake City, UT'));++ assert.ok(r.text.includes('href="https://www.craigslist.org/search/city/salt-lake-city-ut?cat=apa&max_price=1500&min_bedrooms=2&postal=84111&radius=10"'));++ assert.ok(r.text.includes('href="https://www.apartments.com/salt-lake-city-ut/2-bedrooms-under-1500/"'));++ assert.ok(r.text.includes('target="_blank" rel="noopener nofollow"'), 'opens in a new tab, no referrer games');++ assert.ok(r.text.includes('href="/everywhere?q=Salt+Lake+City&kind=room&max=1500&beds=2"'), 'the other kinds keep the place and filters');++ assert.ok(r.text.includes('href="/housing?q=Salt+Lake+City"'), 'affordable housing for the same place');++ assert.ok(r.text.includes('/listings?kind=apartment&city=Salt+Lake+City&max=1500'), 'listings posted here');++ assert.ok(r.text.includes('noindex'), 'a search result page is not indexed');++ const blank = await t.get('/everywhere');++ assert.strictEqual(blank.status, 200);++ assert.ok(blank.text.includes('Search every rental site') && !blank.text.includes('noindex'));++ });++++ await check('/everywhere outside the US: the worldwide sites, and no HUD link', async () => {++ const r = await t.get('/everywhere?q=Toronto&kind=apartment');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('facebook.com/marketplace/toronto/propertyrentals'));++ assert.ok(!r.text.includes('craigslist.org') && !r.text.includes('href="/housing?q='));++ });++++ await check('/api/v1/everywhere answers the place and the sites; bad input is a 422 problem', async () => {++ const r = await t.get('/api/v1/everywhere?q=Salt+Lake+City&kind=room');++ assert.strictEqual(r.status, 200);++ const b = r.json();++ assert.deepStrictEqual([b.place.city, b.place.state, b.place.country, b.kind], ['Salt Lake City', 'UT', 'us', 'room']);++ assert.deepStrictEqual(b.sites.map((x) => x.id), ['craigslist', 'facebook', 'zumper', 'roomies', 'furnishedfinder']);++ assert.strictEqual((await t.get('/api/v1/everywhere?q=x&kind=boats')).status, 422);++ assert.strictEqual((await t.get('/api/v1/everywhere?kind=room')).json().code, 'rent.everywhere.invalid');++ });++++ await check('the home is the aggregator: one search over every site, and it no longer says nothing is scraped', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('Every rental site.') && r.text.includes('One search.'));++ assert.ok(r.text.includes('action="/everywhere"'), 'the hero search goes everywhere');++ for (const name of ['Craigslist', 'Facebook Marketplace', 'Zillow', 'Apartments.com', 'Zumper', 'Turo']) assert.ok(r.text.includes(name), name);++ assert.ok(!/nothing (here )?is scraped|no scraped listings/i.test(r.text), 'the old "nothing is scraped" framing is gone');++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -16,16 +16,17 @@ const listings = require('../server/listings/listings');const lee = t.network.addUser('lee');const nia = t.network.addUser('nia');-- await check('the home page: a search box, the latest listings, how it works, and no payments said plainly', async () => {++ await check('the home page: a search over every rental site, the latest listings posted here, how it works, and no deposits said plainly', async () => {const made = await post(t, kim, { title: 'A flat on the home page' });assert.strictEqual(made.status, 201, made.text);const r = await t.get('/');assert.strictEqual(r.status, 200);-- assert.match(r.text, /action="\/listings"/, 'the search box posts to /listings');++ assert.match(r.text, /action="\/everywhere"/, 'the search box searches every rental site');++ assert.match(r.text, /href="\/listings"/, 'the listings posted here are one click away');assert.ok(r.text.includes('A flat on the home page'), 'the latest listings are on the home page');assert.match(r.text, /How it works/);-- assert.ok(r.text.includes('OpenVibe does not handle payments for listings'), 'the no-payments rule');-- assert.match(r.text, /no scraped listings and no partner feeds/i, 'the sources-to-come statement');++ assert.ok(r.text.includes('Never pay or send a deposit before you have seen the place'), 'the no-deposit rule');++ assert.match(r.text, /Where the listings come from/, 'where the listings come from');assert.match(r.text, /href="\/post"/);assert.match(r.text, /href="\/safety"/);});
@@ -1,20 +1,47 @@
@@ -1,20 +1,47 @@
# OpenVibe.Rent
# OpenVibe.Rent
-> Find a place, or rent yours out.
+> Every rental site, one search.
-**Status:** v1 works end to end: people post listings, anyone searches them, three reports hide one until staff look, and
-"Affordable housing near you" shows HUD's public-domain housing data (public housing, LIHTC, HUD-assisted multifamily,
+**Status:** a rental aggregator. **Search everywhere** (`/everywhere`) opens every major rental site's own search for one
+place and set of filters. People post listings here too, anyone searches them, and three reports hide one until staff
+look. "Affordable housing near you" shows HUD's public-domain housing data (public housing, LIHTC, HUD-assisted multifamily,
public housing authorities and housing counselling agencies) near a place a visitor types, with FHFA home-price context beside it.
public housing authorities and housing counselling agencies) near a place a visitor types, with FHFA home-price context beside it.
**Domain:** `openvibe.rent` · **Port:** 5010 · **Service id:** `rent` · **Env prefix:** `RENT`
**Domain:** `openvibe.rent` · **Port:** 5010 · **Service id:** `rent` · **Env prefix:** `RENT`
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
## What it is
## What it is
-One place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and
-equipment. In this first version the listings are **posted by people on this site** — there are no scraped listings
-and no partner feeds. Other sources come later, and only when their terms are verified; each listing will then carry
-its provenance (the source, a link back to it, and when it was read). Until then every listing here was written by
-the person who posted it, and the page says so.
+The rental aggregator the owner asked for (2026-09-28): one consumer-friendly place that combines every rental site,
+from apartments to commercial space and much more.
+
+**Search everywhere** (`/everywhere`, `GET /api/v1/everywhere`, [server/aggregate/everywhere.js](server/aggregate/everywhere.js))
+takes one place (a city and state, or a ZIP code) and what you are looking for, and opens each site's own search there,
+with the price and bedroom filters where the site takes them:
+
+- **Apartments and houses:** Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com, RentCafe,
+ Apartment List, PadMapper, HotPads, Trulia and Realtor.com.
+- **Rooms:** Craigslist, Facebook, Zumper, Roomies and Furnished Finder.
+- **Furnished and monthly stays:** Craigslist sublets, Furnished Finder and Airbnb monthly.
+- **Commercial space:** Craigslist and LoopNet.
+- **Parking:** Craigslist and SpotHero.
+- **Storage:** Craigslist and SpareFoot.
+- **Cars:** Turo.
+
+Every URL form was opened in a real browser for several cities, including one-word and multi-word names, on
+2026-10-15 (`test/everywhere.test.js` pins them). The listings, messages and payments stay on each site; the person
+sees them in their own browser and account.
+
+This is the permitted way to aggregate sites whose terms forbid automated collection, as the owner decided on
+2026-09-28 (deep links out, a browser extension inside the person's own sessions, licensed APIs, people posting).
+Next:
+- a board that saves listings from any site into one OpenVibe list;
+- the OpenVibe Companion extension;
+- licensed listing feeds through OpenVibe.Sources.
+
+The United States comes first. A place elsewhere gets the sites that work worldwide.
+
+People also **post listings here** (apartments, rooms, houses, commercial space, parking and storage, equipment), and
+each one is written by the person who posted it.
Separately, the site is useful before anyone has posted anything: **"Affordable housing near you"** (`/housing`) shows
Separately, the site is useful before anyone has posted anything: **"Affordable housing near you"** (`/housing`) shows
public housing, tax-credit (LIHTC) and HUD-assisted multifamily properties, public housing authorities and housing
public housing, tax-credit (LIHTC) and HUD-assisted multifamily properties, public housing authorities and housing
@@ -90,3 +90,6 @@ form.inline { display: inline; }
@@ -90,3 +90,6 @@ form.inline { display: inline; }
.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
button.sc-btn { background: transparent; }
button.sc-btn { background: transparent; }
button.sc-btn.sc-primary { border: 0; }
button.sc-btn.sc-primary { border: 0; }
+/* The sites the search-everywhere form opens (home); the results page has css/everywhere.css. */
+.ew-chips { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 12px 0 6px; }
+.ew-chips li { padding: 5px 11px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); font-size: .9rem; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+/* OpenVibe.Rent — /everywhere only (every rental site's own search for one place), so app.css stays inside its budget. */
+.everywhere-kinds { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 4px; }
+.ew-kind { display: inline-block; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .9rem; }
+a.ew-kind:hover { border-color: var(--accent, #3b82f6); }
+.ew-kind.on { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 18%, var(--bg-secondary, #0f1729)); font-weight: 650; }
+.everywhere-sites { list-style: none; padding: 0; margin: 0 0 10px; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
+.ew-site { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); transition: border-color .15s ease, transform .15s ease; }
+.ew-site:hover { border-color: var(--accent, #3b82f6); transform: translateY(-1px); }
+.ew-open { display: flex; flex-direction: column; gap: 2px; text-decoration: none; color: var(--text-primary, #e6edf7); }
+.ew-open:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 3px; border-radius: 6px; }
+.ew-name { font-weight: 700; font-size: 1.05rem; }
+.ew-host { font-size: .82rem; color: var(--accent-text, var(--accent, #3b82f6)); overflow-wrap: anywhere; }
+.ew-best { margin: 0; font-size: .9rem; color: var(--text-secondary, #96a7c2); }
+.everywhere-here { margin: 0; padding-left: 18px; display: grid; gap: 6px; }
+@media (prefers-reduced-motion: reduce) { .ew-site, .ew-site:hover { transition: none; transform: none; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,185 @@
@@ -0,0 +1,185 @@
+'use strict';
+
+/**
+ * Search everywhere: the rental aggregator's front door. One place and one set of filters open every major rental
+ * site's own search for that place — Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com,
+ * RentCafe, Apartment List, PadMapper, HotPads, Trulia, Realtor.com, Roomies, Furnished Finder, Airbnb, LoopNet,
+ * SpotHero, SpareFoot and Turo — next to what OpenVibe.Rent has itself (listings posted here, affordable housing from
+ * HUD). The listings stay on each site: a link opens that site's own results, in the person's own browser and account.
+ * This is the permitted way to aggregate sites whose terms forbid automated collection (the owner's 2026-09-28 Q8:
+ * deep links out, a browser extension inside the person's own sessions, licensed APIs, people posting).
+ *
+ * Every URL pattern here was opened in a real browser for several cities, one-word and multi-word, on 2026-10-15
+ * (tools/rent-links-check.js). Sites that answer automated checks with a bot wall (Zillow, Apartments.com, HotPads,
+ * Trulia, Realtor.com, LoopNet) use their public, long-standing URL forms and open normally for a person.
+ *
+ * US places first (the owner's Q9); a place outside the US gets the sites that work worldwide.
+ */
+
+const KINDS = ['apartment', 'house', 'room', 'stay', 'commercial', 'parking', 'storage', 'vehicle'];
+const KIND_TEXT = {
+ apartment: 'Apartments', house: 'Houses', room: 'Rooms and shares', stay: 'Furnished and monthly stays',
+ commercial: 'Commercial and office space', parking: 'Parking', storage: 'Storage', vehicle: 'Cars and vehicles',
+};
+
+const US_STATE_NAMES = {
+ AL: 'alabama', AK: 'alaska', AZ: 'arizona', AR: 'arkansas', CA: 'california', CO: 'colorado', CT: 'connecticut', DE: 'delaware',
+ DC: 'district-of-columbia', FL: 'florida', GA: 'georgia', HI: 'hawaii', ID: 'idaho', IL: 'illinois', IN: 'indiana', IA: 'iowa',
+ KS: 'kansas', KY: 'kentucky', LA: 'louisiana', ME: 'maine', MD: 'maryland', MA: 'massachusetts', MI: 'michigan', MN: 'minnesota',
+ MS: 'mississippi', MO: 'missouri', MT: 'montana', NE: 'nebraska', NV: 'nevada', NH: 'new-hampshire', NJ: 'new-jersey',
+ NM: 'new-mexico', NY: 'new-york', NC: 'north-carolina', ND: 'north-dakota', OH: 'ohio', OK: 'oklahoma', OR: 'oregon',
+ PA: 'pennsylvania', RI: 'rhode-island', SC: 'south-carolina', SD: 'south-dakota', TN: 'tennessee', TX: 'texas', UT: 'utah',
+ VT: 'vermont', VA: 'virginia', WA: 'washington', WV: 'west-virginia', WI: 'wisconsin', WY: 'wyoming', PR: 'puerto-rico',
+};
+
+/** "Salt Lake City" → "salt-lake-city"; accents and punctuation dropped. */
+const slug = (v) => String(v || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
+/** "Salt Lake City" → "Salt-Lake-City" (SpareFoot, Airbnb keep the capitals). */
+const titleSlug = (v) => slug(v).split('-').filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('-');
+const q = (params) => {
+ const out = new URLSearchParams();
+ for (const [k, v] of Object.entries(params)) if (v != null && v !== '') out.set(k, String(v));
+ const s = out.toString();
+ return s ? `?${s}` : '';
+};
+
+/**
+ * The sites. `kinds` says which searches each one answers; `build(place, f)` returns the URL for a place
+ * ({ city, state, country, postcode, lat, lon }) and filters ({ kind, maxPrice, minBeds }), or null when it cannot
+ * serve that place. `us: true` sites need a US city and state.
+ */
+const SITES = [
+ {
+ id: 'craigslist', name: 'Craigslist', us: true,
+ kinds: ['apartment', 'house', 'room', 'stay', 'commercial', 'parking', 'storage'],
+ best: 'Local listings from owners and small landlords, often not anywhere else.',
+ build: (p, f) => {
+ const cat = { apartment: 'apa', house: 'apa', room: 'roo', stay: 'sub', commercial: 'off', parking: 'prk', storage: 'prk' }[f.kind];
+ const beds = ['apartment', 'house'].includes(f.kind) ? f.minBeds : null;
+ return `https://www.craigslist.org/search/city/${slug(p.city)}-${p.state.toLowerCase()}${q({ cat, max_price: f.maxPrice, min_bedrooms: beds, postal: p.postcode, radius: p.postcode ? 10 : null })}`;
+ },
+ },
+ {
+ id: 'facebook', name: 'Facebook Marketplace', us: false,
+ kinds: ['apartment', 'house', 'room'],
+ best: 'Rooms, sublets and homes from people nearby; sign in to Facebook to see them all.',
+ build: (p) => `https://www.facebook.com/marketplace/${slug(p.city).replace(/-/g, '')}/propertyrentals`,
+ },
+ {
+ id: 'zillow', name: 'Zillow', us: true, kinds: ['apartment', 'house'],
+ best: 'The largest US listing database, with a map and price history.',
+ build: (p, f) => `https://www.zillow.com/${slug(p.city)}-${p.state.toLowerCase()}/${f.kind === 'house' ? 'rent-houses' : 'rentals'}/`,
+ },
+ {
+ id: 'apartments', name: 'Apartments.com', us: true, kinds: ['apartment'],
+ best: 'Managed apartment buildings with floor plans and availability.',
+ build: (p, f) => {
+ const parts = [f.minBeds ? `${f.minBeds}-bedrooms` : null, f.maxPrice ? `under-${f.maxPrice}` : null].filter(Boolean).join('-');
+ return `https://www.apartments.com/${slug(p.city)}-${p.state.toLowerCase()}/${parts ? `${parts}/` : ''}`;
+ },
+ },
+ {
+ id: 'zumper', name: 'Zumper', us: true, kinds: ['apartment', 'house', 'room'],
+ best: 'Fresh listings updated daily, including rooms and houses.',
+ build: (p, f) => `https://www.zumper.com/${{ house: 'houses-for-rent', room: 'rooms-for-rent' }[f.kind] || 'apartments-for-rent'}/${slug(p.city)}-${p.state.toLowerCase()}`,
+ },
+ {
+ id: 'rentcom', name: 'Rent.com', us: true, kinds: ['apartment', 'house'],
+ best: 'Apartments and houses with verified photos and pricing.',
+ build: (p, f) => (US_STATE_NAMES[p.state] ? `https://www.rent.com/${US_STATE_NAMES[p.state]}/${slug(p.city)}-${f.kind === 'house' ? 'houses' : 'apartments'}` : null),
+ },
+ {
+ id: 'rentcafe', name: 'RentCafe', us: true, kinds: ['apartment'],
+ best: 'Apartment communities with online applications.',
+ build: (p) => `https://www.rentcafe.com/apartments-for-rent/${slug(p.city)}-${p.state.toLowerCase()}/`,
+ },
+ {
+ id: 'apartmentlist', name: 'Apartment List', us: true, kinds: ['apartment'],
+ best: 'Matches you to buildings by budget and commute.',
+ build: (p) => `https://www.apartmentlist.com/${p.state.toLowerCase()}/${slug(p.city)}`,
+ },
+ {
+ id: 'padmapper', name: 'PadMapper', us: true, kinds: ['apartment', 'house'],
+ best: 'A map view across many listing sources.',
+ build: (p) => `https://www.padmapper.com/apartments/${slug(p.city)}-${p.state.toLowerCase()}`,
+ },
+ {
+ id: 'hotpads', name: 'HotPads', us: true, kinds: ['apartment', 'house'],
+ best: 'Map-first search with many houses and condos.',
+ build: (p, f) => `https://hotpads.com/${slug(p.city)}-${p.state.toLowerCase()}/${f.kind === 'house' ? 'houses-for-rent' : 'apartments-for-rent'}`,
+ },
+ {
+ id: 'trulia', name: 'Trulia', us: true, kinds: ['apartment', 'house'],
+ best: 'Neighbourhood details: commute, schools, crime.',
+ build: (p) => `https://www.trulia.com/for_rent/${titleSlug(p.city).replace(/-/g, '_')},${p.state}/`,
+ },
+ {
+ id: 'realtor', name: 'Realtor.com', us: true, kinds: ['apartment', 'house'],
+ best: 'Rentals listed by agents.',
+ build: (p) => `https://www.realtor.com/apartments/${titleSlug(p.city)}_${p.state}`,
+ },
+ {
+ id: 'roomies', name: 'Roomies', us: true, kinds: ['room'],
+ best: 'Rooms and roommates.',
+ build: (p) => `https://www.roomies.com/rooms/${slug(p.city)}-${p.state.toLowerCase()}`,
+ },
+ {
+ id: 'furnishedfinder', name: 'Furnished Finder', us: true, kinds: ['stay', 'room'],
+ best: 'Furnished monthly rentals (made for travel nurses, open to all).',
+ build: (p) => `https://www.furnishedfinder.com/housing/us--${p.state.toLowerCase()}--${slug(p.city)}`,
+ },
+ {
+ id: 'airbnb', name: 'Airbnb (monthly)', us: false, kinds: ['stay'],
+ best: 'Furnished stays of a month or more, worldwide.',
+ build: (p) => `https://www.airbnb.com/s/${titleSlug(p.city)}--${p.state || titleSlug(p.countryName || '')}/homes?monthly_stay=true`,
+ },
+ {
+ id: 'loopnet', name: 'LoopNet', us: true, kinds: ['commercial'],
+ best: 'Office, retail and industrial space for lease.',
+ build: (p) => `https://www.loopnet.com/search/commercial-real-estate/${slug(p.city)}-${p.state.toLowerCase()}/for-lease/`,
+ },
+ {
+ id: 'spothero', name: 'SpotHero', us: true, kinds: ['parking'],
+ best: 'Monthly and daily parking in garages and lots.',
+ build: (p) => (p.lat != null && p.lon != null ? `https://spothero.com/search${q({ latitude: Number(p.lat).toFixed(4), longitude: Number(p.lon).toFixed(4), kind: 'monthly' })}` : null),
+ },
+ {
+ id: 'sparefoot', name: 'SpareFoot', us: true, kinds: ['storage'],
+ best: 'Self-storage units compared by price and size.',
+ build: (p) => `https://www.sparefoot.com/${titleSlug(p.city)}-${p.state}-self-storage.html`,
+ },
+ {
+ id: 'turo', name: 'Turo', us: true, kinds: ['vehicle'],
+ best: 'Cars rented from their owners.',
+ build: (p) => `https://turo.com/us/en/search${q({ country: 'US', location: `${p.city}, ${p.state}` })}`,
+ },
+];
+
+/**
+ * The links for one place and filter set, in the catalog's order. `place.state` is a two-letter US state code (or
+ * null), `place.country` a two-letter country code. A site that cannot serve the place is left out, never guessed.
+ */
+function linksFor(place, { kind = 'apartment', maxPrice = null, minBeds = null } = {}) {
+ if (!place || !place.city) return [];
+ const k = KINDS.includes(kind) ? kind : 'apartment';
+ const inUs = (place.country || 'us') === 'us' && /^[A-Z]{2}$/.test(String(place.state || ''));
+ const f = { kind: k, maxPrice: positiveInt(maxPrice, 100000), minBeds: positiveInt(minBeds, 10) };
+ const out = [];
+ for (const site of SITES) {
+ if (!site.kinds.includes(k)) continue;
+ if (site.us && !inUs) continue;
+ let url = null;
+ try { url = site.build(place, f); } catch { url = null; }
+ if (!url || !/^https:\/\//.test(url)) continue;
+ out.push({ id: site.id, name: site.name, url, best: site.best });
+ }
+ return out;
+}
+
+function positiveInt(v, max) {
+ const n = Number(v);
+ return Number.isInteger(n) && n > 0 && n <= max ? n : null;
+}
+
+module.exports = { KINDS, KIND_TEXT, SITES, US_STATE_NAMES, linksFor, slug, titleSlug };
@@ -314,6 +314,30 @@ function elementsToResults(features, def, origin, limit) {
@@ -314,6 +314,30 @@ function elementsToResults(features, def, origin, limit) {
return limit ? out.slice(0, limit) : out;
return limit ? out.slice(0, limit) : out;
}
}
+/**
+ * A place name → one geocoded place: name, coordinates, country code, city and two-letter US state code, the postcode
+ * and the country's name (Nominatim, cached by the upstream). Shared by the affordable-housing search and the
+ * search-everywhere page. Throws housing.place.not_found when nothing matches.
+ */
+async function geocodePlace(upstream, q) {
+ const g = await upstream.geocode(q, { limit: 1 });
+ const hit = Array.isArray(g.body) ? g.body[0] : null;
+ if (!hit) throw new UpstreamError('housing.place.not_found', `no place called “${String(q).slice(0, 80)}”; try a city and state, or a ZIP code`, { status: 404 });
+ const la = Number(hit.lat);
+ const lo = Number(hit.lon);
+ if (!Number.isFinite(la) || !Number.isFinite(lo)) throw new UpstreamError('housing.place.upstream', 'could not reach the place service right now (no coordinates for that place)');
+ const country = hit.address && (hit.address.country_code || hit.address.country)
+ ? String(hit.address.country_code || hit.address.country).toLowerCase() : null;
+ // The two pieces the FHFA House Price Index lookup matches on (server/housing/hpi.js): a city and the
+ // two-letter state code. Nominatim (addressdetails=1) gives a US state as "ISO3166-2-lvl4": "US-CO" and
+ // "state": "Colorado"; there is no state_code field (Rent#30 read one, so production never matched).
+ const addr = hit.address || {};
+ const city = str(addr.city) || str(addr.town) || str(addr.village) || str(addr.hamlet) || str(addr.municipality)
+ || str(addr.county) || str(addr.suburb) || str(addr.city_district);
+ const state = usStateCode(addr);
+ return { name: hit.display_name || String(q), lat: la, lon: lo, from: 'geocode', geocoded_at: g.fetched_at, country_code: country, bbox: parseBbox(hit.boundingbox), city, state, postcode: str(addr.postcode), country_name: str(addr.country) };
+}
+
/**
/**
* The place search behind both the API and the pages.
* The place search behind both the API and the pages.
*
*
@@ -333,22 +357,7 @@ async function searchPlaces(upstream, { q = null, kinds = null } = {}, config) {
@@ -333,22 +357,7 @@ async function searchPlaces(upstream, { q = null, kinds = null } = {}, config) {
let place = null;
let place = null;
if (q && String(q).trim()) {
if (q && String(q).trim()) {
- const g = await upstream.geocode(q, { limit: 1 });
- const hit = Array.isArray(g.body) ? g.body[0] : null;
- if (!hit) throw new UpstreamError('housing.place.not_found', `no place called “${String(q).slice(0, 80)}”; try a city and state, or a ZIP code`, { status: 404 });
- const la = Number(hit.lat);
- const lo = Number(hit.lon);
- if (!Number.isFinite(la) || !Number.isFinite(lo)) throw new UpstreamError('housing.place.upstream', 'could not reach the place service right now (no coordinates for that place)');
- const country = hit.address && (hit.address.country_code || hit.address.country)
- ? String(hit.address.country_code || hit.address.country).toLowerCase() : null;
- // The two pieces the FHFA House Price Index lookup matches on (server/housing/hpi.js): a city and the
- // two-letter state code. Nominatim (addressdetails=1) gives a US state as "ISO3166-2-lvl4": "US-CO" and
- // "state": "Colorado"; there is no state_code field (Rent#30 read one, so production never matched).
- const addr = hit.address || {};
- const city = str(addr.city) || str(addr.town) || str(addr.village) || str(addr.hamlet) || str(addr.municipality)
- || str(addr.county) || str(addr.suburb) || str(addr.city_district);
- const state = usStateCode(addr);
- place = { name: hit.display_name || String(q), lat: la, lon: lo, from: 'geocode', geocoded_at: g.fetched_at, country_code: country, bbox: parseBbox(hit.boundingbox), city, state };
+ place = await geocodePlace(upstream, q);
} else {
} else {
throw new UpstreamError('housing.place.invalid', 'q: a place to search for', { status: 422 });
throw new UpstreamError('housing.place.invalid', 'q: a place to search for', { status: 422 });
}
}
@@ -392,6 +401,6 @@ async function searchPlaces(upstream, { q = null, kinds = null } = {}, config) {
@@ -392,6 +401,6 @@ async function searchPlaces(upstream, { q = null, kinds = null } = {}, config) {
}
}
module.exports = {
module.exports = {
- searchPlaces, haversineKm, envelopeFor, parseBbox, normalizeFeature, toResult, elementsToResults, safeUrl,
+ searchPlaces, geocodePlace, haversineKm, envelopeFor, parseBbox, normalizeFeature, toResult, elementsToResults, safeUrl,
LAYERS, KINDS, KIND_TEXT, KIND_DESCRIPTIONS, ATTRIBUTION, SOURCE, UpstreamError,
LAYERS, KINDS, KIND_TEXT, KIND_DESCRIPTIONS, ATTRIBUTION, SOURCE, UpstreamError,
};
};
@@ -38,6 +38,7 @@ const service = require('../listings/service');
@@ -38,6 +38,7 @@ const service = require('../listings/service');
const { isStaff } = require('../listings/staff');
const { isStaff } = require('../listings/staff');
const housingPlaces = require('../housing/places');
const housingPlaces = require('../housing/places');
const housingHpi = require('../housing/hpi');
const housingHpi = require('../housing/hpi');
+const everywhere = require('../aggregate/everywhere');
/** The listing columns a caller may set, as the validate() input names. */
/** The listing columns a caller may set, as the validate() input names. */
const INPUT_KEYS = ['kind', 'title', 'description', 'price', 'currency', 'period', 'city', 'region', 'country', 'neighbourhood', 'bedrooms'];
const INPUT_KEYS = ['kind', 'title', 'description', 'price', 'currency', 'period', 'city', 'region', 'country', 'neighbourhood', 'bedrooms'];
@@ -57,6 +58,27 @@ function createApi(ctx) {
@@ -57,6 +58,27 @@ function createApi(ctx) {
r.get('/ping', limits.reads('rent.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
r.get('/ping', limits.reads('rent.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
// ── Affordable housing near you (public) ─────────────────
// ── Affordable housing near you (public) ─────────────────
+ // Search everywhere (server/aggregate/everywhere.js): one place and filters → every rental site's own search for it.
+ // No token; it geocodes, so it takes the same per-address budget as /housing.
+ r.get('/everywhere', limits.budget('rent.housing.search'), async (req, res) => {
+ const one = (v) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, 200) : null);
+ const kind = one(req.query.kind) || 'apartment';
+ if (!everywhere.KINDS.includes(kind)) return problem(req, res, 422, 'rent.everywhere.invalid', `kind: one of ${everywhere.KINDS.join(', ')}`);
+ if (!one(req.query.q)) return problem(req, res, 422, 'rent.everywhere.invalid', 'q: a place to search, such as a city and state or a ZIP code');
+ const place = await housingPlaces.geocodePlace(ctx.upstream, one(req.query.q)).catch((err) => {
+ if (err && err.code) { problem(req, res, err.status || 502, err.code, err.message); return null; }
+ throw err;
+ });
+ if (!place) return undefined;
+ const sites = everywhere.linksFor({ city: place.city, state: place.state, country: place.country_code, postcode: place.postcode, lat: place.lat, lon: place.lon, countryName: place.country_name },
+ { kind, maxPrice: one(req.query.max_price), minBeds: one(req.query.min_beds) });
+ res.set('Cache-Control', 'public, max-age=300');
+ return res.json({
+ place: { name: place.name, city: place.city, state: place.state, country: place.country_code, lat: place.lat, lon: place.lon },
+ kind, sites,
+ });
+ });
+
// No token: HUD's open data is the same for everyone. It spends Nominatim's shared one-request-a-second budget, so
// No token: HUD's open data is the same for everyone. It spends Nominatim's shared one-request-a-second budget, so
// it takes the per-address budget in ./caller-limits.js rather than the default read numbers.
// it takes the per-address budget in ./caller-limits.js rather than the default read numbers.
r.get('/housing', limits.budget('rent.housing.search'), async (req, res) => {
r.get('/housing', limits.budget('rent.housing.search'), async (req, res) => {
@@ -48,6 +48,7 @@ const listingText = (row) => `${listings.KIND_TEXT[row.kind] || row.kind} in ${[
@@ -48,6 +48,7 @@ const listingText = (row) => `${listings.KIND_TEXT[row.kind] || row.kind} in ${[
async function publicPages(s) {
async function publicPages(s) {
const static_ = [
const static_ = [
{ path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Rent home', text: 'One place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. Listings are posted by people; OpenVibe handles no payments and no deposits.' },
{ path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Rent home', text: 'One place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. Listings are posted by people; OpenVibe handles no payments and no deposits.' },
+ { path: '/everywhere', changefreq: 'weekly', priority: 0.9, title: 'Search every rental site', text: 'One search across Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com and more: apartments, houses, rooms, furnished stays, commercial space, parking, storage and cars.' },
{ path: '/housing', changefreq: 'daily', priority: 0.8, title: 'Affordable housing near you', text: 'Public housing, tax-credit (LIHTC) and HUD-assisted properties, and the authorities and counselling agencies to apply through, near a place — from U.S. Department of Housing and Urban Development open data (public domain).' },
{ path: '/housing', changefreq: 'daily', priority: 0.8, title: 'Affordable housing near you', text: 'Public housing, tax-credit (LIHTC) and HUD-assisted properties, and the authorities and counselling agencies to apply through, near a place — from U.S. Department of Housing and Urban Development open data (public domain).' },
{ path: '/listings', changefreq: 'hourly', priority: 0.9, title: 'Listings for rent', text: 'Search every active listing by kind, city, country, price and period, newest first.' },
{ path: '/listings', changefreq: 'hourly', priority: 0.9, title: 'Listings for rent', text: 'Search every active listing by kind, city, country, price and period, newest first.' },
{ path: '/safety', changefreq: 'monthly', priority: 0.6, title: 'Renting safely', text: 'Never pay or send a deposit before you have seen the place and signed an agreement. OpenVibe does not handle payments for listings.' },
{ path: '/safety', changefreq: 'monthly', priority: 0.6, title: 'Renting safely', text: 'Never pay or send a deposit before you have seen the place and signed an agreement. OpenVibe does not handle payments for listings.' },
@@ -83,6 +84,7 @@ function createDiscoveryRoutes(ctx) {
@@ -83,6 +84,7 @@ function createDiscoveryRoutes(ctx) {
sections: [
sections: [
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Rent', url: abs('/'), note: 'find something to rent, or offer your own' },
{ title: 'OpenVibe.Rent', url: abs('/'), note: 'find something to rent, or offer your own' },
+ { title: 'Search every rental site', url: abs('/everywhere'), note: 'one place and filters open Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper and more at once' },
{ title: 'Affordable housing', url: abs('/housing'), note: 'public housing, LIHTC and HUD-assisted properties, and where to apply, near you' },
{ title: 'Affordable housing', url: abs('/housing'), note: 'public housing, LIHTC and HUD-assisted properties, and where to apply, near you' },
{ title: 'Listings', url: abs('/listings'), note: 'search by kind, city, country, price and period' },
{ title: 'Listings', url: abs('/listings'), note: 'search by kind, city, country, price and period' },
{ title: 'Renting safely', url: abs('/safety'), note: 'the rules, including the one about deposits' },
{ title: 'Renting safely', url: abs('/safety'), note: 'the rules, including the one about deposits' },
@@ -90,6 +92,7 @@ function createDiscoveryRoutes(ctx) {
@@ -90,6 +92,7 @@ function createDiscoveryRoutes(ctx) {
] },
] },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
{ title: 'Listings (JSON)', url: abs('/api/v1/listings'), note: 'active listings, newest first, with facets' },
{ title: 'Listings (JSON)', url: abs('/api/v1/listings'), note: 'active listings, newest first, with facets' },
+ { title: 'Search every rental site (JSON)', url: abs('/api/v1/everywhere?q=salt+lake+city&kind=apartment'), note: 'the geocoded place and the search link on every rental site' },
{ title: 'Affordable housing (JSON)', url: abs('/api/v1/housing?q=washington'), note: 'the geocoded place and normalised HUD housing results near it' },
{ title: 'Affordable housing (JSON)', url: abs('/api/v1/housing?q=washington'), note: 'the geocoded place and normalised HUD housing results near it' },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
@@ -28,9 +28,10 @@ const service = require('../listings/service');
@@ -28,9 +28,10 @@ const service = require('../listings/service');
const { isStaff } = require('../listings/staff');
const { isStaff } = require('../listings/staff');
const housingPlaces = require('../housing/places');
const housingPlaces = require('../housing/places');
const housingHpi = require('../housing/hpi');
const housingHpi = require('../housing/hpi');
+const everywhere = require('../aggregate/everywhere');
const SITE_NAME = 'OpenVibe.Rent';
const SITE_NAME = 'OpenVibe.Rent';
-const TAGLINE = 'Find a place, or rent yours out.';
+const TAGLINE = 'Every rental site, one search.';
const HOME_LATEST = 6;
const HOME_LATEST = 6;
const STATE_KIND = { active: 'ok', hidden: 'warn', expired: '', removed: 'bad' };
const STATE_KIND = { active: 'ok', hidden: 'warn', expired: '', removed: 'bad' };
@@ -154,52 +155,125 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
@@ -154,52 +155,125 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
const latest = await store.search(s, listings.normalizeFilters({}), { limit: HOME_LATEST });
const latest = await store.search(s, listings.normalizeFilters({}), { limit: HOME_LATEST });
const hero = showcase.hero({
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
- title: 'Find somewhere to live, work or park.',
- accent: 'Posted by people.',
- lede: `${SITE_NAME} is one place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. In this first version people post the listings here themselves — nothing is scraped from another site, and nothing is paid for here.`,
- actions: signedIn(req)
- ? [{ label: 'Post a listing', href: '/post', primary: true }, { label: 'Your listings', href: '/mine' }]
- : [{ label: 'Browse listings', href: '/listings', primary: true }, { label: 'Sign in to post', href: '/auth/login?next=%2Fpost' }],
- note: 'No payments, no deposits, no messaging: a listing carries a way to make contact and nothing more.',
+ title: 'Every rental site.',
+ accent: 'One search.',
+ lede: `${SITE_NAME} searches Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com and a dozen more at once: apartments, houses, rooms, furnished stays, commercial space, parking, storage and cars. Next to them, affordable housing from HUD and listings people post here, all under one OpenVibe account.`,
+ actions: [
+ { label: 'Search every site', href: '/everywhere', primary: true },
+ { label: 'Affordable housing', href: '/housing' },
+ signedIn(req) ? { label: 'Post a listing', href: '/post' } : { label: 'Sign in to post', href: '/auth/login?next=%2Fpost' },
+ ],
+ note: 'Open source (AGPL-3.0). Listings, messages and payments stay on each site; OpenVibe never takes a deposit.',
+ aside: { html: String(everywhereForm({}, { big: true })) },
});
});
page(req, res, {
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
- description: 'Find somewhere to live, work or park, posted by people, and affordable housing near you from U.S. HUD open data. No payments, no scraping.',
+ description: 'Search Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper and more at once: apartments, houses, rooms, stays, commercial space, parking, storage and cars, plus affordable housing from HUD.',
jsonLd: homeJsonLd(config),
jsonLd: homeJsonLd(config),
styles: [showcase.STYLESHEET],
styles: [showcase.STYLESHEET],
body: html`${raw(hero)}
body: html`${raw(hero)}
+<section class="sc-sec" aria-labelledby="h-sites"><h2 id="h-sites">The sites it searches</h2>
+<p class="sc-lede">Type a place once. Each site opens its own search there, with your price and bedrooms where it takes them, so you see every site's newest listings without repeating yourself.</p>
+<ul class="ew-chips">${everywhere.SITES.map((site) => html`<li title="${site.best}">${site.name}</li>`)}</ul>
+<p class="small muted">The United States first; more countries next.</p></section>
+
<section class="sc-sec" aria-labelledby="h-housing"><h2 id="h-housing">Affordable housing near you</h2>
<section class="sc-sec" aria-labelledby="h-housing"><h2 id="h-housing">Affordable housing near you</h2>
-<p class="sc-lede">Public housing, tax-credit (LIHTC) and HUD-assisted properties, and the authorities and counselling agencies to apply through — from U.S. HUD open data, useful before anyone has posted here.</p>
+<p class="sc-lede">Public housing, tax-credit (LIHTC) and HUD-assisted properties, and the authorities and counselling agencies to apply through, from U.S. HUD open data.</p>
<p><a class="sc-btn sc-primary" href="/housing">Find housing near you</a></p></section>
<p><a class="sc-btn sc-primary" href="/housing">Find housing near you</a></p></section>
-<section class="sc-sec" aria-labelledby="h-search"><h2 id="h-search">Search the listings</h2>
-<p class="sc-lede">Straight to it, or <a href="/listings">browse everything</a>.</p>
-${searchForm({}, { big: true })}</section>
-
-<section class="sc-sec" aria-labelledby="h-latest"><h2 id="h-latest">Latest listings</h2>
-<p class="sc-lede">The newest active listings, posted by people on ${SITE_NAME}.</p>
-${listingList(latest.rows, { empty: 'No listings yet — yours could be the first.' })}
-<p><a class="sc-btn" href="/listings">Every listing</a> <a class="sc-btn" href="/post">Post a listing</a></p></section>
+<section class="sc-sec" aria-labelledby="h-latest"><h2 id="h-latest">Posted on ${SITE_NAME}</h2>
+<p class="sc-lede">Listings people post here themselves: apartments, rooms, houses, commercial space, parking and storage, and equipment. <a href="/listings">Browse them</a> or search them by place.</p>
+${listingList(latest.rows, { empty: 'Nobody has posted here yet: yours could be the first.' })}
+<p><a class="sc-btn" href="/listings">Every listing posted here</a> <a class="sc-btn" href="/post">Post a listing</a></p></section>
${raw(showcase.features({
${raw(showcase.features({
title: 'How it works',
title: 'How it works',
- lede: 'A listing here is somebody offering something, and the rules are the same for everyone.',
+ lede: 'One account for every rental search you make, wherever the listing lives.',
items: [
items: [
- { icon: 'ov:account', title: 'Posted by people', text: 'Every listing is posted by a signed-in person, who can edit, renew, hide or delete it. Nothing here is scraped from another site.' },
- { icon: 'ov:gauge', title: 'Safety first', text: 'Never pay or send a deposit before you have seen the place and signed an agreement. OpenVibe does not handle payments for listings.' },
- { icon: 'ov:page', title: 'Reported listings are checked', text: 'Three distinct reports hide a listing until OpenVibe staff look at it and restore or remove it.' },
- { icon: 'ov:db', title: 'Searches you can keep', text: 'Save your filters and see how many listings are new since you last looked. Alerts come later through OpenVibe.Watch.' },
- { icon: 'ov:deploy', title: 'No addresses', text: 'A listing names a city and a neighbourhood, never a street address: a house number with a street is refused.' },
+ { icon: 'ov:search', title: 'One search, every site', text: 'Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com, RentCafe, Apartment List, PadMapper, HotPads, Trulia, Realtor.com, Roomies, Furnished Finder, Airbnb, LoopNet, SpotHero, SpareFoot and Turo, opened at your place.', href: '/everywhere' },
+ { icon: 'ov:map', title: 'Affordable housing', text: 'Public housing, tax-credit and HUD-assisted homes near any U.S. place, with the agencies to apply through.', href: '/housing' },
+ { icon: 'ov:account', title: 'Post your own', text: 'Anyone signed in can list a place, a room, a parking spot or equipment here, and edit, renew or hide it at any time.', href: '/post' },
+ { icon: 'ov:bell', title: 'Alerts that keep looking', text: 'Save a search and get alerts through OpenVibe.Watch when something new matches.' },
+ { icon: 'ov:gauge', title: 'Safety first', text: 'Never pay or send a deposit before you have seen the place and signed an agreement. Reported listings are hidden until staff look.', href: '/safety' },
],
],
}))}
}))}
<section class="sc-sec" aria-labelledby="h-sources"><h2 id="h-sources">Where the listings come from</h2>
<section class="sc-sec" aria-labelledby="h-sources"><h2 id="h-sources">Where the listings come from</h2>
-<p class="sc-prose">Today: people, posting here themselves. There are no scraped listings and no partner feeds — other sources arrive only when their terms are verified, and each listing will then say where it came from and link back to it. <a href="/safety">The safety rules</a> are on every listing page.</p></section>
+<p class="sc-prose">From every major rental site, opened at your place and filters: the listings, the landlord and the payment stay on that site. From U.S. HUD open data, for affordable housing. And from people posting here. Partner feeds and licensed listing data join as they are signed, each listing labelled with its source. <a href="/safety">How to rent safely</a>.</p></section>
-${raw(showcase.cta({ title: 'Have something to rent out?', text: 'Post it in a minute: a description, a price, a period and where it is. You can edit, renew or hide it at any time.', actions: [{ label: 'Post a listing', href: '/post', primary: true }, { label: 'Safety rules', href: '/safety' }] }))}`,
+${raw(showcase.cta({ title: 'Have something to rent out?', text: 'Post it in a minute: a description, a price, a period and where it is. You can edit, renew or hide it at any time.', actions: [{ label: 'Post a listing', href: '/post', primary: true }, { label: 'Search every site', href: '/everywhere' }] }))}`,
});
});
});
});
+ // ── Search everywhere: every rental site's own search for one place ──
+ // What each search-everywhere kind is called on this site's own listings (null: no listing kind matches).
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, 200) : null);
+ const OWN_KIND = { apartment: 'apartment', house: 'house', room: 'room', stay: null, commercial: 'commercial', parking: 'parking', storage: 'parking', vehicle: 'equipment' };
+ const HUD_KINDS = new Set(['apartment', 'house', 'room']);
+ const everywhereForm = (v = {}, { big = false } = {}) => html`<form class="search-form everywhere-form${big ? ' everywhere-big' : ''}" method="get" action="/everywhere" role="search">
+<div class="field"><label for="ew-q">Where</label><input id="ew-q" name="q" type="search" required maxlength="200" placeholder="A city and state, or a ZIP code" value="${v.q || ''}" autocomplete="address-level2"></div>
+<div class="field-row">
+<div class="field"><label for="ew-kind">Looking for</label><select id="ew-kind" name="kind">${everywhere.KINDS.map((k) => html`<option value="${k}"${k === (v.kind || 'apartment') ? raw(' selected') : ''}>${everywhere.KIND_TEXT[k]}</option>`)}</select></div>
+<div class="field"><label for="ew-max">Up to, a month</label><input id="ew-max" name="max" type="number" min="1" max="100000" step="1" inputmode="numeric" placeholder="Any price" value="${v.max || ''}"></div>
+<div class="field"><label for="ew-beds">Bedrooms</label><select id="ew-beds" name="beds"><option value="">Any</option>${[1, 2, 3, 4].map((n) => html`<option value="${n}"${String(n) === String(v.beds || '') ? raw(' selected') : ''}>${n}+</option>`)}</select></div>
+</div>
+<p><button class="sc-btn sc-primary" type="submit">Search every site</button></p>
+</form>`;
+
+ async function everywherePage(req, res, { limited = false } = {}) {
+ const v = { q: str(req.query.q), kind: everywhere.KINDS.includes(String(req.query.kind)) ? String(req.query.kind) : 'apartment', max: str(req.query.max), beds: str(req.query.beds) };
+ let place = null;
+ let problem = null;
+ if (limited) problem = 'Too many searches from you just now. Try again in a minute.';
+ else if (v.q) {
+ try { place = await housingPlaces.geocodePlace(ctx.upstream, v.q); } catch (err) {
+ problem = err && err.code === 'housing.place.not_found' ? err.message : 'The place search is unavailable right now. Try again in a few minutes.';
+ }
+ }
+ const label = place ? [place.city, place.state || (place.country_name || '')].filter(Boolean).join(', ') : null;
+ const links = place ? everywhere.linksFor({ city: place.city, state: place.state, country: place.country_code, postcode: place.postcode, lat: place.lat, lon: place.lon, countryName: place.country_name }, { kind: v.kind, maxPrice: v.max, minBeds: v.beds }) : [];
+ const own = place && OWN_KIND[v.kind] ? `/listings?${listings.filtersToQuery({ kind: OWN_KIND[v.kind], city: place.city, max: v.max ? Number(v.max) || null : null })}` : null;
+ const tab = (k) => `/everywhere?${new URLSearchParams({ q: v.q || '', kind: k, ...(v.max ? { max: v.max } : {}), ...(v.beds ? { beds: v.beds } : {}) })}`;
+ const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } };
+ page(req, res, {
+ title: label ? `${everywhere.KIND_TEXT[v.kind]} near ${label}` : 'Search every rental site',
+ description: 'One search across Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com and more: apartments, houses, rooms, furnished stays, commercial space, parking, storage and cars.',
+ index: !v.q, cache: v.q ? null : PUBLIC_CACHE,
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Search everywhere' }],
+ styles: [showcase.STYLESHEET], localStyles: ['css/everywhere.css'],
+ body: html`<h1>${label ? html`${everywhere.KIND_TEXT[v.kind]} near ${label}` : 'Search every rental site'}</h1>
+<p class="sc-lede">One search, every site: ${SITE_NAME} opens Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper and more at the place and filters you choose, next to the listings people post here and affordable housing from HUD.</p>
+${everywhereForm(v)}
+${problem ? notice(problem, 'warn') : ''}
+${place ? html`<nav class="everywhere-kinds" aria-label="What to look for">${everywhere.KINDS.map((k) => (k === v.kind ? html`<span class="ew-kind on" aria-current="page">${everywhere.KIND_TEXT[k]}</span>` : html`<a class="ew-kind" href="${tab(k)}">${everywhere.KIND_TEXT[k]}</a>`))}</nav>
+<section class="sc-sec" aria-labelledby="h-sites"><h2 id="h-sites">On every site</h2>
+${links.length ? html`<ul class="everywhere-sites">${links.map((l) => html`<li class="ew-site"><a class="ew-open" href="${l.url}" target="_blank" rel="noopener nofollow"><span class="ew-name">${l.name}</span><span class="ew-host">${hostOf(l.url)} ↗</span></a><p class="ew-best">${l.best}</p></li>`)}</ul>
+<p class="small muted">Each link opens that site's own search for this place in a new tab, with your filters where the site takes them. Listings, messages and payments stay on that site: never pay a deposit before you have seen a place (<a href="/safety">the safety rules</a>).</p>`
+ : html`<p class="muted">No site in the list covers this place and kind yet. The list starts with the United States; more countries come next.</p>`}
+</section>
+<section class="sc-sec" aria-labelledby="h-here"><h2 id="h-here">On ${SITE_NAME}</h2><ul class="everywhere-here">
+${own ? html`<li><a href="${own}">Listings people posted here near ${place.city}</a></li>` : ''}
+${HUD_KINDS.has(v.kind) && (place.country_code || 'us') === 'us' ? html`<li><a href="/housing?${new URLSearchParams({ q: v.q })}">Affordable housing near ${place.city}</a>: public housing, tax-credit and HUD-assisted homes</li>` : ''}
+<li><a href="/post">Post a listing</a> of your own</li></ul></section>` : ''}`,
+ }, limited ? 429 : 200);
+ }
+
+ // The geocode spends the same per-caller budget as /housing and the API; over it, the page says so (429).
+ const everywhereSearch = ctx.limits.budget('rent.housing.search');
+ r.get('/everywhere', (req, res) => {
+ if (!str(req.query.q)) return everywherePage(req, res);
+ let settled = false;
+ let retryAfter = '60';
+ const allowed = () => { if (!settled) { settled = true; everywherePage(req, res); } };
+ const refused = () => {
+ if (settled) return;
+ settled = true;
+ res.set('Retry-After', retryAfter);
+ everywherePage(req, res, { limited: true });
+ };
+ everywhereSearch(req, { statusCode: 200, setHeader: (k, val) => { if (String(k).toLowerCase() === 'retry-after') retryAfter = String(val); }, end: refused }, allowed);
+ });
+
// ── Affordable housing near you ──────────────────────────
// ── Affordable housing near you ──────────────────────────
// HUD's data is public domain, but the licence still asks for credit; every /housing page carries it, linked back.
// HUD's data is public domain, but the licence still asks for credit; every /housing page carries it, linked back.
const hudNote = (fetchedAt) => html`<p class="attribution small muted"><a href="${housingPlaces.ATTRIBUTION.url}" rel="noopener">${housingPlaces.ATTRIBUTION.text}</a> · <a href="${housingHpi.ATTRIBUTION.url}" rel="noopener">${housingHpi.ATTRIBUTION.text}</a>. The housing data covers the United States, and availability must be confirmed with the property or authority.${fetchedAt ? html` Fetched ${time(fetchedAt)}.` : ''}</p>`;
const hudNote = (fetchedAt) => html`<p class="attribution small muted"><a href="${housingPlaces.ATTRIBUTION.url}" rel="noopener">${housingPlaces.ATTRIBUTION.text}</a> · <a href="${housingHpi.ATTRIBUTION.url}" rel="noopener">${housingHpi.ATTRIBUTION.text}</a>. The housing data covers the United States, and availability must be confirmed with the property or authority.${fetchedAt ? html` Fetched ${time(fetchedAt)}.` : ''}</p>`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -0,0 +1,134 @@
+'use strict';
+/**
+ * Search everywhere (server/aggregate/everywhere.js, /everywhere, /api/v1/everywhere): one place and filters open every
+ * rental site's own search there, with the URL forms checked in a real browser on 2026-10-15; a site that cannot serve
+ * the place is left out; the page and the API answer with the geocoded place; the home is the aggregator the owner
+ * asked for and no longer says nothing is scraped.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const everywhere = require('../server/aggregate/everywhere');
+
+const SLC = { city: 'Salt Lake City', state: 'UT', country: 'us', postcode: '84111', lat: 40.7608, lon: -111.891, countryName: 'United States' };
+const urls = (links) => Object.fromEntries(links.map((l) => [l.id, l.url]));
+const ids = (links) => links.map((l) => l.id);
+
+const nominatimSlc = [{
+ lat: '40.7596198', lon: '-111.8867975', display_name: 'Salt Lake City, Salt Lake County, Utah, 84111, United States',
+ boundingbox: ['40.6996', '40.8533', '-112.1015', '-111.7393'],
+ address: { city: 'Salt Lake City', county: 'Salt Lake County', state: 'Utah', 'ISO3166-2-lvl4': 'US-UT', postcode: '84111', country: 'United States', country_code: 'us' },
+}];
+const nominatimToronto = [{
+ lat: '43.6534817', lon: '-79.3839347', display_name: 'Toronto, Ontario, Canada', boundingbox: ['43.58', '43.86', '-79.64', '-79.11'],
+ address: { city: 'Toronto', state: 'Ontario', 'ISO3166-2-lvl4': 'CA-ON', country: 'Canada', country_code: 'ca' },
+}];
+const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'Content-Type': 'application/json' } });
+const housingFetchImpl = async (url) => {
+ const u = new URL(String(url));
+ if (u.pathname.endsWith('/search')) return json(/toronto/i.test(u.searchParams.get('q') || '') ? nominatimToronto : nominatimSlc);
+ return json({ features: [] });
+};
+
+(async () => {
+ await check('apartments in a multi-word US city: every site, in order, at the place and with the filters each takes', () => {
+ const links = everywhere.linksFor(SLC, { kind: 'apartment', maxPrice: 1500, minBeds: 2 });
+ assert.deepStrictEqual(ids(links), ['craigslist', 'facebook', 'zillow', 'apartments', 'zumper', 'rentcom', 'rentcafe', 'apartmentlist', 'padmapper', 'hotpads', 'trulia', 'realtor']);
+ const u = urls(links);
+ assert.strictEqual(u.craigslist, 'https://www.craigslist.org/search/city/salt-lake-city-ut?cat=apa&max_price=1500&min_bedrooms=2&postal=84111&radius=10');
+ assert.strictEqual(u.facebook, 'https://www.facebook.com/marketplace/saltlakecity/propertyrentals');
+ assert.strictEqual(u.zillow, 'https://www.zillow.com/salt-lake-city-ut/rentals/');
+ assert.strictEqual(u.apartments, 'https://www.apartments.com/salt-lake-city-ut/2-bedrooms-under-1500/');
+ assert.strictEqual(u.zumper, 'https://www.zumper.com/apartments-for-rent/salt-lake-city-ut');
+ assert.strictEqual(u.rentcom, 'https://www.rent.com/utah/salt-lake-city-apartments');
+ assert.strictEqual(u.rentcafe, 'https://www.rentcafe.com/apartments-for-rent/salt-lake-city-ut/');
+ assert.strictEqual(u.apartmentlist, 'https://www.apartmentlist.com/ut/salt-lake-city');
+ assert.strictEqual(u.padmapper, 'https://www.padmapper.com/apartments/salt-lake-city-ut');
+ assert.strictEqual(u.hotpads, 'https://hotpads.com/salt-lake-city-ut/apartments-for-rent');
+ assert.strictEqual(u.trulia, 'https://www.trulia.com/for_rent/Salt_Lake_City,UT/');
+ assert.strictEqual(u.realtor, 'https://www.realtor.com/apartments/Salt-Lake-City_UT');
+ assert.ok(links.every((l) => l.best && l.name), 'every link says what the site is best for');
+ });
+
+ await check('each kind opens the sites that carry it', () => {
+ const u = (kind) => urls(everywhere.linksFor(SLC, { kind }));
+ assert.deepStrictEqual(Object.keys(u('house')), ['craigslist', 'facebook', 'zillow', 'zumper', 'rentcom', 'padmapper', 'hotpads', 'trulia', 'realtor']);
+ assert.strictEqual(u('house').zillow, 'https://www.zillow.com/salt-lake-city-ut/rent-houses/');
+ assert.strictEqual(u('house').zumper, 'https://www.zumper.com/houses-for-rent/salt-lake-city-ut');
+ assert.strictEqual(u('house').rentcom, 'https://www.rent.com/utah/salt-lake-city-houses');
+ assert.deepStrictEqual(Object.keys(u('room')), ['craigslist', 'facebook', 'zumper', 'roomies', 'furnishedfinder']);
+ assert.match(u('room').craigslist, /\?cat=roo&postal=84111&radius=10$/);
+ assert.strictEqual(u('room').roomies, 'https://www.roomies.com/rooms/salt-lake-city-ut');
+ assert.deepStrictEqual(Object.keys(u('stay')), ['craigslist', 'furnishedfinder', 'airbnb']);
+ assert.match(u('stay').craigslist, /\?cat=sub&/);
+ assert.strictEqual(u('stay').airbnb, 'https://www.airbnb.com/s/Salt-Lake-City--UT/homes?monthly_stay=true');
+ assert.strictEqual(u('stay').furnishedfinder, 'https://www.furnishedfinder.com/housing/us--ut--salt-lake-city');
+ assert.deepStrictEqual(Object.keys(u('commercial')), ['craigslist', 'loopnet']);
+ assert.strictEqual(u('commercial').loopnet, 'https://www.loopnet.com/search/commercial-real-estate/salt-lake-city-ut/for-lease/');
+ assert.deepStrictEqual(Object.keys(u('parking')), ['craigslist', 'spothero']);
+ assert.strictEqual(u('parking').spothero, 'https://spothero.com/search?latitude=40.7608&longitude=-111.8910&kind=monthly');
+ assert.deepStrictEqual(Object.keys(u('storage')), ['craigslist', 'sparefoot']);
+ assert.strictEqual(u('storage').sparefoot, 'https://www.sparefoot.com/Salt-Lake-City-UT-self-storage.html');
+ assert.deepStrictEqual(Object.keys(u('vehicle')), ['turo']);
+ assert.strictEqual(u('vehicle').turo, 'https://turo.com/us/en/search?country=US&location=Salt+Lake+City%2C+UT');
+ });
+
+ await check('outside the US only the sites that work worldwide; nothing is guessed', () => {
+ const toronto = { city: 'Toronto', state: null, country: 'ca', countryName: 'Canada' };
+ assert.deepStrictEqual(ids(everywhere.linksFor(toronto, { kind: 'apartment' })), ['facebook']);
+ assert.deepStrictEqual(urls(everywhere.linksFor(toronto, { kind: 'stay' })), { airbnb: 'https://www.airbnb.com/s/Toronto--Canada/homes?monthly_stay=true' });
+ assert.deepStrictEqual(everywhere.linksFor({ city: null, state: 'UT' }, { kind: 'apartment' }), [], 'no city, no links');
+ });
+
+ await check('filters are bounded, an unknown kind is apartments, and every link is https', () => {
+ const u = urls(everywhere.linksFor(SLC, { kind: 'boats', maxPrice: 'abc', minBeds: 99 }));
+ assert.strictEqual(u.craigslist, 'https://www.craigslist.org/search/city/salt-lake-city-ut?cat=apa&postal=84111&radius=10');
+ assert.strictEqual(u.apartments, 'https://www.apartments.com/salt-lake-city-ut/');
+ for (const kind of everywhere.KINDS) for (const l of everywhere.linksFor(SLC, { kind })) assert.match(l.url, /^https:\/\/[a-z0-9.-]+\//, l.url);
+ });
+
+ const t = await boot({ housingFetchImpl });
+ try {
+ await check('/everywhere: the place, every site for it, the kinds, and what OpenVibe.Rent has there', async () => {
+ const r = await t.get('/everywhere?q=Salt+Lake+City&kind=apartment&max=1500&beds=2');
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.ok(r.text.includes('Apartments near Salt Lake City, UT'));
+ assert.ok(r.text.includes('href="https://www.craigslist.org/search/city/salt-lake-city-ut?cat=apa&max_price=1500&min_bedrooms=2&postal=84111&radius=10"'));
+ assert.ok(r.text.includes('href="https://www.apartments.com/salt-lake-city-ut/2-bedrooms-under-1500/"'));
+ assert.ok(r.text.includes('target="_blank" rel="noopener nofollow"'), 'opens in a new tab, no referrer games');
+ assert.ok(r.text.includes('href="/everywhere?q=Salt+Lake+City&kind=room&max=1500&beds=2"'), 'the other kinds keep the place and filters');
+ assert.ok(r.text.includes('href="/housing?q=Salt+Lake+City"'), 'affordable housing for the same place');
+ assert.ok(r.text.includes('/listings?kind=apartment&city=Salt+Lake+City&max=1500'), 'listings posted here');
+ assert.ok(r.text.includes('noindex'), 'a search result page is not indexed');
+ const blank = await t.get('/everywhere');
+ assert.strictEqual(blank.status, 200);
+ assert.ok(blank.text.includes('Search every rental site') && !blank.text.includes('noindex'));
+ });
+
+ await check('/everywhere outside the US: the worldwide sites, and no HUD link', async () => {
+ const r = await t.get('/everywhere?q=Toronto&kind=apartment');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('facebook.com/marketplace/toronto/propertyrentals'));
+ assert.ok(!r.text.includes('craigslist.org') && !r.text.includes('href="/housing?q='));
+ });
+
+ await check('/api/v1/everywhere answers the place and the sites; bad input is a 422 problem', async () => {
+ const r = await t.get('/api/v1/everywhere?q=Salt+Lake+City&kind=room');
+ assert.strictEqual(r.status, 200);
+ const b = r.json();
+ assert.deepStrictEqual([b.place.city, b.place.state, b.place.country, b.kind], ['Salt Lake City', 'UT', 'us', 'room']);
+ assert.deepStrictEqual(b.sites.map((x) => x.id), ['craigslist', 'facebook', 'zumper', 'roomies', 'furnishedfinder']);
+ assert.strictEqual((await t.get('/api/v1/everywhere?q=x&kind=boats')).status, 422);
+ assert.strictEqual((await t.get('/api/v1/everywhere?kind=room')).json().code, 'rent.everywhere.invalid');
+ });
+
+ await check('the home is the aggregator: one search over every site, and it no longer says nothing is scraped', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('Every rental site.') && r.text.includes('One search.'));
+ assert.ok(r.text.includes('action="/everywhere"'), 'the hero search goes everywhere');
+ for (const name of ['Craigslist', 'Facebook Marketplace', 'Zillow', 'Apartments.com', 'Zumper', 'Turo']) assert.ok(r.text.includes(name), name);
+ assert.ok(!/nothing (here )?is scraped|no scraped listings/i.test(r.text), 'the old "nothing is scraped" framing is gone');
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -16,16 +16,17 @@ const listings = require('../server/listings/listings');
@@ -16,16 +16,17 @@ const listings = require('../server/listings/listings');
const lee = t.network.addUser('lee');
const lee = t.network.addUser('lee');
const nia = t.network.addUser('nia');
const nia = t.network.addUser('nia');
- await check('the home page: a search box, the latest listings, how it works, and no payments said plainly', async () => {
+ await check('the home page: a search over every rental site, the latest listings posted here, how it works, and no deposits said plainly', async () => {
const made = await post(t, kim, { title: 'A flat on the home page' });
const made = await post(t, kim, { title: 'A flat on the home page' });
assert.strictEqual(made.status, 201, made.text);
assert.strictEqual(made.status, 201, made.text);
const r = await t.get('/');
const r = await t.get('/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.match(r.text, /action="\/listings"/, 'the search box posts to /listings');
+ assert.match(r.text, /action="\/everywhere"/, 'the search box searches every rental site');
+ assert.match(r.text, /href="\/listings"/, 'the listings posted here are one click away');
assert.ok(r.text.includes('A flat on the home page'), 'the latest listings are on the home page');
assert.ok(r.text.includes('A flat on the home page'), 'the latest listings are on the home page');
assert.match(r.text, /How it works/);
assert.match(r.text, /How it works/);
- assert.ok(r.text.includes('OpenVibe does not handle payments for listings'), 'the no-payments rule');
- assert.match(r.text, /no scraped listings and no partner feeds/i, 'the sources-to-come statement');
+ assert.ok(r.text.includes('Never pay or send a deposit before you have seen the place'), 'the no-deposit rule');
+ assert.match(r.text, /Where the listings come from/, 'where the listings come from');
assert.match(r.text, /href="\/post"/);
assert.match(r.text, /href="\/post"/);
assert.match(r.text, /href="\/safety"/);
assert.match(r.text, /href="\/safety"/);
});
});