lncrawl-scraper
A scraper that works out which detection layer is blocking it —
and escalates only as far as that layer requires.
The model · Documentation · Examples · White paper · Live report · Changelog
from scraper import Scraper
with Scraper(origin="https://example.com") as scraper:
soup = scraper.get_soup("https://example.com/")
print(soup.select_one("h1").text)
Those four lines already reproduce a real browser's TLS and HTTP/2 fingerprint, hold one address per origin, pace themselves like a person reading, and refuse to follow decoy links.
The idea
A modern mitigation engine runs many largely independent detectors and folds them into one trust score. Admission behaves as a near-conjunction, so:
P(evade) ≲ min( p₁ , p₂ , p₃ , … , pₙ )
▲
└─ the binding layer. Every other layer is
wasted effort until this one stops being
the minimum.
The weakest layer bounds the outcome. If a strategy fails on address reputation, perfecting its TLS profile gains nothing — not a little, zero. So this library diagnoses which layer is binding before it changes anything.
What a detector reads decides whether it can be satisfied.
| The detector reads | Reproducible? | What actually moves it | |
|---|---|---|---|
| Emitted | an artifact the client sends — a TLS ClientHello, an HTTP/2 frame order, a header order |
yes | imitate it faithfully |
| Possessed | a property the client must hold — accumulated per-zone history, a private signing key | no | accrue it, rent it, or hold the key |
That second distinction produces the behaviour that most sets this library apart: when the binding layer reads a possessed property, it does not rotate. Rotating discards the very history the detector is measuring, so it holds the address still and slows down. And two layers raise instead of retrying, because they read a secret you either hold or do not.
Full treatment: the model. The layer model, the emit/possess distinction and the reference patterns come from the paper this library implements — A Layered Model of Modern Web Bot Protection and the Structural Limits of Its Circumvention, included as a PDF.
What it does
| Diagnoses instead of reacting | scraper.diagnose maps a response to one of nineteen layers. A 200 carrying a challenge is a failure; a 429 is a pacing problem, not a bad address; a 403 with error 1010 is about the automation channel, and rotating the exit changes nothing. |
| Escalates on evidence | Four tiers, ordered by real cost. The cheapest one whose reach covers the binding layer is chosen, so a site needing only a header profile never pays for a browser launch. |
| Treats identity as indivisible | A clearance is bound to the address, User-Agent and TLS fingerprint that earned it, and Clearance.usable_by() refuses to replay it under any other — which makes the classic rotating-proxy failure structurally impossible. |
| Solves once and reuses | A browser runs for the challenge, its exact User-Agent is adopted, and everything after is a cheap request on the same identity until the cookie expires. |
| Accumulates rather than fakes | Gamma-distributed pacing, homepage warm-up, real referrer chains, one address per origin, capped concurrency — and it all persists between runs, because a process that forgets cannot accumulate. |
| Avoids the trap with no error response | safe_links enumerates only anchors a person could click; TopicGuard notices content that stopped being about the site. |
| Signs requests, to be welcome | RFC 9421 / Ed25519 Web Bot Auth. A valid signature skips the challenge machinery entirely, making it the cheapest tier there is. |
| Tells you why | scraper.explain(url) names the binding layer, the working tier, the learned pacing and the ladder available. Exceptions carry .layer, not just a status code. |
PageSoup |
Null-safe BeautifulSoup wrapper; selectors never return None. |
The ladder
cost tier reaches
──── ────────── ───────────────────────────────────────────────────
0 archive everything — when a capture exists
10 direct TLS, HTTP/2 frames, header order, post-quantum keyshare
100 clearance + the JavaScript, Turnstile and automation-channel layers
1000 managed + the per-zone composite, at someone else's price
The planner picks the cheapest rung that covers the binding layer, and stops with an explanation when no configured rung does. Writing your own rung: tiers.
Installation
pip install lncrawl-scraper
| Extra | Pulls in | Needed for |
|---|---|---|
lncrawl-scraper[browser] |
nodriver | solving a challenge with a real browser |
lncrawl-scraper[botauth] |
cryptography | signed requests (Web Bot Auth) |
lncrawl-scraper[image] |
Pillow | get_image() |
lncrawl-scraper[all] |
all three |
Impersonation is not an extra. Layers 2–5 are one barrier and an ordinary Python client fails all four in the first round trip, so a build without it would not be a degraded scraper but one that cannot reach a protected page.
Adding reach
Two settings change what this library can do. The rest adjust how it does it.
from scraper import ExitKind, ExitSpec, Scraper, ScraperConfig
from scraper.browser import NoDriverSolver
config = ScraperConfig(
# The only thing that moves layer 1: reputation is not something a client emits.
# Declare the kind honestly — claiming MOBILE for a datacenter range only stops
# this library from telling you that layer 1 is why nothing works.
exits=[ExitSpec(url="http://user:pw@residential.test:8000", kind=ExitKind.RESIDENTIAL)],
# The only thing that reaches the challenge layers.
browser=NoDriverSolver(),
)
with Scraper(origin="https://site.test", config=config) as scraper:
scraper.get("https://site.test/deep/page")
print(scraper.explain("https://site.test/deep/page"))
site.test
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 : direct(10) clearance(100)
exits : residential
When it stops
Failures name the layer and what would move it, because "403 after 3 retries" is the message that sends people to rewrite the part that was already working.
from scraper import Layer
from scraper.exceptions import Exhausted, Impassable
try:
scraper.get(url)
except Impassable as exc:
# Layers 18 and 19 read a secret. Nothing to retry; the message names the route.
print(exc.detail)
except Exhausted as exc:
# A bypass may exist; this configuration does not reach it.
if exc.layer is Layer.IP_REPUTATION:
print(exc.detail)
# "no configured exit clears the reputation layer — datacenter and Tor ranges
# are published, so rotating between them cannot help."
Documentation
| Page | |
|---|---|
| The model | The bound, and emit vs. possess. Start here. |
| Layers | The nineteen layers and what moves each. |
| Tiers | The escalation ladder; writing a tier. |
| Configuration | Every ScraperConfig field. |
| Behaviour | Pacing, warm-up, persistence, shared state. |
| Decoy content | The layer that returns no error. |
| Web-bot-auth | Signed requests and the key directory. |
| Diagnostics | explain(), exceptions, common conclusions. |
| Migration | Porting from 0.2.x. |
| Examples | Ten runnable programs, ordered to explain the design. |
Scope
This library is for retrieving publicly accessible content. It does not attempt authentication bypass, credential abuse, or circumvention of access controls protecting non-public data — layer 19 raises rather than trying, and layer 18 raises where a signature is mandated. Where a site publishes an API or an archive holds what you need, both are cheaper than anything else here and are supported first-class for that reason.
Development
uv sync # deps + editable install
uv run poe lint # ruff + pyright
uv run poe test # pytest
uv run poe cov # with coverage
Tests are offline: the pipeline talks to a two-method Transport, so tests/conftest.py's
FakeTransport covers every tier without a network. The modules that encode judgement —
diagnosis, planner, layers — are pure functions over primitives and are tested as such.
Verifying against real deployments
livetest/ runs the same paths against real Cloudflare deployments, using every host in
lightnovel-crawler's source index as the
corpus. It is not part of poe test — it needs the network, and some scenarios need a local
tor-pool and a real browser.
uv run poe live-all
The recorded output is the live report —
scenario results, which layer each client meets across the corpus, and what a Tor exit
actually costs. It is a standalone page, regenerated in place at livetest/report.html and
published with the docs.
Nearly every defect found before 1.0 was invisible to a stubbed transport, and two of them made whole features silently useless while every unit test passed. Anything the harness finds gets a unit test whose docstring says it was found live, so those docstrings are the record of which assumptions turned out to be wrong.
Credits
Extracted from lightnovel-crawler.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file lncrawl_scraper-1.1.0.tar.gz.
File metadata
- Download URL: lncrawl_scraper-1.1.0.tar.gz
- Upload date:
- Size: 1.6 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1484aa5c9dfdf6c94cffe129afe6030b810252dbd1045fa17667ee433901def0
|
|
| MD5 |
e1a553ded6b1fef9b1127129bd9c7311
|
|
| BLAKE2b-256 |
eafbdc8834668eec0c5c458b3088d7eafec3fabaea73599d4f0b2c9b8b63f6d1
|
Provenance
The following attestation bundles were made for lncrawl_scraper-1.1.0.tar.gz:
Publisher:
publish.yml on lncrawl/scraper
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
lncrawl_scraper-1.1.0.tar.gz -
Subject digest:
1484aa5c9dfdf6c94cffe129afe6030b810252dbd1045fa17667ee433901def0 - Sigstore transparency entry: 2288804329
- Sigstore integration time:
-
Permalink:
lncrawl/scraper@937888ad014da95a321c451638ca56528313f566 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/lncrawl
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@937888ad014da95a321c451638ca56528313f566 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file lncrawl_scraper-1.1.0-py3-none-any.whl.
File metadata
- Download URL: lncrawl_scraper-1.1.0-py3-none-any.whl
- Upload date:
- Size: 111.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
97c0fe1709edcf5ee37a65244da8e4bcc848a762e5249d4c19f61d01c8c09c9a
|
|
| MD5 |
9b4c630cdc2c7e49ae33033da5060c15
|
|
| BLAKE2b-256 |
0ce793e599787c3ab35be9866559108561fd87d27397ccf915157f689616f766
|
Provenance
The following attestation bundles were made for lncrawl_scraper-1.1.0-py3-none-any.whl:
Publisher:
publish.yml on lncrawl/scraper
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
lncrawl_scraper-1.1.0-py3-none-any.whl -
Subject digest:
97c0fe1709edcf5ee37a65244da8e4bcc848a762e5249d4c19f61d01c8c09c9a - Sigstore transparency entry: 2288804411
- Sigstore integration time:
-
Permalink:
lncrawl/scraper@937888ad014da95a321c451638ca56528313f566 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/lncrawl
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@937888ad014da95a321c451638ca56528313f566 -
Trigger Event:
workflow_dispatch
-
Statement type: