Files
stealthii/README.md
T
retoorandClaude Sonnet 5 a3bb7639d2 Initial version of stealthii
An aiohttp/httpx-shaped async HTTP client backed by a single, persistent,
stealth-patched Chromium instance via Playwright. get/post/put/patch/
delete/head/download use context.request (real Chrome TLS/HTTP2
fingerprint, no tab needed); render()/screenshot()/page() escalate to
full JS-executing page rendering for targets that need a JS challenge
solved. Supports named sessions for cookie persistence/warm-up, and
`async with stealth as page` to claim a tab directly, task-safe for
concurrent use on one shared instance. Context/browser recycling bounds
cookie and on-disk cache growth in a long-lived process.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116i7dYLNZTXNR7Lqf439bV
2026-09-09 13:49:07 +02:00

5.5 KiB

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 — the signal most anti-bot systems key on first, before they ever look at a header.

Why

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 get 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. stealthii wraps that fast path (get/post/put/patch/delete/head/download) behind a familiar API, and adds full page rendering (render/screenshot/page) as a heavier fallback for targets that require solving a JS challenge.

On top of that, stealthii removes the JS-level automation tells a stock headless Chromium exposes (navigator.webdriver, missing window.chrome, an empty plugin list, a SwiftShader WebGL renderer, a permissions-query mismatch) — verified clean against bot.sannysoft.com's full check suite.

What it 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=, see below) for those targets; don't expect it alone to be a universal bypass.

Install

pip install -e .
playwright install chromium

Usage

from stealthii import Stealthii

stealth = Stealthii()          # cheap, lazy — launches nothing yet
resp = await stealth.get("https://example.com/api", params={"q": "x"})
resp.status
await resp.json()
await resp.text()

await stealth.shutdown()       # call once at host process shutdown

get/post/put/patch/delete/head accept the same shapes as aiohttp/httpx: params, headers, data (dict → form-urlencoded, bytes/str → raw body), json, files (multipart), cookies (per-call, layered on the context's own jar), auth as an (user, password) Basic tuple, allow_redirects / follow_redirects (both spellings accepted), max_redirects, and timeout.

data = await stealth.download("https://example.com/photo.png")  # raw bytes

png_b64 = await stealth.screenshot("https://example.com")        # base64 PNG
await stealth.screenshot("https://example.com", path="shot.png")  # saved to disk

rendered = await stealth.render("https://example.com", collect_links=True)
rendered["text"]; rendered["links"]; rendered["status"]

Every call is anonymous by default (a shared, periodically recycled context). Pass session="name" to use a dedicated, reused context instead — cookies set by one call under that name are seen by the next:

await stealth.render("https://example.com/", session="acme")       # picks up cookies
resp = await stealth.get("https://example.com/api", session="acme") # reuses them

Raw page access

For anything the high-level API doesn't cover, page() yields a real Playwright Page (already stealth-patched), guaranteed to close even on exception:

async with stealth.page() as pg:
    await pg.goto("https://example.com")
    await pg.click("#accept-cookies")

Configuration

All configuration is passed once, at construction — Stealthii(...) does no I/O itself; the browser launches lazily on first real use:

stealth = Stealthii(
    max_tabs=4,                            # concurrent render()/page() tabs
    fingerprint="ja4",                     # "ja3" or "ja4" — see fingerprint_info()
    request_timeout=15.0,
    max_retries=3,
    disk_cache_bytes=1,                    # keep Chromium's own on-disk cache tiny
    recycle_context_after=(500, 1800.0),   # (max requests, max seconds) per context
    recycle_browser_after_seconds=6*3600,  # full relaunch cadence, or None to disable
)

fingerprint selects which hash fingerprint_info() reports (JA3 or JA4) — it does not change what's sent on the wire; both describe the same genuine Chromium handshake.

Why the recycle settings exist

A browser that runs for the process's entire lifetime otherwise accumulates cookies and an on-disk HTTP cache without bound. stealthii closes and recreates any context — anonymous or named — once it crosses recycle_context_after, and fully relaunches the browser (discarding its whole profile directory) every recycle_browser_after_seconds. Named sessions meant to stay warm longer should get a larger recycle_context_after at construction time.

Testing

make test-unit          # no network required
make test-integration   # hits real sites; requires Chromium installed