← back to Stayclaim

scripts/enrich-entities-wikipedia.ts

106 lines

#!/usr/bin/env -S npx tsx
/**
 * Wikipedia summary enrichment.
 *
 * For each entity that already has wiki_url but is missing bio_short,
 * pulls the page summary via Wikipedia's REST API and stores the extract.
 * Tier-C source.
 *
 * Usage:
 *   npx tsx scripts/enrich-entities-wikipedia.ts
 *   npx tsx scripts/enrich-entities-wikipedia.ts --slug paul-r-williams
 *   npx tsx scripts/enrich-entities-wikipedia.ts --force   # overwrite existing bio
 *   npx tsx scripts/enrich-entities-wikipedia.ts --dry
 */
import { Pool } from 'pg';

const pool = new Pool({
  host: process.env.PGHOST ?? '/tmp',
  database: process.env.PGDATABASE ?? 'stayclaim',
  user: process.env.PGUSER ?? process.env.USER,
  max: 4,
});

function pageTitleFromUrl(url: string): string | null {
  try {
    const u = new URL(url);
    if (!u.hostname.endsWith('wikipedia.org')) return null;
    // /wiki/Foo_Bar  →  Foo_Bar
    const m = u.pathname.match(/^\/wiki\/(.+)$/);
    return m ? decodeURIComponent(m[1]!) : null;
  } catch {
    return null;
  }
}

async function fetchSummary(title: string): Promise<{ extract: string } | null> {
  const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`;
  const res = await fetch(url, {
    headers: {
      'User-Agent': 'pastdoor/0.1 (https://pastdoor.com; data-enrichment)',
      Accept: 'application/json',
    },
  });
  if (!res.ok) return null;
  const j = (await res.json()) as { extract?: string; type?: string };
  if (!j.extract) return null;
  return { extract: j.extract };
}

async function main() {
  const args = process.argv.slice(2);
  const slugArg = args.find((a, i) => args[i - 1] === '--slug');
  const dry = args.includes('--dry');
  const force = args.includes('--force');

  const conds: string[] = ['wiki_url IS NOT NULL'];
  const params: unknown[] = [];
  if (!force) conds.push("(bio_short IS NULL OR bio_short = '')");
  if (slugArg) {
    params.push(slugArg);
    conds.push(`slug = $${params.length}`);
  }
  const sql = `SELECT id, slug, display_name, wiki_url, bio_short
               FROM entity
               WHERE ${conds.join(' AND ')}
               ORDER BY display_name`;
  const { rows } = await pool.query(sql, params);

  let updated = 0;
  let nohit = 0;
  for (const e of rows) {
    const title = pageTitleFromUrl(e.wiki_url);
    if (!title) {
      console.log(`[${e.slug}] no usable Wikipedia title in ${e.wiki_url}`);
      continue;
    }
    let summary;
    try {
      summary = await fetchSummary(title);
    } catch (err) {
      console.warn(`[${e.slug}] fetch error: ${(err as Error).message}`);
      continue;
    }
    if (!summary) {
      nohit++;
      console.log(`[${e.slug}] no Wikipedia summary`);
      await new Promise(r => setTimeout(r, 400));
      continue;
    }
    // Trim to a tight editorial blurb — first 1-2 sentences, max ~280 chars.
    const tight = summary.extract.split(/(?<=\.)\s+/).slice(0, 2).join(' ').slice(0, 280);
    if (dry) {
      console.log(`[${e.slug}] DRY would set bio_short:`, tight);
    } else {
      await pool.query('UPDATE entity SET bio_short = $1 WHERE id = $2', [tight, e.id]);
      console.log(`[${e.slug}] updated`);
      updated++;
    }
    await new Promise(r => setTimeout(r, 600));
  }
  console.log(JSON.stringify({ checked: rows.length, updated, no_summary: nohit, dry }, null, 2));
  await pool.end();
}

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