The escalation ladder¶
A tier is one capability set: a way of getting a page that can pass some subset of the
layers at some cost. A capability (scraper.Capability) describes a tier as
{name, cost, reach} — reach being the layers it can pass.
The ladder is walked on evidence, not climbed by default. scraper.Planner picks the
cheapest capability whose reach covers whatever is actually binding, which is why a site
that only needs a header profile never pays for a browser launch, and a site that needs the
browser does not spend fifty failed requests discovering that.
| Tier | Cost | Reach | Enabled by |
|---|---|---|---|
archive |
free | everything, but stale | ScraperConfig.archive=True |
direct |
one HTTP request | layers 2–5, 11, 12, 16 | always |
clearance |
a browser launch | adds 6, 7, 9, 10, 13 | ScraperConfig.browser=… |
managed |
money, per request | everything reachable | ScraperConfig.managed=… |
Costs are relative and only used for ordering; the gaps reflect real cost, so a browser
launch is orders of magnitude above an HTTP request rather than one tick. The exact reach
sets live in scraper.planner.default_capabilities.
Two things no tier claims: layers 18 and 19. A reach set that listed them would make the planner offer a stronger tier for something no tier can do.
direct — the baseline¶
An impersonated HTTP request on a held identity. This should handle the majority of protected sites, and that is a consequence of the model rather than optimism: layers 2–5 are one barrier that a faithful transport profile clears in one shot, and the lighter enforcement tiers read the same artifacts.
It deliberately does nothing clever on failure. It sends what it was told to send and returns what came back; deciding whether the answer means rotate, slow down or launch a browser belongs to the planner, which can see the history.
Two things it does not do, both of which the previous generation of this library did:
- No cipher rotation. Reordering the cipher list per request does not produce a browser fingerprint, it produces an unstable one — and an unstable TLS fingerprint invalidates any clearance bound to it, so the feature actively breaks the layer above.
- No header randomisation. Header order is read, not just header values. A profile emits a complete, correctly ordered set; writing over it with a hand-assembled dictionary is how a client ends up claiming to be Chrome with Python's header order.
scraper.identity.OVERRIDABLE is the enforcement: the identity may replace values a profile
already sends and may not add headers of its own. Request-specific headers you pass to
fetch are not filtered — Accept for a JSON endpoint and Referer for a navigation are
legitimate — but they land wherever the transport places additions, which is a bounded
imperfection worth accepting over a navigation with no provenance.
clearance — solve once, reuse many¶
A challenge result is not portable. It is bound to the address, User-Agent and TLS fingerprint that earned it, so the browser and the requests that follow are one identity and the solve is an expensive way of upgrading it.
Which is why this tier does not own a transport. It owns a solver and delegates every
actual request to direct. The alternative shape — a browser tier that fetches pages itself
— both wastes a browser on pages that no longer need one and quietly allows the solve and
the fetch to run on different identities.
The failure this structure makes impossible is the usual way the pattern is implemented wrong: solving on one exit and fetching from another produces a clearance rejected on first use, which reads as "the solver does not work" and leads to re-solving forever.
One solver is bundled: scraper.CdpSolver, which drives Chrome over the DevTools protocol
directly and needs only websockets, so it works on every Python this package supports.
It replaced a driver library that could not be imported below 3.10 or from 3.14 and was no
better at clearing — 12 hosts to 11 head to head, same median. Anything satisfying the
two-method scraper.BrowserSolver protocol plugs in besides — a patched Chromium build, a
Firefox speaking a non-CDP protocol, a paid solving service — and
scraper.browser.CallableSolver wraps a plain function for the one-off case.
CdpSolver never enables a CDP domain, and that is the reason to own the wire rather
than wrap a driver. Eagerly-enabled domains are a known tell, and a general-purpose driver
has to enable them because it cannot know what its caller will ask for next. This one does:
Runtime.evaluate and Page.navigate are commands, not subscriptions, so neither
Runtime.enable nor Page.enable is ever sent. Going through a higher-level abstraction —
including Chrome's own WebDriver BiDi, implemented over CDP internally — gives that away.
A solver declares three things about itself. impersonation is the profile its clearance
binds to, which ScraperConfig.profile() then applies to every request; interactive says
a person can reach the window, which buys interactive_solve_timeout instead of the
unattended solve_timeout; engine names the browser binary it drives, which is what
concurrency is bounded per.
One browser per engine at a time, because the limit is a property of the binary and its
profile rather than of this library: past it Firefox refuses session.new and Chrome exits,
and both arrive as the browser exited immediately — indistinguishable from the site
refusing us, so a survey built on it records working hosts as blocked. A Firefox solve and a
Chrome solve may overlap; two Firefox solves may not. Raise it with set_browser_slots.
Two defaults are deliberate and worth not changing:
- Hidden first, and it costs nothing. Measured over 46 challenged hosts, headless clears
all 27 that a headed browser clears. What used to give it away was one substring —
HeadlessChromein the User-Agent — and the solver strips that itself. The old reason given here, a software WebGL renderer, was refuted directly: forcing it changed nothing. So a window is worth only the one thing it uniquely provides, a person to finish a challenge the solver could not.mode="auto"spends the first part of the budget hidden and opens a window only if that fails;mode="headless"never opens one, which is right for a server;mode="headed"always does. - WebRTC off. A STUN request reaches the network directly and reports the host's real address even when every HTTP request goes through the proxy — unbinding the identity by leaking past it, silently.
What does decide it, in a container, is the clock. A container runs on UTC unless told
otherwise, and a browser whose timezone contradicts where its address geolocates reads as
automation: the same browser on the same six challenged hosts cleared 1 of 6 under UTC
and 6 of 6 with TZ set to match the exit. Set it to the zone the traffic appears to
come from.
The browser build matters second. Debian's chromium omits the Google Chrome brand from
Sec-CH-UA and cleared 1 of those 6 even with the clock right — headless, headless with
the User-Agent fixed, or headed under Xvfb alike. Install the browser a real visitor runs;
a virtual display cannot hide a property of the binary.
One browser profile directory per address
(scraper.browser.profile_dir_for). Cookie and session age are behavioural signals, so a
profile reused across a run accumulates the history that makes the session look established
— and sharing one between addresses is how a clean exit inherits a burnt one's session.
The bundled solver does not synthesise mouse, scroll or keystroke dynamics, so both clear the
control-channel layer and leaves the behavioural one entirely to scraper.pacing. That
division is why they are separate modules.
render_soup() — a browser, but not a tier¶
A solver has a second use, and it is not escalation:
Some pages answer 200 with a shell that JavaScript fills in. Nothing is blocking, no layer is binding, and a clearance changes nothing — plain HTTP carrying the cookie returns the same empty shell. So this is not a rung on the ladder: no diagnosis leads here, because there is no detection event to diagnose. The caller knows this about the site; the model cannot infer it.
It goes through the same lease, identity, gate and clock as a fetch, so a render is paced like any other request and leaves from the address the origin is already held on. What it does not do is touch the tier or the success counters: a page the browser rendered is no evidence that the HTTP ladder works, and recording it as one would zero the consecutive failures that promote a diagnosis.
No solver, or a solver that only solves, raises TierUnavailable — never Blocked, which
would be a claim about defences that are not there.
Give it a wait_for. Without one the only stand-in for "the page has run" is a fixed
settle interval, which is both slower than necessary and unreliable. With one the wait ends on
evidence, and a selector that never appears raises RenderError rather than handing back the
shell — returning it is the silent failure this exists to prevent, since the caller parses it,
finds nothing, and reports an empty page rather than a problem.
Choosing the selector is the part that takes care: it must name an element that cannot exist
before the data does. Measured on one live single-page application: the cards hydrate as
empty skeletons and fill in afterwards, so a.line-clamp-2 matched at 1.8s with 457
characters of a page that settles at 9538. Where a site has no such element, no selector is
the honest answer and the settle interval is what you have.
archive — free, but stale¶
An archived snapshot is served from a host with no mitigation stack in front of it, usually as static HTML. Where the content is not time-sensitive this is strictly better than every other tier: no proxy, no browser, no challenge, no standing to protect.
Off by default for two honest reasons rather than any detection problem: coverage is
incomplete and captures are stale, so the caller has to have said stale is acceptable. Set
archive_max_age — a default of "any age" would quietly serve a decade-old page to someone
who asked for the current one.
The response carries the original URL, not the archive URL, and the capture timestamp
arrives in the scraper.tiers.archive.SOURCE_HEADER response header. That matters for
anything that parses the result: relative links resolved against a web.archive.org base
point back into the archive, which silently turns a scrape of a site into a scrape of a
snapshot of a site.
managed — delegation¶
Against a per-zone composite model that is actively tuned, maintaining a bypass becomes a standing engineering cost rather than a piece of work with an end. So the last rung is handing the request to a service, and it is last because it is the only tier that costs money per request.
No provider is bundled. Their request formats differ, they change, and a wrapper that
guesses wrong fails in a way that looks like the site blocking you. scraper.tiers.Provider
is the whole contract:
It must return the origin's status and body. Returning the provider's own status instead breaks diagnosis: a 200 from the provider wrapping a 403 from the site reads as a successful scrape of a block page.
scraper.tiers.http_provider covers the several services shaped as "GET this endpoint with
the target as a query parameter".
Promotion¶
The scoring tiers (11, 12, 14) cannot be told apart from outside. So when a transport profile
keeps being rejected while a stronger tier exists, Planner.promote_after consecutive
failures re-attribute the diagnosis to the per-zone composite and escalate. Recurrence is the
only evidence available, and it needs history to be visible at all — which is why
scraper.memory counts consecutive failures per origin.
A declared-crawler block is never promoted. That one is about the User-Agent, and no number of repetitions turns it into a machine-learning verdict.
Writing a tier¶
Subclass scraper.tiers.Tier, implement send(call) -> requests.Response, declare a cost
and an honest reach, and pass an instance in ScraperConfig.tiers:
from scraper import Layer, Scraper, ScraperConfig
from scraper.tiers import Call, Tier
class CacheTier(Tier):
name = "cache" # what OriginProfile.tier records
cost = 5 # cheaper than direct, so tried first
reach = frozenset({Layer.IP_REPUTATION}) # what it can actually get past
def send(self, call: Call) -> requests.Response:
...
scraper = Scraper(config=ScraperConfig(tiers=[CacheTier()]))
Nothing else is needed. The planner sees it through Tier.capability() and picks it by cost
like any other rung, close() is called with the rest, and the name is refused if it collides
with a built-in one.
Be honest about reach. The planner treats it as a claim about capability, so an inflated
one sends every retrieval to a tier that cannot help and stops the ladder before the tier that
could. Two claims are enforced rather than trusted: naming one of layers 2–5 names all four
(layers.expand, because no technique satisfies one without the others), and naming layer 18
or 19 raises ConfigError — those read a secret, and a rung offering one would be offered for
something no rung can do.
Two rules:
- Everything a tier needs arrives in the
Call; anything it learns goes back through the return value. A tier that reacts on its own is a tier that rotates a proxy over a pacing problem. - Raise
scraper.TierUnavailablewhen the tier cannot serve a call at all — no archive snapshot, a method the provider does not forward. That escalates without attributing anything to a layer, because an archive gap says nothing about the site's defences and recording it as a block would teach the memory something false.
stream() has a working default that buffers through send(), so downloads work in every
tier whether or not its client streams.