← back to CelebritySignatures
scripts/artists-post.mjs
90 lines
#!/usr/bin/env node
// Post-processing for the generated Artists set (data/artists.json), run AFTER
// scripts/build-artists.mjs and scripts/filter-artists.mjs:
// 1. CC salvage — build-artists' PD-only gate drops real signatures whose SVG
// tracing carries a CC BY(-SA) claim (Rembrandt! Kahlo!). Those belong in
// the grid per the site's existing attribution model, not silently gone.
// 2. cross-category dupe drop — anyone already in the hand-curated
// celebrity_signatures.json keeps their original category.
// 3. museums re-join — build-artists strips its private _museums field; the
// grid's 🏛 tags and the museum murals need it back on every row.
// 4. famous-first rerank (Wikidata sitelinks, from artists-raw.json).
// Idempotent — safe to re-run any time artists.json is regenerated.
// Server-side /api/signatures merges celebrity_signatures.json + artists.json,
// so this script NEVER touches the hand-curated file.
//
// Usage: node scripts/artists-post.mjs
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileTitleFromUrl, fetchLicenses, licenseRisk } from './lib/wikidata-common.mjs';
const DATA = join(fileURLToPath(new URL('..', import.meta.url)), 'data');
const qidOf = (r) => (r.wikidata || '').split('/').pop();
const artists = JSON.parse(await readFile(join(DATA, 'artists.json'), 'utf8'));
const raw = JSON.parse(await readFile(join(DATA, 'artists-raw.json'), 'utf8'));
const curated = JSON.parse(await readFile(join(DATA, 'celebrity_signatures.json'), 'utf8'));
const rawByQid = new Map(raw.map((a) => [a.qid, a]));
const curatedQids = new Set(curated.map(qidOf).filter(Boolean));
// same creative-occupation gate as scripts/filter-artists.mjs (kept in sync)
const ART_OCC = /(paint|sculpt|printmak|draughts|illustrat|photograph|architect|designer|engrav|\bartist\b|animat|ceramic|etcher|watercolo|muralist|lithograph|graphic|calligraph|craft|goldsmith|jewel|potter|weav|textile|cartoonist)/;
const isArtist = (qid) => ((rawByQid.get(qid)?.occupations) || []).some((o) => ART_OCC.test(o.toLowerCase()));
// 1) CC salvage: raw visual artists absent from artists.json whose Commons file
// exists under a CC license (PD-only gate dropped them).
const present = new Set(artists.map(qidOf));
const candidates = raw.filter((a) => !present.has(a.qid) && !curatedQids.has(a.qid)
&& isArtist(a.qid) && a.death_year < 1974);
let salvaged = 0;
if (candidates.length) {
const titles = [...new Set(candidates.map((a) => fileTitleFromUrl(a.signature_image_url)).filter(Boolean))];
console.log(`salvage: re-checking ${titles.length} dropped signature files for CC licenses…`);
const lic = await fetchLicenses(titles);
for (const a of candidates) {
const meta = lic[fileTitleFromUrl(a.signature_image_url) || ''];
if (!meta || !meta.directUrl) continue; // truly missing → stays dropped
const risk = licenseRisk(meta.license);
if (risk === 'high') continue; // unknown license → stays dropped
const ok = risk === 'low' || risk === 'low-medium'; // PD/CC0/CC BY(-SA) per site model
artists.push({
category: 'Artists',
rank: 0,
full_name: a.full_name,
wikidata: a.wikidata.replace('http://www.wikidata.org/entity/', 'https://www.wikidata.org/wiki/'),
reason_for_ranking: `Artist in the permanent collections of ${a.museums.join(', ')}; cross-wiki notability: ${a.sitelinks} language Wikipedias`,
ranking_source_urls: 'https://www.metmuseum.org/art/collection | https://www.nga.gov/collection | https://www.si.edu/collections',
signature_image_url: meta.directUrl,
signature_source_type: 'Wikimedia Commons (Wikidata P109 signature file)',
image_license: meta.license,
image_author: meta.artist || '',
deceased: 'yes',
death_date: String(a.death_year),
risk_level: ok ? 'low' : 'medium',
usable_in_commercial_collage: ok ? 'yes' : 'review',
notes: `Deceased ${a.death_year}; signature file carries a ${meta.license} claim on the SVG tracing — ${/sa/i.test(meta.license) ? 'derivative collage may need share-alike + attribution' : 'usable with attribution'}.`,
backup_source: meta.filePage || a.signature_image_url.replace(/^http:/, 'https:'),
});
salvaged++;
}
}
// 2) cross-category dupe drop + 3) museums re-join + 4) famous-first rerank
const cleaned = artists.filter((r) => !curatedQids.has(qidOf(r)));
for (const r of cleaned) {
const rawRow = rawByQid.get(qidOf(r));
if (rawRow) r.museums = rawRow.museums;
}
cleaned.sort((x, y) => (rawByQid.get(qidOf(y))?.sitelinks || 0) - (rawByQid.get(qidOf(x))?.sitelinks || 0));
cleaned.forEach((r, i) => { r.rank = i + 1; });
await writeFile(join(DATA, 'artists.json'), JSON.stringify(cleaned, null, 2));
const byMuseum = {};
cleaned.forEach((r) => (r.museums || []).forEach((m) => { byMuseum[m] = (byMuseum[m] || 0) + 1; }));
console.log(`artists.json: ${cleaned.length} rows (+${salvaged} CC-salvaged, ${artists.length - cleaned.length} curated-dupes dropped)`);
console.log('usable:', cleaned.filter((r) => r.usable_in_commercial_collage === 'yes').length, '/', cleaned.length);
console.log('top museums:', Object.entries(byMuseum).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([m, n]) => `${m}: ${n}`).join(' | '));