← back to Professional Directory

scripts/scrape-emails-llm.js

302 lines

#!/usr/bin/env node
/**
 * LLM-driven email scraper.
 *
 * For every websited org with no email yet, fetch the homepage + several
 * staff/contact-shaped subpaths, then ask qwen3:14b (running on both
 * Mac Studio 2 [127.0.0.1:11434] and Mac Studio 1 [192.168.1.133:11434])
 * to identify every email on each page and which person it belongs to.
 *
 * Each extracted person email is matched against the professionals already
 * linked to that org via professional_locations. On match → emails row gets
 * professional_id. Otherwise → organization_id.
 *
 *   node scripts/scrape-emails-llm.js
 *   LIMIT=10 node ...                # smoke
 *   ONLY_DOCTORS=1 node ...          # restrict to doctor-type orgs
 */
const cheerio = require('cheerio');
const { fetch } = require('undici');
const { pool, query } = require('../agents/shared/db');
const { fetchCompliant, setHostRateLimit } = require('../agents/shared/compliance');

const LIMIT = process.env.LIMIT ? Number(process.env.LIMIT) : null;
const ONLY_DOCTORS = process.env.ONLY_DOCTORS === '1';

const DOCTOR_TYPES = ['medical_group','clinic','dental_office','optometrist_office','surgery_center','hospital','fqhc','tcm_clinic','acupuncture_clinic','chiropractor_office','tcm_herbalist','rcfe','ccrc','adult_residential','home_care_agency','home_health','hospice','nursing_facility'];

// Pages most likely to host staff emails.
const SUBPATHS = [
  '', '/contact', '/contact-us', '/about', '/about-us',
  '/team', '/our-team', '/meet-our-team', '/staff',
  '/providers', '/our-providers', '/doctors', '/our-doctors',
  '/physicians', '/dentists', '/optometrists',
  '/leadership', '/who-we-are',
];

// MS1 (192.168.1.133) is currently saturated by lawyer-directory-builder
// hammering qwen3:32b. We use MS2 only with gemma3:12b (already loaded,
// fast, free of contention). Override via OLLAMA_HOSTS / MODEL env.
const OLLAMA_HOSTS = (process.env.OLLAMA_HOSTS || 'http://127.0.0.1:11434|ms2')
  .split(',')
  .map(s => { const [url, label] = s.split('|'); return { url, label: label || url }; });
const MODEL = process.env.MODEL || 'gemma3:12b';

let hostIdx = 0;
function nextHost() { const h = OLLAMA_HOSTS[hostIdx % OLLAMA_HOSTS.length]; hostIdx++; return h; }

const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
const DECOY_RE = /^(noreply|no-reply|donotreply|do-not-reply|example|test|sentry|webmaster|security|abuse)@/i;
const VENDOR_DOMAINS = new Set([
  'sentry.io','sentry-next.wixpress.com','wix.com','squarespace.com','godaddy.com',
  'cloudflare.com','example.com','example.org','tld.com','site.com','domain.com','website.com','sample.com',
]);

function isPlausibleEmail(e) {
  if (!e || typeof e !== 'string' || e.length > 100 || !e.includes('@')) return false;
  if (DECOY_RE.test(e)) return false;
  const dom = e.split('@')[1];
  if (!dom || VENDOR_DOMAINS.has(dom.toLowerCase())) return false;
  if (/\.(png|jpe?g|gif|svg|webp|css|js)$/i.test(e)) return false;
  if (/[a-f0-9]{32,}/i.test(e.split('@')[0])) return false;
  return true;
}

function originOf(websiteRaw) {
  let s = String(websiteRaw || '').trim();
  if (!s) return null;
  if (!/^https?:\/\//i.test(s)) s = 'https://' + s;
  try { return new URL(s); } catch { return null; }
}

function plainText($) {
  $('script, style, noscript, header nav, footer nav').remove();
  return ($('body').text() || $.text() || '')
    .replace(/[ \t]+/g, ' ')
    .replace(/\n{3,}/g, '\n\n')
    .trim();
}

async function fetchPageText(url) {
  try {
    const res = await fetchCompliant(url, { accept: 'text/html' });
    if (!res.ok) { if (process.env.LLM_DEBUG) console.error(`  fetch ${res.status} ${url}`); return null; }
    const ct = res.headers.get('content-type') || '';
    if (!/html|text/i.test(ct)) { if (process.env.LLM_DEBUG) console.error(`  fetch non-html ${url}`); return null; }
    const html = await res.text();
    if (!html || !html.includes('@')) { if (process.env.LLM_DEBUG) console.error(`  fetch no@ ${url}`); return null; }
    const $ = cheerio.load(html);
    // Return the FULL plain text — emails are often in the footer, past where
    // a naive slice would cut. The LLM call truncates separately to fit ctx.
    return plainText($);
  } catch (e) {
    if (process.env.LLM_DEBUG) console.error(`  fetch err ${e.code || ''} ${e.message} ${url}`);
    if (e?.code === 'CAPTCHA_DETECTED') return null;
    return null;
  }
}

async function llmExtract(pageText, orgName, sourceUrl) {
  const host = nextHost();
  const prompt = `Extract every email address from this page of "${orgName}" and (when stated) the person each email belongs to.

Return ONLY valid JSON in this exact shape, no commentary:
{"emails":[{"email":"...","person":"Full Name or null","role":"Title or null","kind":"person|generic"}]}

If none, return: {"emails":[]}.

PAGE TEXT:
"""
${pageText.slice(0, 12000)}
"""`;

  // AbortSignal.timeout occasionally fails to fire under undici when a TCP
  // connection is established but reads stall — use an explicit AbortController
  // with our own setTimeout to guarantee abortion.
  const ac = new AbortController();
  const killer = setTimeout(() => ac.abort(), 45_000);

  try {
    const res = await fetch(host.url + '/api/generate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        model: MODEL,
        prompt,
        stream: false,
        format: 'json',
        keep_alive: '10m',
        options: { temperature: 0.1, num_ctx: 6144 },
      }),
      signal: ac.signal,
    });
    clearTimeout(killer);
    if (!res.ok) return [];
    const j = await res.json();
    const out = JSON.parse(j.response || '{}');
    const emails = Array.isArray(out.emails) ? out.emails : [];
    return emails.filter(e => e && isPlausibleEmail(e.email)).map(e => ({
      email: String(e.email).toLowerCase().trim(),
      person: e.person && String(e.person).trim() || null,
      role: e.role && String(e.role).trim() || null,
      kind: e.kind === 'person' ? 'person' : 'generic',
      page: sourceUrl,
      via: host.label,
    }));
  } catch (e) {
    clearTimeout(killer);
    return [];
  }
}

function fallbackRegexExtract(pageText, sourceUrl) {
  if (!pageText) return [];
  const out = new Map();
  for (const m of pageText.matchAll(EMAIL_RE)) {
    const e = String(m[0]).toLowerCase().trim();
    if (isPlausibleEmail(e) && !out.has(e)) out.set(e, { email: e, person: null, role: null, kind: 'generic', page: sourceUrl, via: 'regex' });
  }
  return [...out.values()];
}

function tokens(s) {
  return String(s || '').toLowerCase().normalize('NFKD').replace(/[^a-z\s]/g, ' ').split(/\s+/).filter(Boolean);
}

function nameOverlap(personName, fullName) {
  if (!personName || !fullName) return 0;
  const a = new Set(tokens(personName));
  const b = new Set(tokens(fullName));
  let hit = 0;
  for (const t of a) if (b.has(t) && t.length >= 2) hit++;
  return hit;
}

async function matchPersonToProfessional(orgId, personName) {
  if (!personName) return null;
  const pros = (await query(
    `SELECT p.id, p.full_name FROM professional_locations pl
       JOIN professionals p ON p.id = pl.professional_id
      WHERE pl.organization_id = $1 AND p.opted_out = false`,
    [orgId]
  )).rows;
  if (pros.length === 0) return null;
  let best = null, bestScore = 0;
  for (const p of pros) {
    const s = nameOverlap(personName, p.full_name);
    if (s > bestScore) { bestScore = s; best = p; }
  }
  return bestScore >= 2 ? best : null;
}

async function processOrg(o) {
  const origin = originOf(o.website);
  if (!origin) return { pages: 0, found: 0, person_emails: 0, org_emails: 0 };

  setHostRateLimit(origin.hostname, 1.0);

  const seen = new Map();
  let pagesHit = 0;
  let llmCalls = 0;
  for (const sub of SUBPATHS) {
    const url = origin.origin + (sub === '' ? '/' : sub);
    const txt = await fetchPageText(url);
    if (!txt) continue;
    pagesHit++;
    if (process.env.LLM_DEBUG) console.error(`  fetched ${url} (${txt.length}B)`);

    // Always grab regex-found emails (cheap, never wrong).
    const regex = fallbackRegexExtract(txt, url);
    if (process.env.LLM_DEBUG && regex.length > 0) console.error(`  regex found ${regex.length} on ${url}: ${regex.map(e=>e.email).join(',')}`);
    for (const e of regex) {
      if (!seen.has(e.email)) seen.set(e.email, e);
    }

    // Only spend an LLM call when (a) the page has @ AND (b) the page reads
    // like a team/contact page (so we can attribute to a person). Skip the
    // LLM entirely on homepage pages where the regex usually suffices.
    const isStaffShape = /\b(team|staff|provider|doctor|physician|dentist|optometrist|nurse|leadership|about)\b/i.test(sub);
    if (isStaffShape && llmCalls < 3 && txt.includes('@')) {
      const extracted = await llmExtract(txt, o.name, url);
      llmCalls++;
      for (const e of extracted) {
        if (!seen.has(e.email)) seen.set(e.email, e);
        else if (e.kind === 'person' && seen.get(e.email).kind !== 'person') seen.set(e.email, e);
      }
    }
    if (seen.size >= 12 || pagesHit >= 8) break;
  }

  let personEmails = 0, orgEmails = 0;
  for (const e of seen.values()) {
    let proId = null;
    if (e.kind === 'person') {
      const m = await matchPersonToProfessional(o.id, e.person);
      if (m) proId = m.id;
    }
    try {
      if (proId) {
        await query(
          `INSERT INTO emails (professional_id, email, email_type, source_url, last_verified_at, verification_status)
           VALUES ($1, $2, $3, $4, NULL, 'unverified')
           ON CONFLICT DO NOTHING`,
          [proId, e.email, e.role || 'office', e.page]
        );
        personEmails++;
      } else {
        await query(
          `INSERT INTO emails (organization_id, email, email_type, source_url, last_verified_at, verification_status)
           VALUES ($1, $2, $3, $4, NULL, 'unverified')
           ON CONFLICT DO NOTHING`,
          [o.id, e.email, e.kind === 'person' ? 'staff_unmatched' : 'office', e.page]
        );
        orgEmails++;
      }
    } catch (_) { /* dup or fk - skip */ }
  }
  return { pages: pagesHit, found: seen.size, person_emails: personEmails, org_emails: orgEmails };
}

async function main() {
  const where = [
    `o.website IS NOT NULL`, `o.website <> ''`,
    `NOT EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = o.id AND e.source_url LIKE 'http%')`,
  ];
  if (ONLY_DOCTORS) where.push(`o.type = ANY($1)`);
  const params = ONLY_DOCTORS ? [DOCTOR_TYPES] : [];
  const sql = `
    SELECT o.id, o.name, o.website, o.type
      FROM organizations o
     WHERE ${where.join(' AND ')}
     ORDER BY o.id
     ${LIMIT ? `LIMIT ${LIMIT}` : ''}
  `;
  const r = await query(sql, params);
  console.log(`[llm-email] candidates=${r.rowCount} hosts=${OLLAMA_HOSTS.map(h => h.label).join('+')}`);

  let orgs = 0, totalPersonEmails = 0, totalOrgEmails = 0, withAny = 0;
  for (const o of r.rows) {
    orgs++;
    try {
      const stat = await processOrg(o);
      totalPersonEmails += stat.person_emails;
      totalOrgEmails    += stat.org_emails;
      if (stat.person_emails + stat.org_emails > 0) withAny++;
      if (orgs % 5 === 0 || stat.found > 0) {
        console.log(`[llm-email] orgs=${orgs}/${r.rowCount} pages=${stat.pages} found=${stat.found} (person=${stat.person_emails}, org=${stat.org_emails}) | totals: p=${totalPersonEmails} o=${totalOrgEmails} with_any=${withAny}`);
      }
    } catch (e) {
      console.error(`[llm-email] error org=${o.id} (${o.name}): ${e.message}`);
    }
  }

  console.log(`[llm-email] DONE orgs=${orgs} person_emails=${totalPersonEmails} org_emails=${totalOrgEmails} with_any=${withAny}`);
  await pool.end();
}

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