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