← back to Commercialrealestate

scripts/crawl-broker-listings.js

118 lines

// crawl-broker-listings.js — $0 NO-AUTH broker ACTIVE-inventory expansion via Crexi's PUBLIC API.
//
// Companion to crawl-broker-comps.js (which pulls CLOSED deals). Discovery (2026-08-19) proved the
// broker profile's active-listing feed is ALSO a public, no-auth POST:
//   POST https://api.crexi.com/assets/search
//   {"brokerGlobalIds":["<globalId>"],"count":60,"includeUnpriced":true,"offset":0,
//    "sortDirection":"Descending","sortOrder":"activatedOn"}
//   -> {data:[...assets], totalCount}
//
// So for every broker we resolved (broker.global_id, backfilled by crawl-broker-comps.js), we pull
// their FULL current for-sale book, run it through the shared crexiExtract, and fold NEW listings
// into data/listings.json (broker attribution tagged) + broker_other_listing + the broker↔listing graph.
//
// COST $0 (public POST — no browser, no Browserbase, no bearer). GATE: writes data/listings.json
// (reversible — git-tracked) + local `cre` DB (reversible/internal). The listings.json growth reaches
// the LIVE grid only on the next `analyze.js` + deploy — that DEPLOY stays Steve-gated.
//
// Usage: BROKER_LIMIT=40 node scripts/crawl-broker-listings.js      (0 = all brokers w/ global_id)
'use strict';
const fs = require('fs');
const path = require('path');
const db = require('./db/brokers-db');
const { byKey } = require('./sources/firms');
const crexiExtract = byKey('crexi').extract;

const ROOT = path.join(__dirname, '..');
const LIMIT = parseInt(process.env.BROKER_LIMIT || '40', 10);
const CONC = parseInt(process.env.CONC || '6', 10);
const PAGE = 60, MAX_PAGES = 20;   // up to 1,200 active assets/broker
const API = 'https://api.crexi.com/assets/search';
const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
const sleep = (ms) => new Promise(r => setTimeout(r, ms));

async function searchPage(gid, offset, tries = 3) {
  const body = JSON.stringify({ brokerGlobalIds: [gid], count: PAGE, includeUnpriced: true, offset, sortDirection: 'Descending', sortOrder: 'activatedOn' });
  for (let i = 0; i < tries; i++) {
    try {
      const r = await fetch(API, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body });
      if (r.status === 429) { await sleep(1500 * (i + 1)); continue; }
      if (!r.ok) return null; return await r.json();
    } catch { await sleep(500 * (i + 1)); }
  }
  return null;
}
async function pool(items, n, fn) {
  let idx = 0; await Promise.all(Array.from({ length: n }, async () => {
    while (idx < items.length) { const i = idx++; await fn(items[i], i); if (i % 50 === 0) process.stderr.write(`  ${i}/${items.length}\r`); }
  }));
}

(async () => {
  const rows = (await db.pool.query(
    `SELECT b.id, b.name, b.global_id, f.name AS firm
       FROM broker b LEFT JOIN firm f ON f.id=b.firm_id
      WHERE b.global_id IS NOT NULL
      ORDER BY b.total_assets DESC NULLS LAST` + (LIMIT ? ` LIMIT ${LIMIT}` : ``))).rows;
  console.log(`brokers with global_id: ${rows.length} — pulling active inventory (POST /assets/search, $0)`);

  const listingsFile = path.join(ROOT, 'data', 'listings.json');
  const doc = JSON.parse(fs.readFileSync(listingsFile, 'utf8'));
  const have = new Set(doc.listings.map(l => norm(l.address)));
  const haveId = new Set(doc.listings.map(l => l.id));
  let added = 0, otherRows = 0, brokersWithActive = 0, assetsSeen = 0;
  const today = new Date().toISOString().slice(0, 10);

  await pool(rows, CONC, async (b) => {
    const assets = new Map();
    for (let p = 0; p < MAX_PAGES; p++) {
      const j = await searchPage(b.global_id, p * PAGE);
      const arr = j && (j.data || j.assets || []);
      if (!Array.isArray(arr) || !arr.length) break;
      for (const a of arr) { const id = a.id || a.assetId; if (id) assets.set(id, a); }
      if (assets.size >= (j.totalCount || 0)) break;
    }
    if (!assets.size) return;
    brokersWithActive++;
    const extracted = crexiExtract([...assets.values()]);
    assetsSeen += extracted.length;
    for (const a of extracted) {
      const url = 'https://www.crexi.com/properties/' + a.id + (a.urlSlug ? '/' + a.urlSlug : '');
      const type = /retail/i.test((a.types || []).join()) ? 'Retail' : /mixed/i.test((a.types || []).join()) ? 'Mixed-use' : 'Multifamily';
      const r = await db.pool.query(
        `INSERT INTO broker_other_listing(broker_id, ext_id, title, address, city, state, price, asset_type, url, source, found_at)
         VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,'crexi-active-book', now()) ON CONFLICT DO NOTHING`,
        [b.id, String(a.id), a.address, a.address, a.city, null, a.price || null, type, url]).catch(() => ({ rowCount: 0 }));
      otherRows += r.rowCount;
      await db.upsertListing({ id: 'crx' + a.id, address: a.address, city: a.city, type, price: a.price, cap_rate: a.cap, firm_name: b.firm, source: url }).catch(() => {});
      await db.link(b.id, 'crx' + a.id, 'book').catch(() => {});
      const key = norm(a.address);
      if (!haveId.has('crx' + a.id) && key && !have.has(key)) {
        have.add(key); haveId.add('crx' + a.id);
        doc.listings.push({
          id: 'crx' + a.id, address: a.address, city: a.city, zip: '', type,
          price: a.price, units: 1, sqft: null, cap_rate: a.cap, verified: false,
          year_built: null, rent_control: 'verify', status: 'Active',
          firm: 'Crexi', broker_firm: b.firm, broker_agent: b.name, broker_agents: [b.name],
          source_of_truth: 'broker-book',
          upside_note: `Sourced via ${b.name} (${b.firm || 'firm n/a'}) Crexi active book ${today}. ${a.cap ? 'Broker cap ' + a.cap + '% (unverified).' : 'Cap not disclosed.'} Verify status, rent roll & financials before acting.`,
          source: url, _sourced: 'crexi-active-book'
        });
        added++;
      }
    }
  });

  fs.writeFileSync(listingsFile, JSON.stringify(doc, null, 2));
  const report = {
    ran_at: new Date().toISOString(), cost_usd: 0, mode: LIMIT ? `PILOT (${LIMIT} brokers)` : 'FULL',
    brokers_scanned: rows.length, brokers_with_active: brokersWithActive, assets_seen: assetsSeen,
    new_active_listings_added: added, broker_other_listing_rows: otherRows, listings_total_now: doc.listings.length
  };
  fs.writeFileSync(path.join(ROOT, 'data', 'raw', `broker-active-${Date.now()}.json`), JSON.stringify(report, null, 2));
  console.log(`\n=== DONE ($0) ===`); console.log(report);
  console.log('NOTE: data/listings.json grew — run `node scripts/analyze.js` then deploy (deploy is Steve-gated).');
  await db.pool.end().catch(() => {});
  process.exit(0);
})().catch(e => { console.error('FATAL', e); process.exit(1); });