mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Big content update
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,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,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