mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
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
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fetch-anime-art.mjs
|
||||
* ---------------------------------------------------------------------------
|
||||
* Support tool for the Anime page. For each favorite item (those with
|
||||
* `favorite: true`) in src/data/media/anime.json that is missing an `image`,
|
||||
* it queries the AniList GraphQL API (free, no key required) and downloads the
|
||||
* cover to public/images/anime/<slug>.jpg, then writes the local public path
|
||||
* back into the JSON.
|
||||
*
|
||||
* AniList is chosen over Jikan (MyAnimeList) because Jikan's public API is
|
||||
* being discontinued on October 1, 2026. AniList is keyless and stable.
|
||||
*
|
||||
* Idempotent: items that already have an `image` are skipped. Be polite —
|
||||
* there's a small delay between requests.
|
||||
*
|
||||
* USAGE
|
||||
* node scripts/fetch-anime-art.mjs # fetch missing anime covers
|
||||
* node scripts/fetch-anime-art.mjs --force # re-fetch even if image set
|
||||
* node scripts/fetch-anime-art.mjs --sleep 500 # ms between requests
|
||||
* node scripts/fetch-anime-art.mjs --help
|
||||
*/
|
||||
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', 'media', 'anime.json');
|
||||
const OUT_DIR = join(PROJECT_ROOT, 'public', 'images', 'anime');
|
||||
const PUBLIC_PREFIX = '/images/anime';
|
||||
|
||||
const ANILIST_API = 'https://graphql.anilist.co';
|
||||
|
||||
// --- args -------------------------------------------------------------------
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('-h') || args.includes('--help')) {
|
||||
console.log(`Usage: node scripts/fetch-anime-art.mjs [options]
|
||||
|
||||
Reads anime.json favorites and fills in the "image" field for any favorite
|
||||
missing one, using the AniList GraphQL API (free, no key required).
|
||||
|
||||
Options:
|
||||
--force re-fetch even for items that already have an image
|
||||
--sleep <ms> delay between AniList requests (default 500)
|
||||
-h, --help show this help`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const force = args.includes('--force');
|
||||
const sleepIdx = args.indexOf('--sleep');
|
||||
const sleepMs = sleepIdx !== -1 ? Number(args[sleepIdx + 1]) || 500 : 500;
|
||||
|
||||
// --- helpers ----------------------------------------------------------------
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const slugify = (s) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/['’.:()]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
const QUERY = `
|
||||
query ($search: String) {
|
||||
Page(page: 1, perPage: 5) {
|
||||
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
|
||||
title { romaji english }
|
||||
coverImage { large extraLarge }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** Query AniList for a title, returning the best match's cover URL. */
|
||||
async function fetchCover(title) {
|
||||
const res = await fetch(ANILIST_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ query: QUERY, variables: { search: title } }),
|
||||
});
|
||||
if (res.status === 429) {
|
||||
console.warn(' ! rate-limited by AniList (429). Wait and re-run; skipping rest.');
|
||||
return { rateLimited: true };
|
||||
}
|
||||
if (!res.ok) throw new Error(`AniList ${res.status} ${res.statusText}`);
|
||||
const { data, errors } = await res.json();
|
||||
if (errors?.length) throw new Error(errors[0].message);
|
||||
const media = data?.Page?.media?.find((m) => m.coverImage?.large) ?? data?.Page?.media?.[0];
|
||||
if (!media?.coverImage) return null;
|
||||
const url = media.coverImage.extraLarge ?? media.coverImage.large;
|
||||
const name = media.title?.english ?? media.title?.romaji ?? title;
|
||||
return { url, name };
|
||||
}
|
||||
|
||||
// --- main -------------------------------------------------------------------
|
||||
let items;
|
||||
try {
|
||||
items = JSON.parse(await readFile(DATA_FILE, 'utf8'));
|
||||
} catch (err) {
|
||||
if (err?.code === 'ENOENT') {
|
||||
console.error(`✖ ${DATA_FILE} not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const favorites = items.filter((i) => i.favorite);
|
||||
console.log(
|
||||
`→ ${favorites.length} anime favorite(s) in anime.json` +
|
||||
(force ? ' (force re-fetch)' : ''),
|
||||
);
|
||||
|
||||
await mkdir(OUT_DIR, { recursive: true });
|
||||
let changed = false;
|
||||
|
||||
for (let i = 0; i < favorites.length; i++) {
|
||||
const item = favorites[i];
|
||||
if (item.image && !force) {
|
||||
console.log(` ✓ ${item.title} — already has image, skipping.`);
|
||||
continue;
|
||||
}
|
||||
if (i > 0) await sleep(sleepMs);
|
||||
|
||||
console.log(`→ [${i + 1}/${favorites.length}] ${item.title}`);
|
||||
let result;
|
||||
try {
|
||||
result = await fetchCover(item.title);
|
||||
} catch (err) {
|
||||
console.warn(` ! network error: ${err?.message ?? err}; skipping.`);
|
||||
continue;
|
||||
}
|
||||
if (result?.rateLimited) break;
|
||||
if (!result?.url) {
|
||||
console.warn(` ! no cover found for "${item.title}"; skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = `${slugify(item.title)}.jpg`;
|
||||
let buf;
|
||||
try {
|
||||
const imgRes = await fetch(result.url);
|
||||
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);
|
||||
item.image = `${PUBLIC_PREFIX}/${fileName}`;
|
||||
const orig = items.find((o) => o.title === item.title && o.favorite);
|
||||
if (orig) orig.image = item.image;
|
||||
changed = true;
|
||||
console.log(` ✔ ${item.title} → ${item.image}`);
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await writeFile(DATA_FILE, JSON.stringify(items, null, 2) + '\n', 'utf8');
|
||||
console.log(`\n✔ Updated ${DATA_FILE}`);
|
||||
} else {
|
||||
console.log('\n— Nothing to update (all anime favorites already had images).');
|
||||
}
|
||||
@@ -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).');
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fetch-book-covers.mjs
|
||||
* ---------------------------------------------------------------------------
|
||||
* Support tool for the Reading page. For each favorite item (those with
|
||||
* `favorite: true`) in src/data/reading.json that is missing an `image`, it
|
||||
* searches the Open Library Search API (free, no key required) to resolve the
|
||||
* work's OLID, then downloads the cover via the Open Library Covers API to
|
||||
* public/images/reading/<slug>.jpg, and writes the local public path back into
|
||||
* the JSON.
|
||||
*
|
||||
* Idempotent: items that already have an `image` are skipped. Be polite —
|
||||
* there's a small delay between requests.
|
||||
*
|
||||
* The reading.json titles are formatted like "Author — Series"; this script
|
||||
* searches by the series/title portion (after the em dash) for better matches.
|
||||
*
|
||||
* USAGE
|
||||
* node scripts/fetch-book-covers.mjs # fetch missing book covers
|
||||
* node scripts/fetch-book-covers.mjs --force # re-fetch even if image set
|
||||
* node scripts/fetch-book-covers.mjs --sleep 500 # ms between requests
|
||||
* node scripts/fetch-book-covers.mjs --help
|
||||
*/
|
||||
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', 'reading.json');
|
||||
const OUT_DIR = join(PROJECT_ROOT, 'public', 'images', 'reading');
|
||||
const PUBLIC_PREFIX = '/images/reading';
|
||||
|
||||
const SEARCH_API = 'https://openlibrary.org/search.json';
|
||||
const COVERS_API = 'https://covers.openlibrary.org/b';
|
||||
|
||||
// --- args -------------------------------------------------------------------
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('-h') || args.includes('--help')) {
|
||||
console.log(`Usage: node scripts/fetch-book-covers.mjs [options]
|
||||
|
||||
Reads reading.json favorites and fills in the "image" field for any favorite
|
||||
missing one, using the Open Library APIs (free, no key required).
|
||||
|
||||
Options:
|
||||
--force re-fetch even for items that already have an image
|
||||
--sleep <ms> delay between Open Library requests (default 500)
|
||||
-h, --help show this help`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const force = args.includes('--force');
|
||||
const sleepIdx = args.indexOf('--sleep');
|
||||
const sleepMs = sleepIdx !== -1 ? Number(args[sleepIdx + 1]) || 500 : 500;
|
||||
|
||||
// --- helpers ----------------------------------------------------------------
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const slugify = (s) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/['’.:()\u2014-]/g, ' ')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
/** Strip an "Author — Series" prefix, leaving the searchable title/series. */
|
||||
const searchableTitle = (raw) => {
|
||||
// Split on em dash (—) or en dash; take the last non-empty segment.
|
||||
const parts = raw.split(/[\u2014\u2013\u2012-]/).map((p) => p.trim()).filter(Boolean);
|
||||
return parts.length > 1 ? parts[parts.length - 1] : raw;
|
||||
};
|
||||
|
||||
/**
|
||||
* Search Open Library for a title and return the best cover URL. Prefers an
|
||||
* edition with a cover ID; falls back to a work-level cover key.
|
||||
*/
|
||||
async function fetchCover(rawTitle) {
|
||||
const q = searchableTitle(rawTitle);
|
||||
const params = new URLSearchParams({ q, limit: '5', fields: 'title,cover_edition_key,edition_key,cover_i' });
|
||||
const url = `${SEARCH_API}?${params}`;
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||
if (res.status === 429) {
|
||||
console.warn(' ! rate-limited by Open Library (429). Wait and re-run; skipping rest.');
|
||||
return { rateLimited: true };
|
||||
}
|
||||
if (!res.ok) throw new Error(`search ${res.status} ${res.statusText}`);
|
||||
const data = await res.json();
|
||||
const docs = data?.docs ?? [];
|
||||
// Prefer a doc with a cover_edition_key (best cover source).
|
||||
const doc =
|
||||
docs.find((d) => d.cover_edition_key) ?? docs.find((d) => d.cover_i) ?? docs[0];
|
||||
if (!doc) return null;
|
||||
|
||||
if (doc.cover_edition_key) {
|
||||
return { url: `${COVERS_API}/olid/${doc.cover_edition_key}-L.jpg`, name: doc.title };
|
||||
}
|
||||
if (doc.cover_i) {
|
||||
return { url: `${COVERS_API}/id/${doc.cover_i}-L.jpg`, name: doc.title };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- main -------------------------------------------------------------------
|
||||
let items;
|
||||
try {
|
||||
items = JSON.parse(await readFile(DATA_FILE, 'utf8'));
|
||||
} catch (err) {
|
||||
if (err?.code === 'ENOENT') {
|
||||
console.error(`✖ ${DATA_FILE} not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const favorites = items.filter((i) => i.favorite);
|
||||
console.log(
|
||||
`→ ${favorites.length} reading favorite(s) in reading.json` +
|
||||
(force ? ' (force re-fetch)' : ''),
|
||||
);
|
||||
|
||||
await mkdir(OUT_DIR, { recursive: true });
|
||||
let changed = false;
|
||||
|
||||
for (let i = 0; i < favorites.length; i++) {
|
||||
const item = favorites[i];
|
||||
if (item.image && !force) {
|
||||
console.log(` ✓ ${item.title} — already has image, skipping.`);
|
||||
continue;
|
||||
}
|
||||
if (i > 0) await sleep(sleepMs);
|
||||
|
||||
console.log(`→ [${i + 1}/${favorites.length}] ${item.title}`);
|
||||
let result;
|
||||
try {
|
||||
result = await fetchCover(item.title);
|
||||
} catch (err) {
|
||||
console.warn(` ! network error: ${err?.message ?? err}; skipping.`);
|
||||
continue;
|
||||
}
|
||||
if (result?.rateLimited) break;
|
||||
if (!result?.url) {
|
||||
console.warn(` ! no cover found for "${item.title}"; skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = `${slugify(item.title)}.jpg`;
|
||||
let buf;
|
||||
try {
|
||||
const imgRes = await fetch(result.url);
|
||||
if (!imgRes.ok) throw new Error(`${imgRes.status} ${imgRes.statusText}`);
|
||||
buf = Buffer.from(await imgRes.arrayBuffer());
|
||||
// Open Library returns a 1x1 transparent placeholder when no cover exists.
|
||||
if (buf.length < 1024) {
|
||||
console.warn(` ! Open Library has no real cover for "${item.title}"; skipping.`);
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` ! image download failed (${err?.message ?? err}); skipping.`);
|
||||
continue;
|
||||
}
|
||||
await writeFile(join(OUT_DIR, fileName), buf);
|
||||
item.image = `${PUBLIC_PREFIX}/${fileName}`;
|
||||
const orig = items.find((o) => o.title === item.title && o.favorite);
|
||||
if (orig) orig.image = item.image;
|
||||
changed = true;
|
||||
console.log(` ✔ ${item.title} → ${item.image}`);
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await writeFile(DATA_FILE, JSON.stringify(items, null, 2) + '\n', 'utf8');
|
||||
console.log(`\n✔ Updated ${DATA_FILE}`);
|
||||
} else {
|
||||
console.log('\n— Nothing to update (all reading favorites already had images).');
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fetch-box-art.mjs
|
||||
* ---------------------------------------------------------------------------
|
||||
* Support tool for the Gaming Hall of Fame. Downloads box/banner art for a
|
||||
* game from the RAWG API (https://rawg.io/apidocs) and saves it into
|
||||
* `public/images/games/<slug>.jpg`, then prints the public path to paste into
|
||||
* a game file's `art:` field.
|
||||
*
|
||||
* RAWG covers Steam, PlayStation, Xbox, and Nintendo titles in one place, so
|
||||
* this works regardless of platform. Note: RAWG serves landscape banner /
|
||||
* screenshot art (not portrait Steam capsules), which matches the landscape
|
||||
* Hall of Fame cards (aspect-[4/3]).
|
||||
*
|
||||
* PREREQUISITES
|
||||
* 1. Get a free RAWG API key: https://rawg.io/login?forward=developer
|
||||
* 2. Put it in a `.env` file at the project root:
|
||||
* RAWG_API_KEY=your_key_here
|
||||
* (`.env` is gitignored; `.env.example` is the template.)
|
||||
*
|
||||
* USAGE
|
||||
* node scripts/fetch-box-art.mjs "Elden Ring"
|
||||
* node scripts/fetch-box-art.mjs "Hades" --platform pc
|
||||
* node scripts/fetch-box-art.mjs "Celeste" --out celeste.jpg
|
||||
* node scripts/fetch-box-art.mjs --id 3328 # by RAWG id
|
||||
*
|
||||
* Run with `--env-file=.env` to auto-load the key (Node 20.6+):
|
||||
* node --env-file=.env scripts/fetch-box-art.mjs "Elden Ring"
|
||||
*
|
||||
* If you don't use --env-file, export RAWG_API_KEY in your shell first.
|
||||
*/
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { dirname, extname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_ROOT = resolve(__dirname, '..');
|
||||
const OUT_DIR = join(PROJECT_ROOT, 'public', 'images', 'games');
|
||||
|
||||
const RAWG_BASE = 'https://api.rawg.io/api';
|
||||
// RAWG parent platforms that map to the --platform flag values.
|
||||
const PLATFORM_IDS = {
|
||||
pc: 1,
|
||||
playstation: 2,
|
||||
xbox: 3,
|
||||
nintendo: 7,
|
||||
mac: 4,
|
||||
linux: 5,
|
||||
ios: 5,
|
||||
android: 6,
|
||||
};
|
||||
|
||||
// --- arg parsing ------------------------------------------------------------
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0 || args.includes('-h') || args.includes('--help')) {
|
||||
console.log(`Usage: node scripts/fetch-box-art.mjs "<game title>" [options]
|
||||
|
||||
Options:
|
||||
--platform <p> narrow search to a parent platform: pc, playstation, xbox, nintendo
|
||||
--id <rawgId> fetch a specific game by its RAWG id (skips search)
|
||||
--out <name> output filename (default: slug of the title, .jpg)
|
||||
-h, --help show this help
|
||||
|
||||
Examples:
|
||||
node scripts/fetch-box-art.mjs "Elden Ring"
|
||||
node scripts/fetch-box-art.mjs "Hollow Knight" --platform pc
|
||||
node scripts/fetch-box-art.mjs --id 3328
|
||||
|
||||
Tip: run with \`node --env-file=.env scripts/fetch-box-art.mjs ...\` to auto-load
|
||||
the RAWG_API_KEY from your .env file.`);
|
||||
process.exit(args.length === 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
const positional = [];
|
||||
let platform = null;
|
||||
let rawgId = null;
|
||||
let outName = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--platform') platform = args[++i];
|
||||
else if (a === '--id') rawgId = Number(args[++i]);
|
||||
else if (a === '--out') outName = args[++i];
|
||||
else if (!a.startsWith('--')) positional.push(a);
|
||||
}
|
||||
const title = positional.join(' ').trim();
|
||||
|
||||
// --- helpers ----------------------------------------------------------------
|
||||
const slugify = (s) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/['’.:]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
const fail = (msg) => {
|
||||
console.error(`✖ ${msg}`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
const buildImageUrl = (game) => {
|
||||
// Prefer the wide/landscape screenshot-style image, which suits the
|
||||
// aspect-[4/3] Hall of Fame cards. Fall back through RAWG's image fields.
|
||||
const candidates = [
|
||||
game.background_image,
|
||||
game.background_image_additional,
|
||||
];
|
||||
const url = candidates.find(Boolean);
|
||||
if (!url) return null;
|
||||
// RAWG serves http(s) images; force https.
|
||||
return url.replace(/^http:\/\//i, 'https://');
|
||||
};
|
||||
|
||||
// --- main -------------------------------------------------------------------
|
||||
const API_KEY = process.env.RAWG_API_KEY;
|
||||
if (!API_KEY) {
|
||||
fail(
|
||||
'RAWG_API_KEY is not set. Add it to .env (see .env.example) and run with\n' +
|
||||
' node --env-file=.env scripts/fetch-box-art.mjs "..."',
|
||||
);
|
||||
}
|
||||
|
||||
let game;
|
||||
try {
|
||||
if (rawgId) {
|
||||
console.log(`→ Fetching RAWG game id ${rawgId}…`);
|
||||
const res = await fetch(`${RAWG_BASE}/games/${rawgId}?key=${API_KEY}`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) fail(`No RAWG game with id ${rawgId}.`);
|
||||
fail(`RAWG detail request failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
game = await res.json();
|
||||
} else {
|
||||
if (!title) fail('No game title provided.');
|
||||
const params = new URLSearchParams({ key: API_KEY, search: title, page_size: '5' });
|
||||
if (platform) {
|
||||
const pid = PLATFORM_IDS[platform.toLowerCase()];
|
||||
if (!pid) {
|
||||
fail(
|
||||
`Unknown --platform "${platform}". Try one of: ${Object.keys(PLATFORM_IDS).join(', ')}`,
|
||||
);
|
||||
}
|
||||
params.set('parent_platforms', String(pid));
|
||||
}
|
||||
console.log(`→ Searching RAWG for "${title}"${platform ? ` on ${platform}` : ''}…`);
|
||||
const res = await fetch(`${RAWG_BASE}/games?${params}`);
|
||||
if (!res.ok) fail(`RAWG search failed: ${res.status} ${res.statusText}`);
|
||||
const data = await res.json();
|
||||
if (!data.results?.length) fail(`No RAWG results for "${title}".`);
|
||||
game = data.results[0];
|
||||
console.log(` found: "${game.name}" (id ${game.id})${game.released ? `, ${game.released.slice(0, 4)}` : ''}`);
|
||||
}
|
||||
} catch (err) {
|
||||
fail(err?.message ? `Network error: ${err.message}` : 'Network error contacting RAWG.');
|
||||
}
|
||||
|
||||
const imageUrl = buildImageUrl(game);
|
||||
if (!imageUrl) {
|
||||
fail(`RAWG has no usable image for "${game.name}". Try another title or --id.`);
|
||||
}
|
||||
|
||||
// Decide output filename + extension (keep the source extension if --out omits one).
|
||||
const baseName = outName ?? `${slugify(game.name)}.jpg`;
|
||||
const ext = extname(baseName) || '.jpg';
|
||||
const fileName = baseName.replace(/\.[^.]+$/, '') + ext;
|
||||
const outPath = join(OUT_DIR, fileName);
|
||||
|
||||
console.log(`→ Downloading ${imageUrl}`);
|
||||
const imgRes = await fetch(imageUrl);
|
||||
if (!imgRes.ok) fail(`Image download failed: ${imgRes.status} ${imgRes.statusText}`);
|
||||
const buf = Buffer.from(await imgRes.arrayBuffer());
|
||||
|
||||
await mkdir(OUT_DIR, { recursive: true });
|
||||
await writeFile(outPath, buf);
|
||||
|
||||
const publicPath = `/images/games/${fileName}`;
|
||||
console.log(`\n✔ Saved: ${outPath}`);
|
||||
console.log(` Use this in your game file's frontmatter:`);
|
||||
console.log(` art: "${publicPath}"`);
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fetch-tmdb-art.mjs
|
||||
* ---------------------------------------------------------------------------
|
||||
* Support tool for the Movies and TV pages. For each favorite item (those with
|
||||
* `favorite: true`) in src/data/media/movies.json and src/data/media/tv.json
|
||||
* that is missing an `image`, it looks the title up on the TMDB API and
|
||||
* downloads the poster to public/images/<kind>/<slug>.jpg, then writes the
|
||||
* local public path back into the JSON.
|
||||
*
|
||||
* Idempotent: items that already have an `image` are skipped. Be gentle on TMDB
|
||||
* — there's a small delay between requests.
|
||||
*
|
||||
* PREREQUISITES
|
||||
* 1. Get a free TMDB API key: https://www.themoviedb.org/settings/api
|
||||
* 2. Put it in a `.env` file at the project root:
|
||||
* TMDB_API_KEY=your_key_here
|
||||
* (`.env` is gitignored; `.env.example` is the template.)
|
||||
*
|
||||
* USAGE
|
||||
* node scripts/fetch-tmdb-art.mjs # fetch both movies + tv
|
||||
* node scripts/fetch-tmdb-art.mjs --kind movies # movies only
|
||||
* node scripts/fetch-tmdb-art.mjs --kind tv # tv only
|
||||
* node scripts/fetch-tmdb-art.mjs --force # re-fetch even if image set
|
||||
* node scripts/fetch-tmdb-art.mjs --sleep 250 # ms between TMDB calls
|
||||
* node scripts/fetch-tmdb-art.mjs --help
|
||||
*
|
||||
* Run with `--env-file=.env` to auto-load the key (Node 20.6+):
|
||||
* node --env-file=.env scripts/fetch-tmdb-art.mjs
|
||||
*/
|
||||
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_DIR = join(PROJECT_ROOT, 'src', 'data', 'media');
|
||||
const IMAGES_ROOT = join(PROJECT_ROOT, 'public', 'images');
|
||||
|
||||
const TMDB_BASE = 'https://api.themoviedb.org/3';
|
||||
const TMDB_IMG = 'https://image.tmdb.org/t/p/w500';
|
||||
|
||||
// --- args -------------------------------------------------------------------
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('-h') || args.includes('--help')) {
|
||||
console.log(`Usage: node scripts/fetch-tmdb-art.mjs [options]
|
||||
|
||||
Reads movies.json + tv.json favorites and fills in the "image" field for any
|
||||
favorite missing one, using the TMDB API (free key required).
|
||||
|
||||
Options:
|
||||
--kind <k> only fetch one kind: "movies" or "tv" (default: both)
|
||||
--force re-fetch even for items that already have an image
|
||||
--sleep <ms> delay between TMDB API calls (default 250)
|
||||
-h, --help show this help
|
||||
|
||||
Tip: run with \`node --env-file=.env scripts/fetch-tmdb-art.mjs ...\` to auto-load
|
||||
the TMDB_API_KEY from your .env file.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const force = args.includes('--force');
|
||||
const sleepMsIdx = args.indexOf('--sleep');
|
||||
const sleepMs = sleepMsIdx !== -1 ? Number(args[sleepMsIdx + 1]) || 250 : 250;
|
||||
const kindIdx = args.indexOf('--kind');
|
||||
const kindFilter = kindIdx !== -1 ? args[kindIdx + 1] : null;
|
||||
if (kindFilter && !['movies', 'tv'].includes(kindFilter)) {
|
||||
console.error(`✖ Unknown --kind "${kindFilter}". Use "movies" or "tv".`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const API_KEY = process.env.TMDB_API_KEY;
|
||||
if (!API_KEY) {
|
||||
console.error(
|
||||
'✖ TMDB_API_KEY is not set. Add it to .env (see .env.example) and run with\n' +
|
||||
' node --env-file=.env scripts/fetch-tmdb-art.mjs',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------------------
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const slugify = (s) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/['’.:()]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
/** Search TMDB for a title, returning the best result's poster path. */
|
||||
async function fetchPoster(title, kind, year) {
|
||||
const mediaType = kind === 'movies' ? 'movie' : 'tv';
|
||||
const params = new URLSearchParams({
|
||||
api_key: API_KEY,
|
||||
query: title,
|
||||
include_adult: 'false',
|
||||
});
|
||||
if (year) {
|
||||
// TMDB uses different year param names per type.
|
||||
params.set(mediaType === 'movie' ? 'year' : 'first_air_date_year', String(year));
|
||||
}
|
||||
const url = `${TMDB_BASE}/search/${mediaType}?${params}`;
|
||||
const res = await fetch(url);
|
||||
if (res.status === 429) {
|
||||
console.warn(' ! rate-limited by TMDB (429). Wait and re-run; skipping rest.');
|
||||
return { rateLimited: true };
|
||||
}
|
||||
if (!res.ok) throw new Error(`search ${res.status} ${res.statusText}`);
|
||||
const data = await res.json();
|
||||
const result = data.results?.find((r) => r.poster_path) ?? data.results?.[0];
|
||||
if (!result?.poster_path) return null;
|
||||
return { posterPath: result.poster_path, name: result.title ?? result.name };
|
||||
}
|
||||
|
||||
// --- per-kind processing ----------------------------------------------------
|
||||
async function processKind(kind) {
|
||||
const DATA_FILE = join(DATA_DIR, `${kind}.json`);
|
||||
const OUT_DIR = join(IMAGES_ROOT, kind);
|
||||
const PUBLIC_PREFIX = `/images/${kind}`;
|
||||
|
||||
let items;
|
||||
try {
|
||||
items = JSON.parse(await readFile(DATA_FILE, 'utf8'));
|
||||
} catch (err) {
|
||||
if (err?.code === 'ENOENT') {
|
||||
console.warn(` ! ${DATA_FILE} not found; skipping ${kind}.`);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const favorites = items.filter((i) => i.favorite);
|
||||
console.log(
|
||||
`\n→ ${kind}: ${favorites.length} favorite(s) in ${kind}.json` +
|
||||
(force ? ' (force re-fetch)' : ''),
|
||||
);
|
||||
|
||||
await mkdir(OUT_DIR, { recursive: true });
|
||||
let changed = false;
|
||||
|
||||
for (let i = 0; i < favorites.length; i++) {
|
||||
const item = favorites[i];
|
||||
if (item.image && !force) {
|
||||
console.log(` ✓ ${item.title} — already has image, skipping.`);
|
||||
continue;
|
||||
}
|
||||
if (i > 0) await sleep(sleepMs);
|
||||
|
||||
console.log(`→ [${i + 1}/${favorites.length}] ${item.title}`);
|
||||
let result;
|
||||
try {
|
||||
result = await fetchPoster(item.title, kind, item.year);
|
||||
} catch (err) {
|
||||
console.warn(` ! network error: ${err?.message ?? err}; skipping.`);
|
||||
continue;
|
||||
}
|
||||
if (result?.rateLimited) break;
|
||||
if (!result?.posterPath) {
|
||||
console.warn(` ! no poster found for "${item.title}"; skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageUrl = `${TMDB_IMG}${result.posterPath}`;
|
||||
const fileName = `${slugify(item.title)}.jpg`;
|
||||
let buf;
|
||||
try {
|
||||
const imgRes = await fetch(imageUrl);
|
||||
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);
|
||||
item.image = `${PUBLIC_PREFIX}/${fileName}`;
|
||||
// Write the path back onto the original array entry (same object reference).
|
||||
const orig = items.find((o) => o.title === item.title && o.favorite);
|
||||
if (orig) orig.image = item.image;
|
||||
changed = true;
|
||||
console.log(` ✔ ${item.title} → ${item.image}`);
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await writeFile(DATA_FILE, JSON.stringify(items, null, 2) + '\n', 'utf8');
|
||||
console.log(`✔ Updated ${DATA_FILE}`);
|
||||
} else {
|
||||
console.log(`— Nothing to update for ${kind} (all favorites already had images).`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- main -------------------------------------------------------------------
|
||||
const kinds = kindFilter ? [kindFilter] : ['movies', 'tv'];
|
||||
for (const k of kinds) {
|
||||
await processKind(k);
|
||||
}
|
||||
Reference in New Issue
Block a user