Files
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

163 lines
5.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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).');
}