← back to Commercialrealestate
scripts/fetch-brokers.js
93 lines
// fetch-brokers.js — capture each Crexi listing's broker team and ingest into the broker graph DB.
// Strategy: ONE Browserbase session captures Crexi's anonymous Bearer token, then fetches
// api.crexi.com/assets/<id>/brokers for our listing ids FROM INSIDE the page (same browser, авторized),
// in batches. Each broker → firm + agent name + book size → cre DB (firm/broker/listing + edges).
//
// CC_LIMIT=80 node scripts/fetch-brokers.js # sample run (default 80)
// CC_LIMIT=0 node scripts/fetch-brokers.js # all listings
// COST: 1 Browserbase session (~$0.05-0.10 depending on batch count). NODE_PATH → browserbase skill.
'use strict';
const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright-core');
const Browserbase = require('@browserbasehq/sdk').default;
const db = require('./db/brokers-db');
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 ROOT = path.join(__dirname, '..');
const LIMIT = process.env.CC_LIMIT != null ? +process.env.CC_LIMIT : 80;
(async () => {
let all = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'listings.json'), 'utf8')).listings
.filter(l => /^crx\d+/.test(l.id))
.map(l => ({ ...l, assetId: l.id.replace(/^crx/, '') }));
// Skip listings already captured (idempotent + fast incremental runs) unless CC_REDO=1.
if (!process.env.CC_REDO) {
const done = new Set((await db.pool.query('SELECT DISTINCT listing_id FROM broker_listing')).rows.map(r => r.listing_id));
const before = all.length; all = all.filter(l => !done.has(l.id));
console.log(`skipping ${before - all.length} already-captured; ${all.length} new`);
}
const work = LIMIT ? all.slice(0, LIMIT) : all;
console.log(`brokers for ${work.length}/${all.length} Crexi listings…`);
let browser, results = [];
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);
// capture the Authorization bearer token Crexi's UI uses
let auth = null;
page.on('request', req => { const h = req.headers(); if (!auth && h.authorization && /bearer/i.test(h.authorization) && req.url().includes('api.crexi.com')) auth = h.authorization; });
await page.goto('https://www.crexi.com/properties/CA/Los-Angeles/multifamily', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000); await page.mouse.wheel(0, 2500).catch(() => {}); await page.waitForTimeout(2500);
if (!auth) { console.log('no bearer captured; will try cookie-auth in-page fetch'); }
// fetch brokers in-page (same browser context → authorized), batched
const ids = work.map(w => w.assetId);
const BATCH = 25;
for (let i = 0; i < ids.length; i += BATCH) {
const batch = ids.slice(i, i + BATCH);
const out = await page.evaluate(async ({ batch, auth }) => {
const r = [];
for (const id of batch) {
try {
const resp = await fetch(`https://api.crexi.com/assets/${id}/brokers`, { headers: auth ? { authorization: auth } : {} });
if (resp.ok) r.push({ id, brokers: await resp.json() }); else r.push({ id, err: resp.status });
} catch (e) { r.push({ id, err: String(e).slice(0, 60) }); }
}
return r;
}, { batch, auth });
results.push(...out);
process.stdout.write(` ${Math.min(i + BATCH, ids.length)}/${ids.length}\r`);
}
} catch (e) { console.error('FATAL', e.message); } finally { if (browser) await browser.close().catch(() => {}); }
// ingest
const byId = Object.fromEntries(work.map(w => [w.assetId, w]));
let linked = 0, brokersSeen = 0, withBrokers = 0;
for (const row of results) {
const L = byId[row.id]; if (!L) continue;
await db.upsertListing({ id: L.id, address: L.address, city: L.city, zip: L.zip, type: L.type, price: L.price, cap_rate: L.cap_rate, units: L.units, source: L.source });
if (!Array.isArray(row.brokers) || !row.brokers.length) continue;
withBrokers++;
for (const b of row.brokers) {
const name = [b.firstName, b.lastName].filter(Boolean).join(' ').trim(); if (!name) continue;
const firm = b.brokerage && b.brokerage.name;
const brokerId = await db.upsertBroker({ name, firm, total_assets: b.numberOfAssets || null, crexi_id: String(b.id || b.globalId || ''), source: 'crexi' });
await db.link(brokerId, L.id, row.brokers.length > 1 ? 'co' : 'lead');
linked++; brokersSeen++;
}
}
console.log(`\ningested: ${withBrokers}/${results.length} listings had brokers · ${brokersSeen} broker links · ${linked} edges`);
const top = await db.topBrokers(12);
console.log('top brokers by listings in our set:');
top.forEach(t => console.log(` ${t.name} (${t.firm || 'n/a'}) — ${t.listings} here / ${t.total_assets || '?'} total`));
await db.pool.end();
})();