blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git

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 }