Repository navigation
Description
v0.3.0 spec is written and lives in ~/notes/Spec/3008, seven documents.
This issue is the checklist for the whole version.
Each milestone below gets its own issue and its own PR, and this list gets ticked as they land.
Why this version exists
v0.2.0 builds three URLs that robots.txt disallows: /search, /review/list/ and /review/list_rss/.
It does not fetch robots.txt at all, so it is not ignoring the rules, it does not know they exist.
That is the defect. The tool has no opinion when it should have a default and a way to override it.
v0.3.0 fetches, parses and obeys robots.txt by default, and --no-robots turns that off for somebody who has decided it is their call.
Nothing is removed. Three commands need the flag.
The second half of the version is extraction.
Every Goodreads page ships its whole Apollo GraphQL cache inline in __NEXT_DATA__, 129 typed entities on a book page, including fields that are never rendered: ratingsCountDist, textReviewsLanguageCounts, places, characters.
parse.go is 1,033 lines of CSS selectors reading the rendered output of that same data.
M1 robots
-
goodread/robots.gowith a parser for theUser-agent: *group - longest match wins,
AllowbeatsDisallowat equal length, soAllow: /work/editionsoverridesDisallow: /work - matching runs on path plus query, because
Disallow: /*reviewFiltersonly ever matches a query -
$end anchoring. Correction: not in the live file, only the*in/*reviewFiltersis. Supported anyway - fetched on first use, cached one hour, refetched after
- a fetch failure is exit 8 and nothing is attempted, with no compiled in fallback copy
-
Opsregistry insurfaces.go, every readable path registered, nothing builds a URL inline -
--no-robotsas a persistent flag, read from the flag only - one warning per process on stderr, never on stdout
- exit 7 when a read needs the flag, with a message naming the rule, the flag and the allowed alternative
- pace floor of 1s enforced in the client, clamped before it reaches the limiter
-
goodread robotsprints the rules and which surfaces they permit -
goodread robots check <url>answers for one URL and prints the matching rule -
TestNoDisallowedByDefault -
TestNoRobotsNotInConfig -
TestNoRobotsNotInEnv -
TestNoRobotsNotImplied -
TestPaceFloorHolds, timing twenty real requests against a local server with the override on -
TestWorkExceptions -
TestNoConcurrency -
cookies.godeleted andTestNoCookieHeader -
TestNoBrowserDependencyreadinggo.mod -
TestNoChallengeHandling -
/book/auto_completeregistered as s14, which is whygoodread searchstill works with no flag
M2 extraction
-
nextdata.go, matched on the id attribute rather than on position -
apollo.gowithParseFieldKey, name up to the first paren then the argument JSON - cache key split on the first colon only, since ids contain
:// -
__refresolution, depth limited, visited set, cycles resolve to a stub - unresolved refs become
Ref{Resolved: false}rather than an error - level 2, JSON-LD and
og:, implemented and tested even while level 1 answers everything - a test asserting level 1 and level 2 agree on title, author, image, ISBN, rating value and rating count
- level 3 selectors registered in
selectorFieldswith aSincefield, never inlined -
goodread extractionprints the ladder report -
Extractor.setrecords the level for every field, and a lint test fails on direct assignment -
Extra map[string]json.RawMessageon every entity, unknown fields kept and counted. Moved to M3, nothing to hang it off untiltypes.gois rewritten. Landed in M3: the v0.3.0 record model #20 -
goodread verify --sample Nreports unknown and missing fields. Moved to M3, same reason. Landed in M3: the v0.3.0 record model #20 -
goodread verify --strictin CI against pinned captures -
testdata/pages/withcapture.txtas the ledger, andTestCaptureLedger - captures stored gzipped and opened through
gzip.Reader - seven of the eleven captures from the spec. The 404 one cannot be captured, a missing id returns 202 with a WAF challenge and an empty body. Three wait on finding real examples: single rating, no ISBN, fractional series position. Original list, each chosen for a reason: popular book, single rating, no ISBN, fractional series position, multiple contributors, translated edition, prolific author, work editions, work quotes, list and genre, 404
- lazy decode,
map[string]json.RawMessagefirst, entities on demand
M3 model
-
EnvelopewithSurfaces,Sources,RetrievedAt,BuildID,Via,Level,Missed -
Missedgenerated from the page, never hardcoded, naming the command that would fix it -
Bookas an edition, with both descriptions kept,Linksas raw JSON -
PublicationTimeas a pointer from epoch milliseconds, negatives accepted -
NumPagesas a pointer -
WorkwithPlaces,Characters,AwardsWonas first class nodes -
StatswithRatingsCountDistdocumented as 1..5 in order, andTextReviewsByLanguage -
Stats.Via, so an edition population is never mistaken for a work population -
goodread book <id> --checkreconciles the histogram against the count and the mean against the average -
Contributor.Roletaken from the edge, not the node -
SeriesEntry.Positionraw plusNumberparsed, so 2.5 and 1-3 both survive -
ConnwithLoaded,TotalCountandComplete, whereCompleteis notomitemptyLoadedis always 0 today. The only connection a book page carries is the work's editions, and it carries the URL and no editions
- the four states of a missing field, with a test per row
- depths quick, meta, full, deep, where quick and meta are the same request
fullanddeepparse and cost correctly and read no further surfaces yet, so either one says so inmissedrather than quietly doing ametaread
M4 commands
- every read command on the new extractor
-
--depth,--json,--no-cache,-v,-vvon all of them -
--jsondefaults on when stdout is not a TTY - one id parser, taking a bare id, a full URL with a slug suffix, a URL with a query tail, or a
gr:URI - human output with the rating histogram, which is not on the rendered page
- the
not readblock printed frommissed -
goodread search,shelf,review,reviewsbehind--no-robots -
goodread reviewsprints a cost estimate fromtext_reviews_countand requires--yes -
goodread lookupandgoodread findas the allowed routes - exit codes, with 7 and 8 kept separate
- config file with a flag for every key, and no
no_robotskey -
pacebelow the floor is clamped and says so - honest default user agent naming the tool, version and repo
- the v0.2.0 command suite green with
--no-robotsset
M5 store
- SQLite, tables per node type plus
edges - full record in
json, columns as an index over it -
isbn13andasinindexed - FTS5 over title, description and author name
- merge on upsert, field by field, newest non absent wins
- a test that a
quickread after afullread does not delete the editions list -
goodread find,goodread lookup,goodread query
M6 graph
- both id spaces kept, legacy and KCA, neither derived from the other
-
gr:URI space, built from the legacy id -
slug()specified once and tested, with a note that changing it is a breaking change - the twelve edges, each carrying its surface and timestamp
-
contributed_bycarries the role,in_seriescarries the position - traversal with
--depth, defaulting to 1, asking for--yesabove 500 nodes - JSONL export
- RDF export, schema.org where it fits and
grv:where it does not -
schema:exampleOfWorkfor the book to work edge
M7 crawl
- seeds as URIs, repeatable, plus
--seed-fileand--from-sitemap - refuses to start with
--no-robotsunless--yesis also given - single connection, 2s pace, no parallelism flag
- resumable, state in the store
-
--dry-runprints the plan and the request count and reads nothing - progress output with counts per type, requests, cached, errors, elapsed
M8 mcp
-
goodread mcpover stdio - the eleven read tools, argument names matching the flags
- no
search, noshelf, noreviews, and--no-robotshas no effect on the server -
TestMCPHasNoDisallowedTools - startup and
--helpboth say why
M9 release
- README rewritten, one sentence per line
- everything under
docs/rewritten the same way - goreleaser
dockers_v2publishingghcr.io/tamnd/goodread, tagged0.3.0and:latest - the container image actually pulled and run before the issue closes
-
govulncheckagainst the tag, not only against the branch - handwritten release notes with the generated changelog below them
-
v0.3.0tagged
Blocked
- homebrew and scoop publishing, waiting on
HOMEBREW_TAP_GITHUB_TOKENandSCOOP_BUCKET_GITHUB_TOKENas repo secrets
Acceptance
- with default settings, no request the tool can construct is disallowed by the live
robots.txt, proven by a test walking every registered op against a captured copy and a fetched one - with
--no-robots, every v0.2.0 command works -
--no-robotscannot be set by config, by env, or by another flag - the rate floor holds with
--no-robotsset - a record fetched from a disallowed path carries
robots.allowed: falseand the rule that matched it -
cookies.gois gone and no code path sends aCookieheader - level 3 field count is zero, or the release notes say what is left and why
Activity
tamnd commentedon Aug 15, 2026
Milestone issues are open and linked to the v0.3.0 milestone.
--no-robotsoverride__NEXT_DATA__and the Apollo cacheOrder is deliberate. M1 first means the defect that motivates this version is fixed in the first shippable increment, and everything after it is built on a client that cannot construct a disallowed request without being told to.
Starting on M1 now.
tamnd commentedon Aug 15, 2026
M1 is done and up as #18. Ticked above.
Two notes worth carrying into the rest of the milestones.
Where the check lives is the load bearing decision.
It is in
Client.Fetch, notClient.Do.Dowould only cover call sites that had been ported to the op registry, and the defect this version exists for was that three call sites built their own URLs and nothing sat between them and the network. So M2 through M8 can port call sites toDoat whatever pace suits them without leaving a gap while they do it.The spec was wrong about search, and the correction is good news.
/book/auto_complete?format=json&q=is not disallowed, is public JSON, needs no key, and returnsbookId,workId,title,numPages,avgRating,ratingsCountand the author per hit. Sogoodread searchneeds no flag, and it returns more than the scraped version did.workIdon every hit is the one that matters downstream: it makeseditionsandquotesreachable without a second read, which changes what M7 crawl can do cheaply. Registered as s14.~/notes/Spec/3008/01_surfaces.md,00_overview.mdand05_commands.mdare corrected.Starting M2, #10, extraction.
M1: obey robots.txt on every request, add --no-robots (#18)
feat(extraction): read the Apollo cache the page already ships (#19)
tamnd commentedon Aug 15, 2026
M3 is in, merged as #20.
The record model, the mapper from the extractor onto it,
goodread book <id> --checkandgoodread verify --sample N.The v0.2.0 types moved out of the way rather than being ported.
Book,ReviewandUserintypes.goare nowScrapedBook,ScrapedReviewandScrapedUser, and they go when M4 puts the commands on the extractor.Doing the port in the same PR would have been M4 done early and badly.
Two things ticked with a note instead of silently.
Conn.Loadedis always 0, because the only connection the book page carries is the work's editions and it carries the URL and nothing else.fullanddeepparse and cost correctly but read no further surfaces yet, so either one says so inmissed.--checkagainst the live site, both books exact on the sum and matching to two decimals on the mean:M4 next: every read command onto the extractor, which is also what fills in
Loaded,fullanddeep.tamnd commentedon Aug 15, 2026
Walked the acceptance list against the code rather than against memory.
Five of the seven were already held by named tests.
Two were not, and both are now merged.
Held already, and by what:
TestNoDisallowedByDefault(goodread/robots_test.go) walks every op in the registry and checks both directions, so an op marked allowed that the rules refuse fails and an op marked disallowed that the rules permit fails too.TestEverySurfaceHasSampleArgsstops it passing on an empty path, which every rule set permits.TestNoV020CommandWasRemoved(cli/policy_test.go) checks the v0.2.0 list by name and by alias, so thequotetoquotesrename reads as a rename and not as a removal.--no-robotsnot reachable except by typing it:TestNoRobotsNotInConfigFile,TestNoRobotsNotInEnvandTestNoRobotsNotImplied.TestPaceFloorHoldssetsNoRobotsand times five real requests, andTestBurstIsOneandTestNoConcurrencyclose the two routes around a floor.Cookieheader:TestCookiesFlagIsGoneplus a grep that comes back empty.goodread extractionall say which six surfaces are Rails templates and why a selector is the only rung there.Not held, now merged:
#30 implements the robots note. A grep for
robots_allowed,RobotsAllowedandrobots.allowedcame back empty acrossgoodread/andcli/, so this line was simply unimplemented. A record built from a disallowed page now carries arobotsblock withallowed: false, the path, the rule that matched it and which robots.txt said so. A record off an allowed surface carries nothing, so the absence means as much as the presence. Reviews carry it on the row, because a review has no envelope andreviews --allmerges the book page's sample with the walked pages into one list.Writing the test for it turned up a real bug. The v0.3.0 record fetchers built URLs on the hardcoded
BaseURL, so a test that pointed the client at a local server still fetched goodreads.com for real. The first run did exactly that, and the second came back with a WAF challenge, which is how it was noticed. They go through the client's base now.#31 adds the fetched half of the first line. The offline test reads the capture, and the capture is the thing that goes stale: Goodreads can add a
Disallowtomorrow and every offline test here keeps passing while the tool reads a page it is no longer allowed to read. The new test walks every op against the live file, and also compares the capture against it per op so a drift reads as "recapture it". One request at the default pace, skipped under-short, and it skips rather than fails when the site is unreachable.Run against the live file just now: every op agrees and the capture is current.
So the acceptance list is complete. What is left on the version is the outward-facing half in #17, and that waits on a go-ahead.