mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Board game updated pass 1
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
#!/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).');
|
||||
}
|
||||
Reference in New Issue
Block a user