[object Object]

← back to CelebritySignatures

Artists category: Wikidata P195 museum-artist harvest + PD-signature verify + dedupe-safe /api/signatures merge

3c90e389dfaa9063b7a772975c35b3166ad11b99 · 2026-08-03 10:20:22 -0700 · Steve Abrams

Adds scripts/fetch-artists.mjs (Met/Smithsonian/major-museum creators with a P109 signature, deceased >50yr, famous-first via sitelinks) and scripts/build-artists.mjs (Commons imageinfo verify + PD-license gate → data/artists.json). server.js merges the hand-curated catalog with the generated Artists set at /api/signatures, deduped so an artist is never double-listed. Coordinated with celebrity-artists (TK-10181) who owns the roster gap-fill + physical merge. Local-only build, no paid APIs. (TK-10182)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 3c90e389dfaa9063b7a772975c35b3166ad11b99
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 3 10:20:22 2026 -0700

    Artists category: Wikidata P195 museum-artist harvest + PD-signature verify + dedupe-safe /api/signatures merge
    
    Adds scripts/fetch-artists.mjs (Met/Smithsonian/major-museum creators with a P109 signature, deceased >50yr, famous-first via sitelinks) and scripts/build-artists.mjs (Commons imageinfo verify + PD-license gate → data/artists.json). server.js merges the hand-curated catalog with the generated Artists set at /api/signatures, deduped so an artist is never double-listed. Coordinated with celebrity-artists (TK-10181) who owns the roster gap-fill + physical merge. Local-only build, no paid APIs. (TK-10182)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/build-artists.mjs | 142 ++++++++++++++++++++++++++++++++++++++++++++++
 scripts/fetch-artists.mjs | 100 ++++++++++++++++++++++++++++++++
 server.js                 |  18 +++++-
 3 files changed, 259 insertions(+), 1 deletion(-)

diff --git a/scripts/build-artists.mjs b/scripts/build-artists.mjs
new file mode 100644
index 0000000..71e4ca5
--- /dev/null
+++ b/scripts/build-artists.mjs
@@ -0,0 +1,142 @@
+#!/usr/bin/env node
+// Turn data/artists-raw.json into final catalog records matching the
+// celebrity_signatures.json schema. Verifies every P109 signature file exists
+// AND reads its license via the free Wikimedia Commons imageinfo API (batched
+// 50/call). Only files that resolve + are PD/CC0/signature-class are kept.
+// Optional --enrich runs a LOCAL Ollama (qwen3:14b) pass to phrase `notes`
+// ($0, grounded in real fields, deterministic fallback). No paid APIs.
+
+import { readFile, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = fileURLToPath(new URL('..', import.meta.url));
+const DATA = join(ROOT, 'data');
+const ENRICH = process.argv.includes('--enrich');
+const COMMONS = 'https://commons.wikimedia.org/w/api.php';
+const UA = 'CelebritySignatures/1.0 (designerwallcoverings.com; PD-signature artist catalog)';
+
+const raw = JSON.parse(await readFile(join(DATA, 'artists-raw.json'), 'utf8'));
+
+// filename out of a Special:FilePath URL → "File:Name.svg"
+function fileTitle(sigUrl) {
+  const seg = sigUrl.split('/Special:FilePath/').pop();
+  return 'File:' + decodeURIComponent(seg).replace(/_/g, ' ');
+}
+
+async function commonsBatch(titles) {
+  const url = `${COMMONS}?action=query&format=json&prop=imageinfo`
+    + `&iiprop=url|extmetadata&iiextmetadatafilter=LicenseShortName|License`
+    + `&titles=${encodeURIComponent(titles.join('|'))}&origin=*`;
+  const res = await fetch(url, { headers: { 'User-Agent': UA } });
+  if (!res.ok) throw new Error(`commons ${res.status}`);
+  const pages = (await res.json())?.query?.pages || {};
+  const out = {};
+  for (const p of Object.values(pages)) {
+    if (p.missing !== undefined || !p.imageinfo) continue;
+    const ii = p.imageinfo[0];
+    const em = ii.extmetadata || {};
+    out[p.title] = {
+      url: ii.url,
+      license: (em.LicenseShortName?.value || em.License?.value || '').trim(),
+    };
+  }
+  return out;
+}
+
+// Is this license PD / signature-class / CC0 → safe for the deceased-only PD catalog?
+function isPD(license) {
+  const l = (license || '').toLowerCase();
+  if (!l) return true; // no license tag on Commons signatures ≈ below-threshold PD
+  return /public domain|^pd|pd-|cc0|cc-0|no known copyright|expired/.test(l);
+}
+
+// Build title→artist map, batch-verify 50 at a time.
+const byTitle = new Map();
+for (const a of raw) byTitle.set(fileTitle(a.signature_image_url), a);
+const titles = [...byTitle.keys()];
+
+const kept = [];
+const dropped = { missing: 0, nonPD: 0 };
+for (let i = 0; i < titles.length; i += 50) {
+  const chunk = titles.slice(i, i + 50);
+  let info;
+  try { info = await commonsBatch(chunk); }
+  catch (e) { console.warn(`batch ${i} failed: ${e.message}; retrying once`); await new Promise(r => setTimeout(r, 1500)); info = await commonsBatch(chunk); }
+  for (const t of chunk) {
+    const a = byTitle.get(t);
+    const meta = info[t];
+    if (!meta) { dropped.missing++; continue; }
+    if (!isPD(meta.license)) { dropped.nonPD++; continue; }
+    a._directUrl = meta.url;
+    a._license = meta.license || 'Public domain (signature — below threshold of originality)';
+    kept.push(a);
+  }
+  process.stdout.write(`\r  verified ${Math.min(i + 50, titles.length)}/${titles.length} (kept ${kept.length})`);
+}
+console.log(`\n  dropped: ${dropped.missing} missing, ${dropped.nonPD} non-PD`);
+
+// famous-first, then assign per-category rank
+kept.sort((a, b) => b.sitelinks - a.sitelinks);
+
+const artPrimary = ['painter', 'sculptor', 'printmaker', 'draughtsperson', 'illustrator',
+  'photographer', 'architect', 'designer', 'engraver', 'artist', 'animator'];
+function primaryRole(occs) {
+  for (const p of artPrimary) { const hit = occs.find(o => o.toLowerCase() === p); if (hit) return hit; }
+  return occs[0] || 'artist';
+}
+
+const records = kept.map((a, idx) => {
+  const role = primaryRole(a.occupations);
+  const museumList = a.museums.join(', ');
+  return {
+    category: 'Artists',
+    rank: idx + 1,
+    full_name: a.full_name,
+    wikidata: a.wikidata.replace('http://www.wikidata.org/entity/', 'https://www.wikidata.org/wiki/').replace('http://', 'https://'),
+    reason_for_ranking: `${role[0].toUpperCase() + role.slice(1)} in the permanent collections of ${museumList}; cross-wiki notability: ${a.sitelinks} language Wikipedias`,
+    ranking_source_urls: 'https://www.metmuseum.org/art/collection | https://www.si.edu/collections',
+    signature_image_url: a._directUrl,
+    signature_source_type: 'Wikimedia Commons (Wikidata P109 signature file)',
+    image_license: a._license,
+    image_author: '',
+    deceased: 'yes',
+    death_date: a.death_year.toString(),
+    risk_level: 'low',
+    usable_in_commercial_collage: 'yes',
+    notes: `Public-domain signature; ${role} deceased since ${a.death_year} (>50 yr) — no copyright or right-of-publicity exposure. Works held by ${a.museums.length} major museum${a.museums.length > 1 ? 's' : ''}.`,
+    backup_source: a.signature_image_url.replace(/^http:/, 'https:'),
+    _museums: a.museums,
+    _occupations: a.occupations,
+  };
+});
+
+// ---- optional LOCAL Ollama phrasing pass ($0, grounded, safe fallback) ----
+if (ENRICH) {
+  console.log('local-model notes pass (qwen3:14b)…');
+  const BATCH = 20;
+  for (let i = 0; i < records.length; i += BATCH) {
+    const chunk = records.slice(i, i + BATCH);
+    const facts = chunk.map((r, j) => ({ i: j, name: r.full_name, role: r._occupations[0] || 'artist', died: r.death_date, museums: r._museums }));
+    const prompt = `You write one-sentence collector notes for a signature-art catalog. For each artist below, write ONE vivid factual sentence (max 22 words) about why their autograph is desirable, using ONLY the given facts (do not invent works, prizes, or dates). Return ONLY a JSON array of {"i":<index>,"note":"<sentence>"}.\n\n${JSON.stringify(facts)}`;
+    try {
+      const res = await fetch('http://localhost:11434/api/generate', {
+        method: 'POST',
+        body: JSON.stringify({ model: 'qwen3:14b', prompt, stream: false, options: { temperature: 0.4 }, format: 'json' }),
+      });
+      const out = JSON.parse((await res.json()).response);
+      const arr = Array.isArray(out) ? out : (out.notes || out.result || []);
+      for (const o of arr) { const r = chunk[o.i]; if (r && o.note && o.note.length > 15) r.notes = String(o.note).trim(); }
+    } catch (e) { /* keep deterministic notes on any failure */ }
+    process.stdout.write(`\r  enriched ${Math.min(i + BATCH, records.length)}/${records.length}`);
+  }
+  console.log('');
+}
+
+// strip private fields
+for (const r of records) { delete r._museums; delete r._occupations; }
+
+await writeFile(join(DATA, 'artists.json'), JSON.stringify(records, null, 2));
+console.log(`\nwrote data/artists.json — ${records.length} artists`);
+console.log('top 10:');
+records.slice(0, 10).forEach(r => console.log(`  ${r.rank}. ${r.full_name} (d.${r.death_date}) — ${r.image_license}`));
diff --git a/scripts/fetch-artists.mjs b/scripts/fetch-artists.mjs
new file mode 100644
index 0000000..fac3311
--- /dev/null
+++ b/scripts/fetch-artists.mjs
@@ -0,0 +1,100 @@
+#!/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 } 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',
+};
+const VALUES = Object.keys(MUSEUMS).map(q => `wd:${q}`).join(' ');
+
+const QUERY = `
+SELECT ?person ?personLabel ?sig ?dod ?links
+       (GROUP_CONCAT(DISTINCT ?museumLabel; separator="|") AS ?museums)
+       (GROUP_CONCAT(DISTINCT ?occLabel; separator="|") AS ?occs)
+WHERE {
+  VALUES ?museum { ${VALUES} }
+  ?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)
+`;
+
+async function sparql(q) {
+  const url = `${ENDPOINT}?format=json&query=${encodeURIComponent(q)}`;
+  const res = await fetch(url, {
+    headers: {
+      'Accept': 'application/sparql-results+json',
+      'User-Agent': 'CelebritySignatures/1.0 (designerwallcoverings.com; PD-signature artist catalog build)',
+    },
+  });
+  if (!res.ok) throw new Error(`SPARQL ${res.status}: ${(await res.text()).slice(0, 300)}`);
+  return (await res.json()).results.bindings;
+}
+
+const rows = await sparql(QUERY);
+console.log(`raw rows: ${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`));
+
+await writeFile(join(ROOT, 'data', 'artists-raw.json'), JSON.stringify(artists, null, 2));
+console.log(`\nwrote data/artists-raw.json (${artists.length} artists)`);
diff --git a/server.js b/server.js
index 2efeabe..3d7e5c2 100644
--- a/server.js
+++ b/server.js
@@ -140,11 +140,27 @@ createServer(async (req, res) => {
       return sendJSON(res, 200, { ok: true, id });
     }
 
+    // ===== signatures feed = hand-curated catalog + generated Artists set =====
+    // Two sources kept separate on disk (celebrity_signatures.json is
+    // hand-curated; artists.json is regenerated by scripts/build-artists.mjs)
+    // and merged here so the grid sees one flat list.
+    // Dedupe-safe: if the Artists set is ALSO physically merged into
+    // celebrity_signatures.json by the roster pipeline, dupes are skipped so no
+    // artist is ever double-listed — correct whether artists live separately,
+    // get merged in, or both. Key on wikidata URL, else full_name|category.
+    if (path === '/api/signatures' && M === 'GET') {
+      const base = await load('celebrity_signatures.json', []);
+      const artists = await load('artists.json', []);
+      const key = r => r.wikidata || `${r.full_name}|${r.category}`;
+      const seen = new Set(base.map(key));
+      const merged = [...base, ...artists.filter(a => !seen.has(key(a)))];
+      return sendJSON(res, 200, merged, { 'Cache-Control': 'no-cache' });
+    }
+
     // ===== static + page routes =====
     if (path === '/') path = '/public/index.html';
     if (path === '/murals') path = '/public/murals.html';
     if (path === '/account.js') path = '/public/account.js';
-    if (path === '/api/signatures') path = '/data/celebrity_signatures.json';
     if (path === '/api/murals-catalog') path = '/data/murals-catalog.json';
     const SHORT = { d: 'declaration-of-independence', p: 'politics', s: 'sports', h: 'hollywood', c: 'movies-classic', t: 'tv', o: 'oldest-signatures' };
     const km = path.match(/^\/k\/([a-z0-9-]+)$/);

← 4d73f50 Artists groundwork: shared wikidata-common lib (extracted fr  ·  back to CelebritySignatures  ·  auto-save: 2026-08-03T10:23:02 (3 files) — data/artists-raw. e3ae265 →