← back to Lawyer Directory Builder

src/scripts/run_overnight.ts

117 lines

/**
 * Overnight orchestrator — runs every importer + enricher in sequence.
 *
 * Sources wired (in order):
 *   1.  OSM Overpass refresh             (LA County office=lawyer / amenity=lawyer)
 *   2.  Wikidata SPARQL                  (notable LA firms — BigLaw filler)
 *   3.  LA City Business Licenses        (Socrata, NAICS 5411)
 *   4.  OSM name-match                   (broader name-based query)
 *   5.  Wikipedia firm articles          (LA County law-firm category)
 *   6.  Wikipedia lawyer biographies     (LA-county lawyers → professionals table)
 *   7.  Aggregation pass                 (firm_size_band recompute)
 *
 * Enrichers (in order, AFTER importers so they have fresh records):
 *   8.  Nominatim geocoder               (fill lat/lng for new addresses)
 *   9.  DNS website discovery            (candidate-domain check + name verify)
 *   10. Firm-website contact pages       (regex phone/email out of /contact pages)
 *
 * Blocked sources (NOT run; documented in docs/blocked_sources.md):
 *   - CA State Bar attorney search   → SPA, requires Playwright
 *   - CA SOS bizfile                 → POST endpoint requires recaptcha token
 *
 * Loop policy: run once, exit 0. Wrap with launchd if continuous mode is desired.
 */
import 'dotenv/config';
import { spawn } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { pool, query } from '../db/pool.ts';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '../..');
const LOG_DIR = path.join(ROOT, 'logs');
fs.mkdirSync(LOG_DIR, { recursive: true });

const TS = new Date().toISOString().slice(0, 10);
const LOG = path.join(LOG_DIR, `overnight-${TS}.log`);
const log = fs.createWriteStream(LOG, { flags: 'a' });
function out(s: string) { const line = `[${new Date().toISOString()}] ${s}\n`; process.stdout.write(line); log.write(line); }

// LAUNCHD FIX: was `spawn('npx', ['tsx', ...])` which fails under launchd
// because launchd's minimal PATH doesn't include npm/nvm/homebrew bin dirs
// (manual run from a login shell works because shell PATH inherits them —
// hence "manual returns 0, launchd returns 1, empty stderr"). Resolve tsx
// from local node_modules and run it directly with process.execPath (node).
const TSX_CLI = path.join(ROOT, 'node_modules', 'tsx', 'dist', 'cli.mjs');
function run(name: string, scriptPath: string, env: Record<string, string> = {}): Promise<number> {
  out(`▶ ${name}: tsx ${scriptPath}`);
  return new Promise((resolve) => {
    const child = spawn(process.execPath, [TSX_CLI, scriptPath], {
      cwd: ROOT,
      env: { ...process.env, ...env },
      stdio: ['ignore', 'pipe', 'pipe'],
    });
    child.stdout.on('data', d => log.write(d));
    child.stderr.on('data', d => log.write(d));
    child.on('close', code => { out(`✓ ${name} exited ${code}`); resolve(code ?? 0); });
  });
}

async function aggregationPass() {
  out('▶ aggregation: recompute attorney_count + size band');
  // Future: count professional_locations per organization.
  // For now: leave attorney_count as set by importers; only band by attorney_count if available.
  await query(`
    UPDATE organizations
    SET firm_size_band = CASE
      WHEN attorney_count >= 500 THEN 'biglaw'
      WHEN attorney_count >= 100 THEN 'large'
      WHEN attorney_count >= 25  THEN 'medium'
      WHEN attorney_count >= 5   THEN 'small'
      WHEN attorney_count >= 1   THEN 'solo'
      ELSE firm_size_band
    END
    WHERE type='law_firm' AND attorney_count > 0
  `);
  out('✓ aggregation done');
}

async function summary() {
  const r = await query<{ k: string; v: string }>(`
    SELECT 'firms_total' AS k, COUNT(*)::text AS v FROM organizations WHERE type='law_firm'
    UNION ALL SELECT 'with_geo',  COUNT(*)::text FROM organizations WHERE type='law_firm' AND lat IS NOT NULL
    UNION ALL SELECT 'with_site', COUNT(*)::text FROM organizations WHERE type='law_firm' AND website IS NOT NULL
    UNION ALL SELECT 'biglaw',    COUNT(*)::text FROM organizations WHERE type='law_firm' AND firm_size_band='biglaw'
  `);
  out('── summary ──');
  for (const row of r.rows) out(`  ${row.k.padEnd(14)} ${row.v}`);
}

async function main() {
  out('=== overnight run start ===');
  const codes: Record<string, number> = {};
  codes.osm        = await run('osm-overpass',         'src/ingest/osm_overpass.ts');
  codes.wd         = await run('wikidata',             'src/ingest/wikidata.ts');
  codes.lacity     = await run('la-city-licenses',     'src/ingest/la_city_business_licenses.ts');
  codes.osmName    = await run('osm-name-search',      'src/ingest/osm_name_search.ts');
  codes.wikiFirms  = await run('wikipedia-infobox',    'src/ingest/wikipedia_infobox.ts');
  codes.wikiPros   = await run('wikipedia-lawyers',    'src/ingest/wikipedia_lawyers.ts');
  await aggregationPass();
  codes.geocode    = await run('nominatim-geocoder',   'src/enrich/geocode_nominatim.ts');
  codes.discover   = await run('discover-websites',    'src/enrich/discover_websites.ts');
  codes.contacts   = await run('firm-website-contacts','src/enrich/firm_website_contacts.ts');
  await summary();
  const summary_line = Object.entries(codes).map(([k, v]) => `${k}=${v}`).join(' ');
  out(`=== overnight run end (${summary_line}) ===`);
  await pool.end();
  log.end();
}

main().catch(async (err) => {
  out(`fatal: ${(err as Error).message}`);
  try { await pool.end(); } catch {}
  log.end();
  process.exit(1);
});