Documents measured single-request and concurrency (5x/20x) overhead of the context.request fast path against aiohttp: ~15-20ms/request steady state, no serialization bottleneck through the shared context up to at least 20 concurrent requests, browser launch (~1s) paid once at startup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116i7dYLNZTXNR7Lqf439bV
323 lines
14 KiB
Markdown
323 lines
14 KiB
Markdown
# stealthii
|
||
|
||
`retoor <retoor@molodetz.nl>`
|
||
|
||
An `aiohttp`/`httpx`-shaped async HTTP client backed by a single,
|
||
persistent, stealth-patched Chromium instance. Every request goes out
|
||
through the real browser's own network stack, so it carries a genuine
|
||
Chrome TLS/HTTP2/UA fingerprint instead of Python's OpenSSL-based one.
|
||
|
||
This README is the complete documentation. Two companion documents go
|
||
deeper on specific topics: [`STEALTH_PATCHES.md`](./STEALTH_PATCHES.md)
|
||
(every anti-automation patch applied, and why) and
|
||
[`PERFORMANCE.md`](./PERFORMANCE.md) (measured latency and concurrency
|
||
overhead against `aiohttp`).
|
||
|
||
## Why this exists
|
||
|
||
`requests`, `httpx`, and `aiohttp` all negotiate TLS through Python's
|
||
`ssl` module (OpenSSL). Real browsers negotiate through their own TLS
|
||
stack (Chrome: BoringSSL). The resulting ClientHello — cipher suites,
|
||
extensions, curves, and their order — is a fingerprint (JA3/JA4) that
|
||
anti-bot systems read directly off the handshake, independent of
|
||
whatever `User-Agent` header the client claims. A Python client cannot
|
||
fake this from the application layer; the only way to present a real
|
||
Chrome fingerprint is to make the request from an actual Chrome process.
|
||
|
||
Playwright's `BrowserContext.request` API does exactly that: it issues
|
||
plain HTTP requests through the same browser process backing a real
|
||
page, without needing a tab or JS execution — as fast as a normal HTTP
|
||
call, but with the browser's authentic identity. Measured directly
|
||
(`tests/test_stealth_detection.py::test_fast_path_uses_real_chromium_fingerprint`)
|
||
against a plain `aiohttp` client hitting the same TLS-fingerprint echo
|
||
endpoint:
|
||
|
||
| Client | JA3 | JA4 |
|
||
|---|---|---|
|
||
| stealthii (`context.request`) | `944d1e1858cd278718f8a46b65d3212f` | `t13d5211_b262b3658495_8e6e362c5eac` |
|
||
| aiohttp | `304734bb1c086c3453b387400cf83f11` | `t13d1812h1_85036bcba153_d41ae481755e` |
|
||
|
||
Distinct hashes, produced by the same physical request path our
|
||
`get`/`post`/etc. use — proof the fast path is not just claiming to be
|
||
Chrome, it is Chrome's own network stack. Measured latency and
|
||
concurrency overhead of that same fast path against `aiohttp` — steady
|
||
state, and 5×/20× concurrent — are in [`PERFORMANCE.md`](./PERFORMANCE.md).
|
||
|
||
On top of that, `stealthii` removes the JS-level automation tells a
|
||
stock headless Chromium exposes — `navigator.webdriver`, a missing
|
||
`window.chrome`, an empty plugin list, a SwiftShader WebGL renderer, a
|
||
permissions-query mismatch. Every patch and its exact rationale is
|
||
documented in [`STEALTH_PATCHES.md`](./STEALTH_PATCHES.md); the result
|
||
is verified clean against `bot.sannysoft.com`'s full detection suite on
|
||
every test run.
|
||
|
||
## What this does not do
|
||
|
||
TLS/HTTP2 impersonation defeats fingerprint-based detection. It does
|
||
not defeat IP-reputation-based rate limiting, nor the behavioural layer
|
||
of a system like DataDome or a Cloudflare managed challenge — those
|
||
need a clean IP and, for the hardest targets, genuine human-like
|
||
interaction. Layer `stealthii` with residential proxies and session
|
||
warm-up (`session=`, below) for those targets; do not expect it alone
|
||
to be a universal bypass. See "Known limitations" in
|
||
[`STEALTH_PATCHES.md`](./STEALTH_PATCHES.md) for the specific gaps,
|
||
including that the fast request path negotiates HTTP/1.1 rather than
|
||
HTTP/2.
|
||
|
||
## Dependencies
|
||
|
||
Exactly one: [`playwright`](https://playwright.dev/python/) (`>=1.40.0`),
|
||
declared in `pyproject.toml`. No other third-party runtime dependency —
|
||
no `requests`, no `aiohttp`, no stealth-plugin package. The Chromium
|
||
binary itself is installed separately via `playwright install chromium`
|
||
(see Install, below); it is Playwright's own dependency, not
|
||
stealthii's.
|
||
|
||
## Install
|
||
|
||
```bash
|
||
pip install -e .
|
||
playwright install chromium
|
||
```
|
||
|
||
## The `Stealthii` object: construction, laziness, lifetime
|
||
|
||
`Stealthii()` does no I/O. It only records configuration. The browser
|
||
process, its stealth-patched contexts, and every tab are created lazily,
|
||
on the first call that actually needs them — the first `get()`,
|
||
`post()`, `render()`, `page()`, or an explicit `start()`.
|
||
|
||
```python
|
||
from stealthii import Stealthii
|
||
|
||
stealth = Stealthii() # cheap — nothing has launched yet
|
||
```
|
||
|
||
An instance is *not* a Python singleton in the enforced sense (nothing
|
||
stops you from constructing several) — but a `Stealthii` instance owns
|
||
one Chromium process and every context derived from it, so the intended
|
||
usage is exactly like an `aiohttp.ClientSession` or an `httpx.Client`:
|
||
construct one, hand it to whatever needs it, and share it for the
|
||
lifetime of your application. Constructing a second instance launches a
|
||
second, entirely separate Chromium process; there is no coordination
|
||
between instances.
|
||
|
||
Call `start()` once at application boot if you want the browser launch
|
||
latency paid up front rather than by whichever request happens to be
|
||
first:
|
||
|
||
```python
|
||
await stealth.start() # optional — every method below also does this lazily
|
||
```
|
||
|
||
Call `shutdown()` once when your application exits:
|
||
|
||
```python
|
||
await stealth.shutdown() # closes every context and the browser process
|
||
```
|
||
|
||
In an `aiohttp` web application, wire these into `on_startup`/`on_cleanup`;
|
||
in any other async app, wherever your own startup/shutdown hooks live.
|
||
|
||
## HTTP methods
|
||
|
||
`get`, `post`, `put`, `patch`, `delete`, `head` all accept the request
|
||
shape you already know from `aiohttp`/`httpx`:
|
||
|
||
```python
|
||
resp = await stealth.get(url, params={"q": "x"}, headers={...}, timeout=15)
|
||
resp.status
|
||
await resp.json()
|
||
await resp.text()
|
||
await resp.read() # raw bytes
|
||
```
|
||
|
||
- `params` — query string parameters (dict).
|
||
- `headers` — request headers (dict).
|
||
- `data` — a `dict` is sent form-urlencoded; `bytes`/`str` is sent as
|
||
the raw body. Matches both libraries' `data=` behaviour.
|
||
- `json` — JSON-encoded body; sets `Content-Type: application/json`.
|
||
- `files` — multipart file upload, `httpx`-style:
|
||
`files={"upload": ("name.txt", b"content")}`. Combine with `data` for
|
||
the plain form fields alongside the file(s).
|
||
- `cookies` — a per-call `dict`, layered on top of whatever the
|
||
context's own cookie jar already holds for that URL, without
|
||
mutating the jar. Matches `aiohttp`/`httpx`'s per-call `cookies=`.
|
||
- `auth` — an `(username, password)` tuple, sent as HTTP Basic auth.
|
||
- `allow_redirects` (the `aiohttp` spelling) and `follow_redirects`
|
||
(the `httpx` spelling) are both accepted; either disables following
|
||
redirects when passed `False`.
|
||
- `max_redirects` — cap when following redirects (default `20`).
|
||
- `timeout` — seconds; falls back to the instance's `request_timeout`.
|
||
- `session` — see "Named sessions", below.
|
||
|
||
```python
|
||
await stealth.post(url, json={"hello": "world"})
|
||
await stealth.post(url, data={"x": "1"}) # form-urlencoded
|
||
await stealth.post(url, data={"field": "value"},
|
||
files={"upload": ("name.txt", b"content")}) # multipart
|
||
await stealth.get(url, cookies={"flavor": "choc"})
|
||
await stealth.get(url, auth=("user", "pass"))
|
||
await stealth.get(url, allow_redirects=False)
|
||
```
|
||
|
||
None of these open a tab — they go through the shared context's
|
||
`context.request` fast path, described above.
|
||
|
||
### `download()`
|
||
|
||
Raw bytes, for images, PDFs, or any other binary attachment — the same
|
||
fast, no-tab path as `get()`:
|
||
|
||
```python
|
||
png_bytes = await stealth.download("https://example.com/photo.png")
|
||
```
|
||
|
||
## `render()` and `screenshot()`: full page execution
|
||
|
||
For targets that need a JS challenge solved — an SPA that only renders
|
||
results client-side, a Cloudflare-style "verifying your browser"
|
||
interstitial — escalate to a real page load with `render()`:
|
||
|
||
```python
|
||
result = await stealth.render(url, collect_links=True)
|
||
result["status"] # HTTP status of the final navigation
|
||
result["text"] # document.body.innerText after JS has run and settled
|
||
result["links"] # only populated when collect_links=True
|
||
```
|
||
|
||
`screenshot()` renders the same way and captures a PNG:
|
||
|
||
```python
|
||
png_b64 = await stealth.screenshot(url) # base64 string
|
||
path = await stealth.screenshot(url, path="shot.png") # saved to disk, path returned
|
||
```
|
||
|
||
With `path` given, the file is written and `path` is returned. Without
|
||
it, the PNG bytes come back base64-encoded — convenient for embedding
|
||
directly in a data URI or a JSON API response.
|
||
|
||
## Raw page access: `page()` and `async with stealth as page`
|
||
|
||
For anything the high-level API does not cover, `page()` yields a real
|
||
Playwright `Page` — already stealth-patched — and guarantees it (and,
|
||
for an anonymous call, its context) closes again even if your code
|
||
raises:
|
||
|
||
```python
|
||
async with stealth.page() as pg:
|
||
await pg.goto(url)
|
||
await pg.click("#accept-cookies")
|
||
await pg.fill("#search", "query")
|
||
```
|
||
|
||
`Stealthii` also supports using the instance itself as the context
|
||
manager, claiming a tab directly:
|
||
|
||
```python
|
||
async with stealth as pg:
|
||
await pg.goto(url)
|
||
title = await pg.title()
|
||
```
|
||
|
||
This is exactly `page()` under the hood, with one guarantee on top:
|
||
it is task-safe. Two coroutines running `async with stealth as pg` at
|
||
the same time on the same shared `Stealthii` instance each get, and
|
||
independently release, their own tab — the claim is tracked per
|
||
`asyncio` task, not on the instance itself, so concurrent use never
|
||
cross-wires which `pg` belongs to which caller.
|
||
|
||
## Named sessions: cookie persistence and warm-up
|
||
|
||
Every call above is anonymous by default — it uses one shared context
|
||
that is transparently recycled (see below), with no cookie continuity
|
||
implied between calls. Pass `session="some-name"` to any of
|
||
`get`/`post`/`put`/`patch`/`delete`/`head`/`download`/`render`/
|
||
`screenshot`/`page` to use a dedicated, reused context under that name
|
||
instead. Cookies set under one name persist across every subsequent
|
||
call using that same name, in either direction — a `render()` call can
|
||
warm up a session that a later `get()` then reuses, or vice versa,
|
||
because both operate on the very same underlying browser context:
|
||
|
||
```python
|
||
await stealth.render("https://example.com/login", session="acme") # solves a JS challenge, sets cookies
|
||
resp = await stealth.get("https://example.com/api/data", session="acme") # reuses those cookies
|
||
```
|
||
|
||
Anonymous calls and named-session calls never share cookies with each
|
||
other — each name (including the implicit `None` for anonymous calls)
|
||
is its own isolated context.
|
||
|
||
## Configuration
|
||
|
||
Everything is set once, at construction:
|
||
|
||
```python
|
||
stealth = Stealthii(
|
||
max_tabs=4, # concurrent render()/page()/screenshot() tabs
|
||
fingerprint="ja4", # "ja3" or "ja4" — see fingerprint_info()
|
||
request_timeout=15.0, # default seconds for get/post/...
|
||
max_retries=3, # attempts before raising, for both request() and render()/goto
|
||
disk_cache_bytes=1, # Chromium's own on-disk cache size, kept near-zero
|
||
recycle_context_after=(500, 1800.0), # (max requests, max seconds) per context, named or not
|
||
recycle_browser_after_seconds=6*3600, # full relaunch cadence, or None to disable
|
||
)
|
||
```
|
||
|
||
`fingerprint` selects which hash `fingerprint_info()` reports — it does
|
||
not change what is sent on the wire; both values describe the same
|
||
genuine Chromium handshake, just in a different published format:
|
||
|
||
```python
|
||
await stealth.fingerprint_info()
|
||
# {"mode": "ja4", "hash": "t13d5211_...", "http_version": "HTTP/1.1"}
|
||
```
|
||
|
||
### Why the recycle settings exist
|
||
|
||
A browser that runs for the entire lifetime of a process otherwise
|
||
accumulates cookies and an on-disk HTTP cache without bound — a slow,
|
||
silent resource leak in anything long-running. `stealthii` closes and
|
||
recreates any context, anonymous or named, once it crosses
|
||
`recycle_context_after` (request count or age, whichever comes first),
|
||
and fully relaunches the browser process — discarding its whole profile
|
||
directory — every `recycle_browser_after_seconds`. A named session that
|
||
needs to stay warm longer than the default 30 minutes / 500 requests
|
||
should get a larger `recycle_context_after` passed at construction.
|
||
|
||
## Error handling
|
||
|
||
- `StealthUnavailableError` — the browser itself could not be started
|
||
(Playwright not installed, or the Chromium launch failed). Raised
|
||
from `start()` and anything that needs the browser.
|
||
- `StealthRequestError` — a `get`/`post`/`put`/`patch`/`delete`/`head`/
|
||
`download` call failed outright after `max_retries` attempts, each
|
||
separated by a short backoff. The retry is recursive and bounded, not
|
||
an unbounded loop.
|
||
- `render()`/`screenshot()`/`page()`'s navigation retries the same way,
|
||
bounded by `max_retries`, but returns a `status: None` result on
|
||
final failure rather than raising, since a page render already
|
||
produces a body of best-effort content even when the final navigation
|
||
attempt did not fully succeed.
|
||
- Both the tab (`page()`) and, for anonymous calls, its context are
|
||
guaranteed to close in a `finally`, even when your code inside
|
||
`async with stealth.page() as pg:` (or `async with stealth as pg:`)
|
||
raises.
|
||
|
||
## Testing
|
||
|
||
```bash
|
||
make test-unit # no network required
|
||
make test-integration # hits real sites; requires Chromium installed
|
||
```
|
||
|
||
`tests/test_client.py` covers construction, configuration, and the
|
||
pure request-shaping logic without any network access.
|
||
`tests/test_http_methods.py` and `tests/test_stealth_detection.py` are
|
||
integration tests (`@pytest.mark.online`) that exercise every method
|
||
above — including named-session persistence, per-call cookies, auth,
|
||
multipart upload, retries against an unreachable host, and the
|
||
sannysoft stealth regression suite — against real endpoints.
|
||
`scripts/smoke_live.py` is a plain manual script covering the same
|
||
ground end-to-end, useful for a quick sanity check outside pytest.
|