Skip to main content

forktex-scraping

Acquire public data, and be able to prove what you acquired.

A library, not a framework. There is no base class to inherit, no spider to register, no runner that calls you back — every piece works on its own, and you use the ones you need.

pip install forktex-scraping[http]      # stateless: fetch, pace, retry, page
pip install forktex-scraping[browser]   # stateful: a live page, and what its JS asks for

Fetch one page, politely

Six lines, and the politeness is real: robots.txt is checked, the rate is held per-origin, and 429/5xx back off on a budget.

import asyncio
from forktex_scraping import HttpTransport, Politeness, PolitenessSpec, Request

async def main():
    async with HttpTransport() as http:
        response = await Politeness().request(
            http,
            Request(url="https://example.com"),
            PolitenessSpec(rate_per_second=1),
        )
        print(response.status, response.text()[:80])

asyncio.run(main())

PolitenessSpec() defaults to 1 request/second, robots respected, 3 attempts. Politeness is an object you construct and pass, never a global — so two harvests against two hosts in one process do not interfere, and it works inside an API request handler.

A non-2xx is not an exception. It comes back as a Response, because a 404 is an answer.

Watch what a page's JavaScript actually asks for

This is the part worth reaching for. Most sites you would scrape are a thin UI over an API they never document — and the page will tell you the whole thing if you watch it.

Hand it a callback and every call the page makes arrives as it happens. Map it into your own types, write it wherever you keep things, count it, drop it — the library holds nothing.

import asyncio
from forktex_scraping import BrowserSession

async def main():
    rows = []

    async def mine(call):                      # your types, your storage, your rules
        if "api/search" in call.request.url:
            rows.extend(call.response.json()["items"])

    async with BrowserSession(on_exchange=mine) as page:
        await page.goto("https://example.com/search")
        await asyncio.sleep(5)

    print(len(rows), "rows, mapped by you")

asyncio.run(main())

Scraping the rendered DOM gives you the twenty rows on screen. Reading the request that produced them gives you every row, forever, over plain HTTP — and no browser after the first look.

Nothing is buffered when you pass on_exchange. Without it the session keeps a list for you, which is convenient for a short exploration and is what exchanges() and wait_for_exchange() read — but it grows for the life of the session, and a consumer who maps each call and drops it should not pay for a list they never read.

async with BrowserSession() as page:           # buffering: fine for a look around
    await page.goto("https://example.com/search")

    call = await page.wait_for_exchange("api/search", timeout_ms=30_000)
    print(sorted(call.request.body))           # the filter names the UI actually sends

    for call in page.exchanges():
        ...

By default only the site's own API calls are recorded — xhr and fetch, not images and stylesheets. Say otherwise when you need to:

BrowserSession(record_types={"xhr", "fetch", "document"})   # chasing an attachment
BrowserSession(record_types=None)                           # everything

page.observe() returns a PageState — the URL, the title, the text with navigation chrome stripped, and (when buffering) the exchanges since your last look. Pass text_limit= if the default 50 000 characters is the wrong budget for you.

Page an API, with the cap defeated

harvest is for the case that motivates this library: a remote that answers a query it could not fully satisfy without saying so.

import asyncio, pathlib
from forktex_scraping import (
    ApiSource, AssertKind, FilterField, HttpTransport, JsonlSink,
    Politeness, PolitenessSpec, RequestContract, Tier, harvest,
)

source = ApiSource(
    id="example.search",
    tier=Tier.CAPTURED_CONTRACT,
    politeness=PolitenessSpec(rate_per_second=2),
)

contract = RequestContract(
    source="example.search",
    url="https://example.com/api/search",
    method="POST",
    headers={"content-type": "application/json"},
    body_template={"sort": []},
    # Where each filter's effect must show up in a returned row. This is the load-bearing part.
    filters=(FilterField(field="q", assert_path="title", assert_kind=AssertKind.CONTAINS_FOLDED),),
    page_field="page",
    size_field="pageSize",
    page_size=100,
    first_page=0,
    items_path="items",
    total_path="total",
    cap_signal="truncated",   # the flag this remote sets when it capped the answer
    cap_size=3000,
)

async def main():
    sink = JsonlSink(pathlib.Path("out.jsonl"))
    async with HttpTransport() as http:
        coverage = await harvest(
            source, contract,
            transport=http, politeness=Politeness(),
            sink=sink, run_id="run-1",
            filters={"q": "software"},
        )
    await sink.close()

    print(coverage.summary())
    if not coverage.complete:
        for window in coverage.truncated:      # named, never silently missing
            print("could not cover", window.label())

asyncio.run(main())

Three things happen that you did not have to write:

The cap is defeated. When the remote signals truncation, the window is split along its axis and re-fetched until each part comes back honest. A window that cannot be split further is reported in coverage.truncated — not dropped, and not quietly accepted.

Every filter is checked against every row. assert_path says where a filter's effect must appear, and assert_kind says how. A row that violates it raises ContractDriftError, because the remote answered a different question than the one you asked, and filtering client-side would hide that behind a plausible-looking result.

Pick the kind that describes what the remote does, not what you would prefer:

Kind Use when the remote
CONTAINS matches a substring, case-insensitively
CONTAINS_FOLDED also ignores accents — searching platforma returns platformă
PREFIX, EQUALS matches from the start, or exactly
GTE, LTE bounds a value as an instant
DATE_GTE, DATE_LTE bounds by calendar day rather than moment

The folded and date variants exist because a strict comparison twice called a correctly-honoured filter a violation. An assertion that is stricter than the remote aborts good harvests.

Coverage is a claim you can inspect. coverage.complete is derived from what is in truncated and failed, so it cannot disagree with them.

Why assert_path has no default

Because a remote can accept a filter field it does not recognise, ignore it, and answer 200 with its capped unfiltered set — byte-identical to sending no filter at all. Measured, on a public procurement portal: an invented field name returned exactly the same 3 000 rows as sending nothing. A guessed field name does not fail. It produces a confidently wrong dataset, which is worse than an error, and no amount of care in your own code detects it.

So a filter you cannot verify is one this library will not send.

The one distinction to hold

Not HTTP versus browser — whether a thing can be replayed.

Transport (stateless) Session (stateful)
snapshot and replay yes — a request hashes to a stable identity no — order is the state
retry, paginate, cap-split yes not applicable
run concurrently yes, under the pacer one session, one caller

The rule that follows: a session's output is never a result. It is data — a contract, a source definition — that the stateless path then replays forever. Explore statefully; harvest statelessly. That is what keeps a run reproducible.

Other shapes

Not every source is a paged API behind a captured contract:

await harvest_keys(source, keys, ..., payload_for=lambda batch: [{"id": k} for k in batch])
await harvest_catalog(source, ...)    # CKAN-shaped open data
await harvest_sitemap(source, ...)    # enumerate via declared sitemaps, robots honoured

harvest_keys takes a payload_for you write, because a batch body's shape is the one thing that is genuinely per-API — and that knowledge belongs in your code, not this library's.

What you plug in

Everything below is a Protocol. Nothing to inherit — any object with the right methods works, including one you already have.

Port Methods Ships with
Transport fetch, close HttpTransport
Sink emit, close JsonlSink, RunDirectory.sink
SnapshotStore put, get RunDirectory
Session goto, act, observe, exchanges, close BrowserSession

Sink.emit takes a plain dict, deliberately: what a record means is your domain, and this library must not learn it.

Want runs on disk with verbatim evidence? RunDirectory(root) is both a sink and a snapshot store, writing <run-id>/records.jsonl, <run-id>/raw/<hash>.json.gz and a manifest. Want something else? Pass your own object. Want no snapshots? Leave the argument out.

Politeness, stated plainly

robots.txt is honoured and fails open — an unreachable or malformed one is not a refusal, because treating absence as prohibition makes the polite configuration the one that cannot fetch anything. A genuine Disallow raises RobotsRefusedError rather than logging: a politeness module that only warns is one that ships the violation.

The pacer is per-origin and shared across callers, so eight concurrent workers at "one per second" produce one per second, not eight. Retry-After wins over the computed backoff.

There is no stealth. No user-agent rotation, no proxy pool, no fingerprint patching. The agent string says who you are. A package that shipped evasion machinery would be used for evasion.

Errors

Every one is a leaf of forktex.error.AppError, so one except covers them, and each names a condition rather than a status code:

TransportError · ThrottledError · RobotsRefusedError · ContractDriftError · PartitionExhaustedError · ExtractionError · SourceDefinitionError · UnknownSourceError

ContractDriftError is the load-bearing one. It means the contract no longer describes the remote, so it aborts the run rather than being noted per-partition — every other partition is equally suspect.

Requirements

Python 3.14+. Extras import lazily: import forktex_scraping works with none of them installed, and asking for one you did not install raises an ImportError that names the extra.

Extra Pulls For
[http] httpx the stateless transport — almost everything
[browser] playwright watching a page, once per source
[html] selectolax parsing a server-rendered table

Documentation

One page per module in docs/, generated from typed JSON records under docs/sources/ — edit the JSON, not the markdown. docs/design.md is why it is shaped this way.

Licence

Dual-licensed: AGPL-3.0-or-later, or a commercial licence for use in proprietary products and SaaS deployments where AGPL obligations cannot be met — info@forktex.com. See LICENSE and NOTICE.

Metadata

Release files for forktex-scraping 0.1.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for forktex-scraping 0.1.2
File Size Uploaded Provenance Actions
forktex_scraping-0.1.2.tar.gz 48.8 kB Without provenance Details for forktex_scraping-0.1.2.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for forktex-scraping 0.1.2
File Interpreter ABI Platform Actions
forktex_scraping-0.1.2-py3-none-any.whl Python 3 none any Details for forktex_scraping-0.1.2-py3-none-any.whl

Total release size: 108.8 kB

Release files / forktex_scraping-0.1.2.tar.gz

Download URL forktex_scraping-0.1.2.tar.gz
Size 48.8 kB
Tags Source
SHA-256 checksum
How to use checksums
6f03f2cd095026a70a52e9afa40da44ef1f81a6ee871d1a87999a4cfbd8ce369
BLAKE2b-256 checksum
How to use checksums
13090d7bf61839badc8128d487df97741dd723f6d95a0b6efda3808d2095f16c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release files / forktex_scraping-0.1.2-py3-none-any.whl

Download URL forktex_scraping-0.1.2-py3-none-any.whl
Size 60.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3a698cf539c1172570b0b5530236b0df1a9d489417c3842ed20fbe3c3f5c5208
BLAKE2b-256 checksum
How to use checksums
58f7b94ed7a0178696ae1978cf3767403ec6f1f34e91314c5d05df2d9ad49ee0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Security for forktex-scraping 0.1.2

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.2 This release

2 release files No provenance

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