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:
--pages 0,--concurrency 0, and a negative
--pages,--concurrency,--delay,--retry-delayor
--proxy-block-retriesare now usage errors (exit 2) in all three engines,
and a negative--retries/--retry-delayinscraper_api_client.py.
Behaviour change: a page that is served WITH vendor tiles and parses to
zero rows now fails the page withstop_reason: parser_found_nothing:
exit 5 when nothing was gathered, exit 6 after earlier pages, where it used
to be exit 4 ("empty listing") or acompleterun. The Playwright and
pyppeteer engines now also pass the navigation's HTTP status to the
classifier, so a refusal with no vendor marker on it under 401/403/429/5xx
is exit 3 rather than a 25-second wait and an exit-4 "empty listing".
Behaviour change: CSV cells that begin with
=,+,-,@, tab, CR
or LF are prefixed with an apostrophe so a spreadsheet reads them as text.
JSON is unchanged; the sidecar recordscsv_cells_escaped.
Behaviour change: the canary FAILS when neither
FOODPANDA_PROXYnor
FOODPANDA_CDP_ENDPOINTis set, and on exit 5; it used to skip green.
Fixed
- Output files are replaced atomically. JSON, CSV and the
<out>.meta.jsonsidecar are written to a temporary file in the same
directory, flushed, fsynced and renamed over the old one. A write that
failed half-way used to truncate the previous good file into invalid
JSON. A new file gets the modeopen()would have given it under the
current umask, and an existing file keeps its own mode. --allow-emptyon a complete, genuinely empty listing writes
status: "complete", not"failed", sodiff_runs.pycan compare it.- The credential scan reads what it used to skip. Exclusions are judged
on the path inside the repository, so a clone under a directory called
venvno longer scans 0 files and reports clean; a scan that read nothing
now fails. Untracked.jsondumps and.env.*backups are scanned
(.envitself, the user's own file, is not). - pyppeteer no longer re-runs a plain load failure for every block
retry. Only an unusable proxy exit, with a pool to rotate to, earns
another round — the same rule as the other two engines. - The Scraping Browser credentials claim is corrected. README,
TROUBLESHOOTING,.env.exampleand the canary said a profile "lives about
a day". That was never a measured constant (one endpoint was still
accepted ten days after it was set); they now say the credentials expire
and that HTTP 401deny_no_useris the signal. - The Turnstile log no longer calls a user-agent mismatch the likeliest
cause of a refused token; on this site a token minted under a different
user agent was accepted. .gitignorecovers--dump-htmlpage dumps of any name (*.page[0-9]*),
a root-level HTML dump and scratch directories (live/,out/, …).- Removed the unused
single_page_modestop reason. - Comments, docs and release notes no longer cite section numbers of an
internal document readers cannot open; a suite check keeps them out. --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 challenge that never clears no longer buys a solve per retry.
SOLVES_PER_PAGEwas enforced on only one of the two
handle_captcha_if_presentcalls in a fetch attempt, so with no proxy pool
a persistent challenge bought one solve on every block-retry: 6 solver
invocations against a limit of 1, reproduced with the solver stubbed. The
budget (page_flow.SolveBudget) is now spent inside the handler, right
before the purchase, shared by both call sites and not reset by a rotation.
All three engines. --delaynow applies between page 1 and page 2. The pause sat at the
tail of the loop, behind a "more pages remain" test, so it only ran between
pages that had both been fetched already and a two-page run never paused.
It now runs before every navigation after page 1, and once before the
--concurrencyworkers start.diff_runs.pyrefuses two runs that read different listings. Both
runs could becompletewith the same countrysourceand a different
city or area, and the diff then reported the AREA change as vendors
appearing and disappearing. The sidecar'sstart_url(host, path, query
withoutpage) must now agree, and a run that stopped at its own--pages
limit is refused against a deeper run.--forcestill overrides, and a
run with nostart_urlin its sidecar is let through as before.- Scraper API:
waitForis now sent as a JSON object. Measured
2026-09-23 againstscraper.2captcha.com/tasks/sync: the JSON-encoded
string this client sent (the form older docs described) is refused with
HTTP 422 "params.waitFor must be an object" -- and the task is still
billed ($0.0005) -- so every run with--wait-textor its sibling wait flags
failed with exit 5. The object form answers HTTP 200. - Scraper API: the target page's status is read from
http_code. The
response'sstatusfield is the API's own verdict string ("success"),
not the target site's HTTP code, so a target 403/503 was never seen by
this client.http_code(an int) is read now, withstatuskept as a
fallback only when it is an int. scraper_api_client.py --helpdescribed two other sites. Its
description said the pages need JavaScript and a single fetch returns
about 5 products, and--urltold you to wait for a
lodging-card-responsiveelement that arrives over GraphQL. Neither is
true here: foodpanda server-renders its grid, and the docstring's own
measurement is 48 of 48 vendors from one request.--categoryno longer
claims to default to a URL segment — this client does not use it.- Issue templates described an auction site. The bug-report hints talked
about lots, reserve prices, a page-100 cap and/en/locales, and the
site-change template's anchors were__NEXT_DATA__and lot cards.
Rewritten from this repo's README, CONTRIBUTING and TROUBLESHOOTING:
headless refusal, PerimeterX retries, directory URLs, the(100+)and
Up toflags, home-page-only delivery columns. - The pyppeteer and Selenium engines' docstrings listed a
--mode product
and gave a Tokopedia path as their--urlexample; both now describe the
onelistingmode and a real area listing. Their unreachable
--mode productsidecar branch (a shop's id and slug, which nothing here
produces) is gone; the listing branch is unchanged. - Comments that told another site's story as this one's:
/p/<slug>hubs,
"hub category", a search grid over GraphQL, akeyword=kopitracking tail
and a detail page's buy box in the engines; an Akamai 394-byte refusal and
auction lots incaptcha_solver.py; a Tokopedia no-results string, an
HTTP/2 stream reset, an Orlando search andshop_ratingin
output_writer.py. Replaced with what this repo measured, or attributed
to the sibling where it happened.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 now says what the README measures: no account needed,
from a residential IP. - The README was missing its canary badge. The workflow has been
running all along; the badge that reports it was never added, so the
one signal that says whether this scraper still works against the live
site was invisible above the fold. - Fifteen lines of unreachable code removed from
playwright_scraper.py.
A function'sdefline had been lost at some point 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 — so no behaviour changes. The same fifteen lines, byte for
byte, were in six repos of this family.
Added
- A check for a statement the control flow can never reach. The
undefined-name walk beside it cannot see this class by design: it 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
one is narrow — a statement after areturn/raise/break/continuein
the SAME block — and measured across the eighteen repos of this family it
found six real problems and zero false positives. Verified by control:
appendingreturn 1followed by a statement turns the suite red.