← back to Commercialrealestate
scripts/enrich-condo-emails.js
98 lines
#!/usr/bin/env node
/*
* enrich-condo-emails.js — best-effort listing-agent EMAIL for the condo set (TK-10243).
*
* WHY this is best-effort (honest caveats):
* - Redfin does NOT publish agent email (0/115 in the scrape), so there's no source field to copy.
* - The shared email-hunt engine ($0 plain-fetch) needs a WEBSITE and, by design, REFUSES to guess
* a personal email on a shared corporate brokerage megasite (Compass/CBRE/KW/eXp/Sotheby's…) —
* a stranger's email is worse than none. Most condo agents ARE on those big sites, so expected
* yield is LOW. This script never fabricates an address: it only records what the guarded engine
* returns (a name-matched personal email, or a role inbox on the firm's own domain).
*
* Pipeline (per condo broker in data/condo-brokers.json, missing email):
* 1) resolve a candidate website — CURATED brand→domain map only (no domain guessing).
* 2) run emailHunt.huntEmails({name, website}) — the SAME name-guarded engine as the ✉ button.
* 3) on a guarded hit, write agent_email (+ agents[0].email + _email_basis) back into condo-brokers.json.
*
* Cost: $0 (plain fetch of public firm sites, no API, no browser). Read-only outbound GETs.
* Usage: node scripts/enrich-condo-emails.js [--limit N] [--ids a,b] [--only-missing] [--dry-run]
*/
'use strict';
const fs = require('fs');
const path = require('path');
const emailHunt = require('./lib/email-hunt');
const ROOT = path.join(__dirname, '..');
const BROKERS = path.join(ROOT, 'data', 'condo-brokers.json');
const arg = (k, d) => { const i = process.argv.indexOf(k); return i > -1 ? (process.argv[i + 1] ?? true) : d; };
const LIMIT = +arg('--limit', 0) || Infinity;
const ONLY_IDS = (arg('--ids', '') || '').split(',').filter(Boolean);
const ONLY_MISSING = !process.argv.includes('--all'); // default: skip rows already emailed
const DRY = process.argv.includes('--dry-run');
const jload = (p, d) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return d; } };
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Curated firm→domain for the recognizable brands seen in the condo set. For a corporate megasite the
// engine will typically return only a role inbox (or nothing) — that's expected and correct.
const BRAND = {
'compass': 'compass.com', 'coldwell banker': 'coldwellbankerhomes.com', 'keller williams': 'kw.com',
'century 21': 'century21.com', 'exp realty': 'exprealty.com', 'sotheby': 'sothebysrealty.com',
'redfin': 'redfin.com', 'equity union': 'equityunion.com', 'first team': 'firstteam.com',
'pinnacle estate': 'pinnacleestate.com', 'johnhart': 'johnhartrealestate.com',
'douglas elliman': 'elliman.com', 'berkshire hathaway': 'bhhscalifornia.com', 're/max': 'remax.com',
'the agency': 'theagencyre.com', 'rodeo realty': 'rodeorealty.com',
};
// Resolve a candidate website from the firm name — CURATED BRAND MAP ONLY. The firm-slug→.com
// heuristic was REMOVED (TK-10243): it resolved firm names to unrelated/parked domains (e.g.
// "Summit" → summit.com, "…" → domainmarket.com) and the engine then grabbed a stranger's role
// inbox — false emails, worse than none. We now only crawl a KNOWN-REAL brokerage domain.
function firmToSite(firm) {
if (!firm) return '';
const low = firm.toLowerCase();
for (const [k, dom] of Object.entries(BRAND)) if (low.includes(k)) return 'https://' + dom;
return ''; // unknown firm → don't guess a domain
}
// Only a name-matched PERSONAL email is trustworthy enough to record as "the agent's email".
// Reject role-inbox (generic info@) and sole-email (one-address-on-page faith-grab) — those are how
// garbage got written. A real name match on a real brokerage domain is the only accept.
function accept(r) { return r && r.found && /^name-match/.test(r.basis || ''); }
async function main() {
const brokers = jload(BROKERS, {});
let ids = Object.keys(brokers);
if (ONLY_IDS.length) ids = ids.filter(id => ONLY_IDS.includes(id));
if (ONLY_MISSING) ids = ids.filter(id => !brokers[id].agent_email);
ids = ids.filter(id => brokers[id].broker_name || brokers[id].firm_name);
console.log(`condo-email enrich: ${Math.min(ids.length, LIMIT)} target(s) · $0 local plain-fetch${DRY ? ' · DRY-RUN' : ''}`);
let done = 0, found = 0, noSite = 0;
for (const id of ids) {
if (done >= LIMIT) break;
done++;
const b = brokers[id];
const website = firmToSite(b.firm_name);
if (!website) { noSite++; process.stdout.write(`\r ${done} found:${found} noSite:${noSite} `); continue; }
let r;
try {
r = await emailHunt.withTimeout(emailHunt.huntEmails({ id, name: b.broker_name || '', website }), 30000,
() => ({ found: null, basis: 'timeout' }));
} catch (e) { r = { found: null, basis: 'err:' + String(e.message).split('\n')[0] }; }
if (accept(r)) {
found++;
if (!DRY) {
b.agent_email = r.found; b._email_basis = r.basis; b._email_site = website;
if (Array.isArray(b.agents) && b.agents[0] && !b.agents[0].email) b.agents[0].email = r.found;
fs.writeFileSync(BROKERS, JSON.stringify(brokers, null, 1));
}
process.stdout.write(`\r ${done} ✉ ${b.broker_name || b.firm_name} → ${r.found} (${r.basis})\n`);
}
process.stdout.write(`\r ${done} found:${found} noSite:${noSite} `);
await sleep(400);
}
process.stdout.write('\n');
console.log(`done: ${done} tried · ${found} email(s) found · ${noSite} had no resolvable site · $0 (local plain-fetch)`);
if (found && !DRY) console.log(' → wrote agent_email into data/condo-brokers.json (surfaces via /api/condos overlay)');
}
main().catch(e => { console.error(e); process.exit(1); });