← back to AsSeenInMovies

scrapers/wikidata-commons-fallback.js

154 lines

#!/usr/bin/env node
'use strict';
// Commons-search fallback for asim_people who have a wikidata_id but no
// headshot_url (P18 was empty on the wikidata entity, but Commons often
// still has a "Category:Person Name" with usable photos).
//
// Strategy: for each (wd_id, full_name) row missing headshot_url, search
// Commons for `"<full_name>" filemime:image/jpeg|png` in namespace 6, take
// the first result, fetch extmetadata, only apply if license is CC-anything
// (rejects "Fair use" and "Non-free copyright").
//
// Polite: batches 1 search/req, 1.1s sleep. UA per Wikimedia policy.

require('dotenv').config();
const { Pool } = require('pg');

const args = process.argv.slice(2).reduce((m, a) => {
  const [k, v] = a.replace(/^--/, '').split('=');
  m[k] = v ?? true; return m;
}, {});

const LIMIT    = parseInt(args.limit || '200', 10);
const DRY      = !!args.dry;
const SLEEP_MS = parseInt(args.sleep || '1100', 10);
const UA       = 'AsSeenInMovies/0.1 (https://asseeninmovies.com; steve@designerwallcoverings.com)';
const COMMONS  = 'https://commons.wikimedia.org/w/api.php';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
  max: 4,
});

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

// CC-* / public-domain only. Reject anything with "fair use", "non-free",
// "all rights reserved", or no license name at all.
function isFree(licenseShortName) {
  if (!licenseShortName) return false;
  const s = String(licenseShortName).toLowerCase();
  if (/fair use|non-?free|all rights|copyright/i.test(s)) return false;
  return /\b(cc|public domain|pd|cc0|cc by|cc-by)\b/i.test(s);
}

function stripHtml(s) {
  if (!s) return null;
  return String(s).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() || null;
}

async function searchOne(name) {
  // Commons search; namespace 6 = File. Quote name for phrase match.
  const url = new URL(COMMONS);
  url.search = new URLSearchParams({
    action: 'query',
    list: 'search',
    srsearch: `"${name}" filemime:image/jpeg|filemime:image/png`,
    srnamespace: '6',
    srlimit: '3',
    format: 'json',
    origin: '*',
  }).toString();
  const r = await fetch(url.toString(), { headers: { 'user-agent': UA } });
  if (!r.ok) throw new Error(`search ${r.status}`);
  const j = await r.json();
  const hits = (j.query && j.query.search) || [];
  return hits.map(h => h.title);  // "File:Foo Bar.jpg"
}

async function imageInfo(title) {
  const url = new URL(COMMONS);
  url.search = new URLSearchParams({
    action: 'query',
    titles: title,
    prop: 'imageinfo',
    iiprop: 'url|extmetadata',
    format: 'json',
    origin: '*',
  }).toString();
  const r = await fetch(url.toString(), { headers: { 'user-agent': UA } });
  if (!r.ok) throw new Error(`info ${r.status}`);
  const j = await r.json();
  const pages = (j.query && j.query.pages) || {};
  const first = Object.values(pages)[0];
  if (!first || !first.imageinfo || !first.imageinfo[0]) return null;
  const ii = first.imageinfo[0];
  const md = ii.extmetadata || {};
  return {
    url: ii.url,
    title,
    artist: stripHtml((md.Artist && md.Artist.value) || null),
    license: stripHtml((md.LicenseShortName && md.LicenseShortName.value) || null),
    licenseUrl: (md.LicenseUrl && md.LicenseUrl.value) || null,
    descriptionUrl: ii.descriptionurl,
  };
}

(async () => {
  // Order by credit count DESC — most-credited (== most likely to have a
  // Commons photo) first. Random ordering put set decorators at the top.
  const sel = await pool.query(`
    SELECT p.id, p.full_name, p.wikidata_id, COALESCE(cc.c, 0) AS credit_count
      FROM asim_people p
      LEFT JOIN (SELECT person_id, count(*) AS c FROM asim_credits GROUP BY person_id) cc
        ON cc.person_id = p.id
     WHERE p.wikidata_id IS NOT NULL
       AND p.headshot_url IS NULL
       AND p.full_name IS NOT NULL
       AND length(p.full_name) BETWEEN 4 AND 80
     ORDER BY cc.c DESC NULLS LAST, p.id
     LIMIT $1`, [LIMIT]);

  console.log(`[commons-fallback] candidates=${sel.rows.length} dry=${DRY}`);
  let tried = 0, found = 0, applied = 0, skip = 0;

  for (const row of sel.rows) {
    tried++;
    let titles;
    try { titles = await searchOne(row.full_name); }
    catch (e) { console.error(`  search ${row.full_name}: ${e.message}`); await sleep(SLEEP_MS); continue; }

    if (!titles.length) { await sleep(SLEEP_MS); continue; }

    // Take the first hit only. Future: rank by file name match strength.
    const title = titles[0];
    let info;
    try { info = await imageInfo(title); }
    catch (e) { console.error(`  info ${title}: ${e.message}`); await sleep(SLEEP_MS); continue; }
    if (!info) { await sleep(SLEEP_MS); continue; }

    found++;
    if (!isFree(info.license)) {
      skip++;
      if (tried % 25 === 0) console.log(`  ${tried}/${sel.rows.length}  found=${found} applied=${applied} skip-license=${skip}`);
      await sleep(SLEEP_MS); continue;
    }

    if (!DRY) {
      await pool.query(`
        UPDATE asim_people
           SET headshot_url    = $1,
               headshot_credit = $2,
               headshot_license = $3,
               headshot_source_url = $4
         WHERE id = $5
           AND headshot_url IS NULL`,
        [info.url, info.artist || 'Wikimedia Commons', info.license, info.descriptionUrl, row.id]);
      applied++;
    }
    if (tried % 25 === 0) console.log(`  ${tried}/${sel.rows.length}  found=${found} applied=${applied} skip-license=${skip}`);
    await sleep(SLEEP_MS);
  }
  console.log(`[commons-fallback] done. tried=${tried} found=${found} applied=${applied} skip-license=${skip}`);
  await pool.end();
})().catch(e => { console.error(e); process.exit(1); });