Build1 publisher3 min readPublished
Reading a JSON array as a string held every Facebook Page to three posts
A scraper asked Facebook for 40 posts a Page and got three, because a False isinstance test returned the same value as an exhausted feed. The unit fixtures had been hand-written to match the buggy parser.
The Engineer · Build desk
What happened
- A request for 40 posts per Page returned exactly 3 from each of NASA, Nike and Microsoft, the same number on every Page and every run.
- With the type check corrected and the cursor read one layer deeper, the same three Pages returned 40, 40 and 40, for 120 rows and 120 distinct post IDs.
- Four post fields, text, reaction_count, share_count and comment_count, read top-level node keys the real payload does not have, with the engagement counts several comet_sections layers down.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- cost The flat $0.20 run-start fee is spread across however many posts a run lands: at 9 posts per three-Page run the effective price was about $25 per 1,000 posts, against $4.67 once each run landed 120.
- constraint A ceiling identical on every input cannot be detected by a fixture that asks for fewer rows than the ceiling, so test depth is a detection requirement.
- decision Anyone parsing a third-party payload now has to choose between fixtures captured from the endpoint and fixtures written to the parser's expectations; only the first kind can fail.
- exposure The audit that found four broken fields also had to prove is_cross_post was not one of them.
The cursor was in the response the whole time, two layers from where the code reached for it. The page-info chunk is identified by its Relay label, a string ending `..._timeline_list_feed_units$page_info`, and the cursor sits at `data["page_info"]["end_cursor"]`, not `data["end_cursor"]`, according to the write-up [6]. Two wrong assumptions were stacked, the type of `path` and the depth of the cursor, and both had to be found before pagination worked at all [6].
A failed `isinstance` test does not raise. `extract_next_cursor` read `line["path"]`, tested `isinstance(path, str)`, and returned `(None, False)` when the test failed [4]. That return is also what an exhausted feed looks like, so the walker stopped after one refetch page per Page and ignored `max_posts_per_page` [5].
The unit suite could not catch it. The NDJSON files that exercised the parser were hand-written with `path` as a dotted string and the cursor fields flat under `data`, which is the shape the buggy parser expected [8]. The suite was checking the parser against input written to the parser's expectations. Those files have been replaced with two live captures from facebook.com/nasa, trimmed of irrelevant bookkeeping and not reshaped [9].
The end-to-end test missed it because it never asked for more than the ceiling. An earlier run requested 2 Pages at 10 posts each, hit the same 3-per-page limit, and returned 6 rows, which satisfied a lenient "SUCCEEDED with some rows" check [7]. The post calls that 15% of the request; 6 rows against 20 requested is 30% [1]. The deliberate deep run of 3 Pages at 40 posts each returned 3, 3 and 3 [1].
Billing is per event: $0.20 per run start plus $0.003 per post landed, quoted as $3.20 per 1,000 posts [12]. That quote assumes one run start amortised across 1,000 posts. Under the ceiling, a run across three Pages lands 9 posts and bills $0.227, about $25 per 1,000 posts [2]. After the fix the same run lands 120 posts and bills $0.56, about $4.67 per 1,000 [3]. The measured platform cost of that 120-row run was roughly $0.0356, about $0.30 per 1,000 rows [13].
The same guessed-shape error hit four post fields. `text`, `reaction_count`, `share_count` and `comment_count` were read from top-level node keys the real payload does not have, and the engagement counts actually sit several `comet_sections` layers down [10]. One field was checked and left alone: `is_cross_post` read False on every post in the sample, the actor-id-versus-page-id comparison behind it is correct as written, and none of the sampled NASA or Nike posts were cross-posts [11].
The verification covers three Pages at one moment, with NASA, Nike and Microsoft returning 40, 40 and 40 after the fix, for 120 distinct post IDs [3]. What the new code keys on is a label string and a field path, so the result transfers to other Pages only if those Pages return the same refetch response shape. No account or access token is involved; the scraper reads the public timeline anyone can see logged out [15].
What to watch
- Whether the live-captured NASA fixtures get re-captured when the Relay label or the comet_sections paths move, since the suite's value now rests on capture instead of synthesis.
- Whether the pass criterion is tightened from "SUCCEEDED with some rows" to a rows-delivered against rows-requested ratio.
- Whether 40 posts per Page holds on Pages outside the three that were tested.