← back to Commercialrealestate

scripts/refresh-all.js

119 lines

// refresh-all.js — multi-firm sweep. Supersedes refresh-listings.js (which was Crexi-only) by
// driving EVERY firm in sources/firms.js through one shared capture → normalize → dedup → append
// path into data/listings.json. Crexi remains the proven source; the brokerage firms (Marcus &
// Millichap, Lee & Associates, Colliers, CBRE, JLL) are included once their schema is confirmed.
//
//   node scripts/refresh-all.js                 # all VERIFIED firms (default: crexi only)
//   CC_FIRMS=crexi,marcusmillichap node ...      # explicit firm set (incl. needs-discovery ones)
//   CC_NAV="Van Nuys,Reseda" CC_FIRMS=... node ... # override the city list
//
// Idempotent: only appends listings whose normalized address isn't already present (cross-firm).
// COST: each run = ONE Browserbase session (~$0.03–0.05 incl. captcha solves), independent of how
// many firms/cities are swept in that session. Printed at the end.

'use strict';
const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright-core');
const Browserbase = require('@browserbasehq/sdk').default;
const { TYPES, norm, today, FIRMS, byKey, toListing, inBand } = require('./sources/firms');
const { NAV_HUBS } = require('./sources/la-county');

const ROOT = path.join(__dirname, '..');
const bbEnv = fs.readFileSync(process.env.HOME + '/.claude/skills/browserbase/.env', 'utf8');
const get = (t, k) => (t.match(new RegExp('^' + k + '=(.*)$', 'm')) || [])[1]?.replace(/['"]/g, '').trim();

// Geo scope is all of LA County (filter side). For navigation we visit the submarket HUBS by
// default (sweeping all 88 cities in one session is infeasible); override with CC_NAV.
const NAV_CITIES = process.env.CC_NAV
  ? process.env.CC_NAV.split(',').map(s => s.trim())
  : NAV_HUBS;

// Which firms to run. Default = verified only, so an unattended cron never silently scrapes an
// unconfirmed firm. Pass CC_FIRMS to opt specific firms in.
const SELECTED = process.env.CC_FIRMS
  ? process.env.CC_FIRMS.split(',').map(s => s.trim()).map(byKey).filter(Boolean)
  : FIRMS.filter(f => f.status === 'verified');

(async () => {
  if (!SELECTED.length) { console.error('no firms selected'); process.exit(2); }
  console.log('firms:', SELECTED.map(f => `${f.key}(${f.status})`).join(', '));

  // Per-firm bucket of captured intermediate assets, keyed by firm.key.
  const captured = new Map(SELECTED.map(f => [f.key, new Map()]));
  let browser;
  try {
    const bb = new Browserbase({ apiKey: get(bbEnv, 'BROWSERBASE_API_KEY') });
    const session = await bb.sessions.create({
      projectId: get(bbEnv, 'BROWSERBASE_PROJECT_ID'),
      browserSettings: { solveCaptchas: true, viewport: { width: 1440, height: 1000 } }
    });
    console.log('bb session', session.id);
    browser = await chromium.connectOverCDP(session.connectUrl);
    const ctx = browser.contexts()[0];
    const page = ctx.pages()[0] || await ctx.newPage();
    page.setDefaultTimeout(45000);

    // ONE response listener routes each JSON body to whichever firm owns its host, then runs that
    // firm's extractor. This is the generalization of refresh-listings.js's single crexi hook.
    page.on('response', async (resp) => {
      const url = resp.url();
      const firm = SELECTED.find(f => f.apiHosts.some(h => url.includes(h)));
      if (!firm) return;
      let j; try { j = await resp.json(); } catch { return; }
      let assets; try { assets = firm.extract(j) || []; } catch { return; }
      const bucket = captured.get(firm.key);
      for (const a of assets) if (a && a.id && !bucket.has(a.id)) bucket.set(a.id, a);
    });

    // Navigate each firm's search pages (city × type) to make its XHRs fire.
    for (const firm of SELECTED) {
      for (const [slug] of TYPES) {
        for (const city of NAV_CITIES) {
          for (const url of firm.navUrls(city, slug)) {
            try {
              await page.goto(url, { waitUntil: 'domcontentloaded' });
              await page.waitForTimeout(4500);
              await page.mouse.wheel(0, 3000).catch(() => {});
              await page.waitForTimeout(1500);
            } catch (e) { console.log('nav err', firm.key, city, slug, String(e.message).split('\n')[0]); }
          }
        }
      }
      console.log(`  ${firm.key}: captured ${captured.get(firm.key).size} raw assets`);
    }
  } catch (e) {
    console.error('FATAL', e.message);
  } finally {
    if (browser) await browser.close().catch(() => {});
  }

  // ── Normalize + dedup + append (shared path) ─────────────────────────────────────────────────
  const data = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'listings.json'), 'utf8'));
  const have = new Set(data.listings.map(l => norm(l.address)));
  const perFirm = {};
  let added = 0, totalCaptured = 0;

  for (const firm of SELECTED) {
    const cand = [...captured.get(firm.key).values()].filter(inBand);
    totalCaptured += captured.get(firm.key).size;
    let firmAdded = 0;
    for (const a of cand) {
      const key = norm(a.address);
      if (!key || have.has(key)) continue;       // cross-firm dedup: first firm to list an address wins
      have.add(key);
      data.listings.push(toListing(a, firm));
      firmAdded++; added++;
    }
    perFirm[firm.key] = { captured: captured.get(firm.key).size, inBand: cand.length, added: firmAdded };
  }

  fs.writeFileSync(path.join(ROOT, 'data', 'listings.json'), JSON.stringify(data, null, 2));
  fs.writeFileSync(path.join(ROOT, 'data', 'last-refresh.json'),
    JSON.stringify({ date: today(), firms: perFirm, totalCaptured, added, cost: '~$0.03–0.05 (1 Browserbase session)' }, null, 2));

  console.log('per-firm:', JSON.stringify(perFirm));
  console.log(`ADDED ${added} new listings (total ${data.listings.length}). Cost: ~$0.03–0.05 (1 Browserbase session).`);
  console.log('Next: npm run analyze  (re-rank listings.json into ranked.json for the viewer).');
})();