Book tracker !

A Gabriele Passuello Note Nº 05

What’s the point of all these trackers?

YEAR READ 2026 2025 2024 2023 2022 2021 2020 2019 2018 GOODREADS · GRID WIDGET CLOUDFLARE WORKER · JSON var widget_code = ' <div class="gr_grid_book_container"> <a title="Example Title" href="…/review/show/…"> <img src="…/books/…/ 9697320._SY75_.jpg" /> </a> </div> ' GET /tooltips?…Book.9697320 → author, description { "count": 1, "books": [ { "title": "Example Title", "author": "Example Author", "cover": "…/9697320.jpg", "goodreadsUrl": "…/9697320", "description": "…" } ] } WORKER EX LIBRIS · GABRIELE PASSUELLO · v3.0
Every book, year by year Book Tracker !
9 Nov 2026 11 min read Version 3.0

Gabriele Passuello presents every book read since 2018, shelved by year data from the public Goodreads shelf widget parsed by a Cloudflare Worker remembered by Cloudflare KV rendered as an interactive grid of covers the only manual step updating my Goodreads shelves

My Books by Year v.3

Every book I read is organized by year using Goodreads.

Version 2 was built on Goodreads’ RSS feeds: a Cloudflare Worker read the feed of each shelf and turned it into JSON for this page. In November 2026 I noticed that Goodreads had removed the RSS feeds, and without them this page and the “Currently reading” box on the home page were left empty.

Looking for a way out, I found a Reddit thread where another user had solved the same problem by reading Goodreads’ public pages instead of the feeds. I took the same idea and rewrote the Worker around it. Each shelf now comes from the old Goodreads grid widget, which is still public, and the book I’m currently reading gets its author and description from the small tooltips Goodreads shows when you hover over a book.

The page itself barely changed: the Worker returns the same JSON as before, so the grid and the home page kept working. The only manual step is still updating my Goodreads shelves.

The previous versions are still available, but I don’t maintain them anymore: version 2 and version 1.

Each list corresponds to a specific year.

You might be wondering what the point of all these trackers is… well, I just enjoy them.

Loading...

Technical Details

This page works thanks to a small front-end script + a Cloudflare Worker that reads Goodreads’ public pages, keeps a copy of the last good result and returns clean JSON. The “Currently reading” box on the home page uses the same Worker.

The Worker is still called goodreads-rss-parser, even if there is no RSS left: keeping the name kept the URL, so nothing on the site had to change.

Architecture Overview

flowchart TD A["Browser / Front-end
Book tracker page + home page"] -->|"fetch JSON (?shelf=2025)"| B["Cloudflare Worker
goodreads-rss-parser"] B <-->|last good result| K[("Cloudflare KV")] B -->|books of a shelf| C["Goodreads grid widget
review/grid_widget/USER_ID"] B -->|author + description| D["Goodreads tooltips
currently-reading only"] B -.->|fallback| E["Goodreads book page
book/show/BOOK_ID"] B -->|Parsed JSON| A

Front-end Workflow (UI + Fetch)

The front-end is the same as in version 2. When the Book Tracker page loads, the JavaScript running in the browser first determines the current year using new Date().getFullYear() and generates an array of years from the defined START_YEAR up to the current year, reversing the order so that the most recent years appear first. This array is then passed to the generateMenu() function, which builds the navigation menu with a clickable link for each year. The script then calls loadYear() on the first year in the list—usually the most recent one. The only difference from version 2 is the request: instead of building a Goodreads RSS URL for each year, the page now asks the Worker directly for a shelf, for example /api?shelf=2025. Each book in the answer becomes a card with its cover, its title and a link to its Goodreads page.

sequenceDiagram participant U as User participant FE as Front-end (Browser) participant W as Cloudflare Worker participant UI as DOM (Menu + Grid) U->>FE: Load page FE->>FE: currentYear = new Date().getFullYear() FE->>FE: goodreadsLists = [START_YEAR .. currentYear] FE->>FE: goodreadsLists.reverse() FE->>UI: generateMenu(goodreadsLists) FE->>W: GET /api?shelf=2026 W-->>FE: JSON { count, books[] } FE->>UI: Render books grid for selected year U->>FE: Click on another year FE->>W: GET /api?shelf=2025

Backend Workflow (Worker)

When the front-end asks for a shelf, the Worker first checks the request: the shelf must be currently-reading, read, to-read or a year, and the Goodreads user is fixed in the Worker, so it can’t be used to read anyone else’s shelves. Old requests with the rssUrl parameter are still accepted and translated into a shelf name.

Then it looks in Cloudflare KV for the last result saved for that shelf. If it was saved less than an hour ago, the Worker answers with it straight away, without calling Goodreads. Otherwise it downloads the shelf from the Goodreads grid widget, sending a normal browser User-Agent like any visitor would. Only a 200 answer counts: Goodreads sometimes answers with an empty 202, most likely an anti-bot check, and the Worker treats it as a failure.

For the currently-reading shelf the Worker also asks Goodreads for the tooltips of those books, to get author and description, because the home page shows both. Finally it saves the result in KV and returns it to the front-end.

sequenceDiagram participant FE as Front-end participant W as Cloudflare Worker participant KV as Cloudflare KV participant GR as Goodreads FE->>W: GET /api?shelf=2025 W->>W: Validate shelf (currently-reading, read, to-read or a year) W->>KV: Read last saved result alt Saved less than an hour ago KV-->>W: Saved JSON else Missing or older than an hour W->>GR: GET review/grid_widget/USER_ID?shelf=2025 GR-->>W: JavaScript snippet with HTML inside W->>W: Extract title, cover and book id of each book opt Shelf is currently-reading W->>GR: GET tooltips (book ids) GR-->>W: Author, description, book URL end W->>KV: Save result end W-->>FE: JSON { count, books[] }

Data Extraction (from the grid widget)

The widget answer is neither HTML nor JSON: it’s a few lines of JavaScript meant to be pasted into a blog, with the HTML of the grid inside a string (var widget_code = '...'). The Worker pulls out that string, undoes the JavaScript escaping and reads one gr_grid_book_container block per book.

The cover is the most useful part. The widget only gives a thumbnail, but removing the size suffix from the image URL (._SY75_) gives the full-size cover. And the file name of the cover is the id of the book on Goodreads, which is enough to build the link to its public page. I checked it on all the 86 books of my year shelves, from 2018 to 2026, and every id pointed to the right book.

flowchart TD A["var widget_code = '…'"] --> B["Unescape the JavaScript string"] B --> C["gr_grid_book_container
(one per book)"] C --> D["a title
→ title"] C --> E["img src
…/9697320._SY75_.jpg"] C --> J["a href
→ reviewUrl"] E --> G["Remove size suffix
→ cover (full size)"] E --> I["File name = book id
→ goodreadsUrl book/show/9697320"] D --> K["Book JSON"] G --> K I --> K J --> K

Author and Description (from the tooltips)

The widget has neither the author nor the description, and the home page needs both for the book I’m reading. For that shelf the Worker asks Goodreads for the tooltips of the books (the small box you see when you hover over a book on Goodreads), all in a single request. Each tooltip contains the author, the full description (hidden behind a “…more” link) and the canonical link to the book page.

The public book page has the same information, but it’s only a fallback: when it’s requested from a server, Goodreads often answers with that empty 202. If neither answers, the Worker keeps the author and description saved the last time.

flowchart LR A["Book I'm reading"] --> B{"Tooltips"} B -->|ok| Z["author + description
+ book URL"] B -->|failed| C{"Book page"} C -->|ok| Z C -->|"202 / failed"| D{"Saved in KV"} D -->|found| Z D -->|nothing| E["title and cover only
retry in 10 minutes"]

Resulting object example:

{
  "shelf": "currently-reading",
  "count": 1,
  "updatedAt": "2026-11-09T09:21:53.156Z",
  "stale": false,
  "books": [
    {
      "title": "Example Title",
      "author": "Example Author",
      "cover": "https://i.gr-assets.com/.../9697320.jpg",
      "goodreadsUrl": "https://www.goodreads.com/book/show/...",
      "description": "Clean description text",
      "bookId": "9697320",
      "bookUrl": "https://www.goodreads.com/book/show/...",
      "reviewUrl": "https://www.goodreads.com/review/show/...",
      "detailsSource": "tooltip"
    }
  ]
}

detailsSource tells where author and description came from: tooltip, bookPage, saved or empty. If the description ever disappears again, it’s the first thing I’ll look at.

Keeping the Last Good Result

Reading page HTML is more fragile than reading a feed, so the Worker is built to never leave the page empty:

  • Each shelf is saved in Cloudflare KV together with the time it was read. For an hour the Worker answers from KV, so Goodreads gets one request per shelf instead of one per visitor.

  • If Goodreads doesn’t answer, blocks the request or changes its markup, the Worker returns the saved copy marked "stale": true. Only if nothing was ever saved does it answer with an error.

  • A year that suddenly comes back empty is treated as a Goodreads hiccup, and the saved copy wins. Only currently-reading can really become empty: it happens when I finish a book and haven’t started the next one.

  • If author or description of the current book are missing, the shelf is read again after 10 minutes instead of an hour.

flowchart LR A["Request
for a shelf"] --> B{"Saved less than
an hour ago?"} B -->|yes| OK["Saved JSON"] B -->|no| C["Read
Goodreads"] C -->|ok| S["Save in KV
→ fresh JSON"] C -->|"failed / new markup
/ empty year"| D{"Anything
saved?"} D -->|yes| ST["Saved JSON
stale: true"] D -->|no| ERR["Error 502"]

Why the Worker is Needed

Goodreads pages are meant to be opened or embedded, not read by another site’s JavaScript: the browser blocks those requests (CORS), so this page can’t talk to Goodreads directly. The Worker solves this by:

  • reading Goodreads from the server and adding the CORS headers the browser needs

  • turning a JavaScript snippet and some HTML into JSON

  • caching the result, so Goodreads gets one request an hour per shelf

  • hiding Goodreads’ changes from the page: when the markup changes only the Worker needs fixing, and meanwhile the saved data keeps the page alive

flowchart LR A["Goodreads widget + tooltips"] -->|Blocked by CORS| X[Browser] A -->|Worker parses + caches| B[Worker JSON API] B <--> K[("Cloudflare KV")] B -->|Easy fetch + render| X

Limits

  • It reads page HTML, so it breaks if Goodreads changes the markup. The tests run on real Goodreads answers, and the saved data covers the gap until the Worker is fixed.

  • It only sees public shelves. Ratings and reviews aren’t part of it.

  • It’s read-only: nothing is ever written back to Goodreads.

Summary

A personal book tracking page that organizes everything I read by year. After Goodreads removed its RSS feeds, a Cloudflare Worker now reads the public Goodreads shelf widget, keeps the last good result and serves my reading history as JSON for an interactive grid.

What's New in This Version

- Goodreads removed its RSS feeds: the Worker now reads the public grid widget of each shelf instead of the RSS feed.
- Author and full description of the book I'm currently reading now come from Goodreads tooltips, with the public book page as a fallback.
- Book links are built from the book id in the cover file name and always point to the public book page.
- Added a Cloudflare KV cache: each shelf is read from Goodreads at most once an hour, and the last good result is served if Goodreads fails or changes its markup.
- Worker locked to my Goodreads user and to known shelves (currently-reading, read, to-read and years).
- Worker kept backwards compatible with the old rssUrl requests, so the home page needed no changes.
- Front-end now calls the Worker with ?shelf=YEAR.
- Added automated tests based on real Goodreads responses.
- Updated documentation and Mermaid diagrams for the new back-end.

Notes mentioning this note

There are no notes linking to this note.


Here are all the notes on this site, along with their links, visualized as a graph.