Skip to main content

lncrawl-scraper

A scraper that works out which detection layer is blocking it —
and escalates only as far as that layer requires.

PyPI Python CI codecov CodeQL License

The model · Documentation · Examples · 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: docs/model.md.

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: docs/tiers.md.

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
model.md The bound, and emit vs. possess. Start here.
layers.md The nineteen layers and what moves each.
tiers.md The escalation ladder; writing a tier.
configuration.md Every ScraperConfig field.
behaviour.md Pacing, warm-up, persistence, shared state.
decoy-content.md The layer that returns no error.
web-bot-auth.md Signed requests and the key directory.
diagnostics.md explain(), exceptions, common conclusions.
migration.md 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

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

The layer model, the emit/possess distinction and the reference patterns are drawn from A Layered Model of Modern Web Bot Protection and the Structural Limits of Its Circumvention (Sudipto Chandra, 2026). This package is that paper's argument implemented.

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

lncrawl_scraper-1.0.0.tar.gz (938.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

lncrawl_scraper-1.0.0-py3-none-any.whl (107.4 kB view details)

Uploaded Python 3

File details

Details for the file lncrawl_scraper-1.0.0.tar.gz.

File metadata

  • Download URL: lncrawl_scraper-1.0.0.tar.gz
  • Upload date:
  • Size: 938.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for lncrawl_scraper-1.0.0.tar.gz
Algorithm Hash digest
SHA256 f07cdada9c2388766c7dc80874d200601c13fe09853b2c9d4026e2389e728edd
MD5 6b4e77849841ebb4851988a247556900
BLAKE2b-256 4bba07dfe637d1f686c8a68491e7a4c5dfab0f231540b91001934fe67527aec3

See more details on using hashes here.

Provenance

The following attestation bundles were made for lncrawl_scraper-1.0.0.tar.gz:

Publisher: publish.yml on lncrawl/scraper

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file lncrawl_scraper-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: lncrawl_scraper-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 107.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for lncrawl_scraper-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 abd0e5870e2603cbe3a145eefc349be57006c001b081ca1ed196c9bc8b13b629
MD5 1e3f65413ab1a228d9fed25c652eab75
BLAKE2b-256 2eced44b7ffecc9cbe9877e2b5609768b10e6594f259324854802a0a695cf5c1

See more details on using hashes here.

Provenance

The following attestation bundles were made for lncrawl_scraper-1.0.0-py3-none-any.whl:

Publisher: publish.yml on lncrawl/scraper

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

1.9.0

2 files

1.8.0

2 files

1.7.0

2 files

1.6.0

2 files

1.5.0

2 files

1.4.0

2 files

1.3.0

2 files

1.2.0

2 files

1.1.0

2 files

1.0.1

2 files

This release

1.0.0 This release

2 files

0.2.6

2 files

0.2.5

2 files

0.2.4

2 files

0.2.3

2 files

0.2.2

2 files

0.2.1

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page