← back to CelebritySignatures

scripts/lib/wikidata-common.mjs

116 lines

// CelebritySignatures — shared Wikidata/Commons machinery.
// Extracted verbatim from scripts/fetch-wikidata.mjs so build-artists.mjs and
// future fetchers reuse one implementation (WDQS backoff, Commons license
// batching, risk assessment, CSV cells, label backfill).

export const UA = 'CelebritySignatures-research/1.0 (steve@designerwallcoverings.com)';
export const SPARQL = 'https://query.wikidata.org/sparql';
export const COMMONS = 'https://commons.wikimedia.org/w/api.php';

export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export async function runSparql(query, { retries = 20, waitMs = 70000 } = {}) {
  // WDQS periodically throttles to 1 req/min during outages; ride it out.
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(SPARQL, {
      method: 'POST',
      headers: {
        'User-Agent': UA,
        Accept: 'application/sparql-results+json',
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: 'query=' + encodeURIComponent(query),
    });
    if (res.ok) {
      const txt = await res.text();
      try { return JSON.parse(txt).results.bindings; }
      catch { if (attempt > retries) throw new Error('non-JSON from WDQS'); }
    }
    if (attempt > retries) throw new Error(`SPARQL ${res.status} after ${retries} retries`);
    process.stdout.write(`(WDQS ${res.status}; backoff ${waitMs / 1000}s, try ${attempt}/${retries}) `);
    await sleep(waitMs);
  }
}

export function fileTitleFromUrl(sigUrl) {
  // .../Special:FilePath/<urlencoded filename>
  const m = sigUrl && sigUrl.match(/Special:FilePath\/(.+)$/);
  if (!m) return null;
  return 'File:' + decodeURIComponent(m[1]).replace(/_/g, ' ');
}

// Pull license/author per Commons file (batches of 50).
export async function fetchLicenses(titles) {
  const out = {};
  for (let i = 0; i < titles.length; i += 50) {
    const batch = titles.slice(i, i + 50);
    const url = `${COMMONS}?action=query&format=json&prop=imageinfo&iiprop=extmetadata|url&titles=${encodeURIComponent(batch.join('|'))}&origin=*`;
    const res = await fetch(url, { headers: { 'User-Agent': UA } });
    if (!res.ok) { await sleep(500); continue; }
    const j = await res.json();
    const pages = j.query?.pages || {};
    const norm = {};
    (j.query?.normalized || []).forEach((n) => { norm[n.to] = n.from; });
    for (const p of Object.values(pages)) {
      const ii = p.imageinfo?.[0];
      const ext = ii?.extmetadata || {};
      const key = norm[p.title] || p.title;
      out[key] = {
        license: ext.LicenseShortName?.value || 'unknown',
        artist: (ext.Artist?.value || '').replace(/<[^>]+>/g, '').trim(),
        directUrl: ii?.url || null,
        filePage: ii?.descriptionurl || null,
      };
    }
    await sleep(300);
  }
  return out;
}

export function licenseRisk(lic) {
  const l = (lic || '').toLowerCase();
  if (/public domain|pd-|cc0|cc-?0/.test(l)) return 'low';
  if (/cc by/.test(l)) return 'low-medium';
  if (l === 'unknown' || l === '') return 'high';
  return 'medium';
}

export function assess({ deceased, lic, fromArchive }) {
  const lr = fromArchive ? 'low' : licenseRisk(lic);
  const pr = deceased ? 'low' : 'medium';
  const rank = { low: 0, 'low-medium': 1, medium: 2, high: 3 };
  const overall = rank[lr] >= rank[pr] ? lr : pr;
  const risk_level = overall.startsWith('low') ? 'low' : overall;
  let usable;
  const cleanLic = lr === 'low' || lr === 'low-medium';
  if (deceased && cleanLic) usable = 'yes';
  else if (!deceased) usable = 'permission-needed';
  else usable = 'review';
  return { risk_level, usable };
}

export function csvCell(v) {
  const s = (v == null ? '' : String(v)).replace(/[\r\n]+/g, ' ').replace(/"/g, '""');
  return `"${s}"`;
}

// Reliable label resolution via the Wikidata entity API (the SPARQL label SERVICE
// is flaky during WDQS outages and sometimes leaves names as raw QIDs).
export async function fetchLabels(qids) {
  // Prefer the English Wikipedia sitelink title (robust even when Wikidata's
  // label backend is degraded), fall back to the en label.
  const out = {};
  for (let i = 0; i < qids.length; i += 50) {
    const ids = qids.slice(i, i + 50).join('|');
    const url = `https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${ids}&props=sitelinks|labels&sitefilter=enwiki&languages=en&format=json`;
    const res = await fetch(url, { headers: { 'User-Agent': UA } });
    if (!res.ok) { await sleep(400); continue; }
    const j = await res.json();
    for (const [qid, e] of Object.entries(j.entities || {})) {
      out[qid] = e.sitelinks?.enwiki?.title || e.labels?.en?.value || qid;
    }
    await sleep(250);
  }
  return out;
}