← back to La Socrata Ingester

scripts/enrich-domain.js

102 lines

// Contractor enrichment via DOMAIN-GUESSING (READ-ONLY writes to cslb_raw, $0 local).
// The right max-it architecture: instead of one rate-limited search engine, guess each
// contractor's OWN candidate domains and HTTP-check them — every request hits a DIFFERENT
// host, so there's no shared rate limit and it parallelizes freely. A local Ollama model
// verifies the fetched page actually belongs to the contractor + extracts email.
//
// Usage: node scripts/enrich-domain.js [--loop] [--batch=N] [--shard=i/N] [--ollama=URL] [--model=M] [--all]
import { q, pool } from '../src/db.js';

const flags = new Set(process.argv.slice(2).filter(a => a.startsWith('--') && !a.includes('=')));
const kv = Object.fromEntries(process.argv.slice(2).filter(a => a.includes('=')).map(a => a.slice(2).split('=')));
const OLLAMA = kv.ollama || 'http://127.0.0.1:11434';
const MODEL = kv.model || 'qwen2.5:latest';
const BATCH = Number(kv.batch || 50);
const LA_ONLY = !flags.has('--all');
const [SHARD_I, SHARD_N] = (kv.shard || '0/1').split('/').map(Number);
const SHARD = SHARD_N > 1 ? `AND (abs(hashtext(c."LicenseNo")) % ${SHARD_N}) = ${SHARD_I}` : '';
const sleep = ms => new Promise(r => setTimeout(r, ms));

// business name -> candidate domains (their own hosts — no shared rate limit)
function candidates(name) {
  const base = String(name).toLowerCase().replace(/&/g, 'and').replace(/[^a-z0-9 ]/g, ' ').replace(/\s+/g, ' ').trim();
  const noSuffix = base.replace(/\b(inc|llc|corp|corporation|company|co|ltd|lp|the|construction|development|builders|building|group|enterprises|services)\b/g, ' ').replace(/\s+/g, ' ').trim();
  const compact = s => s.replace(/\s+/g, '');
  const words = base.split(' ').filter(Boolean);
  const set = new Set();
  set.add(compact(base));                        // fullnameconstruction
  if (noSuffix && noSuffix !== base) set.add(compact(noSuffix)); // fullname
  if (words.length >= 2) set.add(compact(words.slice(0, 2).join(''))); // firsttwo
  if (words.length >= 2) set.add(words.map(w => w[0]).join(''));       // initials
  const doms = [];
  for (const s of set) if (s.length >= 3 && s.length <= 40) for (const tld of ['.com', '.net']) doms.push(s + tld);
  return [...new Set(doms)].slice(0, 8);
}

async function tryDomain(dom) {
  try {
    const res = await fetch('https://' + dom, { headers: { 'User-Agent': 'Mozilla/5.0' }, signal: AbortSignal.timeout(6000), redirect: 'follow' });
    if (!res.ok) return null;
    const ct = res.headers.get('content-type') || '';
    if (!/html/.test(ct)) return null;
    const html = (await res.text()).slice(0, 40000);
    const text = html.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 1400);
    const email = (html.match(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i) || [])[0] || null;
    return { url: 'https://' + dom, text, email };
  } catch { return null; }
}

async function verify(c, hits) {
  const prompt = `Which of these websites (if any) is the OFFICIAL site of this contractor? Only match if the page clearly is this business (name and, ideally, city/phone appear).
Contractor: ${c.BusinessName}, ${c.City} CA ${c.ZIPCode}, phone ${c.BusinessPhone || 'n/a'}.
${hits.map((h, i) => `${i + 1}. ${h.url}\n   ${h.text.slice(0, 600)}`).join('\n')}
Return ONLY strict JSON: {"index": number|null (1-based, or null if none match), "confidence": "high"|"low"|"none"}`;
  try {
    const res = await fetch(OLLAMA + '/api/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ model: MODEL, prompt, stream: false, format: 'json', options: { temperature: 0 } }) });
    const d = await res.json();
    const j = JSON.parse(d.response);
    const pick = j.index && hits[j.index - 1];
    return pick ? { website: pick.url, email: pick.email, confidence: j.confidence || 'low' } : { website: null, email: null, confidence: 'none' };
  } catch { return { website: null, email: null, confidence: 'none' }; }
}

async function pick() {
  const laFilter = LA_ONLY ? `AND "County"='Los Angeles'` : '';
  return (await q(`
    SELECT c."LicenseNo", c."BusinessName", c."City", c."ZIPCode", c."BusinessPhone"
    FROM cslb_raw c
    WHERE c."PrimaryStatus"='CLEAR' AND c."BusinessName" IS NOT NULL AND c.enriched_at IS NULL ${laFilter} ${SHARD}
    ORDER BY c."Classifications(s)" LIKE 'B%' DESC LIMIT ${BATCH}`)).rows;
}

async function enrichOne(c) {
  let fields = { website: null, email: null, linkedin: null, confidence: 'none' };
  try {
    const doms = candidates(c.BusinessName);
    const hits = (await Promise.all(doms.map(tryDomain))).filter(Boolean);
    if (hits.length) { const v = await verify(c, hits); if (v.website) fields = { ...fields, ...v }; }
  } catch (e) { fields.confidence = 'error:' + e.message.slice(0, 30); }
  await q(`UPDATE cslb_raw SET website=$2, email=COALESCE($3,email), enrich_confidence=$4, enrich_source='domain-guess+local-llm', enriched_at=now() WHERE "LicenseNo"=$1`,
    [c.LicenseNo, fields.website, fields.email, fields.confidence]);
  return fields;
}

async function main() {
  let done = 0, found = 0;
  for (;;) {
    const batch = await pick();
    if (!batch.length) { console.log(`\n✔ complete — no unenriched left (${LA_ONLY ? 'LA County' : 'all'})`); break; }
    for (const c of batch) {
      const f = await enrichOne(c);
      done++; if (f.website) found++;
      if (f.website) console.log(`  [${done}] ${c.BusinessName.slice(0, 32).padEnd(32)} → ${f.website} (${f.confidence})`);
    }
    const rem = Number((await q(`SELECT count(*) c FROM cslb_raw WHERE "PrimaryStatus"='CLEAR' AND enriched_at IS NULL ${LA_ONLY ? `AND "County"='Los Angeles'` : ''}`)).rows[0].c);
    console.log(`— ${done} processed, ${found} sites, ~${rem.toLocaleString()} left — $0`);
    if (!flags.has('--loop')) break;
  }
  console.log(`\nTotal: ${done} processed, ${found} websites. $0 (domain-guess + local model).`);
}
main().catch(e => { console.error('enrich-domain error:', e.message); process.exitCode = 1; }).finally(() => pool.end());