← back to Commercialrealestate

scripts/fetch-redfin-agents.js

191 lines

// fetch-redfin-agents.js — FEED-FIRST capture of the RESIDENTIAL listing agent + brokerage for every
// cre.condo (the 736 Redfin condos). Confirmed by scripts/probe-redfin-agents.js:
//
//   GET /stingray/api/home/details/mainHouseInfoPanelInfo?propertyId=<id>&accessLevel=1
//   -> payload.mainHouseInfo.listingAgents[0]: agentInfo.agentName, brokerName, license,
//      agentPhoneNumber.phoneNumber, brokerPhoneNumber.phoneNumber, agentEmailAddress, brokerEmailAddress
//   (Redfin prefixes JSON with `{}&&`; email/phone present only when MLS exposes them publicly.)
//   listingAgents:[] => Redfin-listed, agent suppressed -> honest "no public agent", NOT fabricated.
//
// Feed-first: ONE warmed Browserbase session fetches the detail endpoint in-page for MANY propertyIds
// (land on the listing URL first so the detail feed returns populated agent data). NO 736 page-loads.
//
// HARD CAP (Steve-approved): stop at MAX_SESSIONS (30) sessions OR ~$1.50 spend, whichever first.
// Per-session cost + RUNNING BATCH TOTAL surfaced live to stderr. Business contact ONLY. NO send.
//
// Usage: NODE_PATH=$HOME/.claude/skills/browserbase/node_modules node scripts/fetch-redfin-agents.js
//   CC_MAX_SESSIONS=30  CC_MAX_COST=1.50  CC_PER_SESSION=40   (propertyIds per warmed session)
'use strict';
const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright-core');
const Browserbase = require('@browserbasehq/sdk').default;
const brokerdb = require('./db/brokers-db');

const env = 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 KEY = get(env, 'BROWSERBASE_API_KEY'), PROJECT = get(env, 'BROWSERBASE_PROJECT_ID');

const SESSION_COST = 0.04;
const MAX_SESSIONS = +(process.env.CC_MAX_SESSIONS || 30);
const MAX_COST = +(process.env.CC_MAX_COST || 1.50);
const PER_SESSION = +(process.env.CC_PER_SESSION || 40);   // propertyIds per warmed session
const ROOT = path.join(__dirname, '..');
const OUT = path.join(ROOT, 'data', 'redfin-agents.json');

const strip = s => s.replace(/^[)\]}'&\s]*\{\}&&/, '').replace(/^[)\]}'\s]+/, '');
const pn = v => (v && typeof v === 'object' ? v.phoneNumber : v) || null;   // phone object -> string

// Pull the residential listing agent out of a mainHouseInfo payload. Returns null when suppressed.
function extractAgent(payloadText) {
  let j; try { j = JSON.parse(strip(payloadText)); } catch { return { parseErr: true }; }
  const mh = (j.payload && j.payload.mainHouseInfo) || (j.mainHouseInfo) || null;
  if (!mh) return { noPayload: true };
  const la = Array.isArray(mh.listingAgents) ? mh.listingAgents[0] : null;
  if (!la) return { suppressed: true };
  const ai = la.agentInfo || {};
  const name = (ai.agentName || '').trim();
  if (!name || ai.isAgentNameBlank) return { suppressed: true };
  return {
    name,
    brokerage: (la.brokerName || '').trim() || null,
    license: (la.license || '').trim() || null,
    phone: pn(la.agentPhoneNumber) || pn(la.brokerPhoneNumber) || null,
    email: (la.agentEmailAddress || la.brokerEmailAddress || '').trim() || null,
    isRedfinAgent: !!ai.isRedfinAgent
  };
}

async function loadTargets() {
  // Every condo with a parseable Redfin propertyId. broker_condo dedup happens at upsert time, but
  // we skip condos already linked so reruns don't re-spend sessions on already-captured agents.
  const r = await brokerdb.pool.query(`
    SELECT c.id, c.source,
           regexp_replace(c.source, '.*/home/([0-9]+).*', '\\1') AS pid,
           c.address, c.city
      FROM condo c
     WHERE c.source ~ '/home/[0-9]+'
       AND NOT EXISTS (SELECT 1 FROM broker_condo bc WHERE bc.condo_id = c.id)
     ORDER BY c.city, c.id`);
  return r.rows.filter(x => x.pid && x.pid !== x.source);
}

async function persistAgent(condo, a, sourceUrl) {
  const firmId = a.brokerage ? await brokerdb.upsertFirm(a.brokerage) : null;
  // Insert/refresh the broker as a RESIDENTIAL agent. upsertBroker keys on (name, firm_id); we set
  // agent_type/license/website directly so commercial rows are untouched.
  const r = await brokerdb.pool.query(
    `INSERT INTO broker(name, firm_id, phone, email, source, agent_type, license)
     VALUES($1,$2,$3,$4,'redfin','residential',$5)
     ON CONFLICT(name, firm_id) DO UPDATE SET
       phone=COALESCE(broker.phone, EXCLUDED.phone),
       email=COALESCE(broker.email, EXCLUDED.email),
       license=COALESCE(broker.license, EXCLUDED.license),
       agent_type='residential'
     RETURNING id`,
    [a.name, firmId, a.phone, a.email, a.license]);
  const brokerId = r.rows[0].id;

  await brokerdb.pool.query(
    `INSERT INTO broker_condo(broker_id, condo_id, role) VALUES($1,$2,'listing')
     ON CONFLICT DO NOTHING`, [brokerId, condo.id]);

  await brokerdb.pool.query(
    `UPDATE condo SET broker_name=$2, firm_name=$3, firm_id=$4 WHERE id=$1`,
    [condo.id, a.name, a.brokerage, firmId]);

  // Per-field provenance (CCPA audit trail), tier 'redfin-detail'.
  const prov = [];
  if (a.phone)   prov.push(['phone', a.phone]);
  if (a.email)   prov.push(['email', a.email]);
  if (a.license) prov.push(['license', a.license]);
  for (const [field, value] of prov) {
    await brokerdb.pool.query(
      `INSERT INTO broker_field_source(broker_id, field, value, source_url, tier)
       VALUES($1,$2,$3,$4,'redfin-detail')
       ON CONFLICT(broker_id, field) DO UPDATE SET value=EXCLUDED.value, source_url=EXCLUDED.source_url, tier=EXCLUDED.tier`,
      [brokerId, field, value, sourceUrl]).catch(() => {});
  }
  return brokerId;
}

(async () => {
  const targets = await loadTargets();
  process.stderr.write(`Condos needing an agent: ${targets.length} (PER_SESSION=${PER_SESSION}, cap ${MAX_SESSIONS} sessions / $${MAX_COST.toFixed(2)})\n`);
  if (!targets.length) { console.log(JSON.stringify({ note: 'all condos already have an agent linked', captured: 0 })); await brokerdb.pool.end(); return; }

  const results = [];
  const summary = { captured: 0, withPhone: 0, withEmail: 0, withLicense: 0, suppressed: 0, errors: 0 };
  let sessions = 0;

  for (let g = 0; g < targets.length; g += PER_SESSION) {
    if (sessions >= MAX_SESSIONS) { process.stderr.write(`\n[CAP] ${MAX_SESSIONS}-session cap reached; stopping.\n`); break; }
    if (sessions * SESSION_COST >= MAX_COST) { process.stderr.write(`\n[CAP] $${MAX_COST.toFixed(2)} cost cap reached; stopping.\n`); break; }
    const group = targets.slice(g, g + PER_SESSION);
    let browser, session;
    sessions++;
    const running = (sessions * SESSION_COST).toFixed(2);
    process.stderr.write(`\n[session ${sessions}/${MAX_SESSIONS}] ${group.length} condos  (per-session $${SESSION_COST.toFixed(2)} · running batch total $${running} / cap $${MAX_COST.toFixed(2)})\n`);

    try {
      const bb = new Browserbase({ apiKey: KEY });
      session = await bb.sessions.create({ projectId: PROJECT, browserSettings: { solveCaptchas: true, viewport: { width: 1440, height: 1000 } } });
      browser = await chromium.connectOverCDP(session.connectUrl);
      const ctx = browser.contexts()[0];
      const page = ctx.pages()[0] || await ctx.newPage();
      page.setDefaultTimeout(45000);
      await page.goto('https://www.redfin.com/city/11203/CA/Los-Angeles/filter/property-type=condo', { waitUntil: 'domcontentloaded' }).catch(() => {});
      await page.waitForTimeout(2000);

      for (const t of group) {
        try {
          // Warm-up: land on the listing page so the detail feed returns populated agent data.
          await page.goto(t.source, { waitUntil: 'domcontentloaded' }).catch(() => {});
          await page.waitForTimeout(900);
          const u = `https://www.redfin.com/stingray/api/home/details/mainHouseInfoPanelInfo?propertyId=${t.pid}&accessLevel=1`;
          const res = await page.evaluate(async (u) => {
            const r = await fetch(u, { headers: { accept: 'application/json' } });
            return { status: r.status, text: await r.text() };
          }, u);
          if (res.status !== 200 || !res.text) { summary.errors++; results.push({ condo: t.id, pid: t.pid, status: res.status, error: 'non-200' }); continue; }
          const a = extractAgent(res.text);
          if (a.suppressed || a.noPayload || a.parseErr) {
            summary.suppressed++;
            results.push({ condo: t.id, pid: t.pid, agent: null, label: 'no public agent (Redfin-listed / suppressed)' });
            continue;
          }
          await persistAgent(t, a, t.source);
          summary.captured++;
          if (a.phone)   summary.withPhone++;
          if (a.email)   summary.withEmail++;
          if (a.license) summary.withLicense++;
          results.push({ condo: t.id, pid: t.pid, agent: a.name, brokerage: a.brokerage, phone: !!a.phone, email: !!a.email });
          process.stderr.write(`  ${t.address}, ${t.city}: ${a.name} / ${a.brokerage || '?'}${a.phone ? ' ☎' : ''}${a.email ? ' ✉' : ''}\n`);
          await page.waitForTimeout(500);
        } catch (e) { summary.errors++; results.push({ condo: t.id, pid: t.pid, error: String(e.message).slice(0, 80) }); }
      }
    } catch (e) {
      process.stderr.write('  session err: ' + e.message.split('\n')[0] + '\n');
    } finally { if (browser) try { await browser.close(); } catch (_) {} }
  }

  const actualCost = (sessions * SESSION_COST).toFixed(2);
  fs.writeFileSync(OUT, JSON.stringify({
    meta: {
      source: 'Redfin per-property detail feed (mainHouseInfoPanelInfo), via warmed Browserbase session',
      endpoint: '/stingray/api/home/details/mainHouseInfoPanelInfo?propertyId=<id>&accessLevel=1',
      sessions_used: sessions,
      cost: `~$${actualCost} (${sessions} Browserbase sessions @ $${SESSION_COST.toFixed(2)})`,
      cap: `HARD CAP ${MAX_SESSIONS} sessions / $${MAX_COST.toFixed(2)}`,
      label: 'Business-contact only (agent name / brokerage / business phone / business email / license). Suppressed agents honestly labeled, not fabricated.',
      fetched_at: new Date().toISOString(),
      summary
    },
    results
  }, null, 2));

  process.stderr.write(`\nSessions ${sessions} · ACTUAL COST ~$${actualCost}\n`);
  console.log(JSON.stringify({ ...summary, sessions, cost: '$' + actualCost, endpoint: 'mainHouseInfoPanelInfo' }));
  await brokerdb.pool.end();
})();