← back to CelebritySignatures
scripts/fetch-portraits.mjs
64 lines
#!/usr/bin/env node
// Portrait fetcher — for every person in the merged dataset, pull their
// Wikidata P18 ("image") filename → a Wikimedia Commons thumbnail URL.
// Commons images are freely licensed (CC/PD by Commons policy), so they're
// safe to show. Output: data/portraits.json = { "<QID>": "<thumbUrl>" }.
// Used to (1) show a portrait on the card chip and (2) show the person's
// image in the popup for NON-artists (politicians, astronauts, authors…)
// who have no artworks.
//
// Usage: node scripts/fetch-portraits.mjs
// Cost: $0 — Wikidata wbgetentities, no keys.
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { UA, sleep } from './lib/wikidata-common.mjs';
const DATA = join(fileURLToPath(new URL('..', import.meta.url)), 'data');
const loadJson = async (f) => { try { return JSON.parse(await readFile(join(DATA, f), 'utf8')); } catch { return []; } };
const qidOf = (r) => (r.wikidata || '').split('/').pop();
const people = [
...await loadJson('celebrity_signatures.json'),
...await loadJson('artists.json'),
...await loadJson('authors.json'),
];
const qids = [...new Set(people.map(qidOf).filter((q) => /^Q\d+$/.test(q)))];
console.log(`${qids.length} unique people to look up`);
// resume: keep any portraits already resolved
let out = {};
try { out = JSON.parse(await readFile(join(DATA, 'portraits.json'), 'utf8')); } catch {}
const todo = qids.filter((q) => !(q in out));
console.log(`${todo.length} still need a portrait (${Object.keys(out).length} cached)`);
const commonsThumb = (file) =>
`https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(file.replace(/ /g, '_'))}?width=120`;
let found = 0;
for (let i = 0; i < todo.length; i += 50) {
const ids = todo.slice(i, i + 50);
const url = `https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${ids.join('|')}&props=claims&format=json&origin=*`;
let j;
try {
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) { if (res.status === 429) { process.stdout.write('(429 60s) '); await sleep(60000); i -= 50; continue; } await sleep(500); continue; }
j = await res.json();
} catch { await sleep(2000); i -= 50; continue; }
for (const [qid, e] of Object.entries(j.entities || {})) {
const img = e.claims?.P18?.[0]?.mainsnak?.datavalue?.value;
if (img) { out[qid] = commonsThumb(img); found++; }
else out[qid] = null; // record the miss so we don't re-query
}
if ((i / 50) % 10 === 0) {
await writeFile(join(DATA, 'portraits.json'), JSON.stringify(out));
process.stdout.write(`\r${i + ids.length}/${todo.length} checked · ${found} portraits`);
}
await sleep(200);
}
// drop the null misses from the shipped file (front-end only needs hits)
const hits = Object.fromEntries(Object.entries(out).filter(([, v]) => v));
await writeFile(join(DATA, 'portraits.json'), JSON.stringify(hits));
console.log(`\nwrote data/portraits.json — ${Object.keys(hits).length} portraits of ${qids.length} people`);