← back to Nationalrealestate

src/enrich/firm_email_crawl.ts

159 lines

/**
 * firm_email_crawl — COLLECT public contact emails for commercial firms.
 *
 * We already have ~1,190 commercial firm websites (from the Places resolve pass).
 * This crawls each firm's OWN site (homepage + a few likely contact pages),
 * extracts the public contact email(s) they publish (info@, leasing@, sales@…),
 * and stores them in firm_contacts(kind='email') for DISPLAY on the directory
 * card. It is a collection-only pass: nothing is ever emailed. $0 (local fetch).
 *
 * Idempotent: firm_contacts has a UNIQUE (firm_id, kind, value) index, so
 * re-runs INSERT ... ON CONFLICT DO NOTHING and never duplicate.
 *
 * Usage:
 *   npx tsx src/enrich/firm_email_crawl.ts            # dry-run (no writes)
 *   npx tsx src/enrich/firm_email_crawl.ts --live     # write to firm_contacts
 *   ... --limit=200   --asset=commercial (default)    --concurrency=8
 */
import 'dotenv/config';
import { pool, query } from '../../db/pool.ts';

// Node's fetch (undici) can negotiate HTTP/2; a remote GOAWAY surfaces as a stray
// 'error' event on the Http2Session — OUTSIDE the promise chain, so a per-firm
// try/catch can't see it and Node escalates it to a process-killing uncaughtException.
// A background crawler must survive these; log and continue (only HTTP fetches +
// idempotent inserts are in flight, so there's no corruptible state to protect).
process.on('uncaughtException', (e) => console.log(`  [uncaught] ${(e as Error).message}`));
process.on('unhandledRejection', (e) => console.log(`  [unhandledRejection] ${String(e)}`));

const LIVE = process.argv.includes('--live');
const LIMIT = Number(process.argv.find(a => a.startsWith('--limit='))?.split('=')[1] || 0) || 0;
const CONCURRENCY = Number(process.argv.find(a => a.startsWith('--concurrency='))?.split('=')[1] || 8);
const ASSET_RAW = (process.argv.find(a => a.startsWith('--asset='))?.split('=')[1] || 'commercial').toLowerCase();
const ASSET = ['commercial', 'residential'].includes(ASSET_RAW) ? ASSET_RAW : 'commercial';

const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
const TIMEOUT_MS = 10_000;
const MAX_EMAILS_PER_FIRM = 3;
const CONTACT_PATHS = ['/contact', '/contact-us', '/contactus', '/about', '/about-us'];

const EMAIL_RE = /[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/gi;

// Junk the regex will otherwise happily grab off a page.
const BAD_DOMAINS = /(example|yourdomain|domain\.com|email\.com|sentry|wixpress|squarespace|godaddy|cloudflare|w3\.org|schema\.org|sentry\.io|googleapis|gstatic)/i;
const ASSET_EXT = /\.(png|jpe?g|gif|svg|webp|ico|css|js|mp4|pdf|woff2?|ttf)$/i;
const NOISE_LOCAL = /^(no-?reply|do-?not-?reply|donotreply|postmaster|abuse|mailer-daemon)/i;
const ROLE_PREFIX = /^(info|contact|sales|hello|admin|leasing|office|mail|inquir(?:y|ies)|reception|team|hi|support)@/i;

interface FirmRow { id: number; name: string; website: string }

function registrable(host: string): string {
  const parts = host.replace(/^www\./, '').toLowerCase().split('.');
  return parts.slice(-2).join('.');
}

function fetchHtml(url: string): Promise<string> {
  return fetch(url, {
    headers: { 'User-Agent': UA, 'Accept': 'text/html,application/xhtml+xml', 'Accept-Language': 'en-US,en;q=0.9' },
    redirect: 'follow',
    signal: AbortSignal.timeout(TIMEOUT_MS),
  }).then(r => (r.ok ? r.text() : Promise.reject(new Error(`http ${r.status}`))));
}

// Pull emails from a page: mailto: hrefs first (highest quality), then raw text.
function extractEmails(html: string): string[] {
  const found = new Set<string>();
  for (const m of html.matchAll(/mailto:([^"'?>\s]+)/gi)) {
    try { found.add(decodeURIComponent(m[1])); } catch { found.add(m[1]); }  // malformed %-escape → keep raw
  }
  for (const m of html.matchAll(EMAIL_RE)) found.add(m[0]);
  return [...found]
    .map(e => e.trim().toLowerCase())
    .filter(e => e.includes('@') && !ASSET_EXT.test(e) && !BAD_DOMAINS.test(e) && !NOISE_LOCAL.test(e))
    .filter(e => { const d = e.split('@')[1] || ''; return d.includes('.') && d.split('.').pop()!.length >= 2 && d.length <= 60; });
}

// Rank: same-domain-as-website beats off-domain; role address beats a person's.
function rankEmails(emails: string[], siteDomain: string): string[] {
  return [...new Set(emails)]
    .map(e => {
      let score = 0;
      if (registrable(e.split('@')[1]) === siteDomain) score += 10;
      if (ROLE_PREFIX.test(e)) score += 5;
      return { e, score };
    })
    .sort((a, b) => b.score - a.score)
    .map(x => x.e)
    .slice(0, MAX_EMAILS_PER_FIRM);
}

async function crawlFirm(f: FirmRow): Promise<{ id: number; emails: string[]; via: string }> {
  let base: URL;
  try { base = new URL(f.website.startsWith('http') ? f.website : 'https://' + f.website); }
  catch { return { id: f.id, emails: [], via: 'bad-url' }; }
  const siteDomain = registrable(base.host);

  // Homepage first; only hit contact pages if it yields nothing.
  const pages = [base.href, ...CONTACT_PATHS.map(p => new URL(p, base.origin).href)];
  let via = '';
  const all: string[] = [];
  for (const url of pages) {
    let html: string;
    try { html = await fetchHtml(url); } catch { continue; }
    const hits = extractEmails(html);
    if (hits.length) { all.push(...hits); via = url; if (all.length) break; }
  }
  return { id: f.id, emails: rankEmails(all, siteDomain), via };
}

async function main() {
  // Website comes from EITHER the Places resolve (firm.website, commercial) OR the
  // free SERP discovery (firm_site.url, residential) — read both so this crawl chains
  // off whichever pass populated the site.
  const { rows } = await query<FirmRow>(
    `SELECT f.id, f.name, COALESCE(NULLIF(f.website,''), fs.url) AS website
       FROM firm f
       LEFT JOIN firm_site fs ON fs.firm_id = f.id AND NULLIF(fs.url,'') IS NOT NULL
      WHERE f.asset_class = $1
        AND COALESCE(NULLIF(f.website,''), fs.url) IS NOT NULL
        AND f.id NOT IN (SELECT firm_id FROM firm_contacts WHERE kind = 'email')
      ORDER BY f.agent_count DESC NULLS LAST, f.id ${LIMIT ? 'LIMIT ' + LIMIT : ''}`, [ASSET]);

  console.log(`[email-crawl] ${ASSET} · ${rows.length} firms with a site & no email yet · concurrency ${CONCURRENCY} · ${LIVE ? 'LIVE (writing)' : 'DRY-RUN'}`);

  let done = 0, withEmail = 0, emailsWritten = 0;
  const queue = [...rows];
  async function worker() {
    for (;;) {
      const f = queue.shift();
      if (!f) return;
      let r;
      try { r = await crawlFirm(f); }              // one bad firm must never kill the batch
      catch (e) { done++; console.log(`  [${done}/${rows.length}] ${f.name.slice(0,32)} → ERR ${(e as Error).message}`); continue; }
      done++;
      if (r.emails.length) {
        withEmail++;
        if (LIVE) {
          for (const e of r.emails) {
            const res = await query(
              `INSERT INTO firm_contacts (firm_id, kind, value, source_url)
               VALUES ($1, 'email', $2, $3)
               ON CONFLICT (firm_id, kind, value) DO NOTHING`, [f.id, e, r.via]);
            emailsWritten += res.rowCount || 0;
          }
        }
        if (done % 25 === 0 || r.emails.length)
          console.log(`  [${done}/${rows.length}] ${f.name.slice(0, 32).padEnd(32)} → ${r.emails.join(', ')}`);
      } else if (done % 100 === 0) {
        console.log(`  [${done}/${rows.length}] …`);
      }
    }
  }
  await Promise.all(Array.from({ length: CONCURRENCY }, worker));

  console.log(`\n[email-crawl] done. crawled ${done} · firms with ≥1 email ${withEmail} (${(withEmail / (done || 1) * 100).toFixed(1)}%) · rows written ${emailsWritten} · cost $0 (local fetch)`);
  await pool.end();
}

main().catch(e => { console.error(e); process.exit(1); });