← back to Lawyer Directory Builder

src/enrich/discover_websites.ts

266 lines

/**
 * Website discovery via DNS + name verification.
 *
 * For every law firm without a `website`, generate candidate domains from the
 * firm name (stripped of legal suffixes), DNS-resolve, then HTTP-GET the
 * homepage and confirm the firm name actually appears in the title/body before
 * accepting.
 *
 * No API keys, no third-party services — just public DNS + a single GET per
 * candidate. Conservative: name must match before we attribute a website.
 *
 * Once a website is set, the existing src/enrich/firm_website_contacts.ts
 * enricher (which only operates on firms WITH a website) can pull phone/email
 * from the contact pages.
 *
 * CLI:
 *   npx tsx src/enrich/discover_websites.ts            # full pass
 *   npx tsx src/enrich/discover_websites.ts --smoke    # first 10 firms
 *   npx tsx src/enrich/discover_websites.ts --limit 100
 */
import 'dotenv/config';
import { promises as dns } from 'node:dns';
import { pool, query } from '../db/pool.ts';

const SOURCE_NAME = 'DNS website discovery';
const RATE_DELAY_MS = Number(process.env.WEBSITE_DISCOVERY_DELAY_MS || 50);
const USER_AGENT = process.env.USER_AGENT
  || 'LawyerDirectoryBuilder/0.1 (research; contact: steveabramsdesigns@gmail.com)';

type Firm = { id: number; name: string };

const argv = process.argv.slice(2);
const SMOKE = argv.includes('--smoke');
const LIMIT_IDX = argv.indexOf('--limit');
const LIMIT = LIMIT_IDX >= 0 ? Number(argv[LIMIT_IDX + 1]) : (SMOKE ? 10 : 0);

// Strip these suffix tokens before forming candidates.
const SUFFIX_TOKENS = new Set([
  'llp', 'llc', 'pllc', 'pc', 'pa', 'lp', 'inc', 'ltd',
  'esq', 'esquire', 'chartered',
  'and', '&',
  'law', 'laws', 'legal', 'attorney', 'attorneys', 'lawyer', 'lawyers',
  'office', 'offices', 'group', 'firm', 'associates', 'partners', 'partnership',
  'a', 'the', 'of', 'at',
]);

// Skip these — too generic / would yield mass false positives.
const NAME_BLOCKLIST = [
  /^law office$/i, /^law offices$/i, /^attorney$/i, /^we the people$/i,
  /^preferred$/i, /^the law (office|firm)$/i, /^attorneys at law$/i,
];

function tokens(name: string): string[] {
  return name
    .toLowerCase()
    .replace(/&/g, ' and ')
    .replace(/[^a-z0-9\s]/g, ' ')
    .split(/\s+/)
    .filter(Boolean);
}

function meaningfulTokens(name: string): string[] {
  return tokens(name).filter(t => !SUFFIX_TOKENS.has(t) && t.length >= 2);
}

function candidateDomains(name: string): string[] {
  const out = new Set<string>();
  const meaningful = meaningfulTokens(name);
  if (meaningful.length === 0) return [];

  // 1. join all meaningful tokens
  out.add(meaningful.join('') + '.com');

  // 2. join meaningful + "law"
  out.add(meaningful.join('') + 'law.com');

  // 3. first 2 meaningful tokens
  if (meaningful.length >= 2) {
    out.add(meaningful.slice(0, 2).join('') + '.com');
    out.add(meaningful.slice(0, 2).join('') + 'law.com');
  }

  // 4. surname + "law" — for 2+ token names, the LAST meaningful token is
  //    the more reliable surname signal (firm names rarely lead with a surname
  //    and "DavidLaw" type matches generic-firm domains too easily).
  if (meaningful.length >= 2 && meaningful[meaningful.length - 1].length >= 5) {
    out.add(meaningful[meaningful.length - 1] + 'law.com');
  }

  // 5. initials + "law" (3+ tokens only — avoids 2-letter false positives)
  if (meaningful.length >= 3) {
    const initials = meaningful.map(t => t[0]).join('');
    if (initials.length >= 3) out.add(initials + 'law.com');
  }

  // Filter out anything < 6 chars total (e.g. "abc.com")
  return [...out].filter(d => d.length >= 7);
}

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', '(public DNS + per-firm websites)',
      'DNS resolution + single HTTP GET per candidate. Robots-respected per host.',
      'crawl', 5.0)
    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): Promise<number> {
  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);
}

async function loadFirms(): Promise<Firm[]> {
  const lim = LIMIT > 0 ? `LIMIT ${LIMIT}` : '';
  const r = await query<Firm>(`
    SELECT id, name FROM organizations
    WHERE type='law_firm' AND website IS NULL
    ORDER BY id
    ${lim}
  `);
  return r.rows.filter(f => !NAME_BLOCKLIST.some(rx => rx.test(f.name)));
}

async function dnsExists(domain: string): Promise<boolean> {
  try {
    const records = await dns.resolve4(domain);
    return Array.isArray(records) && records.length > 0;
  } catch {
    try {
      const recs = await dns.resolve6(domain);
      return Array.isArray(recs) && recs.length > 0;
    } catch { return false; }
  }
}

async function fetchHomepage(domain: string): Promise<{ ok: boolean; html: string; finalUrl: string }> {
  const { fetch } = await import('undici');
  for (const proto of ['https', 'http']) {
    try {
      const url = `${proto}://${domain}/`;
      const r = await fetch(url, {
        method: 'GET',
        headers: { 'User-Agent': USER_AGENT, Accept: 'text/html,application/xhtml+xml' },
        signal: AbortSignal.timeout(8000),
        redirect: 'follow',
      });
      if (r.ok && r.headers.get('content-type')?.includes('text/html')) {
        const html = (await r.text()).slice(0, 20000);
        return { ok: true, html, finalUrl: r.url };
      }
    } catch { /* try next proto */ }
  }
  return { ok: false, html: '', finalUrl: '' };
}

function nameMatchesPage(firmName: string, html: string): boolean {
  const meaningful = meaningfulTokens(firmName);
  if (meaningful.length === 0) return false;
  const lower = html.toLowerCase();
  // For multi-token names: at least 2 meaningful tokens must appear together
  // (adjacent or as a joined string). Single-word matches against "law" pages
  // produce too many false positives (any "Smith Law" firm matches any other
  // "Smith"-themed law site).
  if (meaningful.length >= 2) {
    const firstTwo = meaningful.slice(0, 2);
    if (lower.includes(firstTwo.join(' '))) return true;
    if (lower.includes(firstTwo.join(''))) return true;
    // Also allow last+first or any adjacent pair (firm name may be reversed on site)
    for (let i = 0; i < meaningful.length - 1; i++) {
      if (lower.includes(meaningful[i] + ' ' + meaningful[i + 1])) return true;
    }
    return false;
  }
  // Single-token firm name: require both the token AND the firm-name length
  // to be substantial, plus a legal-services keyword nearby.
  const only = meaningful[0];
  if (only.length >= 6 && /\b(law|attorney|legal|esq|counsel)\b/.test(lower) && lower.includes(only)) return true;
  return false;
}

async function discover(firm: Firm): Promise<{ website: string; matched: string } | null> {
  const candidates = candidateDomains(firm.name);
  for (const c of candidates) {
    if (!(await dnsExists(c))) continue;
    const r = await fetchHomepage(c);
    if (!r.ok) continue;
    if (nameMatchesPage(firm.name, r.html)) {
      return { website: r.finalUrl || `https://${c}/`, matched: c };
    }
  }
  return null;
}

const CONCURRENCY = Number(process.env.DISCOVERY_CONCURRENCY || 20);

async function main() {
  const sourceId = await ensureSource();
  const label = SMOKE ? 'website-discovery:smoke' : (LIMIT > 0 ? `website-discovery:limit-${LIMIT}` : 'website-discovery:la-firms');
  const jobId = await startJob(sourceId, label);

  const firms = await loadFirms();
  console.log(`[disc] scanning ${firms.length} firms (concurrency=${CONCURRENCY})…`);

  let seen = 0, found = 0, errs = 0;

  async function processOne(firm: Firm) {
    seen++;
    try {
      const r = await discover(firm);
      if (r) {
        await query(
          `UPDATE organizations SET website = $2, updated_at = NOW() WHERE id = $1 AND website IS NULL`,
          [firm.id, r.website]);
        found++;
        console.log(`[disc] ✓ #${firm.id}  ${firm.name.slice(0,50).padEnd(50)} → ${r.website}`);
      }
    } catch {
      errs++;
    }
    if (seen % 200 === 0) console.log(`[disc] ${seen}/${firms.length}  found=${found}  errs=${errs}`);
  }

  try {
    // Parallelism via N concurrent workers pulling from a shared queue.
    let cursor = 0;
    const workers = Array.from({ length: CONCURRENCY }, async () => {
      while (true) {
        const idx = cursor++;
        if (idx >= firms.length) break;
        await processOne(firms[idx]);
      }
    });
    await Promise.all(workers);
  } finally {
    await finishJob(jobId, {
      status: 'completed',
      records_found: seen,
      records_updated: found,
      records_skipped: seen - found,
      error_message: errs > 0 ? `${errs} per-firm errors` : null,
    });
  }
  console.log(`[disc] done. seen=${seen} found=${found} errs=${errs}`);
  await pool.end();
}

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