← back to Commercialrealestate

scripts/resolve-redfin-neighborhoods.js

82 lines

// resolve-redfin-neighborhoods.js — resolve the LA-neighborhood misses to Redfin region ids (these are
// type-1 neighborhoods, url /neighborhood/<id>/CA/Los-Angeles/<Name>). gis feed takes region_type=1.
'use strict';
const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright-core');
const Browserbase = require('@browserbasehq/sdk').default;
const { LA_NEIGHBORHOODS } = 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_HOODS=<csv> overrides; CC_ALL=1 = full LA_NEIGHBORHOODS set (title-cased, deduped on alias spellings).
const titleCase = s => s.replace(/\b\w/g, c => c.toUpperCase());
const FULL_HOODS = [...new Set(LA_NEIGHBORHOODS.map(titleCase))];
const HOODS = (process.env.CC_HOODS ? process.env.CC_HOODS.split(',')
  : (process.env.CC_ALL === '1' ? FULL_HOODS
    : 'Van Nuys,Reseda,North Hollywood,Sherman Oaks,Encino,Woodland Hills,Northridge,Canoga Park,Studio City,Echo Park,Highland Park,Koreatown,Venice,Mar Vista,Pacific Palisades,San Pedro'.split(','))).map(s => s.trim()).filter(Boolean);
const HOODS_FILE = path.join(__dirname, '..', 'data', 'redfin-neighborhoods.json');

(async () => {
  let prior = { regions: [] };
  try { prior = JSON.parse(fs.readFileSync(HOODS_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 = HOODS.filter(h => !haveQueries.has(h.toLowerCase()));
  process.stderr.write(`resolve-hoods: ${HOODS.length} requested, ${todo.length} new (skipping ${HOODS.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 hood 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 || [];
          const rows = [];
          for (const s of sections) for (const row of (s.rows || [])) rows.push({ name: row.name, sub: row.subName, url: row.url, type: row.type });
          // Prefer a neighborhood in Los Angeles CA.
          const hoodRow = rows.find(x => /\/neighborhood\/\d+\/CA\//i.test(x.url || '') && /los angeles|, ca/i.test(x.sub || ''));
          const cityRow = rows.find(x => /\/city\/\d+\/CA\//i.test(x.url || ''));
          const pick = hoodRow || cityRow;
          if (pick) {
            const m = pick.url.match(/\/(neighborhood|city)\/(\d+)\//);
            return { name: pick.name, sub: pick.sub, region_id: m && m[2], region_type: m && m[1] === 'neighborhood' ? 1 : 6, url: pick.url };
          }
          return { miss: true, rows: rows.slice(0, 3) };
        } catch (e) { return { err: String(e).slice(0, 100) }; }
      }, hood + ', Los Angeles, CA');
      regions.push({ query: hood, ...r });
      process.stderr.write(`  ${hood} -> ${r.region_id ? r.region_id + ' (type ' + r.region_type + ')' : (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 (_) {} }

  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(HOODS_FILE,
    JSON.stringify({ resolved_at: new Date().toISOString(), count: resolvedTotal, regions: merged }, null, 2));
  console.log(JSON.stringify({ requested: HOODS.length, newly_queried: todo.length,
    appended, resolved_total: resolvedTotal, missing: miss }));
})();