← back to Marketing Command Center

scripts/enrich-contacts-browser.mjs

119 lines

#!/usr/bin/env node
/*
 * enrich-contacts-browser.mjs — deep contact enrichment with a REAL browser +
 * a LOCAL model. Opens each business website in headless Chromium (renders JS
 * that the HTTP crawler can't see), visits a contact/about page, and logs:
 *   • email      (DOM mailto first; local Ollama model deobfuscates "name [at] domain" text if none)
 *   • instagram  (real profile link from the site — direct, not a search)
 *   • linkedin   (company/in profile link)
 *   • phone      (tel: link)
 *
 * Layers on top of a prospects-<slug>.json (from crawl-csv-emails.py): reads it,
 * enriches each business in place, writes it back. Local + free ($0). Nothing
 * is uploaded to Constant Contact.
 *
 *   node scripts/enrich-contacts-browser.mjs public/data/prospects-sf-architects.json [--limit N] [--workers 6] [--model qwen2.5:latest]
 */
import { readFileSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';

// Reuse an existing Playwright install (browsers already cached in ms-playwright).
const require = createRequire(import.meta.url);
let chromium;
for (const p of ['/Users/macstudio3/Projects/Designer-Wallcoverings/node_modules/playwright',
                 '/Users/macstudio3/Projects/astek-landing/node_modules/playwright',
                 'playwright']) {
  try { ({ chromium } = require(p)); break; } catch {}
}
if (!chromium) { console.error('playwright not found'); process.exit(1); }

const args = process.argv.slice(2);
const FILE = args.find(a => !a.startsWith('--'));
const opt = (k, d) => { const i = args.indexOf('--' + k); return i >= 0 ? args[i + 1] : d; };
const LIMIT = Number(opt('limit', 0));
const WORKERS = Number(opt('workers', 6));
const MODEL = opt('model', 'qwen2.5:latest');
const OLLAMA = 'http://127.0.0.1:11434/api/generate';

const data = JSON.parse(readFileSync(FILE, 'utf8'));
let biz = data.businesses || [];
if (LIMIT) biz = biz.slice(0, LIMIT);

const EMAIL_RE = /[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/g;
const JUNK = /(sentry|wix\.com|wixpress|godaddy|squarespace|example\.|domain\.com|yourdomain|\.png|\.jpg|\.svg|sentry\.io|@2x)/i;
const norm = u => { try { return new URL(u.startsWith('http') ? u : 'http://' + u).href; } catch { return null; } };
const host = u => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } };

async function ollamaEmail(text, siteHost) {
  try {
    const r = await fetch(OLLAMA, { method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ model: MODEL, think: false, stream: false,
        prompt: `From this business website contact text, extract the single best PUBLIC contact email address (prefer one on the @${siteHost} domain, or info@/hello@/contact@). If a real email is present, reply with ONLY that email. If none, reply with exactly NONE.\n\n---\n${text.slice(0, 3500)}\n---` }) });
    const j = await r.json();
    const m = (j.response || '').match(EMAIL_RE);
    return m && !JUNK.test(m[0]) ? m[0].toLowerCase() : '';
  } catch { return ''; }
}

async function grabLinks(page) {
  return page.$$eval('a[href]', as => as.map(a => a.getAttribute('href') || '').filter(Boolean)).catch(() => []);
}
function pickSocial(hrefs, re) {
  for (const h of hrefs) { const m = h.match(re); if (m) return m[0].split('?')[0].replace(/\/$/, ''); }
  return '';
}

async function enrichOne(ctx, b) {
  const url = norm(b.website); if (!url) return;
  const siteHost = host(url);
  const page = await ctx.newPage();
  try {
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
    let hrefs = await grabLinks(page);
    // hop to a contact/about page if present
    const contact = hrefs.find(h => /contact|about|connect|team/i.test(h));
    let text = await page.evaluate(() => document.body ? document.body.innerText : '').catch(() => '');
    if (contact) {
      try { await page.goto(new URL(contact, url).href, { waitUntil: 'domcontentloaded', timeout: 12000 });
            hrefs = hrefs.concat(await grabLinks(page));
            text += '\n' + await page.evaluate(() => document.body ? document.body.innerText : '').catch(() => ''); } catch {}
    }
    // deterministic first
    const ig = pickSocial(hrefs, /https?:\/\/(?:www\.)?instagram\.com\/(?!p\/|explore|accounts)[A-Za-z0-9_.]+/i);
    const li = pickSocial(hrefs, /https?:\/\/(?:www\.)?linkedin\.com\/(?:company|in)\/[A-Za-z0-9_%\-]+/i);
    const tel = pickSocial(hrefs, /tel:\+?[0-9().\-\s]{7,}/i);
    let email = '';
    const mailto = hrefs.find(h => /^mailto:/i.test(h));
    if (mailto) { const e = mailto.replace(/^mailto:/i, '').split('?')[0].trim(); if (EMAIL_RE.test(e) && !JUNK.test(e)) email = e.toLowerCase(); }
    if (!email) { const m = (text.match(EMAIL_RE) || []).filter(e => !JUNK.test(e)); if (m.length) email = m[0].toLowerCase(); }
    if (!email) email = await ollamaEmail(text, siteHost);   // local-model fallback for obfuscated emails
    if (ig) b.instagram = ig;
    if (li) b.linkedin = li;
    if (tel) b.phone = b.phone || tel.replace(/^tel:/i, '').trim();
    if (email && !b.email) b.email = email;
    if (email) b.emails = Array.from(new Set([email, ...(b.emails || [])]));
  } catch { /* unreachable site */ } finally { await page.close().catch(() => {}); }
}

const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124 Safari/537.36' });
let done = 0, ig = 0, li = 0, mail = 0;
const queue = biz.slice();
async function worker() {
  while (queue.length) {
    const b = queue.shift();
    await enrichOne(ctx, b);
    done++; if (b.instagram) ig++; if (b.linkedin) li++; if (b.email) mail++;
    if (done % 10 === 0 || done === biz.length) process.stdout.write(`\r  ${done}/${biz.length} · ${mail} email · ${ig} instagram · ${li} linkedin  `);
  }
}
await Promise.all(Array.from({ length: WORKERS }, worker));
await browser.close();

data.with_email = (data.businesses || []).filter(b => b.email).length;
data.with_instagram = (data.businesses || []).filter(b => b.instagram).length;
data.with_linkedin = (data.businesses || []).filter(b => b.linkedin).length;
data.enriched = new Date().toISOString().slice(0, 10);
writeFileSync(FILE, JSON.stringify(data));
console.log(`\n✓ ${FILE} — ${data.with_email} email · ${data.with_instagram} instagram · ${data.with_linkedin} linkedin  [browser + ${MODEL}, $0 local]`);