Skip to content
goodread

v0.3.0

A new record model that tells a work from an edition, robots.txt obeyed on every request, a SQLite graph with SQL over it, a resumable crawler, and an MCP server.

This is the rewrite the tool needed. v0.2.0 read pages and handed back flat records. v0.3.0 reads pages into a model that knows what it is holding, records where every number came from, folds everything into a graph you can query, and is honest about the pages it will not read.

It is a breaking release. The record shape changed, the exit codes changed, two flags are gone and one command was renamed.

A book is not a work

The distinction the whole release is built on. A work is what somebody wrote and a book is one printing of it, and conflating them is the most common error in book data.

book gives you a printing, the new work command gives you the thing itself, and editions gives you every printing there is. A work carries the original title, the awards, the places and the characters. An edition carries its own ISBN, page count, publisher, format and rating population.

Every stats block now names the population it came from, in a via field that is never omitted. A consumer that averages across records without reading it is mixing edition numbers with work numbers and would never find out.

Two more things changed shape for the same reason. A field the read did not find is now absent rather than zero, because a book with no page count and a book with zero pages are different facts. And contributors carry the role from the edge, so an illustrator comes back as an illustrator rather than as an author.

robots.txt, obeyed structurally

Every readable path is a registered op, and nothing builds a URL at the call site. That means the tool can print every surface it can reach with the rule that decides each one:

goodread robots
goodread robots check https://www.goodreads.com/search?q=dune

Four surfaces are disallowed and are no longer read by default: the search page, shelves, reviews past the thirty a book page embeds, and /work/<id>.

Two of those have real replacements on allowed surfaces, and those are the routes the tool takes. lookup goes through /book/auto_complete instead of site search, and work reads the editions page and then the first edition's own page instead of the work page.

There is a --no-robots flag for a person who has decided it is their call. It warns once, the pace floor still applies, it has no config key and no environment variable, and a crawl with it needs --yes as well.

What it reads comes back marked. A record built from a disallowed page carries a robots block with allowed: false, the path, and the rule from the file that matched it, so the data stays marked long after the shell history is gone.

The extraction ladder

The book page is a Next.js route and ships an Apollo cache inline, which is where most of a book record now comes from. Author, series, list, genre, editions and quotes are still Rails templates with nothing but og: tags, so on those a CSS selector is not a shortcut, it is the only thing there is.

That is written down rather than hidden. Every selector is registered with the release it was added in, and goodread extraction prints the ladder with the count per surface, so selector debt is visible instead of being furniture.

Every record carries via and level per field, and a missed list of plain sentences about what a page did not have.

A graph, and SQL over it

Everything read is folded into SQLite as nodes and edges. Nodes are keyed by a gr: URI built from the legacy id, so /book/show/2767052 and /book/show/2767052-the-hunger-games are one row. Edges are a closed set of twelve predicates, and a name outside that set is refused at the write.

goodread find hunger
goodread graph gr:book/2767052 --depth 2
goodread query "select title, isbn13 from books where num_pages > 500"
goodread export --to rdf --kind book > books.ttl

query is one read only statement, so it cannot damage a store that took a week to build. export writes JSONL or Turtle.

A crawler you can stop

crawl reads a seed, stores it, follows the edges the record named, and repeats. Expansion goes through the edge table rather than the HTML, so it follows exactly the relationships the model records.

The frontier lives in the store, so an interrupted crawl continues where it stopped and the pages it already read come back out of the cache. Ctrl-C is a clean stop and exits zero. --dry-run prints the plan and reads nothing, which is how you find out a crawl is twelve hours before starting it.

There is no parallelism flag, and that is the design rather than an omission.

An MCP server

goodread mcp

Eleven read tools over stdio: book_get, work_get, author_get, series_get, editions_list, quotes_list, genre_get, list_get, book_lookup, store_find and store_query.

No search, no shelf, no reviews, and --no-robots has no effect on the server even when the process was started with it. The flag's whole justification is a person deciding it is their call, and a model calling a tool is not that person deciding. An override a model can trigger is not an override, it is a default.

A test fails the build if any tool ever reads a disallowed surface, because the argument for the exclusions is one nobody will remember to re-make in a year.

Breaking changes

The exit codes moved. v0.2.0 used 3 for no data, 4 for partial and 5 for blocked. v0.3.0 says what went wrong rather than how the run ended, which is what a script wants when it is deciding whether to retry.

Code Meaning
0 success
1 an error nothing else classified
2 usage, including a config file that will not load
3 network, meaning the site never answered
4 the site answered and the answer was an error or a block
5 extraction failed, or a record did not reconcile
6 not found
7 refused because robots.txt disallows the path
8 robots.txt could not be read, so nothing can be checked against it

--workers is gone. It was clamped to one, so it did nothing except suggest the tool could be told to open ten connections. A flag that does nothing but imply that is worse than no flag.

--cookies is gone. goodread no longer carries a signed-in session into a blocked page. A challenge is reported and the run exits 4, and the route around a block is a different surface rather than a harder request.

quote is now quotes, and takes --author for an author's page.

crawl --parse is gone. A crawl parses what it reads, because a crawl that stores bytes it has not understood is a crawl that has to be run twice.

search --html is now search --deep, and needs --no-robots.

The record shape changed throughout. Anything built on v0.2.0 JSON needs revisiting. The envelope on every record says which surfaces and which ladder rungs produced it, which is the thing to key on going forward.

New commands

work, editions, lookup, find, query, graph, export, mcp, robots, extraction and verify.

Install

go install github.com/tamnd/goodread-cli/cmd/goodread@latest
brew install --cask tamnd/tap/goodread
docker run --rm ghcr.io/tamnd/goodread:0.3.0 book 2767052

Prebuilt archives for Linux, macOS, Windows and FreeBSD, Linux packages, SBOMs and cosign-signed checksums are on the release page. The binary is pure Go with no runtime dependencies.