← back to Ventura Corridor

src/enrich/apply_exa_websites.ts

61 lines

// Apply Exa-discovered websites to the businesses table.
//
// Reads a JSON array (file or stdin) of `[{ id, name, website, source }, ...]`,
// validates each URL (must be http/https, must NOT be yelp/facebook/yellowpages
// /bbb/manta/google.com — those are aggregators not primary websites),
// then UPDATEs businesses.website where currently null.
//
// Run:
//   npx tsx src/enrich/apply_exa_websites.ts /tmp/exa_results.json
//   curl ... | npx tsx src/enrich/apply_exa_websites.ts -

import * as fs from 'node:fs';
import { pool, query } from '../../db/pool.ts';

const AGGREGATORS = /^(www\.)?(yelp|facebook|instagram|twitter|x|linkedin|yellowpages|bbb|manta|trustpilot|opentable|grubhub|doordash|ubereats|google|maps\.google|tripadvisor|nextdoor|whitepages|spokeo)\.(com|net|org)$/i;

function normalize(raw: string): string | null {
  if (!raw) return null;
  let u = raw.trim();
  if (!u) return null;
  if (!/^https?:\/\//i.test(u)) u = 'https://' + u;
  let parsed: URL;
  try { parsed = new URL(u); } catch { return null; }
  if (AGGREGATORS.test(parsed.host)) return null;
  // Strip query strings and fragments — keep path so brand chain pages survive
  return parsed.protocol + '//' + parsed.host + parsed.pathname.replace(/\/+$/, '');
}

async function main() {
  const path = process.argv[2];
  if (!path) {
    console.error('usage: apply_exa_websites.ts <file.json | ->');
    process.exit(1);
  }
  const raw = path === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(path, 'utf8');
  // Tolerate the agent wrapping the JSON in markdown fences or commentary.
  const m = raw.match(/\[[\s\S]*\]/);
  if (!m) { console.error('no JSON array found in input'); process.exit(1); }
  const rows: Array<{ id: number; name: string; website: string | null; source?: string }> = JSON.parse(m[0]);
  console.log(`[apply_exa] ${rows.length} candidates`);

  let updated = 0, skipped_null = 0, skipped_invalid = 0, skipped_existing = 0;
  for (const r of rows) {
    if (!r.website) { skipped_null++; continue; }
    const norm = normalize(r.website);
    if (!norm) { skipped_invalid++; console.log(`  ✕ ${r.id} ${r.name} → ${r.website} (rejected)`); continue; }

    const existing = await query<{ website: string | null }>(`SELECT website FROM businesses WHERE id = $1`, [r.id]);
    if (!existing.rowCount) { console.log(`  ? ${r.id} not found`); continue; }
    if (existing.rows[0].website) { skipped_existing++; continue; }

    await query(`UPDATE businesses SET website = $1, last_seen_at = NOW() WHERE id = $2`, [norm, r.id]);
    updated++;
    console.log(`  ✓ ${r.id} ${r.name.slice(0, 40).padEnd(40)} → ${norm} [${r.source || '?'}]`);
  }
  console.log(`[apply_exa] done · ${updated} updated · ${skipped_null} null · ${skipped_invalid} invalid · ${skipped_existing} already had website`);
  await pool.end();
}

main().catch(e => { console.error(e); process.exit(1); });