Repository navigation
Audit fixes: a run that fetched nothing no longer reports "0 jobs" - #6
Merged
Merged
Conversation
From a third-party audit dated 2026-10-13. Four of its findings were real
and are fixed here; its headline live result was an artefact of its own
command, and that artefact is fixed too, because it is what made the
misreading possible.
THE ONE THAT MATTERS (the audit's P1, confirmed)
A page the scraper never read reported exit 4, "zero jobs". The chain was
four links long and each looked right alone: state `unknown` ->
should_parse False -> an outcome still marked `ok` (it consulted only
`load_failed` and `blocked_by`) -> stop_reason left at "completed" ->
zero rows -> exit 4. `finish_run` has had the exit-5 branch since the
family unified on it (CLAUDE.md §25) and nothing ever reached it, because
nothing told it the run had failed.
`page_flow.STATE_POLICY` now carries `obtained` per state, and
`stop_reason_for()` is one implementation of a triage the engines were
deriving in SEVEN places between them. `unknown` and `parse_error` exit 5.
`empty` stays exit 4 — the site served a listing and it matched nothing,
which is an answer. So does `not_found`: a 404 is the site saying
definitively that the address holds nothing, and a consumer can conclude
something about the catalogue from it. (I had it in the failure bucket
first; that was wrong and is corrected here rather than shipped.)
THE AUDIT'S OWN COMMAND
It ran `--retries 0`. The load loop is `range(1, retries + 1)`, so zero
never entered it: `goto` was not called, and the engine classified
`about:blank` — 39 bytes, no markers, no assets. Reproduced exactly on
the audited commit. The SAME command with `--retries 1` returns the
site's real refusal: 11,922 bytes, `blocked`, exit 3. With a residential
exit: 115 rows over three pages, exit 0, on 2026-10-13.
So the site had not stopped serving this scraper, and the parser was not
stale. But a flag that silently turns a run into a different run is how a
competent reader reached the opposite conclusion, so `--retries` is now
`page_flow.attempts`: rejected at parse time with a reason, not clamped.
This is CLAUDE.md §27.9's family-wide defect, which lists this repo.
ALSO FIXED
* `--category` in the Scraper API adapter set `row.category` on a
dataclass with no such field — a dynamic attribute `asdict()` never
sees, so the flag did nothing. It is a RUN label and now goes to the
sidecar, which is where the browser engines put it. The adapter also
writes a sidecar at all now, via finish_run.
* The same adapter returned exit 4 for a page the API SERVED that parsed
to nothing. That is this repo's bug, not an empty catalogue (§20), and
it now reports a non-complete stop reason.
* Donor residue the audit did not reach: the adapter's description and
`--mode` help still described bbb-scraper's `profile` mode, which does
not exist here.
* Writes were not atomic — `open(path, "w")` truncates before a byte is
written, so an interrupted run left a SHORTER file where a complete one
was. Worst for `<out>.meta.json`, the file a consumer branches on.
Ported from rakuten-scraper WITH the mode fix it lacks: a naive
NamedTemporaryFile leaves every output 0600. Controlled: a 37,872-byte
good file survives an interrupted write intact, mode 0664, no temp
files left.
* Python 3.13 is in the CI matrix; the badge claimed 3.9–3.13 and CI
tested 3.9 and 3.12.
ROBOTS: AGREED ON THE GAP, NOT ON THE REMEDY
The audit is right that "reads only routes robots.txt allows" was a claim
about behaviour with no mechanism — the word appeared in comments and
nowhere in the code. It is not a runtime gate here, and that is argued
rather than deferred: a preflight fetch is itself 403 from a datacentre
address, so the gate would fail closed exactly where the scraper already
fails, or fail open, which is a gate that does not gate.
What makes the claim true is a property of the code, so it is tested as
one: every route the parser ACCEPTS is allowed, every route the published
rules disallow has no mode, checked against the live file (fetched
2026-10-13 through a residential exit, inlined per §10). The docs now say
what is actually guaranteed.
Every fix controlled in both directions with __pycache__ cleared (§22): the
planted fault turns the suite red on the named check, restoring makes it
green. 435 checks. All three engines re-run live afterwards — 52 identical
rows each, exit 0 — and the failure paths re-verified: a real block is still
exit 3, `--retries 0` is exit 2.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014iC5Xk2LeBPv5TRBXJAU8M
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iC5Xk2LeBPv5TRBXJAU8M
The audit's fifth real finding, and the one that made the other four harder
to diagnose: "тело ответа не было сохранено для последующего анализа".
The blocked path has always written a debug dump. The content-not-obtained
path — `unknown`, `parse_error` — wrote nothing at all, so a failing run
left no artefact and the only evidence of WHY was a log line. That is half
of why the auditor read a never-fetched `about:blank` as the site refusing
us: there was nothing to look at.
All three engines now write `<out>_page{N}_debug.html` on that branch and
log the byte count and the own-asset reference count beside it, which is
what separates "the site served something we could not read" from "nothing
arrived" at a glance.
Controlled: removing the dump from one engine turns the suite red on the
named check; restoring it goes green. 444 checks. All three engines re-run
live — 52 identical rows each, exit 0 — and a blocked run still exits 3 and
writes its dump.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014iC5Xk2LeBPv5TRBXJAU8M
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
From a third-party audit dated 2026-10-13. Four of its findings were real and are fixed here. Its headline live result was an artefact of its own command — and that artefact is fixed too, because it is what made the misreading possible.
The one that matters (the audit's P1 — confirmed)
A page the scraper never read reported exit 4, "zero jobs". The chain was four links long and each link looked right on its own:
finish_runhas had the exit-5 branch since the family unified on it, and nothing ever reached it, because nothing told it the run had failed. An ETL trusting the exit code would conclude the catalogue had emptied.page_flow.STATE_POLICYnow carriesobtainedper state, andstop_reason_for()is one implementation of a triage the three engines were deriving in seven places between them.contentemptynot_foundunknownparse_errorblockednot_foundwas in the failure bucket in my first pass. That was wrong — corrected here rather than shipped.The audit's own command
It ran
--retries 0. The loop isrange(1, retries + 1), so zero never entered it:gotowas not called and the engine classifiedabout:blank— 39 bytes, no markers, no assets. Reproduced exactly on the audited commit, then one variable changed:--retries 0unknown, exit 4--retries 1blocked, exit 3 — the site's real refusal, correctly classified--retries 1+ residential exit, 3 pagesSo the site had not stopped serving this scraper and the parser was not stale. But a flag that silently turns a run into a different run is how a competent reader reached the opposite conclusion, so it is rejected at parse time rather than clamped.
Also fixed
--categorydid nothing in the Scraper API adapter — it setrow.categoryon a dataclass with no such field, a dynamic attributeasdict()never sees. It is a run label and now goes to the sidecar; the adapter writes one at all now.--modehelp still described bbb-scraper'sprofilemode, which does not exist here.open(path, "w")truncates before a byte is written. Worst for<out>.meta.json, the file a consumer branches on. Ported with the mode fix the donor lacks — a naiveNamedTemporaryFileleaves every output0600. Controlled: a 37,872-byte good file survives an interrupted write intact, mode 0664, no temp files left.Robots: agreed on the gap, not on the remedy
The audit is right that "reads only routes robots.txt allows" was a claim about behaviour with no mechanism — the word appeared in comments and nowhere in the code.
It is not a runtime gate here, and that is argued rather than deferred: a preflight fetch of
robots.txtis itself 403 from a datacentre address, like every route on this site. The gate would either fail closed exactly where the scraper already fails — a second failure mode for no safety gained — or fail open, which is a gate that does not gate.What makes the claim true is a property of the code, so it is tested as one: every route the parser accepts is allowed, every route the published rules disallow (
/u/,/_jobs/,/search,/jobs/signup,/auth/) has no mode. Checked against the live file, fetched through a residential exit and inlined. The docs now say what is guaranteed.Verification
Every fix controlled in both directions with
__pycache__cleared: the planted fault turns the suite red on the named check, restoring makes it green. 435 checks.All three engines re-run live afterwards — 52 identical rows each, exit 0 — and the failure paths re-verified: a real block is still exit 3,
--retries 0is exit 2.Not changed, with reasons
🤖 Generated with Claude Code