← back to Lawyer Directory Builder

src/enrich/firm_website_contacts.ts

204 lines

/**
 * Firm-website contact-page enricher.
 *
 * For each org with a non-empty website:
 *   1. fetch homepage (robots-respected, rate-limited)
 *   2. find candidate contact pages (/contact, /contact-us, /about-us, /our-team, /people)
 *   3. fetch each
 *   4. regex-extract emails + phones
 *   5. upsert into emails / phones with email_type='office', phone_type='office'
 *   6. update organizations.phone if NULL
 *
 * Stays scoped to firms WITH websites (≈154 today). For firms without a website,
 * no free + ToS-clean enrichment path exists tonight; document and defer.
 */
import 'dotenv/config';
import { load } from 'cheerio';
import { fetchCompliant } from '../lib/compliance.ts';
import { pool, query } from '../db/pool.ts';

const SOURCE_NAME = 'Firm website contact pages';

const CONTACT_PATHS = ['/contact', '/contact-us', '/contact_us', '/contactus', '/about', '/about-us', '/our-team', '/team', '/attorneys', '/people', '/our-attorneys'];

const EMAIL_RX = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g;
// Be conservative — accept (xxx) xxx-xxxx, xxx-xxx-xxxx, xxx.xxx.xxxx, +1 xxx-xxx-xxxx
const PHONE_RX = /(?:\+?1[\s.-]?)?\(?(\d{3})\)?[\s.-]?(\d{3})[\s.-]?(\d{4})\b/g;

const BAD_EMAIL_DOMAINS = ['example.com', 'sentry.io', 'godaddy.com', 'wixsite.com', 'wordpress.com', 'gmail.com', 'yahoo.com'];

function normPhone(area: string, prefix: string, line: string) {
  return `(${area}) ${prefix}-${line}`;
}

async function ensureSource(): Promise<number> {
  await query(`
    INSERT INTO sources (source_name, source_type, base_url, terms_notes, allowed_method, rate_limit_rps)
    VALUES ($1, 'directory', '(per-firm websites)',
      'Per-firm website contact pages. Robots.txt respected per host. Polite-use 0.5 rps.',
      'crawl', 0.5)
    ON CONFLICT (source_name) DO NOTHING
  `, [SOURCE_NAME]);
  const r = await query<{ id: number }>(`SELECT id FROM sources WHERE source_name = $1`, [SOURCE_NAME]);
  return r.rows[0].id;
}

async function startJob(sourceId: number, label: string) {
  const r = await query<{ id: number }>(`
    INSERT INTO scrape_jobs (source_id, job_label, status, started_at)
    VALUES ($1, $2, 'running', NOW()) RETURNING id
  `, [sourceId, label]);
  return r.rows[0].id;
}

async function finishJob(jobId: number, fields: Record<string, unknown>) {
  const sets: string[] = [];
  const params: unknown[] = [];
  let i = 1;
  for (const [k, v] of Object.entries(fields)) { sets.push(`${k} = $${i++}`); params.push(v); }
  sets.push(`finished_at = NOW()`);
  params.push(jobId);
  await query(`UPDATE scrape_jobs SET ${sets.join(', ')} WHERE id = $${i}`, params);
}

function extractEmails(html: string): string[] {
  const found = new Set<string>();
  const matches = html.match(EMAIL_RX) || [];
  for (const m of matches) {
    const lower = m.toLowerCase();
    const dom = lower.split('@')[1];
    if (BAD_EMAIL_DOMAINS.some(b => dom.endsWith(b))) continue;
    if (lower.includes('@sentry-next.wixpress.com')) continue;
    if (/\.(png|jpg|gif|svg|webp)$/.test(lower)) continue;
    found.add(lower);
  }
  return [...found];
}

function extractPhones(text: string): string[] {
  const found = new Set<string>();
  let m: RegExpExecArray | null;
  while ((m = PHONE_RX.exec(text)) !== null) {
    const area = m[1]; const prefix = m[2]; const line = m[3];
    if (area === '000' || prefix === '000') continue;
    if (area === '111' || area === '999') continue;
    if (area[0] === '0' || area[0] === '1') continue;     // not a real area code
    found.add(normPhone(area, prefix, line));
  }
  return [...found];
}

async function enrichOne(org: { id: number; name: string; website: string }, sourceId: number): Promise<{ phones: number; emails: number }> {
  const baseUrl = (() => {
    try { return new URL(org.website); } catch { return null; }
  })();
  if (!baseUrl) return { phones: 0, emails: 0 };

  // Build candidate URLs: homepage + N contact pages.
  const candidates = [baseUrl.href, ...CONTACT_PATHS.map(p => new URL(p, baseUrl).href)];
  const seenHtml = new Set<string>();
  const allEmails = new Set<string>();
  const allPhones = new Set<string>();

  for (const url of candidates.slice(0, 3)) {                  // hard cap 3 fetches/firm
    try {
      const res = await fetchCompliant(url, { timeoutMs: 15000 });
      if (!res || res.status >= 400) continue;
      const html = await res.text();
      if (seenHtml.has(html.slice(0, 4000))) continue;
      seenHtml.add(html.slice(0, 4000));

      const $ = load(html);
      $('script, style, noscript').remove();
      const body = $('body').text();

      for (const e of extractEmails(html)) allEmails.add(e);
      for (const p of extractPhones(body)) allPhones.add(p);
    } catch (e) {
      const code = (e as { code?: string }).code;
      if (code === 'ROBOTS_DISALLOWED' || code === 'CAPTCHA_DETECTED') return { phones: 0, emails: 0 };
      // transient error: try next candidate
    }
  }

  let phoneCount = 0;
  let emailCount = 0;

  for (const phone of allPhones) {
    const exists = await query(`SELECT 1 FROM phones WHERE organization_id=$1 AND phone=$2 LIMIT 1`, [org.id, phone]);
    if (exists.rowCount === 0) {
      await query(`INSERT INTO phones (organization_id, phone, phone_type, source_url, last_verified_at)
                   VALUES ($1,$2,'office',$3,NOW())`, [org.id, phone, org.website]);
      phoneCount++;
    }
  }
  if (allPhones.size > 0) {
    // Update primary phone on org if NULL.
    await query(`UPDATE organizations SET phone = COALESCE(phone, $2) WHERE id = $1`, [org.id, [...allPhones][0]]);
  }
  for (const email of allEmails) {
    const exists = await query(`SELECT 1 FROM emails WHERE organization_id=$1 AND LOWER(email)=LOWER($2) LIMIT 1`, [org.id, email]);
    if (exists.rowCount === 0) {
      await query(`INSERT INTO emails (organization_id, email, email_type, source_url, last_verified_at)
                   VALUES ($1,$2,'office',$3,NOW())`, [org.id, email, org.website]);
      emailCount++;
    }
  }

  return { phones: phoneCount, emails: emailCount };
}

async function main() {
  console.log('[enrich] firm-website contact crawl…');
  const sourceId = await ensureSource();
  const jobId = await startJob(sourceId, 'enrich:firm-website-contacts');

  const skipDoneDays = Number(process.env.SKIP_DONE_DAYS ?? '30');
  const limit = Number(process.env.LIMIT ?? '0');
  const r = await query<{ id: number; name: string; website: string }>(`
    SELECT id, name, website FROM organizations
    WHERE type='law_firm' AND website ~ '^https?://'
      AND (contact_crawled_at IS NULL OR contact_crawled_at < NOW() - ($1 || ' days')::interval)
    ORDER BY id
    ${limit > 0 ? `LIMIT ${limit}` : ''}
  `, [String(skipDoneDays)]);
  const firms = r.rows;
  console.log(`[enrich] ${firms.length} firms to crawl (skip-done-days=${skipDoneDays}${limit ? `, limit=${limit}` : ''})`);

  const concurrency = Number(process.env.CONCURRENCY ?? '8');
  let phoneTot = 0, emailTot = 0, processed = 0, errors = 0;
  let cursor = 0;
  async function worker() {
    while (cursor < firms.length) {
      const f = firms[cursor++];
      try {
        const { phones, emails } = await enrichOne(f, sourceId);
        phoneTot += phones; emailTot += emails;
        await query(`UPDATE organizations SET contact_crawled_at = NOW() WHERE id = $1`, [f.id]);
        processed++;
        if (processed % 25 === 0) console.log(`[enrich] ${processed}/${firms.length}: phones+=${phoneTot} emails+=${emailTot}`);
      } catch (e) {
        errors++;
        console.error(`[enrich] err ${f.name}: ${(e as Error).message}`);
      }
    }
  }
  console.log(`[enrich] concurrency=${concurrency}`);
  await Promise.all(Array.from({ length: concurrency }, () => worker()));

  await finishJob(jobId, {
    status: 'completed',
    records_found: firms.length,
    records_inserted: phoneTot + emailTot,
    records_skipped: errors,
  });
  console.log(`[enrich] done. firms=${firms.length} phones+=${phoneTot} emails+=${emailTot} errors=${errors}`);
  await pool.end();
}

main().catch(async (err) => {
  console.error('[enrich] fatal:', err);
  try { await pool.end(); } catch {}
  process.exit(1);
});