← back to Commercialrealestate
scripts/refresh-listings-multi.js
113 lines
// refresh-listings-multi.js — generalize the SFV Crexi sweep to ALL 21 CRCP metros.
// Reads data/metro-listings-config.json (per-metro cities + price band), sweeps each
// metro's city/type pages in ONE Browserbase session, captures Crexi's api.crexi.com
// asset JSON, dedupes by address, TAGS each listing with its `metro` (feeds the
// email-alerts metro filter), and APPENDS to data/listings.json.
//
// ⚠ PAID: uses Browserbase (metered cloud browser, solveCaptchas on). Gated.
// Estimate cost before running: ~1 session, ~(sum of cities × types) page navs.
//
// Usage:
// node scripts/refresh-listings-multi.js # ALL metros (paid, gated)
// node scripts/refresh-listings-multi.js los-angeles # one metro
// CC_LOCAL=1 node scripts/refresh-listings-multi.js aspen # try local Chrome ($0, may hit anti-bot)
const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright-core');
const ROOT = path.join(__dirname, '..');
const CFG = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'metro-listings-config.json'), 'utf8'));
const ONLY = process.argv[2]; // optional single metro slug
const LOCAL = process.env.CC_LOCAL === '1';
const norm = s => String(s || '').toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\b(ave|avenue|st|street|blvd|boulevard|dr|drive|rd|road|ln|lane|pl|place|ct|court)\b/g, '').replace(/\s+/g, ' ').trim();
async function makeBrowser() {
if (LOCAL) {
// $0 local Chrome. Crexi is anti-bot-walled, so this may capture little — probe only.
const execPath = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const browser = await chromium.launch({ headless: true, executablePath: execPath });
return { browser, page: await (await browser.newContext({ viewport: { width: 1440, height: 1000 } })).newPage(), local: true };
}
const Browserbase = require('@browserbasehq/sdk').default;
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 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);
const browser = await chromium.connectOverCDP(session.connectUrl);
const ctx = browser.contexts()[0];
return { browser, page: ctx.pages()[0] || await ctx.newPage(), local: false };
}
(async () => {
const metros = ONLY ? { [ONLY]: CFG.metros[ONLY] } : CFG.metros;
if (ONLY && !CFG.metros[ONLY]) { console.error(`unknown metro: ${ONLY}`); process.exit(1); }
const assets = new Map(); // id -> {..., metro}
let browser;
try {
const b = await makeBrowser(); browser = b.browser; const page = b.page;
page.setDefaultTimeout(45000);
page.on('response', async (resp) => {
if (!resp.url().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 id = a.id || a.assetId, price = a.askingPrice || a.price, loc = (a.locations && a.locations[0]) || {};
if (id && price && loc.city && !assets.has(id)) assets.set(id, {
id, price: +price, city: loc.city, address: loc.address || (a.name || ''),
cap: typeof a.capRate === 'number' ? a.capRate : null, urlSlug: a.urlSlug || '',
desc: String(a.description || ''), types: a.types || [], metro: null,
});
}
} catch (_) {}
});
for (const [slug, cfg] of Object.entries(metros)) {
const cityset = new Set(cfg.cities.map(c => c.toLowerCase()));
const before = assets.size;
for (const [tslug] of CFG.types) {
for (const city of cfg.cities) {
const url = `https://www.crexi.com/properties/${cfg.state}/${city.replace(/\s+/g, '-')}/${tslug}`;
try { await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(4500); await page.mouse.wheel(0, 3000).catch(() => {}); await page.waitForTimeout(1500); }
catch (e) { console.log('nav err', slug, city, tslug, e.message.split('\n')[0]); }
}
}
// tag any newly-captured, in-city, in-band asset with this metro
for (const a of assets.values()) {
if (a.metro) continue;
if (cityset.has((a.city || '').toLowerCase()) && a.price >= cfg.min && a.price <= cfg.max) a.metro = slug;
}
console.log(` ${slug}: session now holds ${assets.size} assets (+${assets.size - before} nav'd)`);
}
} catch (e) { console.error('FATAL', e.message); }
finally { if (browser) await browser.close().catch(() => {}); }
const cand = [...assets.values()].filter(a => a.metro && a.address);
console.log(`captured ${assets.size} assets, ${cand.length} metro-tagged candidates`);
const data = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'listings.json'), 'utf8'));
const have = new Set(data.listings.map(l => norm(l.address)));
const today = new Date().toISOString().slice(0, 10);
let added = 0;
for (const a of cand) {
const key = norm(a.address);
if (!key || have.has(key)) continue;
have.add(key);
const type = (a.types || []).some(t => /retail/i.test(t)) ? 'Retail' : (a.types || []).some(t => /office/i.test(t)) ? 'Office' : (a.types || []).some(t => /mixed/i.test(t)) ? 'Mixed-use' : 'Multifamily';
const units = (() => { const m = a.desc.match(/(?:\|\s*)?\b(\d{1,3})\s*units?\b/i); if (m) { const n = +m[1]; if (n >= 1 && n <= 500 && a.price / n >= 50000) return n; } return null; })();
data.listings.push({
id: 'crx' + a.id, address: a.address, city: a.city, metro: a.metro, zip: '', type,
price: a.price, units: units || 1, sqft: null, cap_rate: a.cap, verified: false,
year_built: null, rent_control: 'verify', status: 'Active',
upside_note: `Auto-added by multi-metro Crexi sweep ${today} (${a.metro}). ${a.cap ? 'Broker cap ' + a.cap + '% (unverified).' : 'Cap not disclosed.'} Verify before acting.`,
source: 'https://www.crexi.com/properties/' + a.id + (a.urlSlug ? '/' + a.urlSlug : ''),
});
added++;
}
data.meta = data.meta || {};
data.meta.multi_metro_last_run = { date: today, captured: assets.size, candidates: cand.length, added, metros: Object.keys(metros) };
fs.writeFileSync(path.join(ROOT, 'data', 'listings.json'), JSON.stringify(data, null, 2));
console.log(`ADDED ${added} new listings (total ${data.listings.length}).`);
})();