scripts/goodreads.mjs (3247B)
1 /**
2 * goodreads.mjs — the about page's reading list, read from Goodreads at build time.
3 *
4 * The page renders the books as its own markup rather than framing Goodreads' widget: a
5 * frame cannot take the site's type or colours, and it had every visitor's browser call
6 * Goodreads. Here the public RSS feed of the read shelf is fetched once per build, so the
7 * page carries plain links and the Content-Security-Policy stays 'self'.
8 *
9 * The parsed list is committed as src/lib/goodreads.json. A build that cannot reach
10 * Goodreads warns and builds from that copy instead of failing a deploy over a third-party
11 * feed, and the list is then as old as the last commit of the file. The file is rewritten
12 * only when the books change, so a routine build leaves no diff behind.
13 *
14 * node scripts/goodreads.mjs refresh src/lib/goodreads.json from the feed
15 */
16 import { readFile, writeFile } from 'node:fs/promises';
17
18 const USER = '205369673';
19 const FEED = `https://www.goodreads.com/review/list_rss/${USER}?shelf=read`;
20 const OUT = 'src/lib/goodreads.json';
21
22 const ENTITY = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
23 const decode = (s) =>
24 s.replace(/&(?:#x([0-9a-f]+)|#(\d+)|([a-z]+));/gi, (m, hex, dec, name) =>
25 hex ? String.fromCodePoint(parseInt(hex, 16))
26 : dec ? String.fromCodePoint(Number(dec))
27 : ENTITY[name.toLowerCase()] ?? m);
28
29 /* Every field read here is a flat element, its text either bare or wrapped in CDATA. */
30 const field = (item, tag) => {
31 const m = item.match(new RegExp(String.raw`<${tag}>\s*(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))\s*</${tag}>`));
32 return m ? decode((m[1] ?? m[2]).trim()) : '';
33 };
34
35 /* RFC 822 dates. The read date is a calendar day, sent as midnight UTC, so it is read
36 back in UTC; read in local time it would land on the day before west of Greenwich. */
37 const day = (s) => (s ? new Date(s).toISOString().slice(0, 10) : null);
38
39 async function fetchBooks() {
40 const res = await fetch(FEED, { signal: AbortSignal.timeout(20_000) });
41 if (!res.ok) throw new Error(`HTTP ${res.status}`);
42 const xml = await res.text();
43 if (!xml.includes('<channel>')) throw new Error('the response is not an RSS feed');
44 return [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].map(([, item]) => {
45 const id = field(item, 'book_id');
46 return {
47 id,
48 title: field(item, 'title'),
49 author: field(item, 'author_name'),
50 rating: Number(field(item, 'user_rating')) || 0,
51 readAt: day(field(item, 'user_read_at')),
52 addedAt: day(field(item, 'user_date_added')),
53 url: `https://www.goodreads.com/book/show/${id}`,
54 };
55 });
56 }
57
58 const old = await readFile(OUT, 'utf8').catch(() => null);
59 let books;
60 try {
61 books = await fetchBooks();
62 } catch (e) {
63 if (old === null) throw new Error(`goodreads: cannot read ${FEED} (${e.message}), and there is no ${OUT} to build from`);
64 console.warn(`goodreads: cannot read the feed (${e.message}); building from the committed ${OUT}`);
65 process.exit(0);
66 }
67 const json = JSON.stringify({ feed: FEED, books }, null, 1) + '\n';
68 if (json === old) console.log(`goodreads: ${books.length} books, unchanged`);
69 else {
70 await writeFile(OUT, json);
71 console.log(`goodreads: ${books.length} books, ${OUT} rewritten`);
72 }