← 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();
})();