openvibers/openvibe.rent Commits
Rent: city pages are OpenVibe.Search documents (type city, contracts 0.197.0) (#65)
Changes
@@@ -16,7 +16,8 @@ state's largest (885, all 50 states, DC and Puerto Rico) with the median gross rof error, every rental site's search for that city, affordable housing, homes for sale and nearby cities; state pagesand an index; and **Is this rent fair?**, which places a quoted rent against the city's median for that size (within themargin 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 theCensus 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@@@ -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;}@@@ -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();
@@ -16,7 +16,8 @@ state's largest (885, all 50 states, DC and Puerto Rico) with the median gross r
@@ -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
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
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
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.
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
**OpenVibe.Homes** (since 2026-10-15, https://openvibe.homes): the buying side, served by this process for its own host
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createSearchFeed } = require('openvibe-publishing/search-feed');
const { createSearchFeed } = require('openvibe-publishing/search-feed');
const listings = require('./listings/listings');
const listings = require('./listings/listings');
+const cities = require('./aggregate/cities');
const EVENT_TYPES = ['rent.index_document.upserted', 'rent.index_document.deleted'];
const EVENT_TYPES = ['rent.index_document.upserted', 'rent.index_document.deleted'];
const FLUSH_MS = 250;
const FLUSH_MS = 250;
@@ -47,6 +48,29 @@ function describe(row, nowMs) {
@@ -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 }) {
function createSearchIndex({ config, s, log = console, outbox = null, indexnow = null }) {
const out = outbox || createServiceOutbox({
const out = outbox || createServiceOutbox({
db: s.db, source: 'rent', eventsUrl: config.events.url || null, networkInternalUrl: config.networkInternalUrl,
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 =
@@ -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]),
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),
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();
const pending = new Set();
@@ -104,6 +134,8 @@ function createSearchIndex({ config, s, log = console, outbox = null, indexnow =
@@ -104,6 +134,8 @@ function createSearchIndex({ config, s, log = console, outbox = null, indexnow =
const res = await feed.sweep();
const res = await feed.sweep();
const l = res.listing;
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}`);
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(() => {});
if (out.kick) out.kick().catch(() => {});
return res;
return res;
}
}
@@ -57,6 +57,20 @@ const DAY = 86_400_000;
@@ -57,6 +57,20 @@ const DAY = 86_400_000;
assert.deepStrictEqual(pinged, [`https://openvibe.rent/listings/${id}`, 'https://openvibe.rent/sitemap.xml']);
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 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 store.update(s, id, { title: 'Bright room near the river, bills included' });
await search.settle();
await search.settle();