← back to Ventura Corridor

scripts/wikimedia_ventura_photos.cjs

114 lines

/**
 * Pull all freely-licensed Ventura Blvd photos from Wikimedia Commons.
 * Saves to data/photos/wikimedia-manifest.json.
 *
 * Per Steve's standing rule (`feedback_no_stock_images.md`):
 *   public-domain / CC-licensed only, with proper credit.
 */
const fs = require('fs');
const path = require('path');

const API = 'https://commons.wikimedia.org/w/api.php';
const OUT = path.join(__dirname, '../data/photos/wikimedia-manifest.json');

async function api(params) {
  const qs = new URLSearchParams({ format: 'json', formatversion: '2', origin: '*', ...params });
  const r = await fetch(API + '?' + qs.toString(), {
    headers: { 'User-Agent': 'ventura-corridor/0.1 photo-ingest (steveabramsdesigns@gmail.com)' },
  });
  return r.json();
}

async function listCategory(cat) {
  const out = [];
  let cont;
  do {
    const data = await api({ action: 'query', list: 'categorymembers', cmtitle: cat, cmtype: 'file', cmlimit: 500, ...(cont || {}) });
    for (const m of data.query?.categorymembers || []) out.push(m.title);
    cont = data.continue || null;
  } while (cont);
  return out;
}

async function searchTitles(q) {
  const out = [];
  let off = 0;
  while (off < 200) {
    const data = await api({ action: 'query', list: 'search', srsearch: q, srnamespace: 6, srlimit: 50, sroffset: off });
    for (const m of data.query?.search || []) out.push(m.title);
    if (!data.continue) break;
    off += 50;
  }
  return out;
}

async function imageinfo(titles) {
  // Batch up to 50 titles per request.
  const out = [];
  for (let i = 0; i < titles.length; i += 50) {
    const batch = titles.slice(i, i + 50);
    const data = await api({
      action: 'query', titles: batch.join('|'),
      prop: 'imageinfo|info',
      iiprop: 'url|extmetadata|user|timestamp|mime|size',
      iiurlwidth: '1280',
    });
    for (const p of data.query?.pages || []) {
      const ii = p.imageinfo?.[0];
      if (!ii) continue;
      const m = ii.extmetadata || {};
      out.push({
        title: p.title,
        url: ii.url,
        thumb_url: ii.thumburl || ii.url,
        width: ii.width,
        height: ii.height,
        mime: ii.mime,
        bytes: ii.size,
        uploader: ii.user || null,
        timestamp: ii.timestamp || null,
        license_short: m.LicenseShortName?.value || null,
        license_url: m.LicenseUrl?.value || null,
        artist: (m.Artist?.value || '').replace(/<[^>]+>/g, '').trim() || null,
        credit: (m.Credit?.value || '').replace(/<[^>]+>/g, '').trim() || null,
        description: (m.ImageDescription?.value || '').replace(/<[^>]+>/g, '').trim() || null,
        date_original: m.DateTimeOriginal?.value || null,
        commons_page: 'https://commons.wikimedia.org/wiki/' + encodeURIComponent(p.title.replace(/ /g, '_')),
      });
    }
  }
  return out;
}

(async () => {
  console.log('[wm] gathering candidates…');
  const fromCat = await listCategory('Category:Ventura_Boulevard');
  const fromSearch = await searchTitles('"Ventura Boulevard"');
  const seen = new Set();
  const titles = [...fromCat, ...fromSearch].filter((t) => {
    if (!t.startsWith('File:')) return false;
    if (seen.has(t)) return false;
    seen.add(t);
    return true;
  });
  console.log(`[wm] ${titles.length} unique file titles (${fromCat.length} from category, ${fromSearch.length} from search)`);
  console.log('[wm] fetching imageinfo + license metadata…');
  const photos = await imageinfo(titles);
  console.log(`[wm] ${photos.length} photos with full metadata`);

  // Build credit-line per Steve's standing format
  for (const p of photos) {
    const artist = p.artist || p.uploader || 'Unknown';
    const lic = p.license_short || 'CC';
    p.credit_line = `Photo: ${artist} / Wikimedia Commons / ${lic}`;
  }

  fs.writeFileSync(OUT, JSON.stringify(photos, null, 2));
  console.log(`[wm] wrote ${OUT}`);
  // License breakdown
  const byLicense = {};
  for (const p of photos) byLicense[p.license_short || '?'] = (byLicense[p.license_short || '?'] || 0) + 1;
  console.log('[wm] license breakdown:');
  for (const [k, v] of Object.entries(byLicense).sort((a,b) => b[1] - a[1])) console.log(`  ${k.padEnd(30)} ${v}`);
})();