← back to Professional Directory

scripts/scrape-org-emails.js

177 lines

#!/usr/bin/env node
/**
 * Org-website email scraper.
 *
 * For every organization with a populated website but no email row yet,
 * fetch the homepage + /contact + /about and extract any on-domain or
 * common-vendor email addresses. Politely rate-limited per host via
 * shared/compliance.fetchCompliant.
 *
 * Run:
 *   node scripts/scrape-org-emails.js                # all eligible orgs
 *   LIMIT=20 node scripts/scrape-org-emails.js       # smoke
 *   ONLY_DOCTORS=1 node scripts/scrape-org-emails.js # restrict to doctor-type orgs
 */
const cheerio = require('cheerio');
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'];

const SUBPATHS = ['', '/contact', '/contact-us', '/about', '/about-us'];

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|wix\.com|sentry|godaddy|squarespace|cloudflare|webmaster|admin|support|hello|mail)@/i;
const VENDOR_DOMAINS = new Set([
  'sentry.io','sentry-next.wixpress.com','wix.com','squarespace.com','godaddy.com',
  'cloudflare.com','google.com','gmail.com','example.com','example.org','tld.com',
  'site.com','domain.com','website.com','someone@example.com','sample.com',
]);

function normalize(email) {
  return String(email || '').trim().toLowerCase().replace(/\.$/, '');
}

function isPlausible(email) {
  if (!email) return false;
  if (email.length > 100) return false;
  if (!email.includes('@')) return false;
  if (DECOY_RE.test(email)) return false;
  // Reject literal example domains and obvious vendors.
  const domain = email.split('@')[1];
  if (!domain) return false;
  if (VENDOR_DOMAINS.has(domain)) return false;
  if (/\.(png|jpe?g|gif|svg|webp|css|js)$/i.test(email)) return false;
  // Reject anything that looks like a sentry/analytics token.
  if (/[a-f0-9]{32,}/i.test(email.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; }
}

async function fetchHtml(url) {
  try {
    const res = await fetchCompliant(url, { accept: 'text/html' });
    if (!res.ok) return null;
    const ct = res.headers.get('content-type') || '';
    if (!/html|text/i.test(ct)) return null;
    const text = await res.text();
    if (text.length > 800_000) return text.slice(0, 800_000);
    return text;
  } catch (e) {
    if (e?.code === 'CAPTCHA_DETECTED') return null;
    return null;
  }
}

function extractEmails(html, sourceUrl) {
  if (!html) return [];
  const $ = cheerio.load(html);
  const found = new Map(); // email → page

  // Pass 1: explicit mailto: hrefs.
  $('a[href^="mailto:"]').each((_, el) => {
    const href = ($(el).attr('href') || '').replace(/^mailto:/i, '').split('?')[0];
    const e = normalize(href);
    if (isPlausible(e) && !found.has(e)) found.set(e, sourceUrl);
  });

  // Pass 2: text-mode addresses anywhere in body. Strip script/style tags first.
  $('script, style, noscript').remove();
  const text = $('body').text() || $.text();
  for (const m of text.matchAll(EMAIL_RE)) {
    const e = normalize(m[0]);
    if (isPlausible(e) && !found.has(e)) found.set(e, sourceUrl);
  }

  return [...found.entries()].map(([email, page]) => ({ email, page }));
}

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

  setHostRateLimit(origin.hostname, 1.0);

  const seen = new Map();
  let pagesHit = 0;
  for (const p of SUBPATHS) {
    const url = origin.origin + p;
    const html = await fetchHtml(url);
    if (!html) continue;
    pagesHit++;
    for (const { email, page } of extractEmails(html, url)) {
      if (!seen.has(email)) seen.set(email, page);
    }
    if (seen.size >= 5) break; // diminishing returns
  }

  let inserted = 0;
  for (const [email, page] of seen) {
    try {
      await query(
        `INSERT INTO emails (organization_id, email, email_type, source_url, last_verified_at, verification_status)
         VALUES ($1, $2, 'office', $3, NULL, 'unverified')
         ON CONFLICT DO NOTHING`,
        [o.id, email, page]
      );
      inserted++;
    } catch (e) {
      // duplicate or fk error — skip
    }
  }
  return { found: seen.size, inserted, pages: pagesHit };
}

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)`,
  ];
  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(`[email] candidates=${r.rowCount}`);

  let totalFound = 0, totalOrgs = 0, withAny = 0;
  for (const o of r.rows) {
    totalOrgs++;
    try {
      const stat = await processOrg(o);
      totalFound += stat.inserted || 0;
      if (stat.inserted) withAny++;
      if (totalOrgs % 25 === 0) {
        console.log(`[email] orgs=${totalOrgs} with_any=${withAny} emails_inserted=${totalFound}`);
      }
    } catch (e) {
      console.error(`[email] error org=${o.id} (${o.name}): ${e.message}`);
    }
  }

  console.log(`[email] done. orgs=${totalOrgs} with_any=${withAny} emails_inserted=${totalFound}`);
  await pool.end();
}

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