blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git
commit 78e059a97da4679890a9c78edabc63dea7030da4
parent 8c2e541c9ca3ce1af5dc2a65449a833deb931f30
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Mon, 19 Oct 2026 11:03:38 +0900

Add a Goodreads reading list to the about page

Diffstat:
Mpackage.json | 5+++--
Ascripts/goodreads.mjs | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/goodreads.json | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/pages/index.astro | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
4 files changed, 283 insertion(+), 4 deletion(-)

diff --git a/package.json b/package.json
@@ -7,12 +7,13 @@
   },
   "scripts": {
     "dev": "astro dev",
-    "prebuild": "node scripts/check-content.mjs && node scripts/render-diagrams.mjs && node scripts/build-info.mjs",
+    "prebuild": "node scripts/check-content.mjs && node scripts/goodreads.mjs && node scripts/render-diagrams.mjs && node scripts/build-info.mjs",
     "build": "astro build --force",
     "postbuild": "node scripts/check-dist.mjs",
     "preview": "astro preview",
     "check": "node scripts/check-content.mjs",
-    "diagrams": "node scripts/render-diagrams.mjs"
+    "diagrams": "node scripts/render-diagrams.mjs",
+    "reading": "node scripts/goodreads.mjs"
   },
   "dependencies": {
     "astro": "^7.3.3"
diff --git a/scripts/goodreads.mjs b/scripts/goodreads.mjs
@@ -0,0 +1,72 @@
+/**
+ * goodreads.mjs — the about page's reading list, read from Goodreads at build time.
+ *
+ * The page renders the books as its own markup rather than framing Goodreads' widget: a
+ * frame cannot take the site's type or colours, and it had every visitor's browser call
+ * Goodreads. Here the public RSS feed of the read shelf is fetched once per build, so the
+ * page carries plain links and the Content-Security-Policy stays 'self'.
+ *
+ * The parsed list is committed as src/lib/goodreads.json. A build that cannot reach
+ * Goodreads warns and builds from that copy instead of failing a deploy over a third-party
+ * feed, and the list is then as old as the last commit of the file. The file is rewritten
+ * only when the books change, so a routine build leaves no diff behind.
+ *
+ *   node scripts/goodreads.mjs    refresh src/lib/goodreads.json from the feed
+ */
+import { readFile, writeFile } from 'node:fs/promises';
+
+const USER = '205369673';
+const FEED = `https://www.goodreads.com/review/list_rss/${USER}?shelf=read`;
+const OUT = 'src/lib/goodreads.json';
+
+const ENTITY = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
+const decode = (s) =>
+  s.replace(/&(?:#x([0-9a-f]+)|#(\d+)|([a-z]+));/gi, (m, hex, dec, name) =>
+    hex ? String.fromCodePoint(parseInt(hex, 16))
+      : dec ? String.fromCodePoint(Number(dec))
+      : ENTITY[name.toLowerCase()] ?? m);
+
+/* Every field read here is a flat element, its text either bare or wrapped in CDATA. */
+const field = (item, tag) => {
+  const m = item.match(new RegExp(String.raw`<${tag}>\s*(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))\s*</${tag}>`));
+  return m ? decode((m[1] ?? m[2]).trim()) : '';
+};
+
+/* RFC 822 dates. The read date is a calendar day, sent as midnight UTC, so it is read
+   back in UTC; read in local time it would land on the day before west of Greenwich. */
+const day = (s) => (s ? new Date(s).toISOString().slice(0, 10) : null);
+
+async function fetchBooks() {
+  const res = await fetch(FEED, { signal: AbortSignal.timeout(20_000) });
+  if (!res.ok) throw new Error(`HTTP ${res.status}`);
+  const xml = await res.text();
+  if (!xml.includes('<channel>')) throw new Error('the response is not an RSS feed');
+  return [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].map(([, item]) => {
+    const id = field(item, 'book_id');
+    return {
+      id,
+      title: field(item, 'title'),
+      author: field(item, 'author_name'),
+      rating: Number(field(item, 'user_rating')) || 0,
+      readAt: day(field(item, 'user_read_at')),
+      addedAt: day(field(item, 'user_date_added')),
+      url: `https://www.goodreads.com/book/show/${id}`,
+    };
+  });
+}
+
+const old = await readFile(OUT, 'utf8').catch(() => null);
+let books;
+try {
+  books = await fetchBooks();
+} catch (e) {
+  if (old === null) throw new Error(`goodreads: cannot read ${FEED} (${e.message}), and there is no ${OUT} to build from`);
+  console.warn(`goodreads: cannot read the feed (${e.message}); building from the committed ${OUT}`);
+  process.exit(0);
+}
+const json = JSON.stringify({ feed: FEED, books }, null, 1) + '\n';
+if (json === old) console.log(`goodreads: ${books.length} books, unchanged`);
+else {
+  await writeFile(OUT, json);
+  console.log(`goodreads: ${books.length} books, ${OUT} rewritten`);
+}
diff --git a/src/lib/goodreads.json b/src/lib/goodreads.json
@@ -0,0 +1,86 @@
+{
+ "feed": "https://www.goodreads.com/review/list_rss/205369673?shelf=read",
+ "books": [
+  {
+   "id": "7155145",
+   "title": "Linchpin: Are You Indispensable?",
+   "author": "Seth Godin",
+   "rating": 5,
+   "readAt": "2025-03-10",
+   "addedAt": "2026-10-13",
+   "url": "https://www.goodreads.com/book/show/7155145"
+  },
+  {
+   "id": "450667",
+   "title": "69",
+   "author": "Ryū Murakami",
+   "rating": 4,
+   "readAt": "2025-12-25",
+   "addedAt": "2026-10-13",
+   "url": "https://www.goodreads.com/book/show/450667"
+  },
+  {
+   "id": "2691373",
+   "title": "自殺よりはSEX—村上龍の恋愛・女性論",
+   "author": "Ryū Murakami",
+   "rating": 5,
+   "readAt": "2026-02-11",
+   "addedAt": "2026-10-13",
+   "url": "https://www.goodreads.com/book/show/2691373"
+  },
+  {
+   "id": "17789236",
+   "title": "クズの本懐 1 [Kuzu no Honkai 1] (Scum's Wish, #1)",
+   "author": "Mengo Yokoyari",
+   "rating": 5,
+   "readAt": "2024-12-13",
+   "addedAt": "2026-10-13",
+   "url": "https://www.goodreads.com/book/show/17789236"
+  },
+  {
+   "id": "58340695",
+   "title": "Collective Illusions: Conformity, Complicity, and the Science of Why We Make Bad Decisions",
+   "author": "Todd Rose",
+   "rating": 4,
+   "readAt": "2024-12-30",
+   "addedAt": "2026-10-13",
+   "url": "https://www.goodreads.com/book/show/58340695"
+  },
+  {
+   "id": "34346984",
+   "title": "言の葉の庭 [Kotonoha no Niwa]",
+   "author": "Makoto Shinkai",
+   "rating": 4,
+   "readAt": "2017-02-10",
+   "addedAt": "2026-10-13",
+   "url": "https://www.goodreads.com/book/show/34346984"
+  },
+  {
+   "id": "58580969",
+   "title": "Look Back",
+   "author": "Tatsuki Fujimoto",
+   "rating": 3,
+   "readAt": "2026-10-10",
+   "addedAt": "2026-10-13",
+   "url": "https://www.goodreads.com/book/show/58580969"
+  },
+  {
+   "id": "52219386",
+   "title": "Almond",
+   "author": "Sohn Won-Pyung",
+   "rating": 3,
+   "readAt": "2025-12-01",
+   "addedAt": "2026-10-13",
+   "url": "https://www.goodreads.com/book/show/52219386"
+  },
+  {
+   "id": "51710349",
+   "title": "The WEIRDest People in the World: How the West Became Psychologically Peculiar and Particularly Prosperous",
+   "author": "Joseph Henrich",
+   "rating": 4,
+   "readAt": "2025-03-29",
+   "addedAt": "2026-10-13",
+   "url": "https://www.goodreads.com/book/show/51710349"
+  }
+ ]
+}
diff --git a/src/pages/index.astro b/src/pages/index.astro
@@ -3,18 +3,35 @@ import Base from '../layouts/Base.astro';
 import { getEntry, render } from 'astro:content';
 import { hasHangul } from '../lib/hangul';
 import { assertRendered } from '../lib/rendered';
+import reading from '../lib/goodreads.json';
 
 const entry = await getEntry('about', 'about');
 assertRendered(entry!);
 const { Content } = await render(entry!);
 
+/* The reading list: the most recently finished books on the Goodreads read shelf, which
+   scripts/goodreads.mjs fetches at build time. A book with no finish date sorts by the day
+   it was shelved. The subtitle after ": " is dropped, "Collective Illusions" rather than
+   the eleven words after it, so a title holds to a line or two. */
+const READING_COUNT = 5;
+const books = [...reading.books]
+  .sort((a, b) => (b.readAt ?? b.addedAt ?? '').localeCompare(a.readAt ?? a.addedAt ?? ''))
+  .slice(0, READING_COUNT);
+const shortTitle = (t: string) => t.replace(/:\s.*$/, '');
+const ym = (iso: string) => `${iso.slice(0, 4)}.${iso.slice(5, 7)}`;
+/* Kana or CJK ideographs. Japanese titles are common on the shelf, and lang="ja" is what
+   picks a Japanese voice and Japanese glyph forms over the page's lang="en". */
+const KANA_KANJI = /[぀-ヿ一-鿿]/;
+const titleLang = (t: string) => (hasHangul(t) ? 'ko' : KANA_KANJI.test(t) ? 'ja' : undefined);
+
 /* Ask the body, not the metadata. The title and description below are English whatever
    about.md says, so only the body can tell whether the page needs the 118KB Korean face.
    about.md is English throughout today, so the answer is false and the page fetches no
    Korean font. The test stays because the answer is the body's to give: one Korean line
    added to about.md starts needing that face, and nothing else in this file would
-   notice. */
-const ko = hasHangul(entry!.body);
+   notice. The reading list is part of the body for this purpose: a Korean book title
+   needs the face just as a Korean sentence does. */
+const ko = hasHangul(entry!.body) || books.some((b) => hasHangul(b.title + b.author));
 ---
 <Base
   title="sqzer"
@@ -28,6 +45,32 @@ const ko = hasHangul(entry!.body);
       the other section pages: the reference prints no page title either. */}
   <h1 class="sr-only">About</h1>
   <article class="about"><Content /></article>
+
+  {/* Rows on the Writing index's pattern: a date rail, the title, and here the rating in
+      place of a deck. The stars are drawn twice over, filled and empty, and hidden from
+      assistive tech, which reads the rating from the label instead. */}
+  {books.length > 0 && (
+    <section class="reading" aria-labelledby="reading">
+      <h2 class="reading__label" id="reading">Reading</h2>
+      <ol class="reading__list">
+        {books.map((b) => (
+          <li class="book">
+            {b.readAt && <time class="book__date" datetime={b.readAt.slice(0, 7)}>{ym(b.readAt)}</time>}
+            <p class="book__body">
+              <a class="book__title" href={b.url} lang={titleLang(b.title)}>{shortTitle(b.title)}</a>
+              <span class="book__author">{b.author}</span>
+            </p>
+            {b.rating > 0 && (
+              <span class="book__rating" role="img" aria-label={`Rated ${b.rating} out of 5`}>
+                <span class="book__stars" aria-hidden="true">{'★'.repeat(b.rating)}</span><span class="book__stars book__stars--empty" aria-hidden="true">{'★'.repeat(5 - b.rating)}</span>
+              </span>
+            )}
+          </li>
+        ))}
+      </ol>
+      <p class="reading__more"><a href="https://www.goodreads.com/user/show/205369673-hyukjin-an">More on Goodreads</a></p>
+    </section>
+  )}
 </Base>
 
 {/* is:global for the same reason the article page uses it: the elements <Content />
@@ -75,4 +118,81 @@ const ko = hasHangul(entry!.body);
   }
   .about :is(p, li, h1, h2, h3, h4) { overflow-wrap: break-word; }
   .about a { overflow-wrap: anywhere; }
+
+  /* The label is the site's kicker idiom with its 4px x 3rem rule, as on the Writing
+     index's year headings, rather than a second heading style for one section. */
+  .reading { margin-block-start: var(--sp-48); }
+  .reading__label {
+    margin: 0 0 var(--sp-40);
+    font-family: var(--font-body);
+    font-size: var(--fs-small);
+    font-weight: 700;
+    line-height: var(--lh-medium);
+    letter-spacing: 0.105rem;
+    text-transform: uppercase;
+    color: var(--ppc-text);
+  }
+  .reading__label::before {
+    content: "";
+    display: block;
+    inline-size: 3rem;
+    border-top: 4px solid var(--ppc-ui);
+    margin-block-end: var(--sp-40);
+  }
+  /* Each book is a Writing index row in miniature: the same 72px date rail and 24px gap,
+     hairlines between rows, a title in --page-fg that steps to crimson under the pointer.
+     The rail carries the month the book was finished; the rating takes the end column. */
+  .reading__list { list-style: none; margin: 0; padding: 0; }
+  .book {
+    display: grid;
+    grid-template-columns: 4.5rem minmax(0, 1fr) auto;
+    grid-template-areas: "date body rating";
+    column-gap: 1.5rem;
+    align-items: baseline;
+    padding-block: var(--sp-30);
+    border-bottom: 1px solid var(--page-hairline);
+  }
+  .book:last-child { border-bottom: none; }
+  .book__date {
+    grid-area: date;
+    justify-self: end;
+    font-size: var(--fs-small);
+    font-variant-numeric: tabular-nums;
+    letter-spacing: 0.02em;
+    color: var(--page-meta);
+    white-space: nowrap;
+  }
+  .book__body { grid-area: body; margin: 0; line-height: var(--lh-medium); }
+  .book__title {
+    font-family: var(--font-display);
+    font-size: var(--fs-h6);
+    font-weight: 500;
+    color: var(--page-fg);
+  }
+  .book__title:is(:hover, :focus-visible) { color: var(--crimson); }
+  .book__author {
+    margin-inline-start: 0.6em;
+    font-size: var(--fs-small);
+    color: var(--page-meta);
+    white-space: nowrap;
+  }
+  .book__rating { grid-area: rating; font-size: var(--fs-small); letter-spacing: 0.08em; white-space: nowrap; }
+  .book__stars { color: var(--crimson); }
+  .book__stars--empty { color: var(--page-hairline); }
+  .reading__more {
+    margin-block-start: var(--sp-40);
+    padding-inline-start: calc(4.5rem + 1.5rem);
+    font-size: var(--fs-small);
+  }
+
+  /* Below 40rem the rail would leave the title too narrow a column beside the rating, so
+     the month moves above the title and the rows start at the page edge. */
+  @media (max-width: 40rem) {
+    .book {
+      grid-template-columns: minmax(0, 1fr) auto;
+      grid-template-areas: "date date" "body rating";
+    }
+    .book__date { justify-self: start; }
+    .reading__more { padding-inline-start: 0; }
+  }
 </style>