Files
com.gregkilmer.www/scripts/fetch-book-covers.mjs
T
2026-07-11 20:31:13 -04:00

175 lines
6.3 KiB
JavaScript
Raw 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-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).');
}