← back to AsSeenInMovies
scrapers/tmdb-download-ids.js
140 lines
#!/usr/bin/env node
'use strict';
// Phase A — download TMDB's daily ID exports (no auth required) and bulk-load
// them as queued stubs in asim_ingest_stubs. After this runs we have a complete
// checklist of every TMDB id that exists; Phase B worker chips away at it.
//
// Exports are gzipped JSONL — one line per record, fields {id, popularity, ...}.
// Files live at https://files.tmdb.org/p/exports/<type>_ids_MM_DD_YYYY.json.gz
// and roll over daily at 8AM UTC.
//
// Legal: ID exports + facts are explicitly open-use per TMDB terms. Attribution
// required on any user-facing page (handled in server.js home view).
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const readline = require('readline');
const { Pool } = require('pg');
const RAW_DIR = path.join(__dirname, '..', 'data', 'raw', 'tmdb-id-exports');
fs.mkdirSync(RAW_DIR, { recursive: true });
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
max: 4,
});
// TMDB rotates the export at 8AM UTC. If we're before 8AM, use yesterday's file.
function exportDateStr() {
const d = new Date();
if (d.getUTCHours() < 8) d.setUTCDate(d.getUTCDate() - 1);
const MM = String(d.getUTCMonth() + 1).padStart(2, '0');
const DD = String(d.getUTCDate()).padStart(2, '0');
const YYYY = d.getUTCFullYear();
return `${MM}_${DD}_${YYYY}`;
}
// Each entry: source label we use internally, TMDB export file prefix.
const EXPORTS = [
{ source: 'tmdb-movie', file: 'movie_ids' },
{ source: 'tmdb-tv', file: 'tv_series_ids' },
{ source: 'tmdb-person', file: 'person_ids' },
{ source: 'tmdb-collection', file: 'collection_ids' },
{ source: 'tmdb-company', file: 'production_company_ids' },
{ source: 'tmdb-network', file: 'tv_network_ids' },
{ source: 'tmdb-keyword', file: 'keyword_ids' },
];
async function downloadOne(prefix, dateStr) {
const url = `https://files.tmdb.org/p/exports/${prefix}_${dateStr}.json.gz`;
const dest = path.join(RAW_DIR, `${prefix}_${dateStr}.json.gz`);
if (fs.existsSync(dest) && fs.statSync(dest).size > 1000) {
return { url, dest, cached: true, bytes: fs.statSync(dest).size };
}
const r = await fetch(url);
if (!r.ok) throw new Error(`${prefix}: HTTP ${r.status}`);
const buf = Buffer.from(await r.arrayBuffer());
fs.writeFileSync(dest, buf);
return { url, dest, cached: false, bytes: buf.length };
}
async function bulkLoadStubs(source, gzPath, runId) {
// Stream-parse the gzipped JSONL and bulk-INSERT in chunks of 5000.
// Use ON CONFLICT DO UPDATE to refresh popularity on subsequent runs.
const stream = fs.createReadStream(gzPath).pipe(zlib.createGunzip());
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let batch = [];
const BATCH_SIZE = 5000;
let total = 0;
let inserted = 0;
const flush = async () => {
if (batch.length === 0) return;
const values = batch.map((_, i) => `($1, $${i * 2 + 2}, $${i * 2 + 3})`).join(',');
const params = [source, ...batch.flatMap(b => [String(b.id), b.popularity ?? null])];
const sql = `
INSERT INTO asim_ingest_stubs (source, source_id, popularity)
VALUES ${values}
ON CONFLICT (source, source_id) DO UPDATE
SET popularity = COALESCE(EXCLUDED.popularity, asim_ingest_stubs.popularity)
`;
await pool.query(sql, params);
inserted += batch.length;
batch = [];
};
for await (const line of rl) {
if (!line) continue;
try {
const obj = JSON.parse(line);
if (typeof obj.id !== 'number') continue;
if (obj.adult) { continue; } // skip adult by default
batch.push({ id: obj.id, popularity: typeof obj.popularity === 'number' ? obj.popularity : null });
total++;
if (batch.length >= BATCH_SIZE) await flush();
if (total % 50000 === 0) process.stdout.write(` ${source}: ${total.toLocaleString()} parsed, ${inserted.toLocaleString()} inserted\n`);
} catch (e) {
// Skip malformed lines silently — TMDB files occasionally have trailing junk
}
}
await flush();
return { total, inserted };
}
async function main() {
const dateStr = exportDateStr();
console.log(`[tmdb-ids] using export date ${dateStr}`);
// Open a run-row to track progress in PG.
const runR = await pool.query(
`INSERT INTO asim_ingest_runs (run_kind, source, notes) VALUES ('tmdb-id-export-download', $1, $2) RETURNING id`,
['all', `dateStr=${dateStr}`]
);
const runId = runR.rows[0].id;
let grandTotal = 0;
try {
for (const { source, file } of EXPORTS) {
console.log(`\n[${source}] downloading ${file}_${dateStr}.json.gz`);
const { dest, cached, bytes } = await downloadOne(file, dateStr);
console.log(` ${cached ? 'cached' : 'fetched'} ${(bytes/1024/1024).toFixed(1)}MB → ${dest}`);
const { total, inserted } = await bulkLoadStubs(source, dest, runId);
console.log(` ${source}: ${total.toLocaleString()} records, ${inserted.toLocaleString()} stubs upserted`);
grandTotal += inserted;
}
await pool.query(`UPDATE asim_ingest_runs SET finished_at=NOW(), status='done', records_out=$1 WHERE id=$2`, [grandTotal, runId]);
console.log(`\n[tmdb-ids] DONE — ${grandTotal.toLocaleString()} stubs across ${EXPORTS.length} sources`);
} catch (e) {
console.error(`[tmdb-ids] FAILED:`, e.message);
await pool.query(`UPDATE asim_ingest_runs SET finished_at=NOW(), status='failed', notes=$1 WHERE id=$2`, [String(e.message).slice(0, 800), runId]);
process.exitCode = 1;
} finally {
await pool.end();
}
}
main();