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