← back to Commercialrealestate

scripts/harvest-fd-lee-bb.js

92 lines

// harvest-fd-lee-bb.js — Browserbase (paid, real browser + fresh IP) firm-direct harvest for Lee.
// Beats the plain-fetch 403 that walled the $0 attempt. Also: (1) inspects a live inventory item to
// find the REAL listing-URL field (the $0 run wrongly grabbed link_target="_top"), and (2) adds
// CORROBORATION — the firm-direct listing URL is trusted on address-match (it's Lee's own page for that
// address), but the CURRENT listing agent is recorded SEPARATELY from our historical broker_agent and
// flagged when they disagree, so we never silently overwrite with a wrong agent.
// Writes data/raw/fd-lee-verified.json. ONE Browserbase session (~$0.04). Run:
//   NODE_PATH=~/.claude/skills/browserbase/node_modules node scripts/harvest-fd-lee-bb.js
'use strict';
const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright-core');
const Browserbase = require('@browserbasehq/sdk').default;
const ROOT = path.join(__dirname, '..');
const FEED = 'https://buildout.com/plugins/9a64a93980aeae8db347e72cdfa8ca61017acc9a/inventory.json';

const bbEnv = fs.readFileSync(process.env.HOME + '/.claude/skills/browserbase/.env', 'utf8');
const env = (k) => (bbEnv.match(new RegExp('^' + k + '=(.*)$', 'm')) || [])[1]?.replace(/['"]/g, '').trim();

const SUF = { street:'st', avenue:'ave', av:'ave', boulevard:'blvd', drive:'dr', road:'rd',
  place:'pl', court:'ct', lane:'ln', terrace:'ter', parkway:'pkwy', highway:'hwy', square:'sq' };
function norm(a) {
  let s = String(a || '').toLowerCase().split(',')[0];
  s = s.replace(/#\s*\S+/g, ' ').replace(/\b(ste|suite|unit|apt|no)\b.*$/,'');
  s = s.replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
  return s.split(' ').map(w => SUF[w] || w).join(' ').trim();
}
const lastName = (n) => String(n||'').replace(/,.*$/,'').trim().split(/\s+/).pop().toLowerCase();
const priceOf = (it) => { const p=(it.index_attributes||[]).find(x=>/price/i.test(x[0])); if(!p) return null;
  const n=Number(String(p[1]).replace(/[^\d.]/g,'')); return Number.isFinite(n)&&n>0?n:null; };

(async () => {
  const ranked = JSON.parse(fs.readFileSync(path.join(ROOT,'data','ranked.json'),'utf8'));
  const deals = ranked.ranked || ranked;
  const hostOf = (u)=>{ try { return new URL(u).hostname.replace(/^www\./,''); } catch { return ''; } };
  const targets = deals.filter(d => d.broker_url && hostOf(d.broker_url).includes('lee-associates'))
                       .map(d => ({ id:d.id, address:d.address, our_agent:d.broker_agent||null, key:norm(d.address) }));
  console.log(`Lee targets: ${targets.length}`);

  const bb = new Browserbase({ apiKey: env('BROWSERBASE_API_KEY') });
  const session = await bb.sessions.create({ projectId: env('BROWSERBASE_PROJECT_ID'),
    browserSettings: { viewport: { width: 1280, height: 900 } } });
  console.log('bb session', session.id);
  const browser = await chromium.connectOverCDP(session.connectUrl);
  const page = browser.contexts()[0].pages()[0] || await browser.contexts()[0].newPage();
  const getJson = async (url) => { await page.goto(url, { waitUntil:'domcontentloaded', timeout:45000 });
    const txt = await page.evaluate(() => document.body.innerText); return JSON.parse(txt); };

  // 1) inspect field shape to find the REAL listing url
  const first = await getJson(`${FEED}?page=1`);
  const it0 = (first.inventory || [])[0] || {};
  const urlFields = Object.entries(it0).filter(([k,v]) => typeof v==='string' && /https?:|\/property|\/listing|buildout\.com/i.test(v));
  console.log('Candidate URL fields:', urlFields.map(([k,v])=>`${k}=${v}`).join(' | ') || '(none obvious)');
  const URLKEY = (urlFields.find(([k])=>/(^|_)(url|link|permalink|show)/i.test(k)) || urlFields[0] || [])[0] || null;
  console.log('Using URL field:', URLKEY, '| all keys:', Object.keys(it0).join(','));

  // 2) page the full inventory (Browserbase IP — no 403), index by normalized address
  const index = new Map();
  let total = (first.meta && first.meta.total) || Infinity, seen = 0, page_i = 1, json = first;
  while (seen < total && page_i <= 400) {
    if (page_i > 1) { try { json = await getJson(`${FEED}?page=${page_i}`); } catch (e) { console.log(`page ${page_i} ${e.message}`); break; } }
    const inv = json.inventory || []; if (!inv.length) break;
    for (const it of inv) { const k = norm(it.address_one_line); if (k && !index.has(k)) index.set(k, {
      url: URLKEY ? it[URLKEY] : null, price: priceOf(it),
      brokers: (it.broker_contacts||[]).map(b=>({ name:b.name, email:b.email||null })).filter(b=>b.name),
      addr: it.address_one_line }); }
    seen += inv.length; if (page_i % 40 === 0) console.log(`  paged ${seen}/${total}`); page_i++;
  }
  await browser.close();
  console.log(`Indexed ${index.size} Lee listings from ${seen} rows.`);

  // 3) match + corroborate
  const rows = targets.map(t => {
    const hit = index.get(t.key);
    if (!hit) return { deal_id:t.id, address:t.address, matched:false };
    const names = hit.brokers.map(b=>b.name.replace(/,\s*CalDRE.*$/i,'').trim());
    const agentMatch = !t.our_agent || names.some(n => lastName(n) === lastName(t.our_agent));
    return { deal_id:t.id, address:t.address, matched:true,
      firm_direct_url: hit.url, price: hit.price,
      our_agent: t.our_agent, current_listing_agents: names, current_emails: hit.brokers.map(b=>b.email).filter(Boolean),
      agent_corroborates: agentMatch, source_firm:'Lee & Associates' };
  });
  const m = rows.filter(r=>r.matched), corr = m.filter(r=>r.agent_corroborates);
  const out = { firm:'Lee & Associates', via:'browserbase', url_field:URLKEY, generated:'PENDING_STAMP',
    total_targets:targets.length, matched:m.length, agent_corroborated:corr.length, rows };
  fs.mkdirSync(path.join(ROOT,'data','raw'), { recursive:true });
  fs.writeFileSync(path.join(ROOT,'data','raw','fd-lee-verified.json'), JSON.stringify(out,null,2));
  console.log(`\nMATCHED ${m.length}/${targets.length} | agent-corroborated ${corr.length}/${m.length}`);
  console.log(JSON.stringify(m.slice(0,4).map(r=>({addr:r.address,url:r.firm_direct_url,our:r.our_agent,current:r.current_listing_agents,ok:r.agent_corroborates})),null,2));
  console.log('\nCOST: 1 Browserbase session ≈ $0.04 → data/raw/fd-lee-verified.json');
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });