Skip to content

The nineteen layers

Numbered in the order a request meets them. The numbering is this library's own organising device and is stable API — scraper.Layer members are what exceptions, memory files and log lines refer to. The mechanisms and product names are Cloudflare's.

The authoritative version of this table is scraper.layers.LAYERS, which carries a one-paragraph summary per layer. Print it with examples/02_the_model.py.

# Layer Reads Stance Bypassable
1 IP reputation hybrid lease yes, economically
2 TLS fingerprint (JA3/JA4) emit satisfy yes
3 Post-quantum key share and ECH emit satisfy yes — keep the profile current
4 HTTP/2 and HTTP/3 frames emit satisfy yes
5 Header order emit satisfy yes
6 Browser and JavaScript fingerprint emit, coupled solve mostly
7 DevTools-protocol detection hybrid solve partly
8 Per-zone behavioural model possess accumulate partly, and only by accruing
9 Managed JavaScript challenge hybrid solve yes, on a held address
10 Turnstile hybrid solve yes, on a tighter clock
11 Bot Fight Mode emit satisfy yes
12 Super Bot Fight Mode emit satisfy yes
13 Under Attack Mode hybrid solve yes, per request
14 Bot Management hybrid delegate inconsistently
15 Operator edge code outside avoid site-specific
16 AI bot blocker emit satisfy trivially
17 Decoy-content honeypot outside avoid avoid by not tripping it
18 Cryptographic agent identity possess refuse no, where mandated
19 Identity-provider gate possess refuse no

Stance is what this library does when the layer is binding, and the one worth knowing is refuse: layers 18 and 19 read a secret the caller either holds or does not, so grinding against them is an infinite retry loop against a wall.

Not every layer is nameable from a response. diagnose produces thirteen of these nineteen, and the gap is not an omission. Layers 2–5 are indistinguishable from outside — a 403 does not say which part of the handshake it disliked, and it does not matter, because the remedy is the same for all four (that is what makes them one barrier). Layer 14 is reached by promotion rather than diagnosis: it is what repeated failure after the emit remedy means, and that judgement needs history, so it belongs to Planner. Layer 17 announces nothing at all — it is found by TopicGuard reading content, not by reading a status.

Layers 2–5 are one barrier

They read different parts of the request, but a client built to reproduce one browser's network stack passes all four at once, and one that is not fails all four at once. In the bound they are a single term. scraper.layers.expand closes any reach set over the group, so a tier declaring one automatically declares all of them.

The practical consequence is that the default transport handles the whole group with no configuration, and that adding another emit-reading check of the same kind barely moves the result for a defender.

The one thing that does matter here is not pinning a stale profile. An older impersonation target is a signal on its own: no real user runs a two-year-old browser, and the older profile predates the post-quantum key share that current builds all send — so a client claiming to be current Chrome without one contradicts its own User-Agent. Use the bare family alias ("chrome", "firefox", "safari", "edge") and it tracks whatever the installed build considers current. scraper.transport.stale_profile_warning checks this at construction and logs if you pinned something older.

Layer 1: addresses

Not technical. The address is chosen freely; the reputation attached to it accrued over time and can be rented, never fabricated. Datacenter ranges are cheap to block because almost no human traffic originates there. Mobile-carrier ranges front thousands of real subscribers behind one NAT, so blocking one causes collateral damage — which is what makes them the good ones.

Declare the kind honestly in ExitSpec. Claiming MOBILE for a datacenter range does not change what the reputation database thinks; it only stops this library from telling you that layer 1 is the reason nothing works. An ExitSpec with a kind other than DIRECT and no url is refused outright, because every packet would still leave from the local address while the pool reported reach it does not have.

ExitKind.reach is what the planner consults before recommending a rotation as a cure for layer 1, and a pool of published ranges reaches nothing there. It is not consulted for anything else: whether rotating can produce a different address at all is ExitPool.rotatable, and a dead exit is worth replacing whatever its kind reaches.

Layer 8: the hard one

Request-timing regularity, navigation and referrer chains, cookie and session age, history depth, concurrent sessions per address — correlated across a session window and trained separately per zone. There is no artifact to reproduce.

Everything in scraper.pacing and scraper.memory exists for this layer. See behaviour.md.

Layer 17: the one with no error response

A honeypot inserts hidden nofollow links into a page, leading into a maze of generated decoy pages. Following them causes two harms at once, and neither announces itself: the store fills with plausible irrelevant content, and the session is flagged network-wide.

Unlike every other failure mode in this library, this one has to be looked for. scraper.links.safe_links enumerates only anchors a person could click, and scraper.links.TopicGuard watches for content that stopped being about the site. See decoy-content.md.

Layers 18 and 19: no bypass

Layer 18 verifies a signature over the request against a published key directory. Emulation of any kind is beside the point. Currently deployed fail-open, so an unsigned request falls back to the rest of the stack — but where a signature is required, the only route is to hold a key and be registered. That is worth doing on its own merits: a valid signature is a positive identification that skips the challenge machinery entirely, making it the cheapest tier in the stack. See web-bot-auth.md.

Layer 19 is authentication, not bot mitigation, and is out of scope: retrieving content behind it without credentials would be unauthorised access.

Both raise scraper.Impassable, whose message names the legitimate route.