Big content update

This commit is contained in:
Greg Kilmer
2026-07-11 20:31:13 -04:00
parent 67f3fd311f
commit e0f41c98a7
76 changed files with 3659 additions and 165 deletions
+174
View File
@@ -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).');
}