Book tracker !
What’s the point of all these trackers?
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
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.
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.
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.
(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.
+ 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-readingcan 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.
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
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
What's New in This Version
- 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.