← back to Nineoh Guide

db/ingest-wikimedia-images.mjs

126 lines

// Pulls cast headshots from Wikimedia ONLY when the per-file license is free
// for commercial reuse (PD / CC0 / CC-BY / CC-BY-SA). Rejects NC/ND/fair-use/
// non-free. Records file_url + license + attribution into the assets rights-ledger
// and links cast_people.headshot_asset_id. Never assumes a Commons file is free.
// Run: node db/ingest-wikimedia-images.mjs
import pg from "pg";

const DATABASE_URL =
  process.env.DATABASE_URL ?? "postgresql://localhost/nineoh_guide?host=/tmp";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const UA = "nineoh-guide/0.1 (unofficial fan project; contact: set-at-launch)";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function fetchJson(url, tries = 4) {
  for (let i = 0; i < tries; i++) {
    try {
      const r = await fetch(url, { headers: { "User-Agent": UA } });
      const t = await r.text();
      return JSON.parse(t); // throws on rate-limit HTML → caught + retried
    } catch {
      await sleep(800 * (i + 1)); // backoff
    }
  }
  throw new Error("rate-limited after retries");
}

const FREE = /^(cc0|cc-by-\d|cc-by-sa-\d|pd|public domain)/i;
const NONFREE = /(nc|nd|non-free|fair use|copyright)/i;

function stripHtml(s) {
  return (s || "").replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
}

async function pageImageFile(name) {
  // Lead/infobox image filename + original URL from English Wikipedia.
  const u = `https://en.wikipedia.org/w/api.php?action=query&format=json&prop=pageimages&piprop=original|name&titles=${encodeURIComponent(
    name
  )}`;
  const j = await fetchJson(u);
  const pages = j.query?.pages ?? {};
  const page = Object.values(pages)[0];
  if (!page?.pageimage) return null;
  return { file: page.pageimage, url: page.original?.source ?? null };
}

async function commonsLicense(file) {
  const u = `https://commons.wikimedia.org/w/api.php?action=query&format=json&prop=imageinfo&iiprop=extmetadata|url&titles=${encodeURIComponent(
    "File:" + file
  )}`;
  const j = await fetchJson(u);
  const pages = j.query?.pages ?? {};
  const page = Object.values(pages)[0];
  const info = page?.imageinfo?.[0];
  if (!info) return null;
  const m = info.extmetadata ?? {};
  return {
    license: (m.License?.value || "").toLowerCase(),
    licenseName: m.LicenseShortName?.value || "",
    licenseUrl: m.LicenseUrl?.value || "",
    artist: stripHtml(m.Artist?.value || m.Credit?.value || ""),
    url: info.url,
  };
}

// Only cast that don't already have a headshot — makes re-runs safe (no dup assets).
const cast = (
  await pool.query(`select id, name from cast_people where headshot_asset_id is null order by name`)
).rows;
let accepted = 0,
  skipped = 0;

for (const person of cast) {
  await sleep(700); // be polite to the Wikimedia API
  try {
    const img = await pageImageFile(person.name);
    if (!img) {
      console.log(`SKIP  ${person.name} — no Wikipedia lead image`);
      skipped++;
      continue;
    }
    const lic = await commonsLicense(img.file);
    if (!lic) {
      console.log(`SKIP  ${person.name} — no license metadata`);
      skipped++;
      continue;
    }
    const key = lic.license || lic.licenseName;
    const free = FREE.test(lic.license) || /public domain|cc0|cc by(?!-nc|-nd)/i.test(lic.licenseName);
    const blocked = NONFREE.test(lic.license) || /nc|nd|fair use/i.test(lic.licenseName);
    if (!free || blocked) {
      console.log(`SKIP  ${person.name} — license "${lic.licenseName || key}" not free-for-reuse`);
      skipped++;
      continue;
    }
    const licType = /cc0|public domain|^pd/i.test(key) ? "public-domain"
      : /sa/i.test(key) ? "cc-by-sa" : "cc-by";
    const attribution = `${lic.artist || "Unknown"} / ${lic.licenseName || key} (via Wikimedia Commons)`;
    const url = lic.url || img.url;

    const asset = await pool.query(
      `insert into assets (type, file_url, thumbnail_url, license_type, license_url,
                           attribution_text, usage_scope, source_url, rights_notes, approved_for_marketing)
       values ('image',$1,$1,$2,$3,$4,'editorial',$5,$6,false)
       returning id`,
      [
        url,
        licType,
        lic.licenseUrl || null,
        attribution,
        `https://commons.wikimedia.org/wiki/File:${img.file}`,
        `Wikimedia Commons file, license verified ${new Date().toISOString().slice(0, 10)}`,
      ]
    );
    await pool.query(`update cast_people set headshot_asset_id=$1 where id=$2`, [asset.rows[0].id, person.id]);
    console.log(`OK    ${person.name} — ${licType} (${lic.licenseName})`);
    accepted++;
  } catch (e) {
    console.log(`SKIP  ${person.name} — error ${e.message}`);
    skipped++;
  }
}

console.log(`\naccepted ${accepted}, skipped ${skipped} of ${cast.length}`);
console.log("Only free-for-reuse licenses stored; each asset carries attribution + source.");
await pool.end();