Files
stealthii/README.md
T

315 lines
13 KiB
Markdown
Raw Normal View History

2026-09-09 13:49:07 +02:00
# 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.
2026-09-09 13:49:07 +02:00
## Why this exists
2026-09-09 13:49:07 +02:00
`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.
2026-09-09 13:49:07 +02:00
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:
2026-09-09 13:49:07 +02:00
| Client | JA3 | JA4 |
|---|---|---|
| stealthii (`context.request`) | `944d1e1858cd278718f8a46b65d3212f` | `t13d5211_b262b3658495_8e6e362c5eac` |
| aiohttp | `304734bb1c086c3453b387400cf83f11` | `t13d1812h1_85036bcba153_d41ae481755e` |
2026-09-09 13:49:07 +02:00
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.
2026-09-09 13:49:07 +02:00
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.
2026-09-09 13:49:07 +02:00
## 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()`.
2026-09-09 13:49:07 +02:00
```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)
2026-09-09 13:49:07 +02:00
resp.status
await resp.json()
await resp.text()
await resp.read() # raw bytes
2026-09-09 13:49:07 +02:00
```
- `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.
2026-09-09 13:49:07 +02:00
```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)
2026-09-09 13:49:07 +02:00
```
None of these open a tab — they go through the shared context's
`context.request` fast path, described above.
2026-09-09 13:49:07 +02:00
### `download()`
Raw bytes, for images, PDFs, or any other binary attachment — the same
fast, no-tab path as `get()`:
2026-09-09 13:49:07 +02:00
```python
png_bytes = await stealth.download("https://example.com/photo.png")
2026-09-09 13:49:07 +02:00
```
## `render()` and `screenshot()`: full page execution
2026-09-09 13:49:07 +02:00
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:
2026-09-09 13:49:07 +02:00
```python
async with stealth.page() as pg:
await pg.goto(url)
2026-09-09 13:49:07 +02:00
await pg.click("#accept-cookies")
await pg.fill("#search", "query")
2026-09-09 13:49:07 +02:00
```
`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.
2026-09-09 13:49:07 +02:00
## Configuration
Everything is set once, at construction:
2026-09-09 13:49:07 +02:00
```python
stealth = Stealthii(
max_tabs=4, # concurrent render()/page()/screenshot() tabs
2026-09-09 13:49:07 +02:00
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
2026-09-09 13:49:07 +02:00
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"}
```
2026-09-09 13:49:07 +02:00
### 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.
2026-09-09 13:49:07 +02:00
## 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.