Files
com.gregkilmer.www/scripts/fetch-bgg-covers.mjs
T
2026-07-06 21:50:41 -04:00

169 lines
6.3 KiB
JavaScript

#!/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/<bggId>.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/<bggId>/<slug>
*/
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 <ms> 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 <tagName> element from an XML string. */
const xmlText = (xml, tagName) => {
const re = new RegExp(`<${tagName}[^>]*>([^<]*)</${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 <image> 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).');
}