← 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}`);
})();