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)

OP OpenVibers committed 28 days ago · 71c33519bf · 3 files, +71 −3

Changes

ddiff --git a/server/aggregate/cities.js b/server/aggregate/cities.js
iindex a716d55..888202b 100644
---- a/server/aggregate/cities.js
++++ b/server/aggregate/cities.js
@@@ -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;
ddiff --git a/server/http/city-pages.js b/server/http/city-pages.js
iindex fc2845b..ada148b 100644
---- a/server/http/city-pages.js
++++ b/server/http/city-pages.js
@@@ -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()}`,
});
ddiff --git a/test/cities.test.js b/test/cities.test.js
iindex c6a572d..90de333 100644
---- a/test/cities.test.js
++++ b/test/cities.test.js
@@@ -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'));