mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
163 lines
5.6 KiB
JavaScript
163 lines
5.6 KiB
JavaScript
#!/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).');
|
||
}
|