← back to Commercialrealestate

scripts/enrich-tier2-firmsites.js

131 lines

// enrich-tier2-firmsites.js — Job B TIER 2 ($0, plain HTTPS): fetch each firm's website (homepage +
// likely team/contact/about pages), extract business phone + email (mailto:/tel: + regex), and assign
// them to that firm's brokers. Name-aware: when a broker's name appears near a tel:/mailto:, that
// contact is attributed to the broker; otherwise a single office phone/email is treated as the firm's
// main line and applied to brokers at that firm who still lack one. CCPA: business contacts only;
// source URL recorded per field. NO send. Fully local — $0.
//
// Usage: node scripts/enrich-tier2-firmsites.js     (no Browserbase; uses global fetch)
'use strict';
const db = require('./db/brokers-db');

const CANDIDATE_PATHS = ['', '/team', '/our-team', '/agents', '/brokers', '/people', '/contact', '/about', '/about-us', '/staff', '/professionals'];
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';

function normUrl(w) {
  if (!w) return null;
  w = w.trim();
  if (!/^https?:\/\//i.test(w)) {
    if (!/\./.test(w)) return null;              // 'lyonstahl' etc. — not a usable host
    w = 'https://' + w;
  }
  try { const u = new URL(w); return u.origin; } catch { return null; }
}

const EMAIL_RE = /[a-z0-9](?:[a-z0-9._%+-]{0,40})@(?:[a-z0-9-]+\.)+[a-z]{2,8}/gi;
const PHONE_RE = /(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}/g;
const BAD_EMAIL = /\.(png|jpg|jpeg|gif|webp|svg|css|js)$|@(?:sentry|wix|example|email|domain|2x|godaddy)\b|@sentry\./i;

async function fetchText(url, ms = 9000) {
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), ms);
  try {
    const r = await fetch(url, { headers: { 'user-agent': UA, accept: 'text/html' }, redirect: 'follow', signal: ctrl.signal });
    if (!r.ok) return null;
    const ct = r.headers.get('content-type') || '';
    if (!/html|text/.test(ct)) return null;
    const txt = await r.text();
    return txt.slice(0, 600000);
  } catch { return null; } finally { clearTimeout(t); }
}

function harvest(html) {
  const emails = new Set(), phones = new Set();
  // mailto:/tel: are the highest-confidence.
  for (const m of html.matchAll(/mailto:([^"'?>\s]+)/gi)) { let e; try { e = decodeURIComponent(m[1]); } catch { e = m[1]; } e = e.toLowerCase(); if (/@/.test(e) && !BAD_EMAIL.test(e)) emails.add(e); }
  for (const m of html.matchAll(/tel:([^"'>\s]+)/gi)) { const p = m[1].replace(/[^\d+]/g, ''); if (p.replace(/\D/g, '').length >= 10) phones.add(p); }
  // Fallback regex over visible text (strip tags first to avoid matching asset hashes).
  const text = html.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ');
  for (const m of text.matchAll(EMAIL_RE)) { const e = m[0].toLowerCase(); if (!BAD_EMAIL.test(e)) emails.add(e); }
  for (const m of text.matchAll(PHONE_RE)) { const p = m[0].replace(/[^\d]/g, ''); if (p.length === 10 || (p.length === 11 && p[0] === '1')) phones.add(p.length === 11 ? p.slice(1) : p); }
  return { emails: [...emails], phones: [...phones] };
}

function fmtPhone(p) { const d = p.replace(/\D/g, '').slice(-10); return d.length === 10 ? `(${d.slice(0,3)}) ${d.slice(3,6)}-${d.slice(6)}` : p; }

async function setField(brokerId, field, value, sourceUrl, tier) {
  if (value == null || value === '') return;
  await db.pool.query(
    `INSERT INTO broker_field_source(broker_id, field, value, source_url, tier)
     VALUES($1,$2,$3,$4,$5) ON CONFLICT(broker_id, field) DO NOTHING`,
    [brokerId, field, String(value).slice(0, 500), sourceUrl, tier]);
  const col = { phone: 'phone', email: 'email', website: 'website', linkedin: 'linkedin' }[field];
  if (col) await db.pool.query(`UPDATE broker SET ${col}=COALESCE(${col},$2), enriched_at=now() WHERE id=$1 AND ${col} IS NULL`, [brokerId, String(value).slice(0,500)]);
}

(async () => {
  // Group brokers by their firm website. Only those missing phone/email matter.
  const rows = (await db.pool.query(
    `SELECT b.id, b.name, b.phone, b.email, b.website, f.name AS firm
       FROM broker b LEFT JOIN firm f ON f.id=b.firm_id
      WHERE b.website IS NOT NULL`)).rows;
  const byHost = new Map();
  for (const b of rows) {
    const origin = normUrl(b.website); if (!origin) continue;
    if (!byHost.has(origin)) byHost.set(origin, []);
    byHost.get(origin).push(b);
  }
  const hosts = [...byHost.keys()];
  process.stderr.write(`Tier 2: ${hosts.length} distinct firm sites, ${rows.length} brokers w/ website\n`);

  let sitesOk = 0, emailAssigned = 0, phoneAssigned = 0, linkedinAssigned = 0, done = 0;
  // Bounded concurrency.
  const CONC = 8;
  let idx = 0;
  async function worker() {
    while (idx < hosts.length) {
      const origin = hosts[idx++];
      const brokers = byHost.get(origin);
      const found = { emails: new Set(), phones: new Set(), linkedin: new Set() };
      let any = false, usedUrl = origin;
      for (const p of CANDIDATE_PATHS) {
        const url = origin + p;
        const html = await fetchText(url);
        if (!html) continue;
        any = true; if (p) usedUrl = url;
        const h = harvest(html);
        h.emails.forEach(e => found.emails.add(e));
        h.phones.forEach(ph => found.phones.add(ph));
        for (const m of html.matchAll(/https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/[A-Za-z0-9_-]+/gi)) found.linkedin.add(m[0]);
        // Stop early once we have a solid hit on a team-ish page.
        if (p && (found.emails.size || found.phones.size)) break;
      }
      if (any) sitesOk++;
      // Prefer firm-domain emails (info@/sales@ excluded as generic unless nothing else).
      const host = origin.replace(/^https?:\/\//, '').replace(/^www\./, '');
      const domainEmails = [...found.emails].filter(e => e.split('@')[1] && host.includes(e.split('@')[1].replace(/^www\./, '').split('.').slice(-2).join('.').split('.')[0]));
      const bestEmail = (domainEmails[0] || [...found.emails].find(e => !/^(info|contact|sales|admin|hello|office)@/i.test(e)) || [...found.emails][0]);
      const bestPhone = [...found.phones][0];
      const bestLi = [...found.linkedin][0];

      // Assign to brokers at this firm who still lack the field (office-level fallback).
      for (const b of brokers) {
        if (!b.email && bestEmail) { await setField(b.id, 'email', bestEmail, usedUrl, 'tier2-firmsite'); emailAssigned++; }
        if (!b.phone && bestPhone) { await setField(b.id, 'phone', fmtPhone(bestPhone), usedUrl, 'tier2-firmsite'); phoneAssigned++; }
        if (bestLi) { await setField(b.id, 'linkedin', bestLi, usedUrl, 'tier2-firmsite'); linkedinAssigned++; }
      }
      done++;
      if (done % 25 === 0) process.stderr.write(`  ${done}/${hosts.length} sites · email+${emailAssigned} phone+${phoneAssigned}\r`);
    }
  }
  await Promise.all(Array.from({ length: CONC }, worker));

  const st = (await db.pool.query(
    `SELECT count(*) FILTER (WHERE phone IS NOT NULL) phone, count(*) FILTER (WHERE email IS NOT NULL) email,
            count(*) FILTER (WHERE website IS NOT NULL) website, count(*) FILTER (WHERE linkedin IS NOT NULL) linkedin,
            count(*) total FROM broker`)).rows[0];
  process.stderr.write(`\nTier 2: ${sitesOk}/${hosts.length} sites reachable · assigned email ${emailAssigned} phone ${phoneAssigned} linkedin ${linkedinAssigned}\n`);
  console.log(JSON.stringify({ tier: 2, cost: '$0 (local HTTPS)', sitesReachable: sitesOk, dbState: st }));
  await db.pool.end();
})();