Files
com.gregkilmer.www/legacy/scripts/fetch-bgg-covers.mjs
T
Claude a2f723ca2d Start the rebuild: note schema, vault kit and data migration
Phase 1 of the functional design plan.

- Move the old site into legacy/, unchanged, for reference. It still
  builds from there until cutover.
- Add packages/schema: the one definition of all 20 note types, with
  field titles and descriptions, note and vault checks, and JSON Schema
  export so later editing tools and the MCP server share it.
- Add vault-kit: Obsidian templates generated from the schema, and the
  Site guide for 24.01_PersonalWebsite.
- Add tools/migrate: turns the legacy JSON, collections, store data and
  page text into 545 Site folder notes and 84 images, and checks the
  result with the schema.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 18:40:55 +00: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).');
}