← back to CelebritySignatures
scripts/fetch-signature-evolution.mjs
124 lines
#!/usr/bin/env node
// Signature EVOLUTION — "did this person's signature change over time?"
// For every person in data/celebrity_signatures.json, collect ALL known
// signature files (not just the one shown on the card):
// 1. every Wikidata P109 statement, with point-in-time qualifiers
// (P585 point-in-time, P580 start-time) — some people have several
// 2. Wikimedia Commons "Category:Signatures of <name>" members (the Commons
// convention for multi-era signature sets, e.g. Picasso)
// Years come from qualifiers first, then a year token in the filename.
// Emits data/signature-evolution.json for people with >=2 distinct files:
// { <qid>: { name, sigs: [{file, url, year, source, license}] } }
// Display-only data (the storefront card signature + collage sourcing are
// unchanged); licenses are recorded per file for future commercial gating.
//
// Usage: node scripts/fetch-signature-evolution.mjs
// Cost: $0 — WDQS + Commons APIs. Caches per-person category lookups.
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { UA, COMMONS, runSparql, fetchLicenses, sleep } from './lib/wikidata-common.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const DATA = join(ROOT, 'data');
const CACHE = join(ROOT, 'tmp_artists_cache');
const YEAR_RE = /\b(1[4-9]\d\d|20\d\d)\b/;
// merged view: hand-curated catalog + generated Artists set (server merges the
// same two files for /api/signatures — keep this consumer consistent)
const loadJson = async (f) => { try { return JSON.parse(await readFile(join(DATA, f), 'utf8')); } catch { return []; } };
const people = [...await loadJson('celebrity_signatures.json'), ...await loadJson('artists.json')]
.map((r) => ({ qid: (r.wikidata || '').split('/').pop(), name: r.full_name }))
.filter((p) => /^Q\d+$/.test(p.qid));
const uniq = [...new Map(people.map((p) => [p.qid, p])).values()];
console.log(`${uniq.length} people to sweep`);
// ── 1) all P109 statements + time qualifiers (batched SPARQL) ───────────────
const sigsByQid = new Map(); // qid -> Map(file -> {file, year, source})
const claim = (qid, file, year, source) => {
if (!file) return;
if (!sigsByQid.has(qid)) sigsByQid.set(qid, new Map());
const m = sigsByQid.get(qid);
const cur = m.get(file);
if (cur) { if (year && !cur.year) cur.year = year; }
else m.set(file, { file, year: year || null, source });
};
for (let i = 0; i < uniq.length; i += 300) {
const batch = uniq.slice(i, i + 300);
const q = `SELECT ?person ?sig ?pit ?start WHERE {
VALUES ?person { ${batch.map((p) => `wd:${p.qid}`).join(' ')} }
?person p:P109 ?st . ?st ps:P109 ?sig .
OPTIONAL { ?st pq:P585 ?pit } OPTIONAL { ?st pq:P580 ?start }
}`;
const rows = await runSparql(q);
for (const b of rows) {
const qid = b.person.value.split('/').pop();
const m = (b.sig?.value || '').match(/Special:FilePath\/(.+)$/);
const file = m ? decodeURIComponent(m[1]).replace(/_/g, ' ') : null;
const t = b.pit?.value || b.start?.value || null;
const year = t ? +t.slice(0, 4) : (file && YEAR_RE.test(file) ? +file.match(YEAR_RE)[1] : null);
claim(qid, file, year, 'p109');
}
console.log(`[p109] batch ${i / 300 + 1}/${Math.ceil(uniq.length / 300)} — ${rows.length} statements`);
await sleep(1200);
}
// ── 2) Commons "Category:Signatures of <name>" sweep (cached) ───────────────
await mkdir(CACHE, { recursive: true });
const catCachePath = join(CACHE, 'evo-commons-cats.json');
const catCache = existsSync(catCachePath) ? JSON.parse(await readFile(catCachePath, 'utf8')) : {};
let swept = 0, catHits = 0;
for (const p of uniq) {
if (!(p.qid in catCache)) {
const title = `Category:Signatures of ${p.name}`;
const url = `${COMMONS}?action=query&format=json&list=categorymembers&cmtitle=${encodeURIComponent(title)}&cmtype=file&cmlimit=100&origin=*`;
try {
const res = await fetch(url, { headers: { 'User-Agent': UA } });
const j = res.ok ? await res.json() : null;
catCache[p.qid] = (j?.query?.categorymembers || []).map((m) => m.title.replace(/^File:/, ''));
} catch { catCache[p.qid] = []; }
await sleep(120);
if (++swept % 100 === 0) {
await writeFile(catCachePath, JSON.stringify(catCache));
process.stdout.write(`[commons] ${swept} swept\r`);
}
}
for (const file of catCache[p.qid]) {
const year = YEAR_RE.test(file) ? +file.match(YEAR_RE)[1] : null;
claim(p.qid, file, year, 'commons-cat');
}
if (catCache[p.qid].length) catHits++;
}
await writeFile(catCachePath, JSON.stringify(catCache));
console.log(`\n[commons] ${catHits} people have a "Signatures of <name>" category`);
// ── 3) keep people with >=2 distinct files; fetch licenses ──────────────────
const multi = [...sigsByQid.entries()].filter(([, m]) => m.size >= 2);
const allFiles = [...new Set(multi.flatMap(([, m]) => [...m.keys()]))].map((f) => 'File:' + f);
console.log(`${multi.length} people with >=2 signature files (${allFiles.length} files) — fetching licenses`);
const lic = await fetchLicenses(allFiles);
const nameByQid = new Map(uniq.map((p) => [p.qid, p.name]));
const out = {};
for (const [qid, m] of multi) {
const sigs = [...m.values()]
.map((s) => ({
...s,
url: `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(s.file)}?width=400`,
license: lic['File:' + s.file]?.license || 'unknown',
}))
.sort((a, b) => (a.year ?? 9999) - (b.year ?? 9999));
out[qid] = { name: nameByQid.get(qid), sigs };
}
await writeFile(join(DATA, 'signature-evolution.json'), JSON.stringify(out, null, 1));
const dated = Object.values(out).filter((o) => o.sigs.filter((s) => s.year).length >= 2);
console.log(`wrote data/signature-evolution.json — ${Object.keys(out).length} people with multiple signatures, ${dated.length} with a datable timeline`);
console.log('top timelines:');
Object.values(out)
.sort((a, b) => b.sigs.length - a.sigs.length).slice(0, 10)
.forEach((o) => console.log(` ${o.name}: ${o.sigs.length} signatures ${o.sigs.filter((s) => s.year).length ? `(${o.sigs.filter((s) => s.year).map((s) => s.year).join(', ')})` : '(undated)'}`));