2026-09-09 13:49:07 +02:00
|
|
|
|
# stealthii
|
|
|
|
|
|
|
|
|
|
|
|
`retoor <retoor@molodetz.nl>`
|
|
|
|
|
|
|
2026-09-09 13:56:28 +02:00
|
|
|
|
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
|
|
|
|
|
2026-09-09 14:27:44 +02:00
|
|
|
|
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`).
|
|
|
|
|
|
|
2026-09-09 13:56:28 +02:00
|
|
|
|
## Why this exists
|
2026-09-09 13:49:07 +02:00
|
|
|
|
|
2026-09-09 13:56:28 +02:00
|
|
|
|
`requests`, `httpx`, and `aiohttp` all negotiate TLS through Python's
|
|
|
|
|
|
`ssl` module (OpenSSL). Real browsers negotiate through their own TLS
|
2026-09-10 01:58:55 +02:00
|
|
|
|
stack (Chrome: BoringSSL). The resulting ClientHello - cipher suites,
|
|
|
|
|
|
extensions, curves, and their order - is a fingerprint (JA3/JA4) that
|
2026-09-09 13:56:28 +02:00
|
|
|
|
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
|
2026-09-09 13:56:28 +02:00
|
|
|
|
plain HTTP requests through the same browser process backing a real
|
2026-09-10 01:58:55 +02:00
|
|
|
|
page, without needing a tab or JS execution - as fast as a normal HTTP
|
2026-09-09 13:56:28 +02:00
|
|
|
|
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
|
|
|
|
|
2026-09-09 13:56:28 +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
|
|
|
|
|
2026-09-09 13:56:28 +02:00
|
|
|
|
Distinct hashes, produced by the same physical request path our
|
2026-09-10 01:58:55 +02:00
|
|
|
|
`get`/`post`/etc. use - proof the fast path is not just claiming to be
|
2026-09-09 14:27:44 +02:00
|
|
|
|
Chrome, it is Chrome's own network stack. Measured latency and
|
2026-09-10 01:58:55 +02:00
|
|
|
|
concurrency overhead of that same fast path against `aiohttp` - steady
|
|
|
|
|
|
state, and 5×/20× concurrent - are in [`PERFORMANCE.md`](./PERFORMANCE.md).
|
2026-09-09 13:49:07 +02:00
|
|
|
|
|
2026-09-09 13:56:28 +02:00
|
|
|
|
On top of that, `stealthii` removes the JS-level automation tells a
|
2026-09-10 01:58:55 +02:00
|
|
|
|
stock headless Chromium exposes - `navigator.webdriver`, a missing
|
2026-09-09 13:56:28 +02:00
|
|
|
|
`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
|
2026-09-10 01:58:55 +02:00
|
|
|
|
of a system like DataDome or a Cloudflare managed challenge - those
|
2026-09-09 13:56:28 +02:00
|
|
|
|
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`),
|
2026-09-10 01:58:55 +02:00
|
|
|
|
declared in `pyproject.toml`. No other third-party runtime dependency -
|
2026-09-09 13:56:28 +02:00
|
|
|
|
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
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-09 13:56:28 +02:00
|
|
|
|
## 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,
|
2026-09-10 01:58:55 +02:00
|
|
|
|
on the first call that actually needs them - the first `get()`,
|
2026-09-09 13:56:28 +02:00
|
|
|
|
`post()`, `render()`, `page()`, or an explicit `start()`.
|
2026-09-09 13:49:07 +02:00
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
|
from stealthii import Stealthii
|
|
|
|
|
|
|
2026-09-10 01:58:55 +02:00
|
|
|
|
stealth = Stealthii() # cheap - nothing has launched yet
|
2026-09-09 13:56:28 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
An instance is *not* a Python singleton in the enforced sense (nothing
|
2026-09-10 01:58:55 +02:00
|
|
|
|
stops you from constructing several) - but a `Stealthii` instance owns
|
2026-09-09 13:56:28 +02:00
|
|
|
|
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
|
2026-09-10 01:58:55 +02:00
|
|
|
|
await stealth.start() # optional - every method below also does this lazily
|
2026-09-09 13:56:28 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
2026-09-09 13:56:28 +02:00
|
|
|
|
await resp.read() # raw bytes
|
2026-09-09 13:49:07 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-10 01:58:55 +02:00
|
|
|
|
- `params` - query string parameters (dict).
|
|
|
|
|
|
- `headers` - request headers (dict).
|
|
|
|
|
|
- `data` - a `dict` is sent form-urlencoded; `bytes`/`str` is sent as
|
2026-09-09 13:56:28 +02:00
|
|
|
|
the raw body. Matches both libraries' `data=` behaviour.
|
2026-09-10 01:58:55 +02:00
|
|
|
|
- `json` - JSON-encoded body; sets `Content-Type: application/json`.
|
|
|
|
|
|
- `files` - multipart file upload, `httpx`-style:
|
2026-09-09 13:56:28 +02:00
|
|
|
|
`files={"upload": ("name.txt", b"content")}`. Combine with `data` for
|
|
|
|
|
|
the plain form fields alongside the file(s).
|
2026-09-10 01:58:55 +02:00
|
|
|
|
- `cookies` - a per-call `dict`, layered on top of whatever the
|
2026-09-09 13:56:28 +02:00
|
|
|
|
context's own cookie jar already holds for that URL, without
|
|
|
|
|
|
mutating the jar. Matches `aiohttp`/`httpx`'s per-call `cookies=`.
|
2026-09-10 01:58:55 +02:00
|
|
|
|
- `auth` - an `(username, password)` tuple, sent as HTTP Basic auth.
|
2026-09-09 13:56:28 +02:00
|
|
|
|
- `allow_redirects` (the `aiohttp` spelling) and `follow_redirects`
|
|
|
|
|
|
(the `httpx` spelling) are both accepted; either disables following
|
|
|
|
|
|
redirects when passed `False`.
|
2026-09-10 01:58:55 +02:00
|
|
|
|
- `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
|
2026-09-09 13:56:28 +02:00
|
|
|
|
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
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-10 01:58:55 +02:00
|
|
|
|
None of these open a tab - they go through the shared context's
|
2026-09-09 13:56:28 +02:00
|
|
|
|
`context.request` fast path, described above.
|
2026-09-09 13:49:07 +02:00
|
|
|
|
|
2026-09-09 13:56:28 +02:00
|
|
|
|
### `download()`
|
|
|
|
|
|
|
2026-09-10 01:58:55 +02:00
|
|
|
|
Raw bytes, for images, PDFs, or any other binary attachment - the same
|
2026-09-09 13:56:28 +02:00
|
|
|
|
fast, no-tab path as `get()`:
|
2026-09-09 13:49:07 +02:00
|
|
|
|
|
|
|
|
|
|
```python
|
2026-09-09 13:56:28 +02:00
|
|
|
|
png_bytes = await stealth.download("https://example.com/photo.png")
|
2026-09-09 13:49:07 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-09 13:56:28 +02:00
|
|
|
|
## `render()` and `screenshot()`: full page execution
|
2026-09-09 13:49:07 +02:00
|
|
|
|
|
2026-09-10 01:58:55 +02:00
|
|
|
|
For targets that need a JS challenge solved - an SPA that only renders
|
2026-09-09 13:56:28 +02:00
|
|
|
|
results client-side, a Cloudflare-style "verifying your browser"
|
2026-09-10 01:58:55 +02:00
|
|
|
|
interstitial - escalate to a real page load with `render()`:
|
2026-09-09 13:56:28 +02:00
|
|
|
|
|
|
|
|
|
|
```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
|
2026-09-10 01:58:55 +02:00
|
|
|
|
it, the PNG bytes come back base64-encoded - convenient for embedding
|
2026-09-09 13:56:28 +02:00
|
|
|
|
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
|
2026-09-10 01:58:55 +02:00
|
|
|
|
Playwright `Page` - already stealth-patched - and guarantees it (and,
|
2026-09-09 13:56:28 +02:00
|
|
|
|
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:
|
2026-09-09 13:56:28 +02:00
|
|
|
|
await pg.goto(url)
|
2026-09-09 13:49:07 +02:00
|
|
|
|
await pg.click("#accept-cookies")
|
2026-09-09 13:56:28 +02:00
|
|
|
|
await pg.fill("#search", "query")
|
2026-09-09 13:49:07 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-09 13:56:28 +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
|
2026-09-10 01:58:55 +02:00
|
|
|
|
independently release, their own tab - the claim is tracked per
|
2026-09-09 13:56:28 +02:00
|
|
|
|
`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
|
|
|
|
|
|
|
2026-09-10 01:58:55 +02:00
|
|
|
|
Every call above is anonymous by default - it uses one shared context
|
2026-09-09 13:56:28 +02:00
|
|
|
|
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
|
2026-09-10 01:58:55 +02:00
|
|
|
|
call using that same name, in either direction - a `render()` call can
|
2026-09-09 13:56:28 +02:00
|
|
|
|
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
|
2026-09-10 01:58:55 +02:00
|
|
|
|
other - each name (including the implicit `None` for anonymous calls)
|
2026-09-09 13:56:28 +02:00
|
|
|
|
is its own isolated context.
|
|
|
|
|
|
|
2026-09-09 13:49:07 +02:00
|
|
|
|
## Configuration
|
|
|
|
|
|
|
2026-09-09 13:56:28 +02:00
|
|
|
|
Everything is set once, at construction:
|
2026-09-09 13:49:07 +02:00
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
|
stealth = Stealthii(
|
2026-09-09 13:56:28 +02:00
|
|
|
|
max_tabs=4, # concurrent render()/page()/screenshot() tabs
|
2026-09-10 01:58:55 +02:00
|
|
|
|
fingerprint="ja4", # "ja3" or "ja4" - see fingerprint_info()
|
2026-09-09 13:56:28 +02:00
|
|
|
|
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
|
|
|
|
|
|
)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-10 01:58:55 +02:00
|
|
|
|
`fingerprint` selects which hash `fingerprint_info()` reports - it does
|
2026-09-09 13:56:28 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-09-09 13:56:28 +02:00
|
|
|
|
A browser that runs for the entire lifetime of a process otherwise
|
2026-09-10 01:58:55 +02:00
|
|
|
|
accumulates cookies and an on-disk HTTP cache without bound - a slow,
|
2026-09-09 13:56:28 +02:00
|
|
|
|
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),
|
2026-09-10 01:58:55 +02:00
|
|
|
|
and fully relaunches the browser process - discarding its whole profile
|
|
|
|
|
|
directory - every `recycle_browser_after_seconds`. A named session that
|
2026-09-09 13:56:28 +02:00
|
|
|
|
needs to stay warm longer than the default 30 minutes / 500 requests
|
|
|
|
|
|
should get a larger `recycle_context_after` passed at construction.
|
|
|
|
|
|
|
|
|
|
|
|
## Error handling
|
|
|
|
|
|
|
2026-09-10 01:58:55 +02:00
|
|
|
|
- `StealthUnavailableError` - the browser itself could not be started
|
2026-09-09 13:56:28 +02:00
|
|
|
|
(Playwright not installed, or the Chromium launch failed). Raised
|
|
|
|
|
|
from `start()` and anything that needs the browser.
|
2026-09-10 01:58:55 +02:00
|
|
|
|
- `StealthRequestError` - a `get`/`post`/`put`/`patch`/`delete`/`head`/
|
2026-09-09 13:56:28 +02:00
|
|
|
|
`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
|
|
|
|
|
2026-09-10 01:58:55 +02:00
|
|
|
|
## One instance, one event loop
|
|
|
|
|
|
|
|
|
|
|
|
A `Stealthii` instance binds its lock, semaphore, browser process, and
|
|
|
|
|
|
every context to whichever `asyncio` event loop is running the first
|
|
|
|
|
|
time it is actually used. Using the same instance again from a
|
|
|
|
|
|
*different* event loop raises `StealthUnavailableError` immediately
|
|
|
|
|
|
(rather than hanging) - the fix is either a fresh `Stealthii()` per loop,
|
|
|
|
|
|
or making sure your event loop does not change across the calls that
|
|
|
|
|
|
share one instance. This matters most for test suites: `pytest-asyncio`
|
|
|
|
|
|
defaults to a new event loop per test function, which breaks a
|
|
|
|
|
|
module-level `Stealthii` singleton shared across tests. Set:
|
|
|
|
|
|
|
|
|
|
|
|
```ini
|
|
|
|
|
|
[pytest]
|
|
|
|
|
|
asyncio_default_test_loop_scope = session
|
|
|
|
|
|
asyncio_default_fixture_loop_scope = session
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
so the whole test session shares one loop - matching how a real
|
|
|
|
|
|
application actually runs (one process, one loop, for its entire
|
|
|
|
|
|
lifetime), and how `Stealthii` is meant to be used.
|
|
|
|
|
|
|
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
|
|
|
|
|
|
```
|
2026-09-09 13:56:28 +02:00
|
|
|
|
|
|
|
|
|
|
`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
|
2026-09-10 01:58:55 +02:00
|
|
|
|
above - including named-session persistence, per-call cookies, auth,
|
2026-09-09 13:56:28 +02:00
|
|
|
|
multipart upload, retries against an unreachable host, and the
|
2026-09-10 01:58:55 +02:00
|
|
|
|
sannysoft stealth regression suite - against real endpoints.
|
2026-09-09 13:56:28 +02:00
|
|
|
|
`scripts/smoke_live.py` is a plain manual script covering the same
|
|
|
|
|
|
ground end-to-end, useful for a quick sanity check outside pytest.
|