← back to CelebritySignatures

scripts/fetch-wikidata.mjs

205 lines

#!/usr/bin/env node
// CelebritySignatures — verified-signature fetcher.
// Spine: Wikidata P109 ("signature") links a real person to a real signature
// FILE on Wikimedia Commons. We query the public SPARQL endpoint (no key, free),
// rank by cross-wiki sitelink count, then pull each file's license from the
// Commons API. Nothing is invented — every row traces to a fetchable file.
//
// Usage: node scripts/fetch-wikidata.mjs [category|all]
// Output: data/<category>.json  +  data/celebrity_signatures.{json,csv}

import { writeFile, mkdir } from 'node:fs/promises';
import {
  runSparql, fileTitleFromUrl, fetchLicenses, licenseRisk, assess,
  fetchLabels, csvCell,
} from './lib/wikidata-common.mjs';

const PER_CAT = 320;            // fetch headroom (cross-category dedupe is hungry), trimmed to 100
const TARGET = 100;

// Authoritative human-curated ranking lists per category (cited as guidance;
// the automated `rank` is the Wikidata sitelink-sorted position).
const RANKING_SOURCES = {
  politics: ['https://www.gallup.com/poll/most-admired-man-woman.aspx', 'https://time.com/time100/'],
  sports: ['https://www.espn.com/', 'https://www.olympics.com/en/athletes', 'https://www.laureus.com/'],
  movies: ['https://www.imdb.com/chart/starmeter/', 'https://www.forbes.com/celebrities/'],
  tv: ['https://www.emmys.com/', 'https://yougov.com/ratings/'],
  hollywood: ['https://www.afi.com/afis-100-years-100-stars/', 'https://walkoffame.com/'],
  declaration: ['https://www.archives.gov/founding-docs/declaration', 'https://founders.archives.gov/'],
};

// SPARQL WHERE body per category. Must bind ?person, ?links; ?sig where available.
const CATEGORIES = {
  politics: {
    label: 'Politics',
    body: `?person wdt:P31 wd:Q5 ; wdt:P106 wd:Q82955 ; wdt:P109 ?sig ; wikibase:sitelinks ?links .`,
  },
  sports: {
    label: 'Sports',
    body: `?person wdt:P31 wd:Q5 ; wdt:P641 ?sport ; wdt:P109 ?sig ; wikibase:sitelinks ?links .`,
  },
  movies: {
    label: 'Movies',
    body: `?person wdt:P31 wd:Q5 ; wdt:P106 wd:Q10800557 ; wdt:P109 ?sig ; wikibase:sitelinks ?links .`,
  },
  tv: {
    label: 'TV',
    body: `?person wdt:P31 wd:Q5 ; wdt:P106 ?occ ; wdt:P109 ?sig ; wikibase:sitelinks ?links . VALUES ?occ { wd:Q10798782 wd:Q947873 }`,
  },
  hollywood: {
    // Golden-Age / iconic: film actors who are deceased (cleanest publicity posture).
    label: 'Hollywood',
    body: `?person wdt:P31 wd:Q5 ; wdt:P106 wd:Q10800557 ; wdt:P109 ?sig ; wikibase:sitelinks ?links ; wdt:P570 ?deadrequired .`,
  },
  moviesclassic: {
    // Deceased film actors beyond the Hollywood top tier (dedupe gives the next 100) —
    // a usable, publicity-safe "Movies" set (living movie stars are permission-needed).
    label: 'Movies (Classic)',
    body: `?person wdt:P31 wd:Q5 ; wdt:P106 wd:Q10800557 ; wdt:P109 ?sig ; wikibase:sitelinks ?links ; wdt:P570 ?deadrequired .`,
  },
  // NOTE: the Declaration of Independence set is owned by scripts/fetch-declaration.mjs
  // (MediaWiki/Commons APIs, outage-proof). The combiner below merges its output in.
};

function buildQuery(body, limit) {
  return `SELECT DISTINCT ?person ?personLabel ?sig ?dod ?links WHERE {
    ${body}
    OPTIONAL { ?person wdt:P570 ?dod . }
    SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
  } ORDER BY DESC(?links) LIMIT ${limit}`;
}

async function fetchCategory(catKey) {
  const cat = CATEGORIES[catKey];
  const rows = await runSparql(buildQuery(cat.body, cat.limit || PER_CAT));
  // dedupe by person QID, keep highest sitelinks (already sorted)
  const seen = new Set();
  const people = [];
  for (const b of rows) {
    const qid = b.person.value.split('/').pop();
    if (seen.has(qid)) continue;
    seen.add(qid);
    people.push({
      qid,
      name: b.personLabel?.value || qid,
      sigUrl: b.sig?.value || null,
      dod: b.dod?.value || null,
      links: parseInt(b.links?.value || '0', 10),
    });
  }
  return people;
}

async function main() {
  const arg = (process.argv[2] || 'all').toLowerCase();
  const keys = arg === 'all' ? Object.keys(CATEGORIES) : [arg];
  await mkdir('data', { recursive: true });

  const globalSeen = new Set();
  const all = [];
  // priority order keeps a deceased icon in Hollywood/declaration over movies/tv
  const priority = ['declaration', 'politics', 'sports', 'hollywood', 'moviesclassic', 'movies', 'tv'];
  const order = keys.sort((a, b) => priority.indexOf(a) - priority.indexOf(b));

  for (const key of order) {
    process.stdout.write(`\n[${key}] querying Wikidata… `);
    let people;
    try {
      people = await fetchCategory(key);
    } catch (e) {
      console.log(`FAILED: ${e.message}`);
      continue;
    }
    console.log(`${people.length} candidates with a signature on file`);

    // global dedupe across categories
    people = people.filter((p) => !globalSeen.has(p.qid));

    // backfill any name that came back as a raw QID (label-service flakiness)
    const needLabel = people.filter((p) => /^Q\d+$/.test(p.name)).map((p) => p.qid);
    if (needLabel.length) {
      const labels = await fetchLabels(needLabel);
      for (const p of people) if (labels[p.qid]) p.name = labels[p.qid];
    }

    // license lookup
    const titles = [...new Set(people.map((p) => fileTitleFromUrl(p.sigUrl)).filter(Boolean))];
    process.stdout.write(`[${key}] fetching ${titles.length} Commons licenses… `);
    const lic = titles.length ? await fetchLicenses(titles) : {};
    console.log('done');

    const isDecl = key === 'declaration';
    const catRows = [];
    for (const p of people) {
      if (catRows.length >= (isDecl ? 56 : TARGET)) break;
      globalSeen.add(p.qid);
      const title = fileTitleFromUrl(p.sigUrl);
      const meta = title ? lic[title] : null;
      const deceased = !!p.dod || isDecl; // all DoI signers deceased
      const hasFile = !!p.sigUrl;
      const fromArchive = isDecl && !hasFile;
      const license = fromArchive
        ? 'Public domain (US Gov work / pre-1929)'
        : (meta?.license || (hasFile ? 'unknown (verify on Commons)' : 'n/a'));
      const { risk_level, usable } = assess({ deceased, lic: license, fromArchive });

      const sigImg = fromArchive
        ? 'https://www.archives.gov/founding-docs/declaration-transcript'
        : (meta?.directUrl || p.sigUrl);
      const sourceType = fromArchive
        ? 'US National Archives — engrossed Declaration of Independence (PD)'
        : 'Wikimedia Commons (Wikidata P109 signature file)';
      const filePage = meta?.filePage
        || (title ? `https://commons.wikimedia.org/wiki/${encodeURIComponent(title)}` : null);

      catRows.push({
        category: CATEGORIES[key].label,
        rank: catRows.length + 1,
        full_name: p.name,
        wikidata: `https://www.wikidata.org/wiki/${p.qid}`,
        reason_for_ranking: `Cross-wiki notability: present on ${p.links} language Wikipedias`,
        ranking_source_urls: (RANKING_SOURCES[key] || []).join(' | '),
        signature_image_url: sigImg,
        signature_source_type: sourceType,
        image_license: license,
        image_author: meta?.artist || (fromArchive ? 'US Government (1776)' : ''),
        deceased: deceased ? 'yes' : 'no',
        death_date: p.dod ? p.dod.slice(0, 10) : (isDecl ? 'pre-1830' : ''),
        risk_level,
        usable_in_commercial_collage: usable,
        notes: [
          deceased ? 'Deceased — low publicity-rights risk' : 'LIVING — right-of-publicity exposure for commercial use',
          /share|sa/i.test(license) && /cc by-?sa/i.test(license) ? 'CC BY-SA: derivative collage may need share-alike + attribution' : '',
          fromArchive ? 'No standalone Commons signature file; signature appears on the engrossed Declaration (NARA scan, PD)' : '',
        ].filter(Boolean).join('; '),
        backup_source: isDecl
          ? 'https://founders.archives.gov/'
          : (filePage || 'https://catalog.archives.gov/'),
      });
    }
    await writeFile(`data/${key}.json`, JSON.stringify(catRows, null, 2));
    console.log(`[${key}] -> data/${key}.json (${catRows.length} rows, ${catRows.filter(r => r.usable_in_commercial_collage === 'yes').length} collage-usable)`);
    all.push(...catRows);
  }

  // merge in the dedicated Declaration of Independence set (if built)
  try {
    const { readFile } = await import('node:fs/promises');
    const decl = JSON.parse(await readFile('data/declaration.json', 'utf8'));
    if (decl.length) { all.unshift(...decl); console.log(`merged ${decl.length} Declaration signers`); }
  } catch { /* declaration not built yet */ }

  // combined outputs
  await writeFile('data/celebrity_signatures.json', JSON.stringify(all, null, 2));
  const cols = ['category', 'rank', 'full_name', 'wikidata', 'reason_for_ranking', 'ranking_source_urls', 'signature_image_url', 'signature_source_type', 'image_license', 'image_author', 'deceased', 'death_date', 'risk_level', 'usable_in_commercial_collage', 'notes', 'backup_source'];
  const csv = [cols.join(',')].concat(all.map((r) => cols.map((c) => csvCell(r[c])).join(','))).join('\n');
  await writeFile('data/celebrity_signatures.csv', csv);

  console.log(`\n=== TOTAL ${all.length} rows ===`);
  const byUsable = all.reduce((a, r) => ((a[r.usable_in_commercial_collage] = (a[r.usable_in_commercial_collage] || 0) + 1), a), {});
  console.log('Collage usability:', byUsable);
  console.log('Wrote data/celebrity_signatures.{json,csv}');
}

main().catch((e) => { console.error(e); process.exit(1); });