#!/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/.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 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).'); }