fetch, but it tells you the truth: a verified-fetch primitive that returns a portable trust verdict, not just bytes.
Project description
veriscrape
fetch, but it tells you the truth. A verified-fetch primitive for web scraping: every fetch returns the bytes plus a portable trust verdict, so you know the moment your data is silently wrong, not three days later through a broken downstream report.
pip install veriscrape
import veriscrape
r = veriscrape.get("https://example.com")
r.verdict # OK | BLOCKED | CHALLENGE | HONEYPOT | SOFT_404 | LOGIN_WALL | EMPTY_SHELL | UNVERIFIED
r.cause # "cloudflare_challenge" | "datadome" | "js_app_shell" | ...
r.confidence # 0.0 to 1.0
r.ok # True only when the content is positively real
The problem
Every scraping tool hands you bytes and a 200 and calls it success. In 2026 a 200 OK is no longer
ground truth: it is often a challenge page, a login wall, a soft-404, or an empty JS shell.
Status-code retry logic (the industry default) never notices, so the corruption is stored as data and
surfaces days later. veriscrape classifies the response deterministically (no LLM) into a
verdict, with the evidence and a confidence score.
Verdicts
| verdict | meaning |
|---|---|
OK |
genuine origin content |
BLOCKED |
a hard anti-bot deny |
CHALLENGE |
a JS / CAPTCHA interstitial (solvable, not content) |
HONEYPOT |
a decoy / AI-Labyrinth trap |
SOFT_404 |
a "not found" served as 200 |
LOGIN_WALL |
a sign-in / paywall gate instead of the data |
EMPTY_SHELL |
a JS app skeleton with no server-rendered content |
UNVERIFIED |
couldn't tell, abstains rather than guess |
Detection is two-key and conservative: it would rather abstain (UNVERIFIED) than emit a
confident wrong OK, because a silent false OK is the exact failure the tool exists to prevent.
Today it detects BLOCKED, CHALLENGE, HONEYPOT, SOFT_404, LOGIN_WALL, and EMPTY_SHELL across
seven anti-bot vendors (Cloudflare, DataDome, Akamai, PerimeterX/HUMAN, Kasada, Imperva/Incapsula, F5 BIG-IP ASM)
and three CAPTCHA gates (reCAPTCHA, Turnstile, hCaptcha), plus vendor-agnostic content signals. A
positive OK is emitted for a 200 with substantial server-rendered content, but it stays
conservative: a thin or ambiguous page abstains to UNVERIFIED rather than risk a guessed OK.
CLI
$ veriscrape check https://discord.com/app
https://discord.com/app
!! EMPTY_SHELL (js_app_shell) confidence=0.97
HTTP 200
The exit code is pipeline-friendly: 0 when content looks fine (OK / UNVERIFIED), 1 when a
problem is detected. Drop it into CI to fail a job that silently scraped a wall. veriscrape check --file response.html classifies a saved response with no network; --json emits the record.
The finding
We ran popular fetchers against a set of targets, captured the raw bodies, and labeled each one
independently of veriscrape (benchmark/):
discord.com/appandweb.telegram.orgreturn HTTP 200 with an empty JavaScript app-shell: no server-rendered content, just a mount point and a wall of scripts. Every status-code-only fetcher (requests,curl_cffi,scrapling) stores that husk as a successful page. The status says success, the bytes are a skeleton, and the corruption is saved as data with no signal anything went wrong.
A note on rigor: an earlier cut of this benchmark reported a higher, scrapling-specific rate. Independent re-labeling of the captured bodies showed one headline cell was a veriscrape false positive (a real homepage mislabeled as a login wall), so that framing is retracted and the detector is fixed. Catching that is the point: the tool is built to flag silently-wrong data, and that discipline has to apply to its own output first.
Reproduce: uv run --extra benchmark python -m benchmark.run.
For the longer story (why a 200 stopped being ground truth, and the design rules behind the verdicts), see why veriscrape exists or the dev.to write-up.
Use it with your existing stack
veriscrape.get() is the drop-in for requests.get, but you don't have to switch fetchers. Add
the verdict to what you already have:
from veriscrape.adapters import from_requests, from_response
record = from_requests(requests.get(url)) # a requests.Response
record = from_response(status, headers, body, url=url) # any stack (httpx, Playwright, ...)
Scrapy: add veriscrape.adapters.VeriscrapeMiddleware to DOWNLOADER_MIDDLEWARES, then read
response.meta["veriscrape"] in your spider. Same verdict object everywhere.
Why a verdict, not just bytes
The FetchRecord verdict is portable JSON you own: the same shape travels across stacks
(requests / Scrapy / Playwright) and trends per-domain over time. Every fetch emits one; that shared
object is the spine. Deterministic-first by design: verdicts are computed from status / headers /
cookies / body, dated and reproducible, never a black box.
Status
Pre-alpha · deterministic-first · Apache-2.0 · drop-in for requests.get.
$ uv sync # for local development from a clone
$ uv run pytest # 157 tests
Project details
Release history Release notifications | RSS feed
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 veriscrape-0.2.0.tar.gz.
File metadata
- Download URL: veriscrape-0.2.0.tar.gz
- Upload date:
- Size: 155.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
50a2cb636629492c002f36abaf2392b420e3161beab7942e50ae107d7d42bbba
|
|
| MD5 |
63f256576e8db038abb356bffe49b2c1
|
|
| BLAKE2b-256 |
d9b184e3d36fc901c1ade5885145f3643d0e074b2682d69a699e03ec08ea66b3
|
Provenance
The following attestation bundles were made for veriscrape-0.2.0.tar.gz:
Publisher:
publish.yml on san64777/veriscrape
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
veriscrape-0.2.0.tar.gz -
Subject digest:
50a2cb636629492c002f36abaf2392b420e3161beab7942e50ae107d7d42bbba - Sigstore transparency entry: 1781524940
- Sigstore integration time:
-
Permalink:
san64777/veriscrape@baff389b887bceeba8414f068349240a37c60eb6 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/san64777
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@baff389b887bceeba8414f068349240a37c60eb6 -
Trigger Event:
release
-
Statement type:
File details
Details for the file veriscrape-0.2.0-py3-none-any.whl.
File metadata
- Download URL: veriscrape-0.2.0-py3-none-any.whl
- Upload date:
- Size: 33.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9f9fc357b7f668c54725f7c3b66204f48e8311a9be731d58e6f226a105ef4049
|
|
| MD5 |
1bb85f4a0ba7ea4fd1f9b97afcf7d295
|
|
| BLAKE2b-256 |
6282b8d94c41b9c73e3917b902825d70dc60131779c05b31b98b26b2adceba77
|
Provenance
The following attestation bundles were made for veriscrape-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on san64777/veriscrape
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
veriscrape-0.2.0-py3-none-any.whl -
Subject digest:
9f9fc357b7f668c54725f7c3b66204f48e8311a9be731d58e6f226a105ef4049 - Sigstore transparency entry: 1781525244
- Sigstore integration time:
-
Permalink:
san64777/veriscrape@baff389b887bceeba8414f068349240a37c60eb6 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/san64777
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@baff389b887bceeba8414f068349240a37c60eb6 -
Trigger Event:
release
-
Statement type: