← back to Ventura Corridor
src/jobs/find_photos.ts
120 lines
/**
* Hunt for safe-to-use photos from Wikimedia Commons for each magazine feature.
*
* $ npx tsx src/jobs/find_photos.ts # process 20 features without photos
* $ npx tsx src/jobs/find_photos.ts 100 # process 100
*
* Strategy:
* 1. Try business name + "Ventura Boulevard" exact match (mostly misses; small
* businesses aren't on Wikimedia)
* 2. Fall back to neighborhood + landmark search (Sherman Oaks, Encino, Tarzana,
* Studio City, Woodland Hills) — produces decent stock landmark imagery
* 3. Final fallback: a category-specific Wikimedia search (restaurant interior,
* law office, etc.)
*
* Stores result into magazine_features.photo_url + appends to notes the source URL
* so we have attribution at hand.
*/
import 'dotenv/config';
import { pool, query } from '../../db/pool.ts';
const COUNT = parseInt(process.argv.find(a => /^\d+$/.test(a)) || '20', 10);
const COMMONS = 'https://commons.wikimedia.org/w/api.php';
async function commonsSearch(q: string): Promise<{ url: string; title: string } | null> {
// Generator=search → imageinfo. Filter aggressively to only photographic JPG/PNG
// matching the query in the title (skips text-based docs that match keywords).
const url = `${COMMONS}?action=query&format=json&prop=imageinfo&iiprop=url|extmetadata|size&generator=search&gsrnamespace=6&gsrsearch=${encodeURIComponent(q)}&gsrlimit=15&origin=*`;
try {
const r = await fetch(url, { headers: { 'User-Agent': 'TheCorridorMagazine/1.0 (loopback only)' } });
if (!r.ok) return null;
const j = await r.json();
const pages: any = j?.query?.pages || {};
for (const k of Object.keys(pages)) {
const p = pages[k];
const ii = p?.imageinfo?.[0];
if (!ii?.url) continue;
// ONLY photographic raster formats — skip SVG/PDF/DJVU/audio/video/diagrams
if (!/\.(jpe?g|png|webp|tif?f)$/i.test(ii.url)) continue;
// Mid-size or larger
if ((ii.width || 0) < 600 || (ii.height || 0) < 400) continue;
// Title sanity: skip "wiktionary", "logo", "diagram", "chart"
if (/diagram|chart|logo|map of|graph |scan/i.test(p.title)) continue;
return { url: ii.url, title: p.title };
}
} catch {}
return null;
}
function neighborhoodFor(city: string): string {
const c = (city || '').toUpperCase();
if (c.includes('SHERMAN OAKS')) return 'Sherman Oaks, Los Angeles';
if (c.includes('ENCINO')) return 'Encino, Los Angeles';
if (c.includes('TARZANA')) return 'Tarzana, Los Angeles';
if (c.includes('STUDIO CITY')) return 'Studio City, Los Angeles';
if (c.includes('WOODLAND HILLS')) return 'Woodland Hills, Los Angeles';
if (c.includes('NORTH HOLLYWOOD'))return 'North Hollywood, Los Angeles';
return 'Ventura Boulevard';
}
async function findPhoto(feat: any): Promise<{ url: string; title: string } | null> {
// SKIP business name (Wikimedia rarely has small-business photos and matches noise).
// Go straight to neighborhood + category landmark/streetscape imagery.
const neighborhood = neighborhoodFor(feat.city);
const tries = [
`${neighborhood} ${feat.category_tag || ''} interior`.trim(),
`${neighborhood} streetscape`.trim(),
`${neighborhood} architecture`.trim(),
neighborhood,
`Ventura Boulevard ${feat.city || ''}`.trim(),
'Ventura Boulevard Los Angeles'
];
for (const q of tries) {
if (!q) continue;
const hit = await commonsSearch(q);
if (hit) return hit;
}
return null;
}
async function main() {
const r = await query(
`SELECT mf.id, mf.business_id, mf.category_tag,
b.name AS biz_name, b.city
FROM magazine_features mf
JOIN businesses b ON b.id = mf.business_id
WHERE mf.photo_url IS NULL OR mf.photo_url = ''
ORDER BY mf.generated_at DESC
LIMIT $1`,
[COUNT]
);
console.log(`[find_photos] processing ${r.rowCount} features without photos`);
let hits = 0, misses = 0;
for (const feat of r.rows) {
try {
const photo = await findPhoto(feat);
if (photo) {
await query(
`UPDATE magazine_features SET
photo_url = $1,
notes = CONCAT_WS(E'\\n', notes, 'Photo: ' || $2 || ' (Wikimedia Commons, CC-BY-SA)')
WHERE id = $3`,
[photo.url, photo.title, feat.id]
);
console.log(` ✓ ${feat.id} ${feat.biz_name.slice(0,38).padEnd(38)} → ${photo.title.slice(0, 50)}`);
hits++;
} else {
console.log(` · ${feat.id} ${feat.biz_name.slice(0,38).padEnd(38)} (no Wikimedia match)`);
misses++;
}
await new Promise(r => setTimeout(r, 200)); // be polite to Wikimedia
} catch (e: any) {
console.log(` ✕ ${feat.id} ${feat.biz_name.slice(0,38).padEnd(38)} — ${e.message.slice(0, 60)}`);
}
}
console.log(`[find_photos] hits=${hits} misses=${misses}`);
await pool.end();
}
main().catch(e => { console.error('[find_photos] fatal:', e); process.exit(1); });