← back to Sublease Agentabrams
crawl/bb-crawl.js
167 lines
'use strict';
// Metered Browserbase crawl for the anti-bot firms (CBRE, NAI Capital).
// Strategy: real cloud Chrome runs the site's JS, we INTERCEPT the XHR JSON the
// search app fires (its own listings API) and parse that — plus a DOM-card fallback.
// Hard per-firm time cap keeps spend bounded. Cost logged at $0.15/session-minute.
const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright-core');
const Browserbase = require('@browserbasehq/sdk').default || require('@browserbasehq/sdk');
const base = require('./base-crawler');
const envTxt = fs.readFileSync(path.join(process.env.HOME, '.claude/skills/browserbase/.env'), 'utf8');
const API_KEY = (envTxt.match(/BROWSERBASE_API_KEY=(.*)/) || [])[1]?.trim();
const PROJECT = (envTxt.match(/BROWSERBASE_PROJECT_ID=(.*)/) || [])[1]?.trim();
const RATE = 0.15; // $/session-minute
const FIRMS = {
cbre: { cap_ms: 6 * 60000, entries: [
'https://www.cbre.com/properties/properties-for-lease?sort=Featured&location=Los%20Angeles,%20CA',
'https://www.cbre.com/properties/properties-for-lease' ] },
naicapital: { cap_ms: 4 * 60000, entries: [
'https://www.naicapital.com/property-search/',
'https://www.naicapital.com/listings/',
'https://www.naicapital.com/' ] },
};
const CONSENT_URL = /onetrust|cookielaw|consent|optanon|cookiepedia|geolocation\.onetrust/i;
const CONSENT_KEYS = /groupname|optanon|cookies|isthirdparty|hostid|firstpartycookie/i;
const ADDR_KEYS = ['streetaddress', 'address1', 'address', 'street', 'propertyaddress', 'displayaddress'];
const SIZE_KEYS = ['sqft', 'squarefeet', 'rentablearea', 'size', 'buildingsize', 'availablespace', 'minsize', 'maxsize'];
const PRICE_KEYS = ['askingrent', 'rent', 'price', 'rate', 'pricepersqft'];
// A candidate is a real listing only if it has an address AND (size OR price OR geo) — and is NOT a consent object.
function isListing(o) {
if (!o || typeof o !== 'object') return false;
const keys = Object.keys(o).join(',').toLowerCase();
if (CONSENT_KEYS.test(keys)) return false;
const hasAddr = ADDR_KEYS.some(k => keys.includes(k));
const hasSize = SIZE_KEYS.some(k => keys.includes(k));
const hasPrice = PRICE_KEYS.some(k => keys.includes(k));
const hasGeo = /latitude|longitude/.test(keys);
return hasAddr && (hasSize || hasPrice || hasGeo);
}
function harvest(json, out, depth = 0) {
if (!json || depth > 7) return;
if (Array.isArray(json)) {
if (json.length && json.filter(x => isListing(x)).length >= Math.min(2, json.length)) {
for (const o of json) if (isListing(o)) out.push(o);
} else json.forEach(x => harvest(x, out, depth + 1));
} else if (typeof json === 'object') {
for (const k of Object.keys(json)) harvest(json[k], out, depth + 1);
}
}
const pick = (o, keys) => { for (const k of Object.keys(o)) if (keys.some(x => k.toLowerCase() === x || k.toLowerCase().includes(x))) { const v = o[k]; if (v != null && v !== '') return v; } return null; };
const num = (v) => { if (v == null || typeof v === 'object') return null; const n = String(v).replace(/[^0-9.]/g, ''); return n ? Math.round(+n) : null; };
// address may be a nested object {street/line1, city, ...} — flatten it to a string
function addrStr(v) {
if (!v) return null;
if (typeof v === 'string') return v.slice(0, 200);
if (typeof v === 'object') return [v.street || v.line1 || v.streetAddress, v.city || v.locality].filter(Boolean).join(', ').slice(0, 200) || null;
return null;
}
function toListing(o, firm) {
if (!isListing(o)) return null;
const addr = addrStr(pick(o, ADDR_KEYS));
const title = pick(o, ['propertyname', 'name', 'title', 'buildingname']);
const url = pick(o, ['detailurl', 'url', 'permalink', 'link', 'propertyurl', 'canonicalurl']);
const lat = pick(o, ['latitude', 'lat']), lng = pick(o, ['longitude', 'lng', 'lon']);
const id = pick(o, ['listingid', 'propertyid', 'id', 'slug']) || (addr ? addr.slice(0, 40) : null);
if (!addr && !id) return null;
const cityV = pick(o, ['city', 'cityname', 'locality']);
return {
external_id: String(id).slice(0, 120),
title: String(title || addr || 'Sublease space').slice(0, 200),
address: addr,
city: typeof cityV === 'string' ? cityV : (typeof pick(o, ADDR_KEYS) === 'object' ? (pick(o, ADDR_KEYS).city || null) : null),
state: (() => { const s = pick(o, ['state', 'region']); return typeof s === 'string' ? s : 'CA'; })(),
lat: lat ? +lat : null, lng: lng ? +lng : null,
space_type: (String(pick(o, ['propertytype', 'type', 'assetclass']) || 'office').toLowerCase().match(/office|retail|industrial|flex|medical|land/) || ['office'])[0],
size_sf: num(pick(o, SIZE_KEYS)),
price_amount: num(pick(o, PRICE_KEYS)),
price_unit: (() => { const u = pick(o, ['rentunit', 'priceunit']); return typeof u === 'string' ? u : 'call for terms'; })(),
source_url: url ? (String(url).startsWith('http') ? url : `https://www.${firm}.com${String(url).replace(/^\/?/, '/')}`) : null,
raw: o,
};
}
async function crawlFirm(slug) {
const cfg = FIRMS[slug];
const sponsor = await base.getSponsor(slug);
if (!sponsor) { console.error(`bb-crawl: no sponsor row for ${slug}`); return { cost: 0, found: 0, added: 0 }; }
const runId = await base.startRun(sponsor.id);
const t0 = Date.now();
const captured = [];
let session, browser, found = 0, added = 0, note = '';
try {
const bb = new Browserbase({ apiKey: API_KEY });
session = await bb.sessions.create({ projectId: PROJECT });
browser = await chromium.connectOverCDP(session.connectUrl);
const ctx = browser.contexts()[0]; const page = ctx.pages()[0] || await ctx.newPage();
page.on('response', async (res) => {
try {
const ct = res.headers()['content-type'] || '';
if (!/json/.test(ct)) return;
const url = res.url();
if (/analytics|telemetry|gtm|segment|sentry/i.test(url) || CONSENT_URL.test(url)) return;
const body = await res.json().catch(() => null);
if (body) { const arr = []; harvest(body, arr); if (arr.length) captured.push({ url, arr }); }
} catch { /* ignore */ }
});
const listingsSoFar = () => captured.reduce((n, c) => n + c.arr.length, 0);
// poll: scroll + wait, up to `budget` ms, until we have enough real listings
const poll = async (budget) => {
const end = Math.min(Date.now() + budget, t0 + cfg.cap_ms);
while (Date.now() < end) {
await page.mouse.wheel(0, 3000).catch(() => {});
await page.waitForTimeout(3500);
if (listingsSoFar() >= 5) return true;
}
return listingsSoFar() > 0;
};
for (const entry of cfg.entries) {
if (Date.now() - t0 > cfg.cap_ms) break;
try {
await page.goto(entry, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForTimeout(3000);
// NAI: the real search is behind a JS nav link — click into it from the homepage
if (slug === 'naicapital' && !listingsSoFar()) {
const link = await page.$('a[href*="propert"], a[href*="listing"], a[href*="for-lease"], a[href*="search"]');
if (link) { await link.click().catch(() => {}); await page.waitForTimeout(3500); }
}
await poll(40000);
if (listingsSoFar() >= 5) break;
} catch (e) { note += `[${entry.split('/').pop()}:${String(e).slice(0, 40)}] `; }
}
// Dedupe + convert captured objects → listings
const seen = new Set();
const objs = captured.sort((a, b) => b.arr.length - a.arr.length).flatMap(c => c.arr);
for (const o of objs) {
if (Date.now() - t0 > cfg.cap_ms + 30000) break;
const L = toListing(o, slug); if (!L) continue;
const key = L.external_id + '|' + (L.address || '');
if (seen.has(key)) continue; seen.add(key);
if (!L.lat && (L.address || L.city)) { const g = await base.geocodeCensus([L.address, L.city, L.state].filter(Boolean).join(', ')); if (g) { L.lat = g.lat; L.lng = g.lng; } }
found++; if (await base.upsertListing(sponsor.id, L)) added++;
}
note = `captured ${captured.length} JSON payloads (${objs.length} candidate rows). ` + note;
} catch (e) { note += 'ERR:' + String(e).slice(0, 200); }
finally { try { await browser?.close(); } catch {} }
const mins = (Date.now() - t0) / 60000;
const cost = +(mins * RATE).toFixed(2);
await base.finishRun(runId, { status: found ? 'ok' : 'blocked', found, added, cost, notes: note.slice(0, 400) });
console.log(`${slug}: ${found} found, ${added} new · ${mins.toFixed(1)} min · $${cost.toFixed(2)} ${note.slice(0, 120)}`);
return { cost, found, added };
}
(async () => {
if (!API_KEY || !PROJECT) { console.log('missing Browserbase creds'); process.exit(1); }
const targets = process.argv.slice(2).filter(a => FIRMS[a]);
const run = targets.length ? targets : Object.keys(FIRMS);
let total = 0;
for (const slug of run) { const r = await crawlFirm(slug); total += r.cost; }
console.log(`\n=== TOTAL BROWSERBASE SPEND: $${total.toFixed(2)} ===`);
await base.pool.end();
})();