Diagnosing a failure¶
The design goal here is that you never have to guess. Two habits cover almost everything:
read explain() after a run, and read the exception's layer rather than its status code.
explain()¶
example.com
binding layer : L9 Managed JavaScript challenge — reads a hybrid property, solve
tier : clearance
pacing : 4.2s mean interval
requests : 48 ok / 3 failed
clearance : 712s left
ladder : archive(0) direct(10) clearance(100)
exits : residential
topic guard : 12 pages learned
Each line answers a question you would otherwise ask by reading source: what the library concluded is blocking, which capability it settled on, how fast it has learned it can go, what it has available, and whether the expensive tier's result is still alive.
The exception taxonomy¶
All of these derive from scraper.ScraperError.
| Exception | Means | What to do |
|---|---|---|
Impassable |
The binding layer reads a secret (18, 19). | The message names the only route. Nothing to retry. |
Exhausted |
A bypass may exist; this configuration does not reach it. | Read .layer; the message says what would. |
Blocked |
Base class for both, carrying .layer. |
Branch on the layer, not the status. |
Poisoned |
Content looks like decoy material. | See decoy-content.md. |
TierUnavailable |
A tier cannot serve this call at all. | Internal; escalates without blaming a layer. |
Aborted |
abort() was called, or a per-request signal was set. |
Expected on cancellation. |
RenderError |
render_soup() ran and the page never produced wait_for. |
Check the selector exists once the data has arrived. |
ConfigError / MissingDependency |
Setup problem. | The message says which extra or field. |
from scraper import Layer
from scraper.exceptions import Exhausted, Impassable
try:
response = scraper.get(url)
except Impassable as exc:
print("no bypass:", exc.detail) # register, or authenticate
except Exhausted as exc:
if exc.layer is Layer.IP_REPUTATION:
... # a better address is the only fix
elif exc.layer in (Layer.MANAGED_CHALLENGE, Layer.TURNSTILE, Layer.CDP):
... # configure a browser solver
Exhausted.detail carries the full decision trail, so the message shows every move the
planner made and why — not just the last one.
Diagnosing offline¶
scraper.diagnose is pure: it reads primitives, not a response object. So a page you captured
can be classified with no network at all, which is the fastest way to check what the library
thinks of something you saw in a browser.
from scraper import diagnose
diagnose(status=200, body=saved_html) # solve (L9 …): challenge served with a success status
diagnose(status=429, headers={"retry-after": "30"})
diagnose(status=403, body="<p>Error 1020</p>") # rotate (L1 …)
diagnose(status=403, body="<p>Error 1010</p>") # escalate (L7 …) — not the address
diagnose(status=404) # accept — the site's answer about a path
Three of those are worth internalising because they are where the conventional reading goes wrong:
- A
200can be a challenge. The interstitial is a normal page with a normal status. Parsed as content it yields a successful-looking scrape of nothing, and nothing else in the stack notices. - A
403with code 1010 is not about the address. It says the automation channel was detected. Rotating the exit changes nothing. - A
404is not a layer. It is the site's answer about a path, and attributing it to one would retire a healthy address over a typo in a URL.
What is in front of the site¶
from scraper import edge
edge(response.headers, response.text) # "DataDome", "Cloudflare", "nginx", ""
The layer numbers are organised around Cloudflare's mechanisms, but the classifier is not Cloudflare-only. Twelve other products announce themselves in a header, a cookie name or a block page, and each is mapped to what a refusal from it actually means:
| Product | Recognised by | A refusal reads as |
|---|---|---|
| DataDome | x-datadome, datadome cookie, geo.captcha-delivery.com |
L14, and its captcha is L9 |
| Kasada | x-kpsdk-ct, x-kpsdk-cd |
L14 |
| PerimeterX / HUMAN | _px* cookies, px-captcha, "Press & Hold" |
L14, and its captcha is L9 |
| Akamai Bot Manager | _abck, ak_bmsc, bm_sz, AkamaiGHost |
L14 |
| Imperva / Incapsula | x-iinfo, visid_incap_*, _Incapsula_Resource |
L14, and its interstitial is L9 |
| DDoS-Guard | server: ddos-guard, __ddg* cookies |
L12, and its interstitial is L9 |
| Sucuri | x-sucuri-id, server: Sucuri |
L12 |
| AWS WAF | x-amzn-waf-action, aws-waf-token |
L12 |
| F5 BIG-IP | TS01…/BIGipServer cookies, "The requested URL was rejected" |
L12 |
| CloudFront | x-amz-cf-id |
L15 — an edge rule, not a bot check |
| Fastly | x-fastly-request-id, Fastly error: |
L15 — as above |
| hCaptcha / reCAPTCHA | the widget script, on a refusal only | L9 |
Two distinctions in that table carry the design.
L14 against L12. A product whose verdict is a per-session model over the whole request is layer 14, whose stance is delegate. That is not defeatism: where a browser would help, the vendor's own challenge markers route there first, and the planner promotes to 14 on recurrence. A WAF acting on coarser rules is layer 12, stance satisfy, so the ladder still tries the tier that supplies a better profile.
A CDN is named without being blamed. A CloudFront or Fastly header is on every response those services serve, successful ones included, so its presence says who answered and nothing about why. A refusal there is an operator's own rule — a signed URL, a geo restriction — which is layer 15, not a detection layer.
The captcha row is last for a reason: a bare hCaptcha or reCAPTCHA widget is only read as a challenge on a non-2xx status. Login and comment forms carry one, and treating that as an interstitial launches a browser on content that already arrived. It is the same trap as Turnstile, and the same answer.
Taking inventory¶
explain() answers for one origin. A long-running process needs the other question — what has
this thing learned overall, and what is it doing with the addresses it was given — so Memory
and ExitPool both enumerate.
scraper.memory.count # how many origins are known
scraper.memory.origins() # keys, most recently seen first
scraper.memory.profiles() # copies of every OriginProfile
scraper.memory.export() # the same, JSON-safe, no clearance cookies
scraper.memory.forget("https://example.com/") # drop one conclusion
profiles() hands back copies, so a status page iterating the store cannot edit what the
retrieval loop is reading. export() reduces a stored clearance to its expiry and the
User-Agent it belongs to: the cookies are the one secret in the file, and the question a status
page asks is whether a clearance is held and for how long.
forget() is the escape hatch for a conclusion that has gone stale in a way the store's TTL
will not catch — a site that dropped its edge, or a profile written while a proxy was
misconfigured. The binding layer is the field that misleads longest, because a wrong one sends
every later run up the ladder for nothing.
for exit in scraper.exits.status():
print(exit.name, exit.kind.value, exit.origins, exit.retired, exit.returns_in)
origins is how many origins currently hold a lease on that address, and returns_in is when a
retired one becomes usable again. A scrape that has slowed down for no visible reason is usually
a pool with most of itself resting, and that is otherwise only visible in debug logs. The URL is
deliberately absent — a proxy URL carries its credential, and this view is written to be
displayed.
Logging¶
DEBUG on the scraper logger prints one line per attempt with the decision and its
reasoning:
DEBUG scraper.session: GET https://example.com/x [direct] escalate -> clearance: challenge served with 403
DEBUG scraper.session: GET https://example.com/x [clearance] proceed
INFO is quiet except for the things you want to know happened: a solve completing, with the
clearance lifetime and the identity it belongs to. WARNING covers the failures that
otherwise degrade silently — a stale impersonation profile, a pool rejecting a credential,
suspected decoy content, a memory file that could not be written.
Common conclusions¶
Exhausted at L1 with "no configured exit clears the reputation layer". Every address on
offer is a datacenter or Tor range. These are published, so the replacement is blocklisted for
the same reason as the original — the library says so rather than proving it one exit at a
time. Configure a residential or mobile exit.
Exhausted at L9/L10/L13 with "needs clearance, which is not enabled". The site serves a
challenge and there is no solver. ScraperConfig.browser=CdpSolver().
Exhausted at L14. The zone is running the per-zone composite, reached by promotion after
repeated failures. Results against it are inconsistent by nature; a managed provider is the
rational fallback.
A solve that appears to run forever. Almost always the clearance is being earned under one
identity and replayed under another. Clearance.why_not(identity) names which half changed,
and it is logged at DEBUG when a clearance is dropped. The usual cause is an address that
moved between the solve and the fetch.
Everything works, then stops after a while. Check explain() for the interval. A throttle
widens it permanently for the origin, including across runs, which is intended — but if the
site's limit was temporary, Pacer.learn(origin, value) resets it.
A scrape that reports success and collects nothing useful. Check for decoy content. If
guard_topic is off, turn it on; if it is on, look at knows(url).decoys.