← back to CelebritySignatures
scripts/fetch-artists.mjs
202 lines
#!/usr/bin/env node
// Fetch long-deceased museum-collection artists that have a public-domain
// Wikidata P109 signature file. Source = Wikidata SPARQL (free) + Wikimedia
// Commons (free). NO paid APIs, NO LLM in this step — pure structured query.
//
// "Artists at the MET, Smithsonian, etc" = creators (P170) of works whose
// collection (P195) is one of the major museums below.
// PD guardrail = deceased (P570) with death year < 1974 (dead >50yr), mirroring
// the catalog's existing deceased-only rule.
// "Famous first" = ORDER BY DESC(sitelinks) — Wikipedia language-edition count.
import { writeFile, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const ENDPOINT = 'https://query.wikidata.org/sparql';
// Met + Smithsonian family + the "etc" major-museum canon.
const MUSEUMS = {
Q160236: 'The Metropolitan Museum of Art',
Q1192305: 'Smithsonian American Art Museum',
Q1967668: 'National Portrait Gallery (Smithsonian)',
Q131626: 'Smithsonian Institution',
Q1215884: 'Cooper Hewitt, Smithsonian Design Museum',
Q214867: 'National Gallery of Art',
Q188740: 'Museum of Modern Art',
Q239303: 'Art Institute of Chicago',
Q180788: 'National Gallery, London',
Q19675: 'Louvre',
Q194235: 'Tate',
Q23402: "Musée d'Orsay",
Q190804: 'Rijksmuseum',
Q51252: 'Uffizi Gallery',
Q160112: 'Museo del Prado',
// West-coast + open-collection expansion (Steve 2026-08-03: "all public
// museums — the Broad, MOCA, LACMA… keep building"). QIDs verified via
// wbsearchentities same day.
Q1641836: 'Los Angeles County Museum of Art',
Q17060100: 'The Broad',
Q1134646: 'Museum of Contemporary Art, Los Angeles',
Q731126: 'J. Paul Getty Museum',
Q913672: 'San Francisco Museum of Modern Art',
Q639791: 'Whitney Museum of American Art',
Q201469: 'Solomon R. Guggenheim Museum',
Q657415: 'Cleveland Museum of Art',
Q49133: 'Museum of Fine Arts Boston',
Q510324: 'Philadelphia Museum of Art',
// Amsterdam / Dutch expansion (Steve 2026-08-03: "more from Amsterdam
// museums like the Rijks"). Rijksmuseum already above. QIDs verified same day.
Q224124: 'Van Gogh Museum',
Q924335: 'Stedelijk Museum Amsterdam',
Q277316: 'Rembrandt House Museum',
Q1820897: 'Amsterdam Museum',
Q221092: 'Mauritshuis',
// World-collections expansion (Steve 2026-08-04: "keep going"). The dense
// P6379 path makes any added museum pull its signature-artists. QIDs verified.
Q132783: 'Hermitage Museum',
Q6373: 'British Museum',
Q95569: 'Kunsthistorisches Museum',
Q178065: 'Centre Georges Pompidou',
Q460889: 'Museo Nacional Centro de Arte Reina Sofía',
Q650519: 'Musée Rodin',
Q213322: 'Victoria and Albert Museum',
Q165631: 'Gemäldegalerie Berlin',
Q238587: 'National Portrait Gallery, London',
Q154568: 'Alte Pinakothek',
Q163804: 'Städel Museum',
Q1362629: 'Tokyo National Museum',
};
// Per-museum-batch query. The all-museums-in-one-VALUES query 504s on WDQS now
// (too heavy across 25 museums), so we query in small batches with retry/backoff
// and union the rows (dedup happens in the union step below).
function queryFor(museumQids) {
const vals = museumQids.map(q => `wd:${q}`).join(' ');
return `
SELECT ?person ?personLabel ?sig ?dod ?links
(GROUP_CONCAT(DISTINCT ?museumLabel; separator="|") AS ?museums)
(GROUP_CONCAT(DISTINCT ?occLabel; separator="|") AS ?occs)
WHERE {
VALUES ?museum { ${vals} }
?work wdt:P170 ?person ; wdt:P195 ?museum .
?person wdt:P109 ?sig .
?person wdt:P570 ?dod .
FILTER(YEAR(?dod) < 1974)
?person wikibase:sitelinks ?links .
OPTIONAL { ?person wdt:P106 ?occ .
?occ rdfs:label ?occLabel . FILTER(LANG(?occLabel)="en") }
?museum rdfs:label ?museumLabel . FILTER(LANG(?museumLabel)="en")
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
}
GROUP BY ?person ?personLabel ?sig ?dod ?links
ORDER BY DESC(?links)`;
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function sparql(q, tries = 5) {
const url = `${ENDPOINT}?format=json&query=${encodeURIComponent(q)}`;
for (let i = 1; ; i++) {
let res;
try {
res = await fetch(url, {
headers: {
'Accept': 'application/sparql-results+json',
'User-Agent': 'CelebritySignatures/1.0 (designerwallcoverings.com; PD-signature artist catalog build)',
},
});
} catch (e) { if (i > tries) throw e; await sleep(8000 * i); continue; }
if (res.ok) return (await res.json()).results.bindings;
if ((res.status === 504 || res.status === 429 || res.status >= 500) && i <= tries) {
process.stdout.write(`(WDQS ${res.status}; backoff ${8 * i}s, try ${i}/${tries}) `);
await sleep(8000 * i); continue;
}
throw new Error(`SPARQL ${res.status}: ${(await res.text()).slice(0, 200)}`);
}
}
// Second harvest path: P6379 "has works in the collection" is a DIRECT
// person→museum link, far denser than artwork→P195 (which is sparsely itemized,
// esp. for European museums like the Rijksmuseum). This is what actually pulls
// the Amsterdam/Dutch masters in. Same output columns; unioned with P195.
function queryFor6379(museumQids) {
const vals = museumQids.map(q => `wd:${q}`).join(' ');
return `
SELECT ?person ?personLabel ?sig ?dod ?links
(GROUP_CONCAT(DISTINCT ?museumLabel; separator="|") AS ?museums)
(GROUP_CONCAT(DISTINCT ?occLabel; separator="|") AS ?occs)
WHERE {
VALUES ?museum { ${vals} }
?person wdt:P6379 ?museum ; wdt:P109 ?sig ; wdt:P570 ?dod ; wikibase:sitelinks ?links .
FILTER(YEAR(?dod) < 1974)
OPTIONAL { ?person wdt:P106 ?occ . ?occ rdfs:label ?occLabel . FILTER(LANG(?occLabel)="en") }
?museum rdfs:label ?museumLabel . FILTER(LANG(?museumLabel)="en")
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
}
GROUP BY ?person ?personLabel ?sig ?dod ?links
ORDER BY DESC(?links)`;
}
const ALL_QIDS = Object.keys(MUSEUMS);
const rows = [];
for (let i = 0; i < ALL_QIDS.length; i += 3) {
const batch = ALL_QIDS.slice(i, i + 3);
process.stdout.write(`[P195 ${i + 1}-${i + batch.length}/${ALL_QIDS.length}] ${batch.map(q => MUSEUMS[q]).join(', ')} … `);
const r = await sparql(queryFor(batch));
rows.push(...r);
console.log(`${r.length} rows (total ${rows.length})`);
await sleep(1500);
}
for (let i = 0; i < ALL_QIDS.length; i += 3) {
const batch = ALL_QIDS.slice(i, i + 3);
process.stdout.write(`[P6379 ${i + 1}-${i + batch.length}/${ALL_QIDS.length}] ${batch.map(q => MUSEUMS[q]).join(', ')} … `);
const r = await sparql(queryFor6379(batch));
rows.push(...r);
console.log(`${r.length} rows (total ${rows.length})`);
await sleep(1500);
}
console.log(`raw rows (P195 + P6379): ${rows.length}`);
// One record per person; Special:FilePath URL is directly usable as an <img> src.
const seen = new Set();
const artists = [];
for (const r of rows) {
const qid = r.person.value.split('/').pop();
if (seen.has(qid)) continue;
seen.add(qid);
artists.push({
qid,
wikidata: r.person.value,
full_name: r.personLabel.value,
sitelinks: +r.links.value,
death_date: r.dod.value.slice(0, 10),
death_year: +r.dod.value.slice(0, 4),
signature_image_url: r.sig.value, // http://commons.wikimedia.org/wiki/Special:FilePath/<file>
museums: (r.museums?.value || '').split('|').filter(Boolean),
occupations: (r.occs?.value || '').split('|').filter(Boolean),
});
}
console.log(`unique artists: ${artists.length}`);
console.log('top 15 by fame:');
artists.slice(0, 15).forEach((a, i) =>
console.log(` ${i + 1}. ${a.full_name} — ${a.sitelinks} wikis, d.${a.death_year}, ${a.museums.length} museums`));
// UNION into the existing raw set (never overwrite — the roster gap-fill
// harvester also feeds artists-raw.json). Idempotent by QID; museum
// provenance merges; fresher sitelink/occupation data wins.
const rawPath = join(ROOT, 'data', 'artists-raw.json');
let existing = [];
try { existing = JSON.parse(await readFile(rawPath, 'utf8')); } catch { /* first run */ }
const byQid = new Map(existing.map((a) => [a.qid, a]));
let added = 0, updated = 0;
for (const a of artists) {
const cur = byQid.get(a.qid);
if (!cur) { byQid.set(a.qid, a); added++; continue; }
for (const m of a.museums) if (!cur.museums.includes(m)) { cur.museums.push(m); updated++; }
if (a.occupations.length > (cur.occupations || []).length) cur.occupations = a.occupations;
if (a.sitelinks > (cur.sitelinks || 0)) cur.sitelinks = a.sitelinks;
}
const unioned = [...byQid.values()].sort((x, y) => (y.sitelinks || 0) - (x.sitelinks || 0));
await writeFile(rawPath, JSON.stringify(unioned, null, 2));
console.log(`\nartists-raw.json: ${existing.length} -> ${unioned.length} (+${added} new, ${updated} museum-tag merges)`);