← back to Stayclaim
scripts/ingest-la-newspapers-metadata.ts
184 lines
/**
* ingest-la-newspapers-metadata.ts
*
* Phase 1 of the LA-area historical newspaper ingest. Pulls *metadata only*
* (no OCR text yet) for every issue in 4 confirmed Internet Archive
* collections covering Greater LA pre-1951. Run companion script
* `pull-la-newspapers-ocr.ts` to backfill the OCR text afterward.
*
* Sources (all free, public, IA-hosted, OCR-searchable):
* - pub_los-angeles-times ~17,298 issues 1881–1951
* - newspaperarchive-los-angeles-daily-herald ~2,408 issues 1873–~1900
* - newspaperarchive-pomona-progress-bulletin ~3,065 issues 1967+
* - newspaperarchive-santa-ana-orange-county-register ~339 issues 1929+
*
* IA scrape API: https://archive.org/services/search/v1/scrape
* - cursor-paginated; max 10K rows per cursor; preferred for >1K results
*
* Idempotent — safe to re-run. Uses ON CONFLICT (source_identifier) DO UPDATE.
*
* Tier: A (Internet Archive scan of canonical historical paper).
*
* Usage: bun run scripts/ingest-la-newspapers-metadata.ts
*/
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,
password: process.env.PGPASSWORD,
port: parseInt(process.env.PGPORT ?? '5432', 10),
max: 4,
});
type Paper = {
collection: string;
paperName: string;
paperShort: string;
};
const PAPERS: Paper[] = [
{ collection: 'pub_los-angeles-times', paperName: 'Los Angeles Times', paperShort: 'LATimes' },
{ collection: 'newspaperarchive-los-angeles-daily-herald', paperName: 'Los Angeles Daily Herald', paperShort: 'LADailyHerald' },
{ collection: 'newspaperarchive-pomona-progress-bulletin', paperName: 'Pomona Progress Bulletin', paperShort: 'PomonaProgress' },
{ collection: 'newspaperarchive-santa-ana-orange-county-register', paperName: 'Santa Ana Orange County Register', paperShort: 'SantaAnaRegister' },
];
const SCRAPE = 'https://archive.org/services/search/v1/scrape';
type ScrapeRow = {
identifier: string;
date?: string;
title?: string;
};
async function scrapeCollection(collection: string, onBatch: (rows: ScrapeRow[]) => Promise<void>) {
let cursor: string | undefined;
let total = 0;
while (true) {
const params = new URLSearchParams({
q: `collection:${collection}`,
fields: 'identifier,date,title',
count: '1000',
});
if (cursor) params.set('cursor', cursor);
const res = await fetch(`${SCRAPE}?${params}`);
if (!res.ok) throw new Error(`IA scrape ${collection} → ${res.status}`);
const json = (await res.json()) as { items: ScrapeRow[]; cursor?: string; total: number };
if (!json.items?.length) break;
await onBatch(json.items);
total += json.items.length;
if (!json.cursor) break;
cursor = json.cursor;
}
return total;
}
async function ensureSchema() {
await pool.query(`
CREATE TABLE IF NOT EXISTS news_issue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_collection TEXT NOT NULL,
source_identifier TEXT UNIQUE NOT NULL,
paper_name TEXT NOT NULL,
paper_short TEXT NOT NULL,
issue_date DATE,
ia_url TEXT NOT NULL,
ocr_text_url TEXT NOT NULL,
ocr_text TEXT,
ocr_text_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', coalesce(ocr_text, ''))) STORED,
ocr_fetched_at TIMESTAMPTZ,
page_count INT,
source_tier CHAR(1) NOT NULL DEFAULT 'A',
source_label TEXT NOT NULL DEFAULT 'Internet Archive',
title TEXT,
metadata_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_news_issue_date ON news_issue(issue_date);
CREATE INDEX IF NOT EXISTS idx_news_issue_paper_date ON news_issue(paper_short, issue_date);
CREATE INDEX IF NOT EXISTS idx_news_issue_tsv ON news_issue USING GIN(ocr_text_tsv);
CREATE INDEX IF NOT EXISTS idx_news_issue_ocr_pending ON news_issue(paper_short) WHERE ocr_text IS NULL;
`);
console.log('schema OK');
}
async function upsertBatch(paper: Paper, rows: ScrapeRow[]): Promise<number> {
if (!rows.length) return 0;
const values: any[] = [];
const tuples: string[] = [];
rows.forEach((r, i) => {
const base = i * 8;
tuples.push(`($${base+1},$${base+2},$${base+3},$${base+4},$${base+5},$${base+6},$${base+7},$${base+8})`);
const iaUrl = `https://archive.org/details/${r.identifier}`;
const ocrUrl = `https://archive.org/download/${r.identifier}/${r.identifier}_djvu.txt`;
const issueDate = r.date && /^\d{4}-\d{2}-\d{2}/.test(r.date) ? r.date.slice(0,10) : null;
values.push(
paper.collection,
r.identifier,
paper.paperName,
paper.paperShort,
issueDate,
iaUrl,
ocrUrl,
r.title ?? null,
);
});
const sql = `
INSERT INTO news_issue
(source_collection, source_identifier, paper_name, paper_short, issue_date, ia_url, ocr_text_url, title)
VALUES ${tuples.join(',')}
ON CONFLICT (source_identifier) DO UPDATE SET
issue_date = COALESCE(EXCLUDED.issue_date, news_issue.issue_date),
title = COALESCE(EXCLUDED.title, news_issue.title)
`;
const r = await pool.query(sql, values);
return r.rowCount ?? 0;
}
async function main() {
await ensureSchema();
let grandTotal = 0;
for (const paper of PAPERS) {
console.log(`\n→ ${paper.paperName} (${paper.collection})`);
let pulled = 0;
let inserted = 0;
await scrapeCollection(paper.collection, async (rows) => {
const n = await upsertBatch(paper, rows);
pulled += rows.length;
inserted += n;
process.stdout.write(` scraped ${pulled} … upserted ${inserted}\r`);
});
console.log(` ✓ ${paper.paperName}: ${pulled} scraped, ${inserted} upserted`);
grandTotal += pulled;
}
const { rows: stats } = await pool.query<{
paper_short: string;
issues: string;
earliest: string | null;
latest: string | null;
ocr_pending: string;
}>(`
SELECT paper_short,
count(*)::text as issues,
min(issue_date)::text as earliest,
max(issue_date)::text as latest,
count(*) FILTER (WHERE ocr_text IS NULL)::text as ocr_pending
FROM news_issue
GROUP BY paper_short
ORDER BY paper_short
`);
console.log('\n--- summary ---');
for (const s of stats) {
console.log(` ${s.paper_short.padEnd(20)} issues=${s.issues.padStart(6)} ${s.earliest ?? '?'} → ${s.latest ?? '?'} pending OCR=${s.ocr_pending}`);
}
console.log(`\ngrand total scraped: ${grandTotal}`);
await pool.end();
}
main().catch(e => {
console.error('FATAL', e);
process.exit(1);
});