← back to Commercialrealestate

scripts/discover-firm.js

95 lines

// discover-firm.js — generalized schema cracker for any firm in sources/firms.js. Warms the firm's
// search page in one Browserbase session and logs EVERY JSON response on its apiHosts: method, path,
// item-count, top-level keys, and a sample asset. Use this to confirm a 'needs-discovery' firm's API
// shape, then either trust genericExtract or write a precise extract() in firms.js and flip it to
// 'verified'. This is the same technique that originally cracked Crexi (discover.js / discover2.js).
//
//   node scripts/discover-firm.js marcusmillichap            # default first SFV city
//   CC_NAV="Van Nuys" node scripts/discover-firm.js colliers
//
// COST: ~$0.03–0.05 (one Browserbase session). Printed at the end. READ-ONLY — writes nothing to
// listings.json; output goes to data/raw/discover-<firm>.json for inspection.

'use strict';
const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright-core');
const Browserbase = require('@browserbasehq/sdk').default;
const { TYPES, byKey, genericExtract } = require('./sources/firms');

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();

const KEY = process.argv[2];
const firm = byKey(KEY);
if (!firm) { console.error('usage: node scripts/discover-firm.js <firmKey>   e.g. marcusmillichap | lee-associates | colliers | cbre | jll'); process.exit(2); }
const CITY = (process.env.CC_NAV || 'Van Nuys').split(',')[0].trim();

(async () => {
  const hits = [];
  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, '| firm', firm.key, '| city', CITY);
    browser = await chromium.connectOverCDP(session.connectUrl);
    const ctx = browser.contexts()[0];
    const page = ctx.pages()[0] || await ctx.newPage();
    page.setDefaultTimeout(60000);

    page.on('response', async (resp) => {
      const url = resp.url();
      if (!firm.apiHosts.some(h => url.includes(h))) return;
      const ct = resp.headers()['content-type'] || '';
      if (!/json/i.test(ct)) return;
      let j; try { j = await resp.json(); } catch { return; }
      let sample = null, count = 0, generic = 0;
      try {
        const g = genericExtract(j); generic = g.length; sample = g[0] || null;
        const arr = Array.isArray(j) ? j : (j.data || j.results || j.assets || j.items || []);
        count = Array.isArray(arr) ? arr.length : 0;
      } catch {}
      let u; try { u = new URL(url); } catch { u = { pathname: url, hostname: '' }; }
      hits.push({
        method: resp.request().method(), host: u.hostname, path: u.pathname,
        topKeys: Array.isArray(j) ? '[array]' : Object.keys(j || {}).slice(0, 14),
        rawCount: count, genericExtracted: generic, sample
      });
    });

    for (const [slug] of TYPES) {
      for (const url of firm.navUrls(CITY, slug)) {
        console.log('→', url);
        try {
          await page.goto(url, { waitUntil: 'domcontentloaded' });
          await page.waitForTimeout(8000);
          await page.mouse.wheel(0, 3000).catch(() => {});
          await page.waitForTimeout(4000);
        } catch (e) { console.log('nav err', String(e.message).split('\n')[0]); }
      }
    }
  } catch (e) {
    console.error('FATAL', e.message);
  } finally {
    if (browser) await browser.close().catch(() => {});
  }

  hits.sort((a, b) => b.genericExtracted - a.genericExtracted);
  const outFile = path.join(ROOT, 'data', 'raw', `discover-${firm.key}.json`);
  fs.writeFileSync(outFile, JSON.stringify({ firm: firm.key, city: CITY, capturedResponses: hits.length, hits }, null, 2));

  console.log(`\n${firm.name}: ${hits.length} JSON responses on ${firm.apiHosts.join('/')}`);
  for (const h of hits.slice(0, 12)) {
    console.log(`  [${h.method}] ${h.path}  raw=${h.rawCount} generic=${h.genericExtracted}  keys=${Array.isArray(h.topKeys) ? h.topKeys.join(',') : h.topKeys}`);
  }
  const best = hits.find(h => h.genericExtracted > 0);
  console.log(best
    ? `\n✓ genericExtract already pulls ${best.genericExtracted} assets from ${best.path} — sample: ${JSON.stringify(best.sample)}\n  → safe to run: CC_FIRMS=${firm.key} node scripts/refresh-all.js`
    : `\n✗ genericExtract found 0 assets — inspect ${outFile} and write a precise extract() in sources/firms.js for "${firm.key}".`);
  console.log('Cost: ~$0.03–0.05 (1 Browserbase session).');
})();