← 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(' | '));