Skip to content

M2: read __NEXT_DATA__ and the Apollo cache instead of the rendered page #10

Closed

Description

@tamnd

Every Goodreads page ships its whole Apollo GraphQL normalised cache inline, 129 typed entities on the measured book page.
parse.go is 1,033 lines of CSS selectors reading the rendered output of that same data, and it misses fields that are never rendered at all: ratingsCountDist, textReviewsLanguageCounts, places, characters.

Spec: ~/notes/Spec/3008/02_extraction.md.

Two things that look like details and are not.
Cache keys split on the first colon only, because the id contains ://.
Field keys carry arguments, description({"stripped":true}) and quotes({"pagination":{"limit":1}}), so they get parsed rather than string matched, and the limit read out of the key is what generates an honest missed sentence.

Level 2 gets built and tested even while level 1 answers everything, because it is a cross check today and the thing that survives a framework removal tomorrow.

Checklist

  • nextdata.go, matched on the id attribute rather than on position
  • apollo.go with ParseFieldKey, name up to the first paren then the argument JSON
  • cache key split on the first colon only, since ids contain ://
  • __ref resolution, 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 selectorFields with a Since field, never inlined
  • goodread extraction prints the ladder report
  • Extractor.set records the level for every field, and a lint test fails on direct assignment
  • Extra map[string]json.RawMessage on every entity, unknown fields kept and counted. Moved to M3, since there are no typed entities to hang it off until types.go is rewritten. Landed in M3: the v0.3.0 record model #20
  • goodread verify --sample N reports unknown and missing fields. Moved to M3 for the same reason. goodread verify over the pinned captures is in. Landed in M3: the v0.3.0 record model #20
  • goodread verify --strict in CI against pinned captures
  • testdata/pages/ with capture.txt as the ledger, and TestCaptureLedger
  • captures stored gzipped and opened through gzip.Reader
  • captures, seven of the eleven: two book pages, prolific author, work editions, work quotes, list, genre. The 404 one cannot be captured, a missing book id returns 202 with an empty body and x-amzn-waf-action: challenge. The remaining three are single rating, no ISBN and fractional series position, and they wait on finding real examples
  • lazy decode, map[string]json.RawMessage first, entities on demand

Part of #8.

Activity

  1. added this to the v0.3.0 milestone on Aug 15, 2026
  2. added
    v0.3.0The v0.3.0 rewrite
    extraction__NEXT_DATA__, Apollo cache, the extraction ladder
    on Aug 15, 2026
  3. tamnd commented on Aug 15, 2026

    @tamnd
    OwnerAuthor

    Done, in #19. Two items moved to M3 and one cannot be done, all marked above.

    The spec was wrong about the ladder, and it matters.

    Only the book page is a Next.js route. Author, series, list, genre, editions and quotes are still Rails templates with no Apollo cache, and four of them carry no og: tags either. So the ladder is per surface, not universal, and for those surfaces level 3 is not debt to be driven to zero, it is the only source there is. goodread extraction says that rather than printing a zero that reads as good news. TestNoNextDataOnRailsPages pins it, so if Goodreads does move them we find out from a failing test and go and delete selectors.

    Cache key ids come in three shapes, not one.

    The spec said kca:// uris. The measured page has 69 of those, 29 bare integers on User, and 30 whole JSON objects on Shelving, and those objects carry kca:// uris several levels down. Splitting on the first colon handles all three, but the test now asserts the round trip rather than the prefix, because that is the invariant that actually holds.

    You cannot pick the entity by type.

    The measured page has three Book entities and two are stubs holding nothing but __typename and id, because something on the page referenced them. Apollo.Root goes through ROOT_QUERY, which names the query the page actually ran.

    On the visited set.

    It unmarks on the way out. Only an ancestor being revisited is a cycle. Two fields pointing at the same author is a shared author, and a resolver that keeps the mark drops the second one silently.

    The 404 capture.

    A book page for a missing id returns 202 with an empty body and x-amzn-waf-action: challenge, not a 404. There is nothing to capture. The client already reports that as blocked rather than trying to solve it, which is where I want it to stay.

    Numbers.

    Freshly fetched live page: 32 fields at level 1, one at level 2, none on selectors. The ratings distribution sums to the published count and its weighted mean matches the published average to two decimals, which is the check that catches a reversed slice.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    extraction__NEXT_DATA__, Apollo cache, the extraction ladderv0.3.0The v0.3.0 rewrite

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions