← back to Stayclaim

scripts/pull-la-newspapers-ocr.ts

174 lines

/**
 * pull-la-newspapers-ocr.ts
 *
 * Phase 2 of the LA newspaper ingest. Pulls IA `_djvu.txt` plain-text OCR
 * for issues with NULL ocr_text in news_issue. Uses the official Internet
 * Archive `ia` CLI (Python, on-policy bulk tool) with 10 parallel workers.
 * Each worker downloads → reads file → upserts to PG → deletes file.
 *
 * Source: https://archive.org/details/{identifier} via `ia download`
 *
 * Usage: npx tsx scripts/pull-la-newspapers-ocr.ts [--paper=LADailyHerald]
 *                                                  [--limit=100]
 *                                                  [--concurrency=10]
 *                                                  [--workdir=/tmp/ia-ocr]
 */
import { Pool } from 'pg';
import { spawn } from 'node:child_process';
import { readFile, mkdir, rm, stat } from 'node:fs/promises';
import { join } from 'node:path';

const pool = new Pool({
  host: process.env.PGHOST ?? '/tmp',
  database: process.env.PGDATABASE ?? 'stayclaim',
  user: process.env.PGUSER ?? process.env.USER,
  password: process.env.PGPASSWORD,
  port: parseInt(process.env.PGPORT ?? '5432', 10),
  max: 12,
});

const args = Object.fromEntries(
  process.argv.slice(2).map(a => {
    const m = a.match(/^--([^=]+)(?:=(.*))?$/);
    return m ? [m[1], m[2] ?? 'true'] : [a, 'true'];
  })
);
const PAPER       = args.paper as string | undefined;
const LIMIT       = args.limit ? parseInt(args.limit as string, 10) : undefined;
const CONCURRENCY = args.concurrency ? parseInt(args.concurrency as string, 10) : 10;
const WORKDIR     = (args.workdir as string) || '/tmp/ia-ocr';
const MAX_BYTES   = (args['skip-large'] ? parseFloat(args['skip-large'] as string) : 5) * 1024 * 1024;

type PendingRow = { id: string; source_identifier: string; paper_short: string };

async function loadQueue(): Promise<PendingRow[]> {
  const params: any[] = [];
  let where = 'ocr_text IS NULL';
  if (PAPER) { where += ` AND paper_short = $${params.length+1}`; params.push(PAPER); }
  let sql = `SELECT id, source_identifier, paper_short FROM news_issue WHERE ${where} ORDER BY issue_date NULLS LAST`;
  if (LIMIT) sql += ` LIMIT ${LIMIT}`;
  const { rows } = await pool.query<PendingRow>(sql, params);
  return rows;
}

function iaDownload(identifier: string, cwd: string): Promise<{ ok: boolean; reason?: string }> {
  return new Promise((resolve) => {
    const args = [
      'download', identifier,
      '--glob=*_djvu.txt',
      '--retries=3',
      '--no-change-timestamp',
      '-q',
    ];
    const proc = spawn('ia', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
    let stderr = '';
    proc.stderr.on('data', (d) => { stderr += d.toString(); });
    proc.on('close', (code) => {
      if (code === 0) resolve({ ok: true });
      else resolve({ ok: false, reason: `ia exit ${code}: ${stderr.slice(0, 200)}` });
    });
    proc.on('error', (e) => resolve({ ok: false, reason: String(e) }));
  });
}

const stats = {
  processed: 0,
  fetched: 0,
  empty: 0,
  failed: 0,
  bytes: 0,
};

async function processOne(row: PendingRow): Promise<void> {
  const subdir = join(WORKDIR, row.source_identifier);
  const txtPath = join(subdir, `${row.source_identifier}_djvu.txt`);
  try {
    const r = await iaDownload(row.source_identifier, WORKDIR);
    if (!r.ok) {
      stats.failed++;
      console.error(`  FAIL ${row.source_identifier}: ${r.reason}`);
      return;
    }
    let text: string;
    try {
      const st = await stat(txtPath);
      if (st.size > MAX_BYTES) {
        stats.empty++;
        await pool.query(`UPDATE news_issue SET ocr_text='', ocr_fetched_at=now() WHERE id=$1`, [row.id]);
        return;
      }
      text = await readFile(txtPath, 'utf8');
    } catch {
      // ia returned ok but no _djvu.txt found — issue may not have OCR
      stats.empty++;
      await pool.query(`UPDATE news_issue SET ocr_text='', ocr_fetched_at=now() WHERE id=$1`, [row.id]);
      return;
    }
    await pool.query(
      `UPDATE news_issue SET ocr_text=$1, ocr_fetched_at=now() WHERE id=$2`,
      [text, row.id]
    );
    stats.fetched++;
    stats.bytes += text.length;
  } catch (e) {
    stats.failed++;
    console.error(`  FAIL ${row.source_identifier}: ${(e as Error).message}`);
  } finally {
    stats.processed++;
    // clean up downloaded file/dir
    await rm(subdir, { recursive: true, force: true }).catch(() => {});
  }
}

async function worker(workerId: number, queue: PendingRow[]): Promise<void> {
  while (queue.length > 0) {
    const row = queue.shift();
    if (!row) break;
    await processOne(row);
  }
}

async function main() {
  await mkdir(WORKDIR, { recursive: true });
  // wipe any stragglers from a previous run
  for await (const _ of [] as any[]) {}
  const queue = await loadQueue();
  console.log(`pull-la-newspapers-ocr (ia parallel)`);
  console.log(`  paper=${PAPER ?? '(all)'}  queue=${queue.length}  concurrency=${CONCURRENCY}  workdir=${WORKDIR}`);
  if (queue.length === 0) {
    console.log('  nothing to do');
    await pool.end();
    return;
  }

  const t0 = Date.now();
  const reporter = setInterval(() => {
    const dt = (Date.now() - t0) / 1000;
    const rate = stats.processed / dt;
    const remaining = queue.length;
    const etaSec = remaining / Math.max(rate, 0.1);
    const mb = (stats.bytes / 1024 / 1024).toFixed(1);
    process.stdout.write(
      `  ${stats.processed} done (ok=${stats.fetched} empty=${stats.empty} fail=${stats.failed}) — ${rate.toFixed(2)}/s — ${mb} MB — ${remaining} left, ETA ${(etaSec/60).toFixed(1)}m   \r`
    );
  }, 2000);

  const workers = Array.from({ length: CONCURRENCY }, (_, i) => worker(i, queue));
  await Promise.all(workers);

  clearInterval(reporter);
  const dt = (Date.now() - t0) / 1000;
  console.log('');
  console.log('--- done ---');
  console.log(`  processed=${stats.processed}  fetched=${stats.fetched}  empty=${stats.empty}  failed=${stats.failed}`);
  console.log(`  elapsed=${dt.toFixed(1)}s  rate=${(stats.processed/dt).toFixed(2)}/s`);
  console.log(`  bytes=${(stats.bytes/1024/1024).toFixed(1)} MB`);
  await rm(WORKDIR, { recursive: true, force: true }).catch(() => {});
  await pool.end();
}

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