openvibers/openvibe.rent Commits

Rent: city pages are OpenVibe.Search documents (type city, contracts 0.197.0) (#65)

OP OpenVibers committed 28 days ago · 220e59d63d · 3 files, +48 −1

Changes

ddiff --git a/README.md b/README.md
iindex 9e47c1e..3239f26 100644
---- a/README.md
++++ b/README.md
@@@ -16,7 +16,8 @@ state's largest (885, all 50 states, DC and Puerto Rico) with the median gross r
of error, every rental site's search for that city, affordable housing, homes for sale and nearby cities; state pages
and an index; and **Is this rent fair?**, which places a quoted rent against the city's median for that size (within the
margin is typical; far below warns of scams). A listing posted here in a listed US city shows the same line. All of them
--are in the sitemap and llms.txt. The data is `server/data/cities.json`, built by `node tools/build-cities.js <dir>` from the
++are in the sitemap and llms.txt, and each city page is an OpenVibe.Search document (type `city`, id `<state>-<slug>`,
++sent by the search sweep; contracts 0.197.0). The data is `server/data/cities.json`, built by `node tools/build-cities.js <dir>` from the
Census Bureau's keyless ACS 5-year table files and gazetteer (see the script for the URLs); nothing is fetched at run time.
**OpenVibe.Homes** (since 2026-10-15, https://openvibe.homes): the buying side, served by this process for its own host
ddiff --git a/server/search-index.js b/server/search-index.js
iindex 8462ef9..af12691 100644
---- a/server/search-index.js
++++ b/server/search-index.js
@@@ -19,6 +19,7 @@
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createSearchFeed } = require('openvibe-publishing/search-feed');
const listings = require('./listings/listings');
++const cities = require('./aggregate/cities');
const EVENT_TYPES = ['rent.index_document.upserted', 'rent.index_document.deleted'];
const FLUSH_MS = 250;
@@@ -47,6 +48,29 @@ function describe(row, nowMs) {
};
}
++/**
++ * Rent by city's pages (server/http/city-pages.js) as Search documents (type city, contracts 0.197.0): the typical
++ * rents by size, so a network search for "rent in Austin" finds the city page. The rows are the committed city data,
++ * in id order (<state>-<slug>); the sweep sends what changed and tombstones a city that left the data.
++ */
++const CITY_ROWS = cities.cities.map((c) => ({ ...c, id: `${c.state.toLowerCase()}-${c.slug}` })).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
++const CITY_IDS = new Set(CITY_ROWS.map((c) => c.id));
++function describeCity(c) {
++ const sizes = cities.SIZES.map((z) => (c.rent[z.key] != null ? `${z.text} ${cities.rentText(c.rent[z.key])}` : null)).filter(Boolean);
++ const where = `${c.name}, ${c.state}`;
++ return {
++ listed: true,
++ title: `Renting in ${where}`,
++ summary: `Typical rent in ${where}: ${sizes.slice(0, 4).join(' · ')}. Every rental site's search for ${c.name} in one place.`,
++ body: [`Renting in ${where}, ${c.stateName}.`, `Median gross rent by size (Census ACS ${cities.SOURCE.years}): ${sizes.join(', ')}.`,
++ `Every rental site's search for ${c.name}: Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper and more. Affordable housing, homes for sale and a fair-rent check.`].join('\n'),
++ facets: { state: c.state, population: c.population, rent_2br: c.rent.b2 },
++ authorship: { mode: 'human' },
++ publishedAt: cities.SOURCE.built ? `${cities.SOURCE.built}T00:00:00.000Z` : null,
++ updatedAt: cities.SOURCE.built ? `${cities.SOURCE.built}T00:00:00.000Z` : null,
++ };
++}
++
function createSearchIndex({ config, s, log = console, outbox = null, indexnow = null }) {
const out = outbox || createServiceOutbox({
db: s.db, source: 'rent', eventsUrl: config.events.url || null, networkInternalUrl: config.networkInternalUrl,
@@@ -61,6 +85,12 @@ function createSearchIndex({ config, s, log = console, outbox = null, indexnow =
rows: (after, limit) => s.db.many('SELECT * FROM rent_listings WHERE id > $1 ORDER BY id LIMIT $2', [after, limit]),
exists: async (ids) => (await s.db.many('SELECT id FROM rent_listings WHERE id = ANY($1)', [ids])).map((r) => r.id),
},
++ city: {
++ page: (c) => c.path,
++ document: (c) => describeCity(c),
++ rows: async (after, limit) => CITY_ROWS.filter((c) => c.id > after).slice(0, limit),
++ exists: async (ids) => ids.filter((id) => CITY_IDS.has(String(id))),
++ },
},
});
const pending = new Set();
@@@ -104,6 +134,8 @@ function createSearchIndex({ config, s, log = console, outbox = null, indexnow =
const res = await feed.sweep();
const l = res.listing;
if (l.sent || l.removed || l.failed) log.log(`[Search] listings: ${l.sent} sent, ${l.removed} removed, ${l.failed} failed of ${l.seen}`);
++ const c = res.city;
++ if (c && (c.sent || c.removed || c.failed)) log.log(`[Search] cities: ${c.sent} sent, ${c.removed} removed, ${c.failed} failed of ${c.seen}`);
if (out.kick) out.kick().catch(() => {});
return res;
}
ddiff --git a/test/search-index.test.js b/test/search-index.test.js
iindex 8a003a0..3dc7df1 100644
---- a/test/search-index.test.js
++++ b/test/search-index.test.js
@@@ -57,6 +57,20 @@ const DAY = 86_400_000;
assert.deepStrictEqual(pinged, [`https://openvibe.rent/listings/${id}`, 'https://openvibe.rent/sitemap.xml']);
});
++ await check('Rent by city: the first sweep sends a city document per city page, exactly the contract; the next sends none', async () => {
++ const before = (await outbox()).length;
++ const res = await search.sweep();
++ assert.ok(res.city.sent >= 850, JSON.stringify(res.city));
++ const cityEnvs = (await outbox()).slice(before).filter((e) => e.payload && e.payload.type === 'city');
++ assert.strictEqual(cityEnvs.length, res.city.sent);
++ const austin = valid(cityEnvs.find((e) => e.payload.id === 'tx-austin'));
++ assert.strictEqual(austin.payload.canonical_url, 'https://openvibe.rent/in/tx/austin');
++ assert.match(austin.payload.title, /^Renting in Austin, TX$/);
++ assert.match(austin.payload.summary, /2 bedrooms \$1,845/);
++ assert.deepStrictEqual([austin.payload.facets.state, austin.payload.facets.rent_2br], ['TX', 1845]);
++ assert.strictEqual((await search.sweep()).city.sent, 0, 'unchanged cities send nothing');
++ });
++
await check('an edit is the next revision; a sweep then sends nothing', async () => {
await store.update(s, id, { title: 'Bright room near the river, bills included' });
await search.settle();