← back to Commercialrealestate

scripts/resolve-redfin-regions.js

84 lines

// resolve-redfin-regions.js — ONE Browserbase session: resolve every NAV_HUB city to its Redfin
// region_id + canonical /city/<id>/CA/<Name> path via the location-autocomplete API, in-page. Writes
// data/redfin-regions.json so the scraper can hit the gis-csv feed directly (1 session per ~all cities
// for resolution; then 1 session per city for the actual condo feed). ~$0.04 for this whole resolve.
'use strict';
const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright-core');
const Browserbase = require('@browserbasehq/sdk').default;
const { NAV_HUBS, INCORPORATED, UNINCORPORATED } = require('./sources/la-county');
const env = fs.readFileSync(process.env.HOME + '/.claude/skills/browserbase/.env', 'utf8');
const get = (t, k) => (t.match(new RegExp('^' + k + '=(.*)$', 'm')) || [])[1]?.replace(/['"]/g, '').trim();
const KEY = get(env, 'BROWSERBASE_API_KEY'), PROJECT = get(env, 'BROWSERBASE_PROJECT_ID');

// CC_CITIES=<csv> overrides; CC_ALL=1 = full county place set (88 incorporated + unincorporated
// communities), title-cased for the autocomplete query; default = the NAV_HUBS investment set.
const titleCase = s => s.replace(/\b\w/g, c => c.toUpperCase());
const FULL_PLACES = [...new Set([...INCORPORATED, ...UNINCORPORATED].map(titleCase))];
const CITIES = (process.env.CC_CITIES ? process.env.CC_CITIES.split(',')
  : (process.env.CC_ALL === '1' ? FULL_PLACES : NAV_HUBS)).map(s => s.trim()).filter(Boolean);
const REGIONS_FILE = path.join(__dirname, '..', 'data', 'redfin-regions.json');

(async () => {
  // Load existing so we APPEND + DEDUPE by region_id rather than overwrite (cities that don't resolve
  // are logged + skipped, never faked).
  let prior = { regions: [] };
  try { prior = JSON.parse(fs.readFileSync(REGIONS_FILE, 'utf8')); } catch (_) {}
  const haveIds = new Set((prior.regions || []).filter(r => r.region_id).map(r => String(r.region_id)));
  const haveQueries = new Set((prior.regions || []).filter(r => r.region_id).map(r => (r.query || '').toLowerCase()));
  const todo = CITIES.filter(c => !haveQueries.has(c.toLowerCase()));
  process.stderr.write(`resolve-regions: ${CITIES.length} requested, ${todo.length} new (skipping ${CITIES.length - todo.length} already resolved)\n`);

  const regions = [];
  let browser, session;
  try {
    const bb = new Browserbase({ apiKey: KEY });
    session = await bb.sessions.create({ projectId: PROJECT, browserSettings: { solveCaptchas: true, viewport: { width: 1440, height: 1000 } } });
    browser = await chromium.connectOverCDP(session.connectUrl);
    const ctx = browser.contexts()[0];
    const page = ctx.pages()[0] || await ctx.newPage();
    page.setDefaultTimeout(45000);
    await page.goto('https://www.redfin.com/', { waitUntil: 'domcontentloaded' });
    await page.waitForTimeout(3000);

    for (const city of todo) {
      const r = await page.evaluate(async (q) => {
        try {
          const resp = await fetch('https://www.redfin.com/stingray/do/location-autocomplete?location=' +
            encodeURIComponent(q) + '&v=2&al=1&iss=false', { headers: { accept: 'application/json' } });
          const t = await resp.text();
          const j = JSON.parse(t.replace(/^[)\]}'&\s]*\{\}&&/, '').replace(/^[)\]}'\s]+/, ''));
          const sections = (j.payload && j.payload.sections) || j.sections || [];
          for (const s of sections) for (const row of (s.rows || [])) {
            // type "2" = city/place. Prefer a /city/ url in CA.
            if (row.url && /^\/city\/\d+\/CA\//i.test(row.url)) {
              const m = row.url.match(/\/city\/(\d+)\//);
              return { name: row.name, region_id: m && m[1], url: row.url };
            }
          }
          return { miss: true };
        } catch (e) { return { err: String(e).slice(0, 100) }; }
      }, city + ', CA');
      regions.push({ query: city, ...r });
      process.stderr.write(`  ${city} -> ${r.region_id || r.miss && 'MISS' || r.err}\n`);
      await page.waitForTimeout(400);
    }
  } catch (e) { process.stderr.write('ERR ' + e.message + '\n'); }
  finally { if (browser) try { await browser.close(); } catch (_) {} }

  // MERGE: keep all prior entries, append newly-resolved ones whose region_id we don't already have.
  const merged = [...(prior.regions || [])];
  let appended = 0, miss = [];
  for (const r of regions) {
    if (!r.region_id) { miss.push(r.query); continue; }
    if (haveIds.has(String(r.region_id))) continue;
    haveIds.add(String(r.region_id)); merged.push(r); appended++;
  }
  const resolvedTotal = merged.filter(r => r.region_id).length;
  fs.writeFileSync(REGIONS_FILE,
    JSON.stringify({ resolved_at: new Date().toISOString(), count: resolvedTotal, regions: merged }, null, 2));
  console.log(JSON.stringify({ requested: CITIES.length, newly_queried: todo.length,
    appended, resolved_total: resolvedTotal, missing: miss }));
})();