← back to AsSeenInMovies
scrapers/wikimedia-credit-backfill.js
145 lines
#!/usr/bin/env node
'use strict';
// Backfill image credit + license + source URL for every asim_movies poster
// and asim_people headshot we previously imported from Wikimedia Commons.
//
// Per Steve's fan-art credit policy (memory: feedback_fan_art_credit_policy):
// every fan-art / non-PD image must carry artist + license + source URL or it
// cannot be displayed. We have 783 movie posters + a handful of headshots
// already in place without that metadata. This script fetches it.
//
// Commons API: action=query, titles=File:..., prop=imageinfo,
// iiprop=extmetadata returns Artist, LicenseShortName, LicenseUrl per file.
// Batches up to 50 files per request.
require('dotenv').config();
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
max: 4,
});
const COMMONS_API = 'https://commons.wikimedia.org/w/api.php';
const UA = 'AsSeenInMovies/0.1 (https://asseeninmovies.com; steve@designerwallcoverings.com)';
const BATCH = 50;
const SLEEP_MS = 1100;
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
function fileNameFromCommonsUrl(url) {
// https://commons.wikimedia.org/wiki/Special:FilePath/Goosebumps%202023%20poster.jpg?width=600
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
if (!url) return null;
const m = url.match(/Special:FilePath\/([^?]+)/i);
if (!m) return null;
try { return decodeURIComponent(m[1]).replace(/_/g, ' '); }
catch { return m[1].replace(/_/g, ' '); }
}
function stripHtml(s) {
if (!s) return null;
// Commons returns "Artist" wrapped in <a>...</a> HTML
return String(s).replace(/<[^>]+>/g, '').replace(/&/g, '&').trim();
}
async function commonsImageInfo(filenames) {
const titles = filenames.map(n => `File:${n}`).join('|');
const params = new URLSearchParams({
action: 'query',
titles,
prop: 'imageinfo',
iiprop: 'extmetadata|url|user',
format: 'json',
formatversion: '2',
});
const r = await fetch(`${COMMONS_API}?${params}`, { headers: { 'User-Agent': UA } });
if (!r.ok) throw new Error(`commons ${r.status}`);
const j = await r.json();
const out = new Map();
const pages = j.query?.pages || [];
for (const p of pages) {
if (!p.imageinfo || !p.imageinfo[0]) continue;
const ii = p.imageinfo[0];
const ext = ii.extmetadata || {};
const title = p.title.replace(/^File:/, '');
out.set(title, {
artist: stripHtml(ext.Artist?.value) || ii.user || null,
license: ext.LicenseShortName?.value || ext.License?.value || null,
licenseUrl: ext.LicenseUrl?.value || null,
descriptionUrl: ii.descriptionurl || `https://commons.wikimedia.org/wiki/File:${encodeURIComponent(title)}`,
});
}
return out;
}
async function backfillMovies() {
const sel = await pool.query(`
SELECT id, poster_url FROM asim_movies
WHERE poster_source='wikimedia-cc' AND poster_credit IS NULL
ORDER BY id LIMIT 2000`);
console.log(`[backfill-movies] ${sel.rows.length} rows`);
let ok = 0, miss = 0;
for (let i = 0; i < sel.rows.length; i += BATCH) {
const slice = sel.rows.slice(i, i + BATCH);
const idToFile = new Map();
for (const r of slice) {
const f = fileNameFromCommonsUrl(r.poster_url);
if (f) idToFile.set(r.id, f);
}
if (!idToFile.size) continue;
let meta;
try { meta = await commonsImageInfo([...idToFile.values()]); }
catch (e) { console.error(` batch ${i / BATCH}: ${e.message}`); await sleep(5000); continue; }
for (const [id, file] of idToFile) {
const m = meta.get(file);
if (!m) { miss++; continue; }
await pool.query(
`UPDATE asim_movies SET poster_credit=$1, poster_license=$2, poster_source_url=$3, updated_at=NOW() WHERE id=$4`,
[m.artist, m.license, m.descriptionUrl, id]
);
ok++;
}
if ((i / BATCH) % 5 === 0) console.log(` …batch ${Math.floor(i/BATCH)+1}/${Math.ceil(sel.rows.length/BATCH)} ok=${ok} miss=${miss}`);
await sleep(SLEEP_MS);
}
console.log(`[backfill-movies] done. ok=${ok} miss=${miss}`);
}
async function backfillPeople() {
const sel = await pool.query(`
SELECT id, headshot_url FROM asim_people
WHERE headshot_source='wikimedia-cc' AND headshot_credit IS NULL
ORDER BY id LIMIT 1000`);
console.log(`[backfill-people] ${sel.rows.length} rows`);
let ok = 0, miss = 0;
for (let i = 0; i < sel.rows.length; i += BATCH) {
const slice = sel.rows.slice(i, i + BATCH);
const idToFile = new Map();
for (const r of slice) {
const f = fileNameFromCommonsUrl(r.headshot_url);
if (f) idToFile.set(r.id, f);
}
if (!idToFile.size) continue;
let meta;
try { meta = await commonsImageInfo([...idToFile.values()]); }
catch (e) { console.error(` batch ${i / BATCH}: ${e.message}`); await sleep(5000); continue; }
for (const [id, file] of idToFile) {
const m = meta.get(file);
if (!m) { miss++; continue; }
await pool.query(
`UPDATE asim_people SET headshot_credit=$1, headshot_license=$2, headshot_source_url=$3, updated_at=NOW() WHERE id=$4`,
[m.artist, m.license, m.descriptionUrl, id]
);
ok++;
}
await sleep(SLEEP_MS);
}
console.log(`[backfill-people] done. ok=${ok} miss=${miss}`);
}
(async () => {
await backfillMovies();
await backfillPeople();
await pool.end();
})().catch(e => { console.error('fatal:', e); process.exit(1); });