Skip to content

Use a browser or your own transport

Databrew fetches with plain HTTP by default, through fetchkit. This guide covers the cases that need more: a proxy, a site that checks the TLS fingerprint, a page that needs JavaScript, or a transport of your own. [fetch].backend picks one of fetchkit's three backends, and the table's other keys go to that backend's constructor.

Backend Use it for Install
httpx Plain HTTP; the default included
curl_cffi Sites that check the TLS fingerprint, by impersonating a browser the curl_cffi extra
zendriver Pages that need JavaScript, in a real Chrome the zendriver extra, and Chrome

Install an extra with the package:

uv tool install "databrew[zendriver] @ git+https://github.com/eyayaw/databrew"

A proxy or a browser's TLS fingerprint

The HTTP backends take a proxy, and curl_cffi impersonates a browser's TLS fingerprint:

[fetch]
backend = "curl_cffi"
proxy = "http://proxy.example:8080"

fetchkit's HTTP backends lists their options, among them timeout, headers, and http_version.

A browser for JavaScript pages

[fetch]
backend = "zendriver"
headless = true  # the default opens a window, which a server or CI runner lacks
timeout = 30.0

databrew parse CONFIG URL --save page.html shows whether a page needs it: if the saved HTML from the default backend lacks the cards you see in the browser, the page builds them with JavaScript.

The browser backend works in up to max_tabs tabs, which databrew sets from concurrency unless [fetch] sets it. A browser is slower and heavier than an HTTP client, so keep concurrency low. Two features of the HTTP backends are missing:

  • No per-request headers. A middleware that sets ctx.headers makes every request fail. Static headers in [fetch] are not accepted either.
  • No conditional revisits. A revisit fetches the whole page instead of asking whether it changed.

The browser's other options are in fetchkit's browser backend docs.

Sites behind a challenge

Some sites answer a crawler with a challenge page, such as Cloudflare's, instead of content. The built-in backends report it as the HTTP error it arrives with, often a 403, which is not retried and which only a middleware's on_error sees. Databrew does not solve challenges: how to pass one depends on the site, and that knowledge belongs in your code. fetchkit's browser offers ZendriverFetcher.page(url) and its solve_challenge(), so a fetcher of your own, described next, can wrap the browser and try a solve. A fetcher that returns the challenge category gets the URL retried and lets a middleware's on_challenge react.

Your own fetcher

For any transport fetchkit does not offer, write a class with two async methods, fetch and close, and pass an instance to run. This one fetches with aiohttp, which you install yourself:

crawl.py
import asyncio

import aiohttp
from fetchkit import ErrorCategory, FetchError, FetchResult

import databrew


class AiohttpFetcher:
    """Fetch with aiohttp, returning failures as results the way fetchkit's backends do."""

    def __init__(self) -> None:
        self._session = aiohttp.ClientSession()

    async def fetch(self, url: str, headers: dict[str, str] | None = None) -> FetchResult:
        try:
            async with self._session.get(url, headers=headers) as response:
                text = await response.text()
        except (aiohttp.ClientError, TimeoutError) as error:
            return FetchResult(url=url, error=FetchError(ErrorCategory.NETWORK, str(error)))
        result = FetchResult(url=str(response.url), status_code=response.status, text=text)
        if response.status == 429:
            result.error = FetchError(ErrorCategory.RATE_LIMITED, "HTTP error: 429")
        elif response.status >= 400:
            category = ErrorCategory.SERVER if response.status >= 500 else ErrorCategory.CLIENT
            result.error = FetchError(category, f"HTTP error: {response.status}")
        return result

    async def close(self) -> None:
        await self._session.close()


async def main() -> None:
    report = await databrew.run("site.toml", fetcher=AiohttpFetcher())
    print(report.stop, report.outcome)


if __name__ == "__main__":
    asyncio.run(main())

The fetcher returns failures as a FetchResult with an error rather than raising, and its category and status decide whether databrew retries, by the retry table. A fetch that raises fails its URL without a retry. Databrew awaits fetch and close without a timeout of its own, so both must finish in bounded time; run closes the fetcher when the run ends.

A supplied fetcher replaces the one [fetch] describes, for that run only. databrew check and the run command still build the configured backend, so a custom fetcher is for runs started from Python. Revisits through it fetch the whole page, because databrew cannot know whether it sends per-request headers.