#!/usr/bin/env node /** * fetch-bgg-covers.mjs * --------------------------------------------------------------------------- * Support tool for the Board games section on the TTRPGs page. For each game * in src/data/boardgames.json that is missing an `image`, it looks the game up * on the BoardGameGeek XML2 API (public, no key — just a User-Agent), and * either: * * (default) downloads the cover to public/images/boardgames/.jpg and * writes the local public path back into the JSON, OR * --remote writes BGG's remote cover URL straight into the JSON (hotlinked; * no download, but depends on BGG's CDN staying up). * * Idempotent: games that already have an `image` are skipped. Be gentle on BGG * — they rate-limit, so there's a small delay between requests. * * USAGE * node scripts/fetch-bgg-covers.mjs # download covers locally * node scripts/fetch-bgg-covers.mjs --remote # hotlink BGG URLs instead * node scripts/fetch-bgg-covers.mjs --force # re-fetch even if image set * node scripts/fetch-bgg-covers.mjs --sleep 1500 # ms between BGG calls * node scripts/fetch-bgg-covers.mjs --help * * Edit the list itself in src/data/boardgames.json: * { "name": "Wingspan", "bggId": 266192 } * Find a game's bggId in its BoardGameGeek URL: * https://boardgamegeek.com/boardgame// */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = resolve(__dirname, '..'); const DATA_FILE = join(PROJECT_ROOT, 'src', 'data', 'boardgames.json'); const OUT_DIR = join(PROJECT_ROOT, 'public', 'images', 'boardgames'); const BGG_API = 'https://boardgamegeek.com/xmlapi2'; // --- args ------------------------------------------------------------------- const args = process.argv.slice(2); if (args.includes('-h') || args.includes('--help')) { console.log(`Usage: node scripts/fetch-bgg-covers.mjs [options] Reads src/data/boardgames.json and fills in the "image" field for any game missing one, using BoardGameGeek's public XML2 API (no key required). Options: --remote write BGG's remote cover URL into the JSON instead of downloading --force re-fetch even for games that already have an image --sleep delay between BGG API calls (default 1200; BGG rate-limits) -h, --help show this help Examples: node scripts/fetch-bgg-covers.mjs node scripts/fetch-bgg-covers.mjs --remote --sleep 2000`); process.exit(0); } const remoteMode = args.includes('--remote'); const force = args.includes('--force'); const sleepIdx = args.indexOf('--sleep'); const sleepMs = sleepIdx !== -1 ? Number(args[sleepIdx + 1]) || 1200 : 1200; // --- helpers ---------------------------------------------------------------- const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const fail = (msg, code = 1) => { console.error(`✖ ${msg}`); process.exit(code); }; /** Extract text of the first element from an XML string. */ const xmlText = (xml, tagName) => { const re = new RegExp(`<${tagName}[^>]*>([^<]*)`, 'i'); const m = xml.match(re); return m ? m[1].trim() : null; }; // --- load data -------------------------------------------------------------- let games; try { const raw = await readFile(DATA_FILE, 'utf8'); games = JSON.parse(raw); } catch (err) { if (err?.code === 'ENOENT') fail(`Could not read ${DATA_FILE}. Does it exist?`); fail(`Could not parse ${DATA_FILE}: ${err.message}`); } if (!Array.isArray(games)) fail(`${DATA_FILE} is not a JSON array.`); console.log( `→ ${games.length} games in ${DATA_FILE.split(/[\\/]/).pop()}` + (remoteMode ? ' (remote mode: hotlink BGG URLs)' : ' (local mode: download covers)'), ); // --- process each game ------------------------------------------------------ let changed = false; const UA = 'GregKilmerProfileSite/1.0 (board-game-cover fetcher)'; await mkdir(OUT_DIR, { recursive: true }); for (let i = 0; i < games.length; i++) { const g = games[i]; if (typeof g.bggId !== 'number') { console.warn(` ! "${g.name}" has no bggId; skipping.`); continue; } if (g.image && !force) { console.log(` ✓ ${g.name} — already has image, skipping.`); continue; } // BGG rate-limits; be polite between calls. if (i > 0) await sleep(sleepMs); const url = `${BGG_API}/thing?id=${g.bggId}&type=boardgame`; console.log(`→ [${i + 1}/${games.length}] ${g.name} (${url})`); let res; try { res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/xml' } }); } catch (err) { console.warn(` ! network error: ${err?.message ?? err}; skipping.`); continue; } if (res.status === 429) { console.warn(' ! rate-limited by BGG (429). Wait a bit and re-run; skipping rest.'); break; } if (!res.ok) { console.warn(` ! BGG returned ${res.status} ${res.statusText}; skipping.`); continue; } const xml = await res.text(); const remoteImage = xmlText(xml, 'image'); // BGG's primary box-art URL if (!remoteImage) { console.warn(` ! no in BGG response for bggId ${g.bggId}; skipping.`); continue; } if (remoteMode) { g.image = remoteImage; changed = true; console.log(` ✔ ${g.name} → ${remoteImage}`); } else { const ext = (remoteImage.match(/\.(jpe?g|png|webp)$/i)?.[1] ?? 'jpg').toLowerCase(); const fileName = `${g.bggId}.${ext === 'jpeg' ? 'jpg' : ext}`; let buf; try { const imgRes = await fetch(remoteImage, { headers: { 'User-Agent': UA } }); if (!imgRes.ok) throw new Error(`${imgRes.status} ${imgRes.statusText}`); buf = Buffer.from(await imgRes.arrayBuffer()); } catch (err) { console.warn(` ! image download failed (${err?.message ?? err}); skipping.`); continue; } await writeFile(join(OUT_DIR, fileName), buf); g.image = `/images/boardgames/${fileName}`; changed = true; console.log(` ✔ ${g.name} → ${g.image}`); } } // --- write back ------------------------------------------------------------- if (changed) { await writeFile(DATA_FILE, JSON.stringify(games, null, 2) + '\n', 'utf8'); console.log(`\n✔ Updated ${DATA_FILE}`); } else { console.log('\n— Nothing to update (all games already had an image).'); }