openvibers/openvibe.rent Commits
Rent: /fair reads a listing's address and title (rent, bedrooms, city) when opened from it, and says what it read (#66)
Changes
@@@ -123,3 +123,43 @@ function fair({ city, beds, rent }) {}module.exports = { SOURCE: DATA.source, MIN_POPULATION: DATA.minPopulation, SIZES, STATE_NAMES, cities, find, inState, states, nearby, match, fair, rentText, valueText, stateCode, distanceKm };++++/**++ * What a rental listing's own address and title say, for /fair opened from the OpenVibe Companion ("Check the rent"):++ * the monthly rent (the first $ amount from $100 to $100,000), the bedrooms ("2br", "2 bd", "3 bedrooms", "studio")++ * and the city (a Craigslist city subdomain; "…-austin-tx…" in a Zillow, Apartments.com, Zumper or Trulia address;++ * "Austin, TX" in the title). Only what is plainly there: anything else is left for the person to fill in.++ * → { rent, beds, city, read: ['rent', 'beds', 'city'] (what was found) }++ */++function readListing({ url, title } = {}) {++ const t = String(title || '').replace(/\s+/g, ' ').slice(0, 300);++ const out = { rent: null, beds: null, city: null, read: [] };++ const money = /\$\s?(\d{1,3}(?:,\d{3})+|\d{3,6})(?:\.\d{2})?(?!\d)/.exec(t);++ if (money) { const n = Number(money[1].replace(/,/g, '')); if (n >= 100 && n <= 100000) { out.rent = n; out.read.push('rent'); } }++ const beds = /\b(\d{1,2})\s*(?:br|bd|bds|bed|beds|bedrooms?)\b/i.exec(t);++ if (beds && Number(beds[1]) <= 20) { out.beds = Number(beds[1]); out.read.push('beds'); } else if (/\bstudio\b/i.test(t)) { out.beds = 0; out.read.push('beds'); }++ let u = null;++ try { u = new URL(String(url || '')); } catch { u = null; }++ if (u && /^https?:$/.test(u.protocol)) {++ const host = u.hostname.toLowerCase();++ const cl = /^([a-z]+)\.craigslist\.org$/.exec(host);++ if (cl) out.city = cities.find((c) => c.slug.replace(/-/g, '') === cl[1]) || null;++ if (!out.city) {++ // "…-salt-lake-city-ut-84111…" or "…/the-bowie-austin-tx/…": the longest run of words before a state code that names a listed city.++ const words = decodeURIComponent(u.pathname).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);++ for (let i = words.length - 1; i > 0 && !out.city; i--) {++ const st = /^[a-z]{2}$/.test(words[i]) ? stateCode(words[i]) : null;++ if (!st) continue;++ for (let k = Math.min(4, i); k >= 1 && !out.city; k--) out.city = find(st, words.slice(i - k, i).join('-'));++ }++ }++ }++ if (!out.city) {++ const named = /([A-Z][A-Za-z.' -]{1,40}),\s*([A-Z]{2})\b/.exec(t);++ if (named) out.city = match(`${named[1].trim()}, ${named[2]}`);++ }++ if (out.city) out.read.push('city');++ return out;++}++++module.exports.readListing = readListing;@@@ -132,7 +132,16 @@ ${near.length ? html`<section class="sc-sec" aria-labelledby="h-near"><h2 id="h-let city = null;if (typeof req.query.city === 'string' && /^[a-z]{2}\/[a-z0-9-]{1,80}$/.test(req.query.city)) { const [st, sl] = req.query.city.split('/'); city = cities.find(st, sl); }if (!city && v.where) city = cities.match(v.where);-- const asked = Boolean(v.rent && (req.query.city || v.where));++ // Opened from a listing (the OpenVibe Companion's "Check the rent", or a link): what its address and title say++ // fills what the person did not type, and the page says which values were read so they can check them.++ let fromListing = null;++ if (typeof req.query.url === 'string' || typeof req.query.title === 'string') {++ fromListing = cities.readListing({ url: String(req.query.url || '').slice(0, 2000), title: String(req.query.title || '').slice(0, 300) });++ if (!v.rent && fromListing.rent) v.rent = String(fromListing.rent);++ if (v.beds == null && fromListing.beds != null) v.beds = String(fromListing.beds);++ if (!city && fromListing.city) city = fromListing.city;++ }++ const asked = Boolean(v.rent && (req.query.city || v.where || city));const result = asked && city ? cities.fair({ city, beds: v.beds, rent: v.rent }) : null;if (city && !v.where) v.where = placeLabel(city);const problem = asked && !city ? `No city of ${cities.MIN_POPULATION.toLocaleString('en-US')} people or more called “${v.where}” in the list. Try “City, ST”, or pick it from Rent by city.`@@@ -140,13 +149,14 @@ ${near.length ? html`<section class="sc-sec" aria-labelledby="h-near"><h2 id="h-page(req, res, {title: result ? `${result.size.text} at $${Number(v.rent).toLocaleString('en-US')} in ${placeLabel(city)}: is it fair?` : 'Is this rent fair?',description: 'Check a monthly rent from any site against the middle rent for that size in the city, from the Census Bureau\'s American Community Survey.',-- index: !asked, cache: asked ? null : PUBLIC,++ index: !asked && !fromListing, cache: asked || fromListing ? null : PUBLIC,crumbs: [{ label: 'Home', href: '/' }, { label: 'Rent by city', href: '/in' }, { label: 'Is this rent fair?' }],styles: [showcase.STYLESHEET], localStyles: STYLES,body: html`<h1>Is this rent fair?</h1><p class="sc-lede">Put in a price from Craigslist, Zillow or anywhere else: it is compared with the middle rent for that size in the city, from the Census Bureau.</p>++${fromListing ? html`<p class="notice" role="status">${fromListing.read.length ? html`Read from the listing: ${fromListing.read.map((k) => ({ rent: `the rent ($${Number(v.rent).toLocaleString('en-US')})`, beds: `${Number(v.beds) === 0 ? 'a studio' : `${v.beds} bedroom${Number(v.beds) === 1 ? '' : 's'}`}`, city: `the city (${city ? `${city.name}, ${city.state}` : ''})` }[k])).join(', ')}. Check them below${fromListing.read.length < 3 ? ' and fill in the rest' : ''}.` : 'The listing\'s page did not say its rent, size and city plainly: fill them in below.'}</p>` : ''}${result ? fairResult(city, result, Number(v.rent)) : ''}--${problem ? html`<p class="notice warn" role="status">${problem}</p>` : ''}++${problem && !(fromListing && !v.rent) ? html`<p class="notice warn" role="status">${problem}</p>` : ''}<section class="fair-card fair-page">${fairForm(v)}</section>${source()}`,});@@@ -50,6 +50,17 @@ const cities = require('../server/aggregate/cities');assert.strictEqual(cities.fair({ city: a, beds: 2, rent: -5 }), null);});++ await check('reading a listing: rent, bedrooms and city from Craigslist, Zillow and Apartments.com addresses and titles; nothing invented', () => {++ const cl = cities.readListing({ url: 'https://austin.craigslist.org/apa/d/austin-sunny/7712345.html', title: '$1,850 / 2br - 900ft2 - Sunny two bed near Zilker' });++ assert.deepStrictEqual([cl.rent, cl.beds, cl.city.path, cl.read], [1850, 2, '/in/tx/austin', ['rent', 'beds', 'city']]);++ assert.strictEqual(cities.readListing({ url: 'https://www.zillow.com/homedetails/123-Main-St-Salt-Lake-City-UT-84111/1_zpid/' }).city.path, '/in/ut/salt-lake-city');++ assert.strictEqual(cities.readListing({ url: 'https://www.apartments.com/the-bowie-austin-tx/7gd3w0p/' }).city.path, '/in/tx/austin');++ const studio = cities.readListing({ title: 'Studio for $1,200 in Denver, CO' });++ assert.deepStrictEqual([studio.rent, studio.beds, studio.city.path], [1200, 0, '/in/co/denver']);++ assert.deepStrictEqual(cities.readListing({ url: 'https://example.com/', title: 'Call for price, 12345 sq ft' }).read, [], 'no amount, size or city: nothing');++ assert.strictEqual(cities.readListing({ title: '$45 deposit' }).rent, null, 'under $100 is not a monthly rent');++ });++const t = await boot();const kim = t.network.addUser('kim');@@@ -87,6 +98,13 @@ const cities = require('../server/aggregate/cities');assert.ok(typed.text.includes('About typical'));const unknown = await t.get('/fair?where=Nowhereville&beds=1&rent=900');assert.ok(unknown.text.includes('No city of 50,000 people or more called'));++ const fromCl = await t.get(`/fair?${new URLSearchParams({ url: 'https://austin.craigslist.org/apa/d/x/1.html', title: '$1,200 / 2br - Sunny two bed' })}`);++ assert.ok(fromCl.text.includes('Read from the listing: the rent ($1,200), 2 bedrooms, the city (Austin, TX)') && fromCl.text.includes('Well below typical'), 'judged from the listing, saying what was read');++ assert.match(fromCl.text, /<meta name="robots" content="noindex, nofollow">/);++ const partial = await t.get(`/fair?${new URLSearchParams({ url: 'https://www.zillow.com/homedetails/1-Main-St-Austin-TX-78701/1_zpid/', title: 'A home' })}`);++ assert.ok(partial.text.includes('Read from the listing: the city (Austin, TX). Check them below and fill in the rest.') && !partial.text.includes('fair-result'));++ const nothing = await t.get(`/fair?${new URLSearchParams({ url: 'https://example.com/', title: 'Hello' })}`);++ assert.ok(nothing.text.includes('did not say its rent, size and city plainly'));const blank = await t.get('/fair');assert.match(blank.text, /<meta name="robots" content="index, follow">/);assert.ok(!blank.text.includes('fair-result'));
@@ -123,3 +123,43 @@ function fair({ city, beds, rent }) {
@@ -123,3 +123,43 @@ function fair({ city, beds, rent }) {
}
}
module.exports = { SOURCE: DATA.source, MIN_POPULATION: DATA.minPopulation, SIZES, STATE_NAMES, cities, find, inState, states, nearby, match, fair, rentText, valueText, stateCode, distanceKm };
module.exports = { SOURCE: DATA.source, MIN_POPULATION: DATA.minPopulation, SIZES, STATE_NAMES, cities, find, inState, states, nearby, match, fair, rentText, valueText, stateCode, distanceKm };
+
+/**
+ * What a rental listing's own address and title say, for /fair opened from the OpenVibe Companion ("Check the rent"):
+ * the monthly rent (the first $ amount from $100 to $100,000), the bedrooms ("2br", "2 bd", "3 bedrooms", "studio")
+ * and the city (a Craigslist city subdomain; "…-austin-tx…" in a Zillow, Apartments.com, Zumper or Trulia address;
+ * "Austin, TX" in the title). Only what is plainly there: anything else is left for the person to fill in.
+ * → { rent, beds, city, read: ['rent', 'beds', 'city'] (what was found) }
+ */
+function readListing({ url, title } = {}) {
+ const t = String(title || '').replace(/\s+/g, ' ').slice(0, 300);
+ const out = { rent: null, beds: null, city: null, read: [] };
+ const money = /\$\s?(\d{1,3}(?:,\d{3})+|\d{3,6})(?:\.\d{2})?(?!\d)/.exec(t);
+ if (money) { const n = Number(money[1].replace(/,/g, '')); if (n >= 100 && n <= 100000) { out.rent = n; out.read.push('rent'); } }
+ const beds = /\b(\d{1,2})\s*(?:br|bd|bds|bed|beds|bedrooms?)\b/i.exec(t);
+ if (beds && Number(beds[1]) <= 20) { out.beds = Number(beds[1]); out.read.push('beds'); } else if (/\bstudio\b/i.test(t)) { out.beds = 0; out.read.push('beds'); }
+ let u = null;
+ try { u = new URL(String(url || '')); } catch { u = null; }
+ if (u && /^https?:$/.test(u.protocol)) {
+ const host = u.hostname.toLowerCase();
+ const cl = /^([a-z]+)\.craigslist\.org$/.exec(host);
+ if (cl) out.city = cities.find((c) => c.slug.replace(/-/g, '') === cl[1]) || null;
+ if (!out.city) {
+ // "…-salt-lake-city-ut-84111…" or "…/the-bowie-austin-tx/…": the longest run of words before a state code that names a listed city.
+ const words = decodeURIComponent(u.pathname).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
+ for (let i = words.length - 1; i > 0 && !out.city; i--) {
+ const st = /^[a-z]{2}$/.test(words[i]) ? stateCode(words[i]) : null;
+ if (!st) continue;
+ for (let k = Math.min(4, i); k >= 1 && !out.city; k--) out.city = find(st, words.slice(i - k, i).join('-'));
+ }
+ }
+ }
+ if (!out.city) {
+ const named = /([A-Z][A-Za-z.' -]{1,40}),\s*([A-Z]{2})\b/.exec(t);
+ if (named) out.city = match(`${named[1].trim()}, ${named[2]}`);
+ }
+ if (out.city) out.read.push('city');
+ return out;
+}
+
+module.exports.readListing = readListing;
@@ -132,7 +132,16 @@ ${near.length ? html`<section class="sc-sec" aria-labelledby="h-near"><h2 id="h-
@@ -132,7 +132,16 @@ ${near.length ? html`<section class="sc-sec" aria-labelledby="h-near"><h2 id="h-
let city = null;
let city = null;
if (typeof req.query.city === 'string' && /^[a-z]{2}\/[a-z0-9-]{1,80}$/.test(req.query.city)) { const [st, sl] = req.query.city.split('/'); city = cities.find(st, sl); }
if (typeof req.query.city === 'string' && /^[a-z]{2}\/[a-z0-9-]{1,80}$/.test(req.query.city)) { const [st, sl] = req.query.city.split('/'); city = cities.find(st, sl); }
if (!city && v.where) city = cities.match(v.where);
if (!city && v.where) city = cities.match(v.where);
- const asked = Boolean(v.rent && (req.query.city || v.where));
+ // Opened from a listing (the OpenVibe Companion's "Check the rent", or a link): what its address and title say
+ // fills what the person did not type, and the page says which values were read so they can check them.
+ let fromListing = null;
+ if (typeof req.query.url === 'string' || typeof req.query.title === 'string') {
+ fromListing = cities.readListing({ url: String(req.query.url || '').slice(0, 2000), title: String(req.query.title || '').slice(0, 300) });
+ if (!v.rent && fromListing.rent) v.rent = String(fromListing.rent);
+ if (v.beds == null && fromListing.beds != null) v.beds = String(fromListing.beds);
+ if (!city && fromListing.city) city = fromListing.city;
+ }
+ const asked = Boolean(v.rent && (req.query.city || v.where || city));
const result = asked && city ? cities.fair({ city, beds: v.beds, rent: v.rent }) : null;
const result = asked && city ? cities.fair({ city, beds: v.beds, rent: v.rent }) : null;
if (city && !v.where) v.where = placeLabel(city);
if (city && !v.where) v.where = placeLabel(city);
const problem = asked && !city ? `No city of ${cities.MIN_POPULATION.toLocaleString('en-US')} people or more called “${v.where}” in the list. Try “City, ST”, or pick it from Rent by city.`
const problem = asked && !city ? `No city of ${cities.MIN_POPULATION.toLocaleString('en-US')} people or more called “${v.where}” in the list. Try “City, ST”, or pick it from Rent by city.`
@@ -140,13 +149,14 @@ ${near.length ? html`<section class="sc-sec" aria-labelledby="h-near"><h2 id="h-
@@ -140,13 +149,14 @@ ${near.length ? html`<section class="sc-sec" aria-labelledby="h-near"><h2 id="h-
page(req, res, {
page(req, res, {
title: result ? `${result.size.text} at $${Number(v.rent).toLocaleString('en-US')} in ${placeLabel(city)}: is it fair?` : 'Is this rent fair?',
title: result ? `${result.size.text} at $${Number(v.rent).toLocaleString('en-US')} in ${placeLabel(city)}: is it fair?` : 'Is this rent fair?',
description: 'Check a monthly rent from any site against the middle rent for that size in the city, from the Census Bureau\'s American Community Survey.',
description: 'Check a monthly rent from any site against the middle rent for that size in the city, from the Census Bureau\'s American Community Survey.',
- index: !asked, cache: asked ? null : PUBLIC,
+ index: !asked && !fromListing, cache: asked || fromListing ? null : PUBLIC,
crumbs: [{ label: 'Home', href: '/' }, { label: 'Rent by city', href: '/in' }, { label: 'Is this rent fair?' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Rent by city', href: '/in' }, { label: 'Is this rent fair?' }],
styles: [showcase.STYLESHEET], localStyles: STYLES,
styles: [showcase.STYLESHEET], localStyles: STYLES,
body: html`<h1>Is this rent fair?</h1>
body: html`<h1>Is this rent fair?</h1>
<p class="sc-lede">Put in a price from Craigslist, Zillow or anywhere else: it is compared with the middle rent for that size in the city, from the Census Bureau.</p>
<p class="sc-lede">Put in a price from Craigslist, Zillow or anywhere else: it is compared with the middle rent for that size in the city, from the Census Bureau.</p>
+${fromListing ? html`<p class="notice" role="status">${fromListing.read.length ? html`Read from the listing: ${fromListing.read.map((k) => ({ rent: `the rent ($${Number(v.rent).toLocaleString('en-US')})`, beds: `${Number(v.beds) === 0 ? 'a studio' : `${v.beds} bedroom${Number(v.beds) === 1 ? '' : 's'}`}`, city: `the city (${city ? `${city.name}, ${city.state}` : ''})` }[k])).join(', ')}. Check them below${fromListing.read.length < 3 ? ' and fill in the rest' : ''}.` : 'The listing\'s page did not say its rent, size and city plainly: fill them in below.'}</p>` : ''}
${result ? fairResult(city, result, Number(v.rent)) : ''}
${result ? fairResult(city, result, Number(v.rent)) : ''}
-${problem ? html`<p class="notice warn" role="status">${problem}</p>` : ''}
+${problem && !(fromListing && !v.rent) ? html`<p class="notice warn" role="status">${problem}</p>` : ''}
<section class="fair-card fair-page">${fairForm(v)}</section>
<section class="fair-card fair-page">${fairForm(v)}</section>
${source()}`,
${source()}`,
});
});
@@ -50,6 +50,17 @@ const cities = require('../server/aggregate/cities');
@@ -50,6 +50,17 @@ const cities = require('../server/aggregate/cities');
assert.strictEqual(cities.fair({ city: a, beds: 2, rent: -5 }), null);
assert.strictEqual(cities.fair({ city: a, beds: 2, rent: -5 }), null);
});
});
+ await check('reading a listing: rent, bedrooms and city from Craigslist, Zillow and Apartments.com addresses and titles; nothing invented', () => {
+ const cl = cities.readListing({ url: 'https://austin.craigslist.org/apa/d/austin-sunny/7712345.html', title: '$1,850 / 2br - 900ft2 - Sunny two bed near Zilker' });
+ assert.deepStrictEqual([cl.rent, cl.beds, cl.city.path, cl.read], [1850, 2, '/in/tx/austin', ['rent', 'beds', 'city']]);
+ assert.strictEqual(cities.readListing({ url: 'https://www.zillow.com/homedetails/123-Main-St-Salt-Lake-City-UT-84111/1_zpid/' }).city.path, '/in/ut/salt-lake-city');
+ assert.strictEqual(cities.readListing({ url: 'https://www.apartments.com/the-bowie-austin-tx/7gd3w0p/' }).city.path, '/in/tx/austin');
+ const studio = cities.readListing({ title: 'Studio for $1,200 in Denver, CO' });
+ assert.deepStrictEqual([studio.rent, studio.beds, studio.city.path], [1200, 0, '/in/co/denver']);
+ assert.deepStrictEqual(cities.readListing({ url: 'https://example.com/', title: 'Call for price, 12345 sq ft' }).read, [], 'no amount, size or city: nothing');
+ assert.strictEqual(cities.readListing({ title: '$45 deposit' }).rent, null, 'under $100 is not a monthly rent');
+ });
+
const t = await boot();
const t = await boot();
const kim = t.network.addUser('kim');
const kim = t.network.addUser('kim');
@@ -87,6 +98,13 @@ const cities = require('../server/aggregate/cities');
@@ -87,6 +98,13 @@ const cities = require('../server/aggregate/cities');
assert.ok(typed.text.includes('About typical'));
assert.ok(typed.text.includes('About typical'));
const unknown = await t.get('/fair?where=Nowhereville&beds=1&rent=900');
const unknown = await t.get('/fair?where=Nowhereville&beds=1&rent=900');
assert.ok(unknown.text.includes('No city of 50,000 people or more called'));
assert.ok(unknown.text.includes('No city of 50,000 people or more called'));
+ const fromCl = await t.get(`/fair?${new URLSearchParams({ url: 'https://austin.craigslist.org/apa/d/x/1.html', title: '$1,200 / 2br - Sunny two bed' })}`);
+ assert.ok(fromCl.text.includes('Read from the listing: the rent ($1,200), 2 bedrooms, the city (Austin, TX)') && fromCl.text.includes('Well below typical'), 'judged from the listing, saying what was read');
+ assert.match(fromCl.text, /<meta name="robots" content="noindex, nofollow">/);
+ const partial = await t.get(`/fair?${new URLSearchParams({ url: 'https://www.zillow.com/homedetails/1-Main-St-Austin-TX-78701/1_zpid/', title: 'A home' })}`);
+ assert.ok(partial.text.includes('Read from the listing: the city (Austin, TX). Check them below and fill in the rest.') && !partial.text.includes('fair-result'));
+ const nothing = await t.get(`/fair?${new URLSearchParams({ url: 'https://example.com/', title: 'Hello' })}`);
+ assert.ok(nothing.text.includes('did not say its rent, size and city plainly'));
const blank = await t.get('/fair');
const blank = await t.get('/fair');
assert.match(blank.text, /<meta name="robots" content="index, follow">/);
assert.match(blank.text, /<meta name="robots" content="index, follow">/);
assert.ok(!blank.text.includes('fair-result'));
assert.ok(!blank.text.includes('fair-result'));