← back to Commercialrealestate

scripts/broker-email-finder.js

61 lines

#!/usr/bin/env node
/*
 * broker-email-finder.js — for brokers that have a website but no email, visit the site,
 * find the "Contact" area, and extract an email. $0 (plain fetch, no paid API, no browser).
 *   node broker-email-finder.js --test 5          # dry: 5 candidates, print, NO db write
 *   node broker-email-finder.js --ids 293,1015     # dry: specific brokers
 *   node broker-email-finder.js --limit 50 --commit  # write found emails to broker.email
 * Strategy: fetch homepage → collect mailto: + text emails → also fetch up to 3 "contact"-ish
 * linked pages → score candidates (prefer address on the site's own domain; drop junk/role-y noise).
 * The engine (fetch/extract/pick guards) lives in lib/email-hunt.js — shared with serve.js's
 * on-demand /api/broker/find-email button so the batch and the button can never diverge.
 */
'use strict';
const { pool } = require('./db/brokers-db');
const { huntEmails, siteDomain, withTimeout, sleep } = require('./lib/email-hunt');
const args = process.argv.slice(2);
const COMMIT = args.includes('--commit');
const ti = args.indexOf('--test'); const TEST_N = ti > -1 ? +args[ti + 1] : 0;
const ii = args.indexOf('--ids'); const IDS = ii > -1 ? args[ii + 1].split(',').map(Number) : null;
const li = args.indexOf('--limit'); const LIMIT = li > -1 ? +args[li + 1] : 25;
// --browser: when plain fetch fails (403 anti-bot) or returns no email (JS-rendered contact),
// retry with a local headless Chrome (Playwright, $0). Real browser fingerprint + JS execution
// recovers a big chunk of the plain-fetch misses. Shared browser instance, reused across brokers.
const USE_BROWSER = args.includes('--browser');
const { browserHtml, closeBrowser } = require('./lib/email-hunt-browser');

(async () => {
  let sql, params = [];
  if (IDS) { sql = `SELECT id, name, website FROM broker WHERE id = ANY($1)`; params = [IDS]; }
  else {
    const n = TEST_N || LIMIT;
    sql = `SELECT id, name, website FROM broker
             WHERE website IS NOT NULL AND website<>'' AND (email IS NULL OR email='') AND website ~* '^https?://'
             ORDER BY (SELECT count(*) FROM broker_listing bl WHERE bl.broker_id=broker.id) DESC NULLS LAST
             LIMIT ${n}`;
  }
  const rows = (await pool.query(sql, params)).rows;
  console.log(`brokers to scan: ${rows.length} | mode ${COMMIT ? 'COMMIT' : 'DRY (no db write)'}\n`);
  let found = 0, wrote = 0;
  for (const b of rows) {
    const cap = USE_BROWSER ? 75000 : 45000;   // browser tier legitimately needs longer
    const r = await withTimeout(huntEmails(b, USE_BROWSER ? { browserHtml } : {}), cap, () => ({ id: b.id, name: b.name, website: b.website, domain: siteDomain(b.website), found: null, basis: 'timeout', all: [], tried: [{ status: 'HANG>cap' }] }));
    if (r.found) {
      found++;
      console.log(`✓ ${r.name}  (#${r.id})  [${r.basis}]`);
      console.log(`    site: ${r.website}`);
      console.log(`    EMAIL: ${r.found}${r.all.length > 1 ? '   [others: ' + r.all.filter(e => e !== r.found).slice(0, 4).join(', ') + ']' : ''}`);
      if (COMMIT) { await pool.query(`UPDATE broker SET email=$1 WHERE id=$2 AND (email IS NULL OR email='')`, [r.found, r.id]); wrote++; }
    } else {
      const why = r.tried.map(t => `${t.status || t.err || '?'}`).join('/');
      const reason = r.basis === 'ambiguous-shared-page' ? `refused: shared page, ${r.all.length} other-agent emails, none match ${r.name}`
        : (r.tried.every(t => t.status && t.status < 400)) ? 'reached site but no email present' : `fetch: ${why}`;
      console.log(`✗ ${r.name}  (#${r.id})  — no email  [${r.website}]  (${reason})`);
    }
    await sleep(500);
  }
  console.log(`\nDONE: ${found}/${rows.length} emails found${COMMIT ? `, ${wrote} written to broker.email` : ' (dry — nothing written)'}. $0 (local${USE_BROWSER ? ' fetch+browser' : ' fetch'}).`);
  await closeBrowser();
  await pool.end();
})().catch(async e => { await closeBrowser(); console.error(e); process.exit(1); });