← back to Commercialrealestate

scripts/backfill-showcase-brokers.js

70 lines

#!/usr/bin/env node
// backfill-showcase-brokers.js — FREE ($0) broker backfill for the Showcase listings.
// The Showcase list cards carry no broker; each detail page does — in .brokerInfo-wrapper:
// "<Agent Name><Firm>...CALL <phone>". Showcase detail pages are Akamai-blocked to curl/headless,
// but openclaw's REAL Chrome clears them at $0 (vs ~$0.10-0.20/page on browserbase = $44-88 for 440).
// We connect Playwright to openclaw's Chrome over CDP and open OUR OWN tab, so we don't fight whatever
// other agent is using the shared browser's active tab. Extraction is structured DOM (no LLM cost;
// exo/local is available as a fallback but not needed). RESUMABLE: only fetches listings still missing
// a broker; writes progress back to data/showcase-listings.json after each page.
//
// Usage: node scripts/backfill-showcase-brokers.js [limit]   (limit = max pages this run, default 60)
'use strict';
const fs = require('fs');
const path = require('path');
const NP = require('child_process').execSync('npm root -g').toString().trim();
process.env.NODE_PATH = NP;
require('module').Module._initPaths();
const { chromium } = require('playwright');

const FILE = path.join(__dirname, '..', 'data', 'showcase-listings.json');
const CDP = 'http://127.0.0.1:18800';   // openclaw real-Chrome CDP endpoint (openclaw browser status)
const LIMIT = parseInt(process.argv[2] || '60', 10);

// area-code sanity: real CA local line, not a toll-free/CoStar footer number.
const TOLLFREE = new Set(['800','833','844','855','866','877','888']);
const cleanPhone = p => { const d = String(p||'').replace(/\D/g,'').replace(/^1(?=\d{10}$)/,''); return d.length===10 ? `(${d.slice(0,3)}) ${d.slice(3,6)}-${d.slice(6)}` : ''; };

(async () => {
  const doc = JSON.parse(fs.readFileSync(FILE, 'utf8'));
  const todo = doc.listings.filter(r => r.source && !r.broker_phone && !r._brokerBackfilled).slice(0, LIMIT);
  if (!todo.length) { console.log('Showcase broker backfill: nothing to do (all backfilled).'); return; }
  console.log(`Showcase broker backfill: ${todo.length} listings this run (of ${doc.listings.length} total), via openclaw ($0).`);

  const browser = await chromium.connectOverCDP(CDP);
  const ctx = browser.contexts()[0] || await browser.newContext();
  const page = await ctx.newPage();                 // OUR OWN tab — doesn't disturb other agents' active tab
  let done = 0, hit = 0;
  for (const r of todo) {
    try {
      await page.goto(r.source, { waitUntil: 'domcontentloaded', timeout: 30000 });
      await page.waitForTimeout(1200);
      const info = await page.evaluate(() => {
        const el = document.querySelector('.brokerInfo-wrapper') || document.querySelector('.brokerInfo-component');
        if (!el) return null;
        const t = (el.innerText || '').replace(/\s+/g, ' ').trim();
        const phone = (t.match(/CALL\s*([0-9()\-. ]{10,16})/i) || [])[1] || (t.match(/\(?\d{3}\)?[ .\-]\d{3}[ .\-]\d{4}/) || [])[0] || '';
        // "Andrew Sparks The Sparks Agency More Info CALL ..." → name = up to firm; firm heuristic: the run before 'More Info'/'CALL'
        const head = t.split(/More Info|CALL/i)[0].trim();
        return { head, phone };
      });
      r._brokerBackfilled = true;                    // mark attempted (resumable) even if no broker found
      if (info && info.head) {
        // head ≈ "<Agent Name><Firm>" concatenated; split on the case boundary between name and firm is unreliable,
        // so store the whole head as broker label + the phone; usre still resolves via the firm/name text.
        r.broker_label = info.head.slice(0, 80);
        const ph = cleanPhone(info.phone);
        if (ph && !TOLLFREE.has(ph.slice(1,4))) { r.broker_phone = ph; hit++; }
        r.broker_agents = r.broker_agents && r.broker_agents.length ? r.broker_agents : [info.head.slice(0, 50)];
      }
    } catch (e) { r._brokerBackfilled = true; /* mark so we don't spin on a dead page */ }
    done++;
    if (done % 10 === 0) { fs.writeFileSync(FILE, JSON.stringify(doc)); process.stdout.write(`  ${done}/${todo.length} (${hit} phones)\r`); }
    await page.waitForTimeout(600);                  // throttle — be polite + reduce contention
  }
  await page.close();
  fs.writeFileSync(FILE, JSON.stringify(doc));
  const remaining = doc.listings.filter(r => r.source && !r._brokerBackfilled).length;
  console.log(`\nDone: ${done} pages, ${hit} real phones backfilled. ${remaining} listings still pending (re-run to continue).`);
})().catch(e => { console.error('backfill FAILED:', e.message); process.exit(1); });