Repository navigation
Behaviour change:
--retries 0is now a usage error (exit 2). It used
to be accepted and made the page-load loop unreachable, so nothing was
fetched and the engine reported the browser's own blank page as a result:
exit 3 or 4 with a 39-byte "capture", in tens of milliseconds. If a
script of yours passes 0, pass 1: the flag counts ATTEMPTS, so 1 means
"try once and do not retry".
Behaviour change for existing users: a page BBB serves that cannot be
read now fails the run instead of ending it. A run that used to finish
completewith exit 0 on such a page now finishespartialwith exit 6
(rows were gathered) or exit 5 (none were), and its sidecar names the page
inpages_failedwithstop_reason: "unreadable_response". A pipeline
that branched on exit 0 was reading an unread page as the end of a listing.
Behaviour change: a URL BBB answers with its own 404 page now stops at
once withstop_reason: "not_found"(exit 5 when nothing was gathered).
It used to wait 30 s, re-fetch, and end asunreadable_response.
Behaviour change: negative values for
--pages,--delay,
--retry-delay,--proxy-block-retriesand--concurrency(and 0 for
--pagesand--concurrency) are now usage errors (exit 2) in every
engine, and so is a negative--retries/--retry-delayin
scraper_api_client.py.
Behaviour change: CSV cells that begin with
=,+,-,@, a tab
or a line break are now prefixed with an apostrophe so a spreadsheet does
not run them as formulas. The JSON output is unchanged; the sidecar says
how many cells differ incsv_cells_escaped. Output files are now written
through a temporary file and renamed, keeping the modeopen()would give
a new file and the existing mode of a file being replaced.
Correction to v1.0.1. Its README billed the Managed Challenge solve to
the wrong product. It read "Captcha solving (--twocaptcha-key) — for
the Managed Challenge only" with a table marking that challenge
solvable. The challenge is indeed solvable, but not by anything a
--twocaptcha-keybuys here:captcha_solver.pybuildsRecaptchaV2Task,
RecaptchaV2TaskProxylessandRecaptchaV3TaskProxylessand no Turnstile
task at all. What actually clears it isCaptcha.setAutoSolveover
--cdp-endpoint, which is the Scraping Browser API — a separately billed
product. A reader following the old README would have set a key, met the
challenge anyway, and had no way to tell why.
Fixed
-
The Selenium and pyppeteer engines crashed on a block with no proxy.
With no--proxy, a blocked page (and, in pyppeteer, a dead exit) reached
pool.current/pool.advanceon None: a traceback, exit 1, and every row
from earlier pages lost. Both now re-fetch on the same path, as the
Playwright engine always did, and report exit 3. -
An interrupted write destroyed the previous good output.
out.json,
out.csvandout.meta.jsonwere truncated before being written, so a
failure halfway left a short or empty file. They are now written to a
temporary file beside the target, synced and renamed over it. -
The HTTP status never reached the classifier. Playwright and
pyppeteer now pass the response status, so BBB's 404 is terminal and a
marker-less 403 is a block. Selenium has no status to pass; BBB's own 404
title is recognised in the body for it. -
A
BBB_URLfrom.envblocked--text/--location. A URL from the
environment is now a default that the query flags replace; a TYPED
--urlwith query flags is still refused..env.exampleshipsBBB_URL
commented out. -
The credential scan judged exclusions on the absolute path, so a
clone under a directory namedvenvscanned 0 files and reported clean.
Exclusions are now relative to the repository, a scan of 0 files fails,
and.env*copies plus.json,.csvand.htmlfiles are read too. -
Dump and capture files were not ignored:
X.page3from
--dump-html, a raw.htmlat the repository root, andlive/,
out/-style working directories. -
The profile canary went green without testing anything. With no
BBB_CDP_ENDPOINTsecret it skipped with a notice, and with one it
passed on exit 3 or 5. It now fails with an error naming the missing
secret, and every non-zero exit or empty result is red. The listing
canary needs no secret and stays scheduled; it now also fails explicitly
on zero rows. -
--solve-captchahelp, a docstring and the README's exit-code table
described refusals and exit 5 inaccurately; corrected. Comments no longer
point at documents a reader of this repository cannot open. -
--retries 0fetched nothing and called it a result. The load loop is
range(1, retries + 1), so 0 never runs it and the engine judged
about:blank. Three third-party audits of this family ran exactly that and
reported a working scraper as broken.--retriesnow goes through
page_flow.attemptsin every entry point; a check reads the source of each
one and calls the validator, so a new engine declaring the flag with a
plain int fails the suite. -
A served page that could not be read was taken for the end of the
listing.unknown(BBB's page, neither the listing payload nor its own
empty-result marker) left the page outcomeokwith no rows, so the loop
read "added nothing new" as the end of the results. Reproduced with the
real fetch and finish code and only the browser replaced: page 1 good,
page 2 with the row container renamed, page 3 good gavecomplete,
no_new_products, exit 0, page 3 never requested. The same state on page 1
was worse: pages 2 and 3 were still fetched and the run reportedcomplete
over 14 rows instead of 21, the first page lost without a trace. An
unreadable page is now a failed page in all three engines. A CONFIRMED
empty result set (totalResults: 0) is unchanged: it is still a finished
page and still ends a run as complete. -
The captcha budget was advisory.
handle_captcha_if_presentruns once
before a page is classified and once after, and only the second call was
counted, while the first repeats on every block-retry: three solver calls
againstSOLVES_PER_PAGE = 1, reproduced with the solver stubbed and no
proxy pool. The allowance is now onepage_flow.SolveBudgetper page,
spent at the call to the solver itself in all three engines (counting calls
would have spent it on a call that found no captcha). It does not reset on
rotation. There is deliberately no run-wide cap on top of it: a run of N
pages that each need a solve has bought what it needed. -
diff_runs.pycompared two different queries. The sort guard caught two
orderings of one query and nothing caught two queries, sorestaurantsin
New York againstrestaurantsin Chicago, both a-z, both complete, was
accepted and every line of the diff was the change of city. The scope is
now read from each sidecar'sfinal_url(query text, location,--state,
category; page, sort, host andwww.ignored) and a mismatch is refused,
with--forceas before. A run the user cut at--pages 3is refused
against one that read 15 for the same reason; a listing that merely ran
out of pages is not.diff_runs.pyhad no behavioural check at all
(CI ran--help), so its sort and completeness guards are pinned too. -
The Scraper API path (
scraper_api_client.py) failed whenever a wait flag
was given, and never saw the target's status. Measured 2026-09-23 against
the live/tasks/syncendpoint:waitForsent as a JSON-encoded string
(what this client sent) is answered HTTP 422 "params.waitFor must be an
object" and is still billed ($0.0005); sent as an object it is answered
HTTP 200. It is now an object. And the response'sstatusis the API's own
verdict ("success"), while the target site's HTTP code ishttp_code— the
client handedstatusonward, so a target 403/503 never reached the page
classifier. It now readshttp_code, falling back tostatusonly if that
is an integer. After the fix, one live call (--mode profile --wait-text BBB, the README's profile URL, no--cdp-url) answered HTTP 200, upstream 200, 1 business row. -
Issue templates described Etsy, not BBB. The bug-report hints talked
about DataDome,t=bv,shop_ratingand exit-IP storefronts, the example
command scraped an Etsy search, and the site-change template pointed at a
JSON-LD parser this repo does not have. Rewritten from this repo's README:
the Cloudflare-gated profile mode, the 15-pages-of-15 cap, unrated
businesses written as null, and the__PRELOADED_STATE__anchors. The
bug-report and question templates no longer link aTROUBLESHOOTING.md
this repo never had. -
--retrieshelp said an empty "hub category" is a correct answer in
all three engines. BBB has no hub categories; the empty case here is a
query that matched nothing, and the help now says so. The matching engine
comments were corrected the same way. -
Comments that described another site's pages as this one's: the
akamaihd.nethub-page incident is now attributed to the sibling
tokopedia-scraper where it happened, the pyppeteer engine's single-page
note no longer talks about a--mode shopthis repo does not have, and
captcha_solver.pyno longer points at a "No DataDome solver" section
that does not exist. -
SECURITY.mdsaid this project has no releases or tags; it has both. -
The access badge's alt text now says what its label already did: the
listing modes run without an account (profile mode needs a residential
exit). -
The README now names the product that clears each refusal, and says in
those words that this repo does not implementTurnstileTaskProxyless
— nor the init script that capturessitekey,action,cDataand
chlPageDatafrom Cloudflare's single call toturnstile.render(), which
is the only way to obtain them because they appear nowhere in the served
HTML. That is a gap in this repo, not in 2Captcha, which solves Turnstile;
foodpanda-scraperin this family implements the interception. -
Why it is not implemented is now stated with the measurement behind it:
the listing path is not behind Cloudflare at all, and the profile path —
which is — already needs a Scraping Browser session to be reachable
(measured 2026-09-16: the same profile URL returned 403 direct and 200 in
full through one). On that path the auto-solve clears the challenge before
a local solver would get a turn. -
Fifteen lines of unreachable code removed from
playwright_scraper.py.
A function'sdefline had been lost before this repo's first commit,
leaving its docstring and itstry: return page.content()body indented
into the end of_mask_credentials, where the control flow can never
arrive. Nothing called it —_content_when_settledbelow it does the job.
The same fifteen lines, byte for byte, were in six repos of this family. -
Five dead helpers removed from the engines, two of them calling a
page_flowAPI that does not exist in this repo:page_flow.comparable,
page_flow.next_page_selectorandpage_flow.next_page_candidatesare
Tokopedia's, and came here with the code — one docstring still described
Tokopedia'skeyword=kopi&search_id=tracking tail. Nothing called any of
the five. This repo paginates onpage_url()and the endpoint's own
totalPages, which is the arithmetic the site does for us, so a
selector-walking next-page helper had no role here even had it worked. -
parse_qslis no longer imported by the two engines that only used it in
the helper above. Six other unused imports predate this work and are left
alone deliberately, so this diff stays attributable.
Added
-
The signature-binding check no longer swallows a name that is absent.
It resolved the callee withgetattr(owner, attr, None)and skipped
anything that came back not-callable — so a call into a shared module
function that does not exist was treated as "nothing to bind" rather
than as the loudest failure available. That is how three calls into a
page_flowAPI this repo does not have sat in two engines under a green
run of that very check. Absent is now a failure, naming the caller and its
line. Verified by control: adding a call topage_flow.does_not_exist()
turns the suite red. -
check_captcha_capability_claims_match_the_code— a guard in both
directions. It fails the build on a documented claim that a captcha cannot
be solved, and on a README that credits a Turnstile solve to this repo
while no Turnstile task type is built. The second is asserted as a PAIRING
— task type absent ⇒ the README must carry the disclaimer in those words —
so it cannot go quiet the way a keyword search can. Verified by control:
flipping the disclaimer turns the suite red. -
A check for a statement the control flow can never reach, and one that
the existing undefined-name walk cannot see by design: that walk pools
every binding in a file rather than tracking scopes, so a name used inside
dead code passes as long as anything else in the module binds it. The new
check is narrow — a statement after areturn/raise/break/continue
in the SAME block — and across the eighteen repos of this family it found
six real problems and zero false positives. Verified by control.