← back to NationalPaperHangers

scripts/fetch-pd-images.js

216 lines

#!/usr/bin/env node
// Fetch public-domain interior/wallcovering imagery from Wikimedia Commons.
// Strict rule: PD or CC0 only. Anything else gets dropped. Steve's standing
// "no stock images" rule applies — Unsplash/Pexels/Getty NEVER appear here.

import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const OUT_DIR = path.join(ROOT, 'public', 'img', 'segments');
const MANIFEST = path.join(OUT_DIR, 'manifest.json');

const UA = 'NationalPaperHangers/0.1 (https://nationalpaperhangers.com; ops@nationalpaperhangers.com)';

const SEGMENTS = {
  luxury_residential: [
    'drawing room interior wallpaper',
    'Victorian parlor interior',
    'Edwardian drawing room',
    'historic interior wallpaper',
  ],
  hospitality: [
    'art deco hotel lobby',
    'historic hotel interior',
    'luxury hotel lobby interior',
  ],
  museum: [
    'museum interior decoration',
    'historic dining room interior',
    'period room museum',
  ],
  hand_painted: [
    'hand painted wallpaper',
    'chinoiserie wallpaper',
    'panoramic wallpaper Zuber',
  ],
  grasscloth: [
    'grasscloth wallcovering',
    'natural fiber wall covering',
    'bamboo wall covering interior',
  ],
  silk: [
    'silk damask wallpaper',
    'damask wallpaper interior',
    'historic silk wall hanging',
  ],
  mural: [
    'mural wallpaper interior',
    'scenic wallpaper panorama',
    'fresco interior decoration',
  ],
  generic: [
    'wallpaper sample',
    'wallpaper pattern',
    'wallpaper sidewall',
    'wall covering pattern',
    'historic interior decoration',
    'antique interior decoration',
    'wallpaper roll Cooper Hewitt',
  ],
};

const PER_QUERY_LIMIT = 12;
const TARGET_PER_SEGMENT = 14;
const THUMB_W = 1200;

const PD_HINTS = ['public domain', 'cc0', 'pd-old', 'pd-us', 'pd-art', 'cc-zero'];

function isPublicDomain(meta) {
  if (!meta) return false;
  const license = (meta.LicenseShortName?.value || meta.UsageTerms?.value || '').toLowerCase();
  const tmpl = (meta.LicenseUrl?.value || '').toLowerCase();
  if (PD_HINTS.some((h) => license.includes(h))) return true;
  if (tmpl.includes('publicdomain') || tmpl.includes('zero/1.0')) return true;
  return false;
}

async function searchCommons(query) {
  const params = new URLSearchParams({
    action: 'query',
    format: 'json',
    prop: 'imageinfo',
    generator: 'search',
    gsrsearch: `${query} filetype:bitmap`,
    gsrnamespace: '6',
    gsrlimit: String(PER_QUERY_LIMIT),
    iiprop: 'url|extmetadata|size|mime',
    iiurlwidth: String(THUMB_W),
  });
  const url = `https://commons.wikimedia.org/w/api.php?${params}`;
  const res = await fetch(url, { headers: { 'User-Agent': UA } });
  if (!res.ok) throw new Error(`commons search ${res.status} for ${query}`);
  const j = await res.json();
  return Object.values(j?.query?.pages || {});
}

async function downloadBinary(url, dest) {
  // Be polite to Wikimedia's CDN — bulk hammering triggers 429.
  await new Promise(r => setTimeout(r, 350));
  const res = await fetch(url, { headers: { 'User-Agent': UA } });
  if (res.status === 429) {
    // Back off and retry once.
    await new Promise(r => setTimeout(r, 5000));
    const res2 = await fetch(url, { headers: { 'User-Agent': UA } });
    if (!res2.ok) throw new Error(`download ${res2.status} ${url}`);
    const buf2 = Buffer.from(await res2.arrayBuffer());
    await fs.writeFile(dest, buf2);
    return buf2.length;
  }
  if (!res.ok) throw new Error(`download ${res.status} ${url}`);
  const buf = Buffer.from(await res.arrayBuffer());
  await fs.writeFile(dest, buf);
  return buf.length;
}

function safeName(title, idx) {
  const base = title
    .replace(/^File:/i, '')
    .replace(/\.[a-z0-9]+$/i, '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 60);
  return `${String(idx).padStart(2, '0')}-${base || 'img'}.jpg`;
}

async function ensureDir(p) {
  await fs.mkdir(p, { recursive: true });
}

async function loadManifest() {
  try {
    const txt = await fs.readFile(MANIFEST, 'utf8');
    return JSON.parse(txt);
  } catch {
    return { generated_at: null, items: [] };
  }
}

async function main() {
  await ensureDir(OUT_DIR);
  const manifest = await loadManifest();
  const seen = new Set(manifest.items.map((it) => it.source_title));

  for (const [segment, queries] of Object.entries(SEGMENTS)) {
    const segDir = path.join(OUT_DIR, segment);
    await ensureDir(segDir);
    let kept = (await fs.readdir(segDir)).filter((f) => f.endsWith('.jpg')).length;
    if (kept >= TARGET_PER_SEGMENT) {
      console.log(`[${segment}] already has ${kept}, skipping`);
      continue;
    }

    for (const q of queries) {
      if (kept >= TARGET_PER_SEGMENT) break;
      let pages;
      try {
        pages = await searchCommons(q);
      } catch (e) {
        console.warn(`[${segment}] search failed for "${q}": ${e.message}`);
        continue;
      }
      for (const page of pages) {
        if (kept >= TARGET_PER_SEGMENT) break;
        const ii = page.imageinfo?.[0];
        if (!ii) continue;
        if (!ii.mime || !ii.mime.startsWith('image/')) continue;
        if (seen.has(page.title)) continue;
        if (!isPublicDomain(ii.extmetadata)) continue;
        const thumbUrl = ii.thumburl || ii.url;
        const idx = manifest.items.length + 1;
        const filename = safeName(page.title, idx);
        const dest = path.join(segDir, filename);
        try {
          const bytes = await downloadBinary(thumbUrl, dest);
          if (bytes < 8000) {
            await fs.unlink(dest);
            continue;
          }
          const item = {
            file: `/img/segments/${segment}/${filename}`,
            segment,
            query: q,
            source_title: page.title,
            source_url: ii.descriptionurl || `https://commons.wikimedia.org/wiki/${encodeURIComponent(page.title)}`,
            license: ii.extmetadata?.LicenseShortName?.value || 'Public domain',
            creator: (ii.extmetadata?.Artist?.value || '').replace(/<[^>]+>/g, '').trim() || null,
            credit: (ii.extmetadata?.Credit?.value || '').replace(/<[^>]+>/g, '').trim() || null,
            width: ii.thumbwidth || ii.width,
            height: ii.thumbheight || ii.height,
            bytes,
          };
          manifest.items.push(item);
          seen.add(page.title);
          kept += 1;
          console.log(`[${segment}] +${filename}  (${item.license})`);
        } catch (e) {
          console.warn(`[${segment}] download failed ${page.title}: ${e.message}`);
        }
      }
    }
    console.log(`[${segment}] final count: ${kept}`);
  }

  manifest.generated_at = new Date().toISOString();
  await fs.writeFile(MANIFEST, JSON.stringify(manifest, null, 2));
  console.log(`\nmanifest written: ${manifest.items.length} items at ${MANIFEST}`);
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});