← back to Commercialrealestate

scripts/live-comps-bb.js

105 lines

// live-comps-bb.js — Browserbase-backed live comps. Drives a cloud browser to LoopNet's
// city+type for-sale search and scrapes the rendered result cards (price / cap / address / url).
// Usage: CC_CITY="Reseda" CC_TYPE="Multifamily" node scripts/live-comps-bb.js  -> prints JSON to stdout.
const fs = require('fs');
const { chromium } = require('playwright-core');
const Browserbase = require('@browserbasehq/sdk').default;

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

const CITY = process.env.CC_CITY || 'Van Nuys';
const TYPE = (process.env.CC_TYPE || 'Multifamily').toLowerCase();
// Crexi is more tolerant of cloud browsers than LoopNet (which hard-blocks with Access Denied).
const SLUG = TYPE.includes('retail') ? 'retail' : TYPE.includes('mixed') ? 'mixed-use' : TYPE.includes('office') ? 'office' : 'multifamily';
const citySlug = CITY.replace(/\s+/g, '-');
const URL = `https://www.crexi.com/properties/CA/${citySlug}/${SLUG}`;

(async () => {
  const out = { source: 'Crexi via Browserbase', url: URL, comps: [] };
  let browser;
  try {
    const bb = new Browserbase({ apiKey: KEY });
    const 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);
    // Capture the listing JSON the Crexi SPA fetches for itself (authed + geo-scoped) — robust vs DOM scrape.
    const apiAssets = new Map();
    page.on('response', async (resp) => {
      const u = resp.url();
      if (!u.includes('api.crexi.com')) return;
      try {
        const j = await resp.json();
        const arr = Array.isArray(j) ? j : (j.data || j.assets || j.results || []);
        if (Array.isArray(arr)) for (const a of arr) {
          const price = a.askingPrice || a.price;
          const loc = (a.locations && a.locations[0]) || {};
          if ((a.id || a.assetId) && price && loc.city) {
            const id = a.id || a.assetId;
            if (!apiAssets.has(id)) apiAssets.set(id, {
              title: (a.name || a.description || loc.fullAddress || '').slice(0, 80),
              url: a.urlSlug ? 'https://www.crexi.com/properties/' + id + '/' + a.urlSlug : 'https://www.crexi.com/properties/' + id,
              price: '$' + Number(price).toLocaleString(),
              cap: a.capRate ? a.capRate + '% Cap' : null,
              units: (() => {
                let n = null;
                for (const k of ['numberOfUnits', 'units', 'unitCount', 'numUnits', 'totalUnits']) {
                  if (typeof a[k] === 'number' && a[k] >= 1 && a[k] <= 500) { n = a[k]; break; }
                }
                // Crexi description format: "Multifamily | 4 Units" — anchored, capped, never swallows a zip
                if (n == null) { const m = String(a.description || '').match(/(?:\|\s*)?\b(\d{1,3})\s*units?\b/i); if (m) n = +m[1]; }
                if (!n || n < 1 || n > 500) return null;
                // Plausibility gate: an implausibly low $/unit means the source count is junk -> suppress it.
                if (price && price / n < 50000) return null;
                return n + (n === 1 ? ' Unit' : ' Units');
              })(),
              sf: null, city: loc.city, state: (loc.state && loc.state.code) || 'CA'
            });
          }
        }
      } catch (_) {}
    });
    await page.goto(URL, { waitUntil: 'domcontentloaded' });
    await page.waitForTimeout(7000);
    for (let i = 0; i < 4; i++) { await page.mouse.wheel(0, 3500).catch(() => {}); await page.waitForTimeout(2200); }
    const data = await page.evaluate(() => {
      const body = (document.body.innerText || '');
      const challenge = /captcha|unusual traffic|verify you are|access denied|are you a robot|blocked/i.test(body) ? (document.title || 'challenge') : null;
      const anchors = [...document.querySelectorAll('a[href*="/properties/"]')].filter(a => /\/properties\/\d|\/properties\/[a-z]/.test(a.getAttribute('href') || ''));
      const seen = {}, res = [];
      for (const a of anchors) {
        const href = a.href.split('?')[0];
        if (seen[href]) continue;
        // climb up to 5 ancestors to find a node whose text carries the price
        let node = a, txt = '';
        for (let k = 0; k < 5 && node; k++) { const t = (node.innerText || '').replace(/\s+/g, ' '); if (/\$[\d,]{4,}/.test(t)) { txt = t; break; } node = node.parentElement; }
        if (!txt) continue;
        const price = (txt.match(/\$[\d,]{4,}/) || [])[0] || null;
        if (!price) continue;
        seen[href] = 1;
        res.push({
          title: (a.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 80) || (href.split('/properties/')[1] || href).slice(0, 50),
          url: href, price,
          cap: (txt.match(/(\d+\.?\d*)%\s*Cap/i) || [])[0] || null,
          units: (txt.match(/\b(\d{1,3})\s*units?\b/i) || [])[0] || null,
          sf: (txt.match(/\b([\d,]{2,7})\s*SF\b/i) || [])[0] || null
        });
      }
      return { comps: res.slice(0, 8), challenge, anchorCount: anchors.length, title: document.title };
    });
    const apiArr = [...apiAssets.values()];
    const cityMatch = apiArr.filter(a => a.city && a.city.toLowerCase() === CITY.toLowerCase());
    const chosen = cityMatch.length ? cityMatch : (data.comps.length ? data.comps : apiArr);
    out.comps = chosen.slice(0, 8).map(c => ({ title: c.title, url: c.url, price: c.price, cap: c.cap, units: c.units, sf: c.sf }));
    out.debug = { anchorCount: data.anchorCount, title: data.title, apiAssets: apiArr.length, cityMatch: cityMatch.length };
    if (cityMatch.length === 0 && apiArr.length) out.note = 'No exact ' + CITY + ' matches in the page feed; showing nearest available.';
    if (data.challenge && !out.comps.length) out.error = 'Crexi challenge/block hit (' + data.challenge + ')';
  } catch (e) { out.error = String(e.message || e).split('\n')[0]; }
  finally { if (browser) await browser.close().catch(() => {}); }
  process.stdout.write(JSON.stringify(out));
  process.exit(0);
})();