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

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