← back to Commercialrealestate
scripts/enrich-sfr-pools.js
153 lines
#!/usr/bin/env node
// enrich-sfr-pools.js — pool enrichment for the SFV $700k–$1.2M active SFR set.
//
// WHY: the Redfin gis-csv feed we ingest into cre.sfr carries NO pool/amenity data (price/beds/baths/
// sqft only). Pool status lives ONLY on each listing's Redfin DETAIL page, in an embedded structured
// amenity blob: {"amenityName":"Pool Features","referenceName":"POOL_FEATURES","amenityValues":[...]}
//
// TRANSPORT: plain curl/fetch is soft-blocked by Redfin after ~8 rapid hits (200s go empty). So we drive
// ONE warmed real-Chrome session (playwright-core + the installed Chrome app + the AutomationControlled
// evasion flag) — the SAME transport fetch-sfr-redfin.js uses to beat Redfin's anti-bot. Run it with the
// browserbase skill's node_modules on NODE_PATH (that's where playwright-core lives):
// NODE_PATH=$HOME/.claude/skills/browserbase/node_modules node scripts/enrich-sfr-pools.js
//
// We read the STRUCTURED POOL_FEATURES field (NOT free-text "swimming pool", which false-positives on
// "community pool" / reviews) → has_pool = true / false / null(unknown: detail page had no such field).
// Caches to cre.sfr_pool + data/sfr-pools.json (prod snapshot). Idempotent + resumable: only (re)checks
// rows not seen within FRESH_DAYS, so it fills the band incrementally across scheduled runs.
'use strict';
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const { chromium } = require('playwright-core');
const ROOT = path.join(__dirname, '..');
const CHROME_PATH = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const pool = new Pool({ host: '/tmp', port: 5432, database: 'cre', user: process.env.USER || 'stevestudio2' });
const SFV_CITIES = ['Encino','Tarzana','Woodland Hills','Sherman Oaks','Van Nuys','North Hollywood',
'Studio City','Reseda','Northridge','Granada Hills','Canoga Park','Winnetka','West Hills','Chatsworth',
'Panorama City','Sun Valley','Valley Village','Valley Glen','North Hills','Porter Ranch','Arleta',
'Pacoima','Sylmar','Mission Hills','Lake Balboa','Toluca Lake','Sunland','Tujunga','Shadow Hills'];
const PRICE_MIN = +(process.env.PRICE_MIN || 700000);
const PRICE_MAX = +(process.env.PRICE_MAX || 1200000);
const CAP = +(process.env.CAP || 1500);
const FRESH_DAYS = +(process.env.FRESH_DAYS || 21);
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Parse the structured POOL_FEATURES amenity. true=real pool value, false=explicit None, null=field absent.
function classifyPool(html) {
const m = html.match(/"amenityName\\?":\\?"Pool Features\\?"[^}]*?"amenityValues\\?":\\?\[([^\]]*)\]/i)
|| html.match(/POOL_FEATURES\\?"[^}]*?"amenityValues\\?":\\?\[([^\]]*)\]/i);
if (!m) return { hasPool: null, features: null };
const raw = m[1].replace(/\\"/g, '"');
const vals = (raw.match(/"([^"]+)"/g) || []).map(s => s.replace(/"/g, '').trim()).filter(Boolean);
const features = vals.join(', ') || null;
const joined = vals.join(' ').toLowerCase();
const positive = /(in ground|above ground|private|community|association|lap|infinity|heated|gunite|fenced|salt|solar|pool\/spa|pool spa)/.test(joined);
const negative = vals.length === 0 || (/\bnone\b/.test(joined) && !positive) || /^no\b/.test(joined.trim());
return { hasPool: positive ? true : (negative ? false : (vals.length ? true : null)), features };
}
// Lot size from the structured "Lot Size" amenity (display value) + a numeric lotSize field (sqft).
function extractLot(html) {
let display = null, sqft = null;
const am = html.match(/"amenityName\\?":\\?"Lot Size\\?"[^}]*?"amenityValues\\?":\\?\[\\?"([^"\\]{1,40})/i);
if (am) display = am[1].replace(/\\u002c/gi, ',').trim();
const num = html.match(/"lotSize\\?":\s*(\d{3,9})/i);
if (num) sqft = +num[1];
// derive sqft from a "X square feet"/"X Sq. Ft." display if the numeric field was absent
if (sqft == null && display) { const m = display.replace(/,/g, '').match(/(\d{3,9})\s*(sq|square)/i); if (m) sqft = +m[1]; }
// acres display → sqft
if (sqft == null && display) { const a = display.replace(/,/g, '').match(/([\d.]+)\s*acre/i); if (a) sqft = Math.round(parseFloat(a[1]) * 43560); }
if (!display && sqft != null) display = sqft.toLocaleString() + ' sq ft';
return { lot_size: display, lot_sqft: sqft };
}
// Primary LISTING BROKER + agent from the MLS record Redfin surfaces (the primary listing broker who
// holds the listing — what Steve wants over aggregator attribution). Structured JSON fields.
function extractBroker(html) {
const b = html.match(/"listingBrokerName\\?":\s*\\?"([^"\\]{2,80})/i) || html.match(/"brokerName\\?":\s*\\?"([^"\\]{2,80})/i);
const a = html.match(/"listingAgentName\\?":\s*\\?"([^"\\]{2,60})/i) || html.match(/"agentName\\?":\s*\\?"([^"\\]{2,60})/i);
const clean = s => s ? s.replace(/\\u0026/g,'&').replace(/\\u002c/g,',').trim() : null;
return { broker: clean(b && b[1]), agent: clean(a && a[1]) };
}
// Primary listing photo from og:image (falls back to the first cdn-redfin bigphoto).
function extractPhoto(html) {
const og = html.match(/property=\\?"og:image\\?"[^>]*content=\\?"(https:\/\/[^"\\ >]{20,160})/i)
|| html.match(/content=\\?"(https:\/\/ssl\.cdn-redfin\.com\/photo\/[^"\\ >]{20,160})\\?"[^>]*property=\\?"og:image/i);
if (og) return og[1].replace(/\\u002f/gi, '/');
const cdn = html.match(/https:\/\/ssl\.cdn-redfin\.com\/photo\/[^"\\ >]{20,120}\.jpg/i);
return cdn ? cdn[0] : null;
}
async function main() {
await pool.query(`CREATE TABLE IF NOT EXISTS sfr_pool (
id text PRIMARY KEY, has_pool boolean, pool_features text, http_status int,
lot_size text, lot_sqft int, photo_url text,
checked_at timestamptz NOT NULL DEFAULT now())`);
await pool.query(`ALTER TABLE sfr_pool ADD COLUMN IF NOT EXISTS lot_size text,
ADD COLUMN IF NOT EXISTS lot_sqft int, ADD COLUMN IF NOT EXISTS photo_url text`);
const cities = SFV_CITIES.map(c => `'${c.replace(/'/g, "''")}'`).join(',');
const { rows } = await pool.query(
`SELECT s.id, s.address, s.city, s.source
FROM sfr s LEFT JOIN sfr_pool p ON p.id=s.id
WHERE s.status='active' AND s.price::int BETWEEN $1 AND $2 AND s.city IN (${cities})
AND s.source IS NOT NULL
AND (p.id IS NULL OR p.has_pool IS NULL OR p.photo_url IS NULL
OR p.checked_at < now() - ($3 || ' days')::interval)
ORDER BY (p.id IS NULL) DESC, (p.photo_url IS NULL) DESC, (p.has_pool IS NULL) DESC, s.price::int DESC
LIMIT $4`, [PRICE_MIN, PRICE_MAX, FRESH_DAYS, CAP]);
console.log(`[enrich-pools] real-Chrome; ${rows.length} listings to (re)check`);
if (!rows.length) { await snapshot(); await pool.end(); return; }
const browser = await chromium.launch({ executablePath: CHROME_PATH, args: ['--disable-blink-features=AutomationControlled'] });
const ctx = await browser.newContext({ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36', viewport: { width: 1440, height: 1000 } });
const page = await ctx.newPage();
// warm the session (get Redfin cookies before hammering detail pages)
await page.goto('https://www.redfin.com/city/11203/CA/Los-Angeles', { waitUntil: 'domcontentloaded' }).catch(() => {});
await page.waitForTimeout(2500);
let done = 0, withPool = 0, without = 0, unknown = 0, failed = 0, blockedStreak = 0;
for (const row of rows) {
let hasPool = null, features = null, status = 0, lot = null, lotSqft = null, photo = null;
try {
const resp = await page.goto(row.source, { waitUntil: 'domcontentloaded', timeout: 25000 });
status = resp ? resp.status() : 0;
await page.waitForTimeout(400);
const html = await page.content();
if (html && html.length > 40000) { // real listing page (>40KB); stripped blocks are tiny
const c = classifyPool(html); hasPool = c.hasPool; features = c.features;
const L = extractLot(html); lot = L.lot_size; lotSqft = L.lot_sqft;
photo = extractPhoto(html); blockedStreak = 0;
} else { blockedStreak++; } // soft-block / empty
} catch (_) { failed++; blockedStreak++; }
await pool.query(
`INSERT INTO sfr_pool(id,has_pool,pool_features,http_status,lot_size,lot_sqft,photo_url,checked_at)
VALUES($1,$2,$3,$4,$5,$6,$7,now())
ON CONFLICT (id) DO UPDATE SET has_pool=$2, pool_features=$3, http_status=$4,
lot_size=$5, lot_sqft=$6, photo_url=$7, checked_at=now()`,
[row.id, hasPool, features, status || null, lot, lotSqft, photo]);
hasPool === true ? withPool++ : hasPool === false ? without++ : unknown++;
done++;
if (done % 25 === 0) { console.log(`[enrich-pools] ${done}/${rows.length} pool:${withPool} no:${without} unk:${unknown} fail:${failed}`); await snapshot(); }
// if Redfin starts soft-blocking (many empty pages in a row), back off hard then keep trying
if (blockedStreak >= 6) { console.log(`[enrich-pools] soft-block streak ${blockedStreak} — backing off 60s`); await sleep(60000); blockedStreak = 0; }
else await sleep(1200 + Math.floor(Math.random() * 1600)); // 1.2–2.8s between pages
}
await browser.close();
await snapshot();
console.log(`[enrich-pools] DONE checked:${done} withPool:${withPool} without:${without} unknown:${unknown} failed:${failed}`);
await pool.end();
}
async function snapshot() {
const snap = (await pool.query(`SELECT id, has_pool, pool_features, lot_size, lot_sqft, photo_url, checked_at FROM sfr_pool`)).rows;
fs.mkdirSync(path.join(ROOT, 'data'), { recursive: true });
fs.writeFileSync(path.join(ROOT, 'data', 'sfr-pools.json'),
JSON.stringify({ updated_at: new Date().toISOString(), pools: snap }, null, 0));
}
main().catch(e => { console.error('[enrich-pools] FATAL', e); process.exit(1); });