← back to CelebritySignatures
scripts/fetch-authors.mjs
116 lines
#!/usr/bin/env node
// "Authors" category — famous deceased writers with REAL P109 signatures.
// Same architecture as the Artists set: Wikidata universe by literary
// occupation (writer, novelist, poet, playwright, essayist, short-story
// writer, children's writer, science-fiction writer), deceased >50yr
// (death < 1974, the site's publicity/copyright posture), famous-first by
// cross-wiki sitelinks, every signature verified on Wikimedia Commons with a
// license gate. Output: data/authors.json (regenerable; server merges it into
// /api/signatures — the hand-curated file is never touched).
// Dedupe: anyone already in celebrity_signatures.json or artists.json keeps
// their existing category; they are skipped here.
//
// Usage: node scripts/fetch-authors.mjs
// Cost: $0 — WDQS + Commons, no keys.
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
runSparql, fileTitleFromUrl, fetchLicenses, licenseRisk, assess, fetchLabels,
sleep,
} from './lib/wikidata-common.mjs';
const DATA = join(fileURLToPath(new URL('..', import.meta.url)), 'data');
const DEATH_CUTOFF = 1974;
const OCCUPATIONS = {
Q36180: 'writer', Q6625963: 'novelist', Q49757: 'poet', Q214917: 'playwright',
Q11774202: 'essayist', Q15949613: 'short-story writer',
Q4853732: "children's writer", Q18844224: 'science-fiction writer',
};
// ── 1) universe: deceased authors with a P109 signature ─────────────────────
const byQid = new Map();
for (const [occ, label] of Object.entries(OCCUPATIONS)) {
const q = `SELECT ?person ?personLabel ?sig ?dod ?links WHERE {
?person wdt:P31 wd:Q5 ; wdt:P106 wd:${occ} ; wdt:P109 ?sig ; wdt:P570 ?dod ; wikibase:sitelinks ?links .
FILTER(YEAR(?dod) < ${DEATH_CUTOFF})
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
}`;
process.stdout.write(`[authors] ${label} … `);
const rows = await runSparql(q);
let added = 0;
for (const b of rows) {
const qid = b.person.value.split('/').pop();
if (byQid.has(qid)) continue;
byQid.set(qid, {
qid,
name: b.personLabel?.value || qid,
sigUrl: b.sig?.value || null,
dod: b.dod?.value || null,
links: parseInt(b.links?.value || '0', 10),
role: label,
});
added++;
}
console.log(`${rows.length} rows (+${added}, total ${byQid.size})`);
await sleep(1500);
}
// ── 2) cross-category dedupe (curated + artists keep their people) ──────────
const loadJson = async (f, fb) => { try { return JSON.parse(await readFile(join(DATA, f), 'utf8')); } catch { return fb; } };
const qidOf = (r) => (r.wikidata || '').split('/').pop();
const taken = new Set([
...(await loadJson('celebrity_signatures.json', [])).map(qidOf),
...(await loadJson('artists.json', [])).map(qidOf),
].filter(Boolean));
const candidates = [...byQid.values()].filter((p) => !taken.has(p.qid) && p.sigUrl && p.dod);
console.log(`[authors] ${byQid.size} in universe → ${candidates.length} after cross-category dedupe`);
// label backfill for raw QIDs (WDQS label-service flakiness)
const needLabel = candidates.filter((p) => /^Q\d+$/.test(p.name)).map((p) => p.qid);
if (needLabel.length) {
const labels = await fetchLabels(needLabel);
for (const p of candidates) if (labels[p.qid]) p.name = labels[p.qid];
}
// ── 3) Commons license verify (batched; PD/CC kept, unknown dropped) ────────
const titles = [...new Set(candidates.map((p) => fileTitleFromUrl(p.sigUrl)).filter(Boolean))];
console.log(`[authors] fetching ${titles.length} Commons licenses …`);
const lic = await fetchLicenses(titles);
candidates.sort((a, b) => b.links - a.links);
const out = [];
let droppedLic = 0;
for (const p of candidates) {
const title = fileTitleFromUrl(p.sigUrl);
const meta = title ? lic[title] : null;
if (!meta || !meta.directUrl) { droppedLic++; continue; } // missing file
const license = meta.license || 'unknown';
if (licenseRisk(license) === 'high') { droppedLic++; continue; } // unknown license
const { risk_level, usable } = assess({ deceased: true, lic: license, fromArchive: false });
out.push({
category: 'Authors',
rank: out.length + 1,
full_name: p.name,
wikidata: `https://www.wikidata.org/wiki/${p.qid}`,
reason_for_ranking: `${p.role[0].toUpperCase() + p.role.slice(1)}; cross-wiki notability: present on ${p.links} language Wikipedias`,
ranking_source_urls: 'https://www.nobelprize.org/prizes/literature/ | https://www.loc.gov/',
signature_image_url: meta.directUrl,
signature_source_type: 'Wikimedia Commons (Wikidata P109 signature file)',
image_license: license,
image_author: meta.artist || '',
deceased: 'yes',
death_date: p.dod.slice(0, 10),
risk_level,
usable_in_commercial_collage: usable,
notes: `Deceased ${p.dod.slice(0, 4)} — low publicity-rights risk${/sa/i.test(license) && /cc by-?sa/i.test(license) ? '; CC BY-SA: derivative collage may need share-alike + attribution' : ''}`,
backup_source: meta.filePage || (title ? `https://commons.wikimedia.org/wiki/${encodeURIComponent(title)}` : ''),
});
}
await writeFile(join(DATA, 'authors.json'), JSON.stringify(out, null, 2));
console.log(`[authors] wrote data/authors.json — ${out.length} authors (${droppedLic} dropped: missing file/unknown license)`);
console.log('top 12:');
out.slice(0, 12).forEach((r) => console.log(` ${r.rank}. ${r.full_name} (${r.death_date.slice(0, 4)}) — ${r.image_license}`));