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
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
@@ -0,0 +1,32 @@
|
||||
.PHONY: install dev test test-unit lint format clean build uninstall
|
||||
|
||||
install:
|
||||
pip install -e ".[test]"
|
||||
playwright install chromium
|
||||
|
||||
dev:
|
||||
pip install -e ".[test]"
|
||||
playwright install chromium
|
||||
|
||||
test: test-unit
|
||||
|
||||
test-unit:
|
||||
pytest tests/ -v -m 'not online'
|
||||
|
||||
test-integration:
|
||||
pytest tests/ -v -m online
|
||||
|
||||
lint:
|
||||
ruff check stealthii tests
|
||||
|
||||
format:
|
||||
ruff format stealthii tests
|
||||
|
||||
clean:
|
||||
rm -rf __pycache__ *.egg-info dist build .eggs stealthii/__pycache__ tests/__pycache__ .pytest_cache .ruff_cache
|
||||
|
||||
build:
|
||||
python -m build
|
||||
|
||||
uninstall:
|
||||
pip uninstall -y stealthii
|
||||
@@ -0,0 +1,142 @@
|
||||
# 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
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
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`.
|
||||
|
||||
```python
|
||||
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"]
|
||||
```
|
||||
|
||||
### Named sessions (cookie persistence / warm-up)
|
||||
|
||||
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:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
```bash
|
||||
make test-unit # no network required
|
||||
make test-integration # hits real sites; requires Chromium installed
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "stealthii"
|
||||
version = "0.1.0"
|
||||
description = "aiohttp/httpx-shaped HTTP client backed by a persistent, stealth-patched Chromium"
|
||||
authors = [
|
||||
{name = "retoor", email = "retoor@molodetz.nl"}
|
||||
]
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.9"
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
]
|
||||
dependencies = [
|
||||
"playwright>=1.40.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"ruff>=0.6.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/retoor/stealthii"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["stealthii*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"online: test requires live network access",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "B", "UP", "Q"]
|
||||
ignore = ["E501", "E402", "B008", "B904"]
|
||||
|
||||
[tool.ruff.lint.flake8-quotes]
|
||||
inline-quotes = "single"
|
||||
multiline-quotes = "double"
|
||||
docstring-quotes = "double"
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "single"
|
||||
@@ -0,0 +1,60 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
import asyncio
|
||||
|
||||
from stealthii import Stealthii
|
||||
|
||||
|
||||
async def main():
|
||||
s = Stealthii()
|
||||
|
||||
print('=== get (json) ===')
|
||||
resp = await s.get('https://httpbin.org/get', params={'a': '1'})
|
||||
print(resp.status, (await resp.json()).get('args'))
|
||||
|
||||
print('=== post (json body) ===')
|
||||
resp = await s.post('https://httpbin.org/post', json={'hello': 'world'})
|
||||
data = await resp.json()
|
||||
print(resp.status, data.get('json'))
|
||||
|
||||
print('=== post (form data) ===')
|
||||
resp = await s.post('https://httpbin.org/post', data={'x': '1', 'y': '2'})
|
||||
data = await resp.json()
|
||||
print(resp.status, data.get('form'))
|
||||
|
||||
print('=== cookies per-call ===')
|
||||
resp = await s.get('https://httpbin.org/cookies', cookies={'flavor': 'choc'})
|
||||
print(resp.status, await resp.json())
|
||||
|
||||
print('=== auth ===')
|
||||
resp = await s.get('https://httpbin.org/basic-auth/user/pass', auth=('user', 'pass'))
|
||||
print(resp.status, await resp.json())
|
||||
|
||||
print('=== download (binary) ===')
|
||||
png = await s.download('https://httpbin.org/image/png')
|
||||
print('bytes:', len(png), png[:8])
|
||||
|
||||
print('=== render ===')
|
||||
r = await s.render('https://example.com', collect_links=True)
|
||||
print(r['status'], r['text'][:60], len(r['links']))
|
||||
|
||||
print('=== screenshot (base64) ===')
|
||||
b64 = await s.screenshot('https://example.com')
|
||||
print('b64 len:', len(b64))
|
||||
|
||||
print('=== screenshot (path) ===')
|
||||
path = await s.screenshot('https://example.com', path='/tmp/stealthii_shot.png')
|
||||
print('saved to', path)
|
||||
|
||||
print('=== session warm-up (cookie persistence) ===')
|
||||
await s.get('https://httpbin.org/cookies/set/persisted/yes', session='warm', allow_redirects=True)
|
||||
resp = await s.get('https://httpbin.org/cookies', session='warm')
|
||||
print(await resp.json())
|
||||
|
||||
print('=== fingerprint_info (ja4 default) ===')
|
||||
print(await s.fingerprint_info())
|
||||
|
||||
await s.shutdown()
|
||||
print('OK - all smoke checks completed')
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,19 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
"""stealthii — an aiohttp/httpx-shaped HTTP client backed by a single,
|
||||
persistent, stealth-patched Chromium instance, so scraping traffic carries
|
||||
a genuine browser TLS/HTTP2/UA fingerprint instead of Python's own.
|
||||
"""
|
||||
|
||||
from .client import Stealthii, StealthRequestError, StealthResponse, StealthUnavailableError
|
||||
from .stealth_js import STEALTH_INIT_JS
|
||||
|
||||
__version__ = '0.1.0'
|
||||
|
||||
__all__ = [
|
||||
'Stealthii',
|
||||
'StealthResponse',
|
||||
'StealthRequestError',
|
||||
'StealthUnavailableError',
|
||||
'STEALTH_INIT_JS',
|
||||
'__version__',
|
||||
]
|
||||
@@ -0,0 +1,574 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
"""The Stealthii client: a single class that owns one persistent, stealth-
|
||||
patched Chromium instance and exposes an aiohttp/httpx-shaped API
|
||||
(get/post/download) backed by it, plus render() for targets that need real
|
||||
JS execution.
|
||||
|
||||
Usage::
|
||||
|
||||
stealth = Stealthii() # cheap, lazy — launches nothing yet
|
||||
resp = await stealth.get(url) # launches the browser on first use
|
||||
await stealth.shutdown() # call once at host process shutdown
|
||||
|
||||
A ``Stealthii`` instance must be constructed at least once by the host
|
||||
application before use; construction itself does no I/O.
|
||||
|
||||
Every request is anonymous (a shared, periodically recycled context) unless
|
||||
a ``session`` name is passed to get/post/download/render/page, in which
|
||||
case its own dedicated, cookie-persisting context is used and reused —
|
||||
useful for warm-up (render a homepage, then get() a protected endpoint
|
||||
under the same session name and its cookies carry over).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json as json_module
|
||||
import logging
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncIterator, Dict, Optional, Tuple, Union
|
||||
|
||||
from .stealth_js import STEALTH_INIT_JS
|
||||
|
||||
logger = logging.getLogger('stealthii')
|
||||
|
||||
_LOCALE_BY_LANG = {'nl': 'nl-NL', 'de': 'de-DE', 'fr': 'fr-FR', 'es': 'es-ES', 'en': 'en-US'}
|
||||
_LAUNCH_ARGS = [
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
]
|
||||
_IGNORE_DEFAULT_ARGS = ['--enable-automation']
|
||||
_FINGERPRINT_ECHO_URL = 'https://tls.peet.ws/api/all'
|
||||
_DEFAULT_SESSION = None
|
||||
|
||||
|
||||
class StealthUnavailableError(Exception):
|
||||
"""The stealth browser could not be started: Playwright is not
|
||||
installed, or the Chromium launch itself failed."""
|
||||
|
||||
|
||||
class StealthRequestError(Exception):
|
||||
"""A request through the stealth browser failed outright, after
|
||||
exhausting retries."""
|
||||
|
||||
|
||||
class StealthResponse:
|
||||
"""A fully-buffered response, shaped like an aiohttp ``ClientResponse``
|
||||
closely enough to drop into existing ``async with session.get(...) as
|
||||
resp:`` call sites unchanged."""
|
||||
|
||||
__slots__ = ('status', 'headers', 'url', '_body')
|
||||
|
||||
def __init__(self, status: int, headers: Dict[str, str], body: bytes, url: str):
|
||||
self.status = status
|
||||
self.headers = headers
|
||||
self.url = url
|
||||
self._body = body
|
||||
|
||||
async def text(self) -> str:
|
||||
return self._body.decode('utf-8', errors='replace')
|
||||
|
||||
async def json(self, content_type: Optional[str] = None) -> Any:
|
||||
return json_module.loads(self._body.decode('utf-8', errors='replace'))
|
||||
|
||||
async def read(self) -> bytes:
|
||||
return self._body
|
||||
|
||||
async def __aenter__(self) -> 'StealthResponse':
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class _ContextSlot:
|
||||
__slots__ = ('context', 'created_at', 'count', 'patched')
|
||||
|
||||
def __init__(self, context):
|
||||
self.context = context
|
||||
self.created_at = time.monotonic()
|
||||
self.count = 0
|
||||
self.patched = False
|
||||
|
||||
|
||||
class Stealthii:
|
||||
"""Owns one persistent, stealth-patched Chromium and every context
|
||||
derived from it (the shared anonymous context, named persistent
|
||||
sessions, rendered-page tabs).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
max_tabs:
|
||||
Maximum concurrent full-page renders (:meth:`page`/:meth:`render`).
|
||||
Cheap no-tab requests (:meth:`get`/:meth:`post`/:meth:`download`)
|
||||
are not limited by this — they cost no tab.
|
||||
fingerprint:
|
||||
``'ja3'`` or ``'ja4'`` (default). Selects which hash
|
||||
:meth:`fingerprint_info` reports. Both describe the same real
|
||||
Chromium TLS handshake — this does not change what is sent on the
|
||||
wire, only which fingerprint format is surfaced for diagnostics.
|
||||
request_timeout:
|
||||
Default timeout in seconds for get/post/download.
|
||||
max_retries:
|
||||
Number of attempts for get/post/download/render before raising.
|
||||
disk_cache_bytes:
|
||||
Chromium's own on-disk HTTP/media cache size, in bytes. Kept tiny
|
||||
by default since this browser runs indefinitely — an unbounded
|
||||
cache is otherwise a slow, silent disk leak.
|
||||
recycle_context_after:
|
||||
``(max_requests, max_seconds)``. Any context — anonymous or a named
|
||||
session — is closed and recreated once it crosses either bound,
|
||||
which is what actually keeps cookie/storage growth in check for a
|
||||
long-lived process. Pass a larger tuple at construction time for
|
||||
sessions meant to stay warm longer.
|
||||
recycle_browser_after_seconds:
|
||||
Fully relaunch the browser (fresh profile, discarding its on-disk
|
||||
cache directory too) after this many seconds. ``None`` disables it.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_tabs: int = 4,
|
||||
fingerprint: str = 'ja4',
|
||||
request_timeout: float = 15.0,
|
||||
max_retries: int = 3,
|
||||
disk_cache_bytes: int = 1,
|
||||
recycle_context_after: tuple = (500, 1800.0),
|
||||
recycle_browser_after_seconds: Optional[float] = 6 * 3600.0,
|
||||
):
|
||||
if fingerprint not in ('ja3', 'ja4'):
|
||||
raise ValueError(f"fingerprint must be 'ja3' or 'ja4', got {fingerprint!r}")
|
||||
self.max_tabs = max_tabs
|
||||
self.fingerprint = fingerprint
|
||||
self.request_timeout = request_timeout
|
||||
self.max_retries = max_retries
|
||||
self.disk_cache_bytes = disk_cache_bytes
|
||||
self.recycle_context_after_count, self.recycle_context_after_s = recycle_context_after
|
||||
self.recycle_browser_after_s = recycle_browser_after_seconds
|
||||
|
||||
self._playwright = None
|
||||
self._browser = None
|
||||
self._browser_started_at = 0.0
|
||||
self._contexts: Dict[Optional[str], _ContextSlot] = {}
|
||||
self._user_agent = None
|
||||
self._tab_semaphore = None
|
||||
self._lock: Optional[asyncio.Lock] = None
|
||||
self._attempted = False
|
||||
self._claimed_pages: Dict[Any, Any] = {}
|
||||
|
||||
def _get_lock(self) -> asyncio.Lock:
|
||||
if self._lock is None:
|
||||
self._lock = asyncio.Lock()
|
||||
return self._lock
|
||||
|
||||
async def start(self):
|
||||
"""Idempotently launch the browser. Calling this explicitly at host
|
||||
startup is optional — every other method calls it lazily — but
|
||||
doing so at boot means the first real request isn't the one paying
|
||||
for browser startup latency."""
|
||||
async with self._get_lock():
|
||||
await self._ensure_browser_locked()
|
||||
return self._browser
|
||||
|
||||
async def _ensure_browser_locked(self):
|
||||
"""Must be called with self._lock held."""
|
||||
now = time.monotonic()
|
||||
if self._browser is not None:
|
||||
if self.recycle_browser_after_s is not None and now - self._browser_started_at > self.recycle_browser_after_s:
|
||||
logger.info('stealthii: recycling browser (age threshold reached)')
|
||||
await self._close_browser_locked()
|
||||
else:
|
||||
return self._browser
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
except ImportError as e:
|
||||
raise StealthUnavailableError('playwright is not installed') from e
|
||||
try:
|
||||
pw = await async_playwright().start()
|
||||
browser = await pw.chromium.launch(
|
||||
headless=True,
|
||||
args=[
|
||||
*_LAUNCH_ARGS,
|
||||
f'--disk-cache-size={self.disk_cache_bytes}',
|
||||
f'--media-cache-size={self.disk_cache_bytes}',
|
||||
],
|
||||
ignore_default_args=_IGNORE_DEFAULT_ARGS,
|
||||
)
|
||||
except Exception as e:
|
||||
self._attempted = True
|
||||
raise StealthUnavailableError(f'chromium launch failed: {e}') from e
|
||||
self._playwright = pw
|
||||
self._browser = browser
|
||||
self._browser_started_at = now
|
||||
self._tab_semaphore = asyncio.Semaphore(self.max_tabs)
|
||||
self._user_agent = (
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
|
||||
f'(KHTML, like Gecko) Chrome/{browser.version} Safari/537.36'
|
||||
)
|
||||
logger.info(f'stealthii: browser started (Chromium {browser.version})')
|
||||
return self._browser
|
||||
|
||||
async def _close_browser_locked(self):
|
||||
for slot in self._contexts.values():
|
||||
try:
|
||||
await slot.context.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._contexts.clear()
|
||||
try:
|
||||
if self._browser is not None:
|
||||
await self._browser.close()
|
||||
if self._playwright is not None:
|
||||
await self._playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._browser = None
|
||||
self._playwright = None
|
||||
|
||||
async def _get_context(self, session: Optional[str]):
|
||||
"""Return the BrowserContext for ``session`` (``None`` = the
|
||||
shared anonymous one), creating or recycling it as needed. Every
|
||||
context — named or not — is subject to the same recycle bounds, so
|
||||
a long-lived process never accumulates unbounded cookies/storage
|
||||
under any one name."""
|
||||
async with self._get_lock():
|
||||
await self._ensure_browser_locked()
|
||||
now = time.monotonic()
|
||||
slot = self._contexts.get(session)
|
||||
if slot is not None:
|
||||
stale = (
|
||||
slot.count >= self.recycle_context_after_count
|
||||
or now - slot.created_at > self.recycle_context_after_s
|
||||
)
|
||||
if stale:
|
||||
logger.info(f'stealthii: recycling context {session!r} (cache/cookie bound reached)')
|
||||
try:
|
||||
await slot.context.close()
|
||||
except Exception:
|
||||
pass
|
||||
slot = None
|
||||
if slot is None:
|
||||
context = await self._browser.new_context(user_agent=self._user_agent)
|
||||
slot = _ContextSlot(context)
|
||||
self._contexts[session] = slot
|
||||
slot.count += 1
|
||||
return slot
|
||||
|
||||
@asynccontextmanager
|
||||
async def page(self, lang: str = 'en', session: Optional[str] = None) -> AsyncIterator[Any]:
|
||||
"""A stealth-patched tab (Playwright ``Page``), for full JS
|
||||
rendering. With ``session`` set, the tab's context is a named,
|
||||
reused session (cookies persist across calls under that name);
|
||||
otherwise a fresh, isolated context is used and closed with the
|
||||
tab. Always closes the tab; only closes the context too when it
|
||||
was created just for this call (anonymous, session=None) — a named
|
||||
session's context stays open for reuse."""
|
||||
sem = self._tab_semaphore
|
||||
if sem is None:
|
||||
await self.start()
|
||||
sem = self._tab_semaphore
|
||||
async with sem:
|
||||
slot = await self._get_context(session)
|
||||
ctx = slot.context
|
||||
try:
|
||||
ctx.set_default_navigation_timeout(self.request_timeout * 1000)
|
||||
except Exception:
|
||||
pass
|
||||
if not slot.patched:
|
||||
try:
|
||||
await ctx.add_init_script(STEALTH_INIT_JS)
|
||||
slot.patched = True
|
||||
except Exception:
|
||||
pass
|
||||
pg = await ctx.new_page()
|
||||
try:
|
||||
yield pg
|
||||
finally:
|
||||
await pg.close()
|
||||
|
||||
async def __aenter__(self) -> Any:
|
||||
"""``async with stealth as pg:`` claims a tab for exclusive use —
|
||||
equivalent to ``async with stealth.page() as pg:`` — and hands back
|
||||
a plain Playwright ``Page`` you can drive with any of its normal
|
||||
methods. Task-local, so concurrent ``async with stealth as pg``
|
||||
blocks from different coroutines on the same shared instance each
|
||||
get, and release, their own tab independently."""
|
||||
cm = self.page()
|
||||
pg = await cm.__aenter__()
|
||||
self._claimed_pages[asyncio.current_task()] = cm
|
||||
return pg
|
||||
|
||||
async def __aexit__(self, *exc_info) -> bool:
|
||||
cm = self._claimed_pages.pop(asyncio.current_task(), None)
|
||||
if cm is None:
|
||||
return False
|
||||
return bool(await cm.__aexit__(*exc_info))
|
||||
|
||||
async def render(
|
||||
self, url: str, lang: str = 'en', wait_until: str = 'domcontentloaded',
|
||||
collect_links: bool = False, timeout: float = 25.0, settle_ms: int = 500,
|
||||
session: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Navigate to ``url`` with full JS execution and return
|
||||
``{'text', 'links', 'status'}``. For targets that need a JS
|
||||
challenge solved (Cloudflare-style interstitials, SPA-rendered
|
||||
results) — the heavier alternative to get()/post()."""
|
||||
out: Dict[str, Any] = {'text': '', 'links': [], 'status': None}
|
||||
async with self.page(lang=lang, session=session) as pg:
|
||||
resp = await self._goto_with_retry(pg, url, wait_until, timeout)
|
||||
out['status'] = resp.status if resp else None
|
||||
await pg.wait_for_timeout(settle_ms)
|
||||
out['text'] = await pg.evaluate("() => (document.body && document.body.innerText) || ''")
|
||||
if collect_links:
|
||||
out['links'] = await pg.evaluate(
|
||||
"""() => Array.from(document.querySelectorAll('a[href]'))
|
||||
.map(a => ({href: a.href, text: (a.innerText || '').trim().slice(0, 120)}))
|
||||
.filter(l => /^https?:/.test(l.href))
|
||||
.slice(0, 200)
|
||||
"""
|
||||
)
|
||||
return out
|
||||
|
||||
async def screenshot(
|
||||
self, url: str, path: Optional[str] = None, lang: str = 'en',
|
||||
wait_until: str = 'domcontentloaded', full_page: bool = True,
|
||||
timeout: float = 25.0, settle_ms: int = 500, session: Optional[str] = None,
|
||||
) -> Union[str, None]:
|
||||
"""Render ``url`` and capture a PNG screenshot.
|
||||
|
||||
With ``path`` given, saves the PNG there and returns ``path``.
|
||||
Without it, returns the PNG content as a base64 string — handy for
|
||||
embedding straight into a data URI or an API response.
|
||||
"""
|
||||
async with self.page(lang=lang, session=session) as pg:
|
||||
await self._goto_with_retry(pg, url, wait_until, timeout)
|
||||
await pg.wait_for_timeout(settle_ms)
|
||||
if path:
|
||||
await pg.screenshot(path=path, full_page=full_page, type='png')
|
||||
return path
|
||||
png_bytes = await pg.screenshot(full_page=full_page, type='png')
|
||||
return base64.b64encode(png_bytes).decode('ascii')
|
||||
|
||||
async def _goto_with_retry(self, pg, url: str, wait_until: str, timeout: float, attempt: int = 1):
|
||||
try:
|
||||
return await pg.goto(url, wait_until=wait_until, timeout=timeout * 1000)
|
||||
except Exception as e:
|
||||
if attempt >= self.max_retries:
|
||||
logger.info(f'stealthii: goto {url} failed after {attempt} attempts: {e}')
|
||||
return None
|
||||
await asyncio.sleep(0.4 * attempt)
|
||||
return await self._goto_with_retry(pg, url, wait_until, timeout, attempt=attempt + 1)
|
||||
|
||||
async def _cookie_header(self, ctx, url: str, cookies: Dict[str, str]) -> str:
|
||||
"""Merge ``cookies`` (per-call overrides) on top of whatever the
|
||||
context already holds for ``url``, and render as a Cookie header
|
||||
value — mirrors aiohttp/httpx's per-call ``cookies=`` semantics
|
||||
without mutating the context's own persistent cookie jar."""
|
||||
try:
|
||||
existing = await ctx.cookies(url)
|
||||
except Exception:
|
||||
existing = []
|
||||
merged = {c['name']: c['value'] for c in existing}
|
||||
merged.update(cookies)
|
||||
return '; '.join(f'{k}={v}' for k, v in merged.items())
|
||||
|
||||
@staticmethod
|
||||
def _build_body(
|
||||
headers: Dict[str, str], data: Any, json: Any, files: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Shape (data, json, files) into the one Playwright fetch() body
|
||||
kwarg that matches aiohttp/httpx semantics: json= is a JSON body,
|
||||
data=dict is form-urlencoded, data=bytes/str is a raw body, and
|
||||
files= (optionally combined with data=) is multipart."""
|
||||
if files:
|
||||
multipart: Dict[str, Any] = dict(data) if isinstance(data, dict) else {}
|
||||
for field, value in files.items():
|
||||
if isinstance(value, tuple):
|
||||
filename, content = value[0], value[1]
|
||||
else:
|
||||
filename, content = field, value
|
||||
multipart[field] = {
|
||||
'name': filename,
|
||||
'mimeType': 'application/octet-stream',
|
||||
'buffer': content if isinstance(content, (bytes, bytearray)) else str(content).encode(),
|
||||
}
|
||||
return {'multipart': multipart}
|
||||
if json is not None:
|
||||
headers['Content-Type'] = 'application/json'
|
||||
return {'data': json_module.dumps(json)}
|
||||
if isinstance(data, dict):
|
||||
return {'form': data}
|
||||
if data is not None:
|
||||
return {'data': data}
|
||||
return {}
|
||||
|
||||
async def request(
|
||||
self, method: str, url: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
data: Any = None,
|
||||
json: Any = None,
|
||||
files: Optional[Dict[str, Any]] = None,
|
||||
cookies: Optional[Dict[str, str]] = None,
|
||||
auth: Optional[Tuple[str, str]] = None,
|
||||
allow_redirects: Optional[bool] = None,
|
||||
follow_redirects: Optional[bool] = None,
|
||||
max_redirects: int = 20,
|
||||
timeout: Optional[float] = None,
|
||||
session: Optional[str] = None,
|
||||
_attempt: int = 1,
|
||||
) -> StealthResponse:
|
||||
"""Issue one HTTP request through a browser context's real Chromium
|
||||
network stack — no tab is opened, so this is the fast path.
|
||||
|
||||
Accepts the same request shape as aiohttp/httpx: ``params``,
|
||||
``headers``, ``data`` (dict = form-urlencoded, bytes/str = raw
|
||||
body), ``json``, ``files`` (multipart, optionally combined with
|
||||
``data`` for the plain fields), ``cookies`` (per-call, layered on
|
||||
top of the context's own jar), ``auth`` as an ``(user, password)``
|
||||
Basic-auth tuple, and both ``allow_redirects`` (aiohttp) and
|
||||
``follow_redirects`` (httpx) spellings.
|
||||
|
||||
With ``session`` set, reuses that named context's cookies (e.g.
|
||||
picked up by an earlier render() warm-up call). Retries recursively
|
||||
(bounded by ``max_retries``) with a short backoff on any failure.
|
||||
"""
|
||||
effective_timeout = self.request_timeout if timeout is None else timeout
|
||||
slot = await self._get_context(session)
|
||||
req_headers = dict(headers or {})
|
||||
|
||||
if auth is not None and 'Authorization' not in req_headers:
|
||||
token = base64.b64encode(f'{auth[0]}:{auth[1]}'.encode()).decode()
|
||||
req_headers['Authorization'] = f'Basic {token}'
|
||||
if cookies:
|
||||
req_headers['Cookie'] = await self._cookie_header(slot.context, url, cookies)
|
||||
|
||||
follow = follow_redirects if follow_redirects is not None else allow_redirects
|
||||
follow = True if follow is None else follow
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
'headers': req_headers,
|
||||
'timeout': effective_timeout * 1000,
|
||||
'max_redirects': max_redirects if follow else 0,
|
||||
}
|
||||
if params:
|
||||
kwargs['params'] = params
|
||||
kwargs.update(self._build_body(req_headers, data, json, files))
|
||||
|
||||
try:
|
||||
resp = await slot.context.request.fetch(url, method=method, **kwargs)
|
||||
body = await resp.body()
|
||||
return StealthResponse(resp.status, dict(resp.headers), body, resp.url)
|
||||
except Exception as e:
|
||||
if _attempt >= self.max_retries:
|
||||
raise StealthRequestError(f'{method} {url} failed after {_attempt} attempts: {e}') from e
|
||||
await asyncio.sleep(0.3 * _attempt)
|
||||
return await self.request(
|
||||
method, url, params=params, headers=headers, data=data, json=json,
|
||||
files=files, cookies=cookies, auth=auth, allow_redirects=allow_redirects,
|
||||
follow_redirects=follow_redirects, max_redirects=max_redirects,
|
||||
timeout=timeout, session=session, _attempt=_attempt + 1,
|
||||
)
|
||||
|
||||
async def get(
|
||||
self, url: str, params: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
cookies: Optional[Dict[str, str]] = None,
|
||||
auth: Optional[Tuple[str, str]] = None,
|
||||
allow_redirects: Optional[bool] = None, follow_redirects: Optional[bool] = None,
|
||||
max_redirects: int = 20, timeout: Optional[float] = None,
|
||||
session: Optional[str] = None,
|
||||
) -> StealthResponse:
|
||||
return await self.request(
|
||||
'GET', url, params=params, headers=headers, cookies=cookies, auth=auth,
|
||||
allow_redirects=allow_redirects, follow_redirects=follow_redirects,
|
||||
max_redirects=max_redirects, timeout=timeout, session=session,
|
||||
)
|
||||
|
||||
async def post(
|
||||
self, url: str, params: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None, data: Any = None,
|
||||
json: Any = None, files: Optional[Dict[str, Any]] = None,
|
||||
cookies: Optional[Dict[str, str]] = None,
|
||||
auth: Optional[Tuple[str, str]] = None,
|
||||
allow_redirects: Optional[bool] = None, follow_redirects: Optional[bool] = None,
|
||||
max_redirects: int = 20, timeout: Optional[float] = None,
|
||||
session: Optional[str] = None,
|
||||
) -> StealthResponse:
|
||||
return await self.request(
|
||||
'POST', url, params=params, headers=headers, data=data, json=json,
|
||||
files=files, cookies=cookies, auth=auth, allow_redirects=allow_redirects,
|
||||
follow_redirects=follow_redirects, max_redirects=max_redirects,
|
||||
timeout=timeout, session=session,
|
||||
)
|
||||
|
||||
async def put(
|
||||
self, url: str, params: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None, data: Any = None,
|
||||
json: Any = None, cookies: Optional[Dict[str, str]] = None,
|
||||
auth: Optional[Tuple[str, str]] = None, timeout: Optional[float] = None,
|
||||
session: Optional[str] = None,
|
||||
) -> StealthResponse:
|
||||
return await self.request(
|
||||
'PUT', url, params=params, headers=headers, data=data, json=json,
|
||||
cookies=cookies, auth=auth, timeout=timeout, session=session,
|
||||
)
|
||||
|
||||
async def patch(
|
||||
self, url: str, params: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None, data: Any = None,
|
||||
json: Any = None, cookies: Optional[Dict[str, str]] = None,
|
||||
auth: Optional[Tuple[str, str]] = None, timeout: Optional[float] = None,
|
||||
session: Optional[str] = None,
|
||||
) -> StealthResponse:
|
||||
return await self.request(
|
||||
'PATCH', url, params=params, headers=headers, data=data, json=json,
|
||||
cookies=cookies, auth=auth, timeout=timeout, session=session,
|
||||
)
|
||||
|
||||
async def delete(
|
||||
self, url: str, params: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
cookies: Optional[Dict[str, str]] = None,
|
||||
auth: Optional[Tuple[str, str]] = None, timeout: Optional[float] = None,
|
||||
session: Optional[str] = None,
|
||||
) -> StealthResponse:
|
||||
return await self.request(
|
||||
'DELETE', url, params=params, headers=headers, cookies=cookies,
|
||||
auth=auth, timeout=timeout, session=session,
|
||||
)
|
||||
|
||||
async def head(
|
||||
self, url: str, params: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None, timeout: Optional[float] = None,
|
||||
session: Optional[str] = None,
|
||||
) -> StealthResponse:
|
||||
return await self.request('HEAD', url, params=params, headers=headers, timeout=timeout, session=session)
|
||||
|
||||
async def download(
|
||||
self, url: str, headers: Optional[Dict[str, str]] = None,
|
||||
cookies: Optional[Dict[str, str]] = None,
|
||||
timeout: Optional[float] = None, session: Optional[str] = None,
|
||||
) -> bytes:
|
||||
"""Fetch raw bytes (images, PDFs, any binary attachment) through
|
||||
the same fast, no-tab path as get()/post()."""
|
||||
resp = await self.get(url, headers=headers, cookies=cookies, timeout=timeout or 30.0, session=session)
|
||||
return await resp.read()
|
||||
|
||||
async def fingerprint_info(self) -> Dict[str, Any]:
|
||||
"""Self-diagnostic: fetch this client's own current TLS fingerprint
|
||||
from a public echo endpoint, reported in the configured format
|
||||
(``ja3`` or ``ja4``). Useful to verify stealth quality after
|
||||
changes, not needed for normal operation."""
|
||||
resp = await self.get(_FINGERPRINT_ECHO_URL)
|
||||
data = await resp.json()
|
||||
tls = data.get('tls', {}) if isinstance(data, dict) else {}
|
||||
key = 'ja3_hash' if self.fingerprint == 'ja3' else 'ja4'
|
||||
return {'mode': self.fingerprint, 'hash': tls.get(key), 'http_version': data.get('http_version')}
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Close the browser and every context derived from it (anonymous
|
||||
and named sessions alike). Call once at host process shutdown."""
|
||||
async with self._get_lock():
|
||||
await self._close_browser_locked()
|
||||
@@ -0,0 +1,119 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
"""Browser-side JS patches injected into every page/context before any page
|
||||
script runs. Removes the automation tells that a stock headless Chromium
|
||||
otherwise exposes (navigator.webdriver, missing window.chrome, empty
|
||||
PluginArray, SwiftShader WebGL renderer, permissions-query mismatch,
|
||||
iframe.contentWindow leaking the absence of window.chrome).
|
||||
"""
|
||||
|
||||
STEALTH_INIT_JS = r"""
|
||||
(() => {
|
||||
const patchNativeToString = (fn, name) => {
|
||||
const src = `function ${name}() { [native code] }`;
|
||||
const proxied = new Proxy(fn, {
|
||||
apply(target, thisArg, args) { return Reflect.apply(target, thisArg, args); },
|
||||
});
|
||||
Object.defineProperty(proxied, 'toString', {
|
||||
value: () => src, configurable: true, enumerable: false, writable: false,
|
||||
});
|
||||
return proxied;
|
||||
};
|
||||
const define = (obj, prop, getter) => {
|
||||
try {
|
||||
Object.defineProperty(obj, prop, { get: patchNativeToString(getter, 'get ' + prop), configurable: true });
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
define(Navigator.prototype, 'webdriver', () => undefined);
|
||||
|
||||
try {
|
||||
if (!window.chrome || !window.chrome.runtime) {
|
||||
window.chrome = {
|
||||
runtime: {
|
||||
connect: () => {}, sendMessage: () => {}, onMessage: { addListener: () => {} },
|
||||
id: undefined,
|
||||
},
|
||||
loadTimes: function () {}, csi: function () {}, app: {
|
||||
isInstalled: false,
|
||||
InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
|
||||
RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const originalQuery = window.navigator.permissions && window.navigator.permissions.query;
|
||||
if (originalQuery) {
|
||||
window.navigator.permissions.query = patchNativeToString((parameters) => (
|
||||
parameters && parameters.name === 'notifications'
|
||||
? Promise.resolve({ state: Notification.permission, onchange: null })
|
||||
: originalQuery(parameters)
|
||||
), 'query');
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const fakeMimeType = (type, description, suffixes) => ({ type, description, suffixes, enabledPlugin: null });
|
||||
const fakePlugin = (name, description, filename, mimes) => {
|
||||
const p = { name, description, filename, length: mimes.length };
|
||||
mimes.forEach((m, i) => { p[i] = m; m.enabledPlugin = p; });
|
||||
p.item = (i) => p[i] || null;
|
||||
p.namedItem = (n) => mimes.find((m) => m.type === n) || null;
|
||||
return p;
|
||||
};
|
||||
const pdfMime = fakeMimeType('application/pdf', 'Portable Document Format', 'pdf');
|
||||
const plugins = [
|
||||
fakePlugin('PDF Viewer', 'Portable Document Format', 'internal-pdf-viewer', [pdfMime]),
|
||||
fakePlugin('Chrome PDF Viewer', 'Portable Document Format', 'internal-pdf-viewer', [pdfMime]),
|
||||
fakePlugin('Chromium PDF Viewer', 'Portable Document Format', 'internal-pdf-viewer', [pdfMime]),
|
||||
fakePlugin('Microsoft Edge PDF Viewer', 'Portable Document Format', 'internal-pdf-viewer', [pdfMime]),
|
||||
fakePlugin('WebKit built-in PDF', 'Portable Document Format', 'internal-pdf-viewer', [pdfMime]),
|
||||
];
|
||||
const pluginArray = plugins;
|
||||
pluginArray.item = (i) => pluginArray[i] || null;
|
||||
pluginArray.namedItem = (n) => pluginArray.find((p) => p.name === n) || null;
|
||||
pluginArray.refresh = () => {};
|
||||
pluginArray.forEach((p) => {
|
||||
try { Object.setPrototypeOf(p, Plugin.prototype); } catch (e) {}
|
||||
Object.defineProperty(p, Symbol.toStringTag, { value: 'Plugin', configurable: true });
|
||||
});
|
||||
Object.defineProperty(pluginArray, Symbol.toStringTag, { value: 'PluginArray', configurable: true });
|
||||
try { Object.setPrototypeOf(pluginArray, PluginArray.prototype); } catch (e) {}
|
||||
define(Navigator.prototype, 'plugins', () => pluginArray);
|
||||
define(Navigator.prototype, 'mimeTypes', () => {
|
||||
const arr = [pdfMime];
|
||||
Object.defineProperty(arr, Symbol.toStringTag, { value: 'MimeTypeArray', configurable: true });
|
||||
try { Object.setPrototypeOf(arr, MimeTypeArray.prototype); } catch (e) {}
|
||||
return arr;
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
define(Navigator.prototype, 'deviceMemory', () => 8);
|
||||
define(Navigator.prototype, 'hardwareConcurrency', () => 8);
|
||||
|
||||
try {
|
||||
const spoofVendor = (ctxProto) => {
|
||||
const original = ctxProto.getParameter;
|
||||
ctxProto.getParameter = patchNativeToString(function (parameter) {
|
||||
if (parameter === 37445) return 'Google Inc. (NVIDIA)';
|
||||
if (parameter === 37446) return 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)';
|
||||
return original.apply(this, arguments);
|
||||
}, 'getParameter');
|
||||
};
|
||||
spoofVendor(WebGLRenderingContext.prototype);
|
||||
if (window.WebGL2RenderingContext) spoofVendor(WebGL2RenderingContext.prototype);
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const contentWindowDesc = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow');
|
||||
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
|
||||
get: patchNativeToString(function () {
|
||||
const win = contentWindowDesc.get.call(this);
|
||||
try { if (win && !win.chrome) win.chrome = window.chrome; } catch (e) {}
|
||||
return win;
|
||||
}, 'get contentWindow'),
|
||||
});
|
||||
} catch (e) {}
|
||||
})();
|
||||
"""
|
||||
@@ -0,0 +1,75 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
import pytest
|
||||
|
||||
from stealthii import Stealthii, StealthResponse
|
||||
from stealthii.client import _ContextSlot
|
||||
|
||||
|
||||
def test_construction_is_lazy_and_cheap():
|
||||
s = Stealthii()
|
||||
assert s._browser is None
|
||||
assert s._playwright is None
|
||||
assert s._contexts == {}
|
||||
|
||||
|
||||
def test_invalid_fingerprint_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
Stealthii(fingerprint='ja5')
|
||||
|
||||
|
||||
def test_defaults():
|
||||
s = Stealthii()
|
||||
assert s.fingerprint == 'ja4'
|
||||
assert s.max_tabs == 4
|
||||
assert s.max_retries == 3
|
||||
assert s.recycle_context_after_count == 500
|
||||
assert s.recycle_context_after_s == 1800.0
|
||||
|
||||
|
||||
def test_build_body_json():
|
||||
headers = {}
|
||||
kwargs = Stealthii._build_body(headers, data=None, json={'a': 1}, files=None)
|
||||
assert kwargs == {'data': '{"a": 1}'}
|
||||
assert headers['Content-Type'] == 'application/json'
|
||||
|
||||
|
||||
def test_build_body_form_dict():
|
||||
kwargs = Stealthii._build_body({}, data={'x': '1'}, json=None, files=None)
|
||||
assert kwargs == {'form': {'x': '1'}}
|
||||
|
||||
|
||||
def test_build_body_raw_bytes():
|
||||
kwargs = Stealthii._build_body({}, data=b'raw', json=None, files=None)
|
||||
assert kwargs == {'data': b'raw'}
|
||||
|
||||
|
||||
def test_build_body_none():
|
||||
assert Stealthii._build_body({}, data=None, json=None, files=None) == {}
|
||||
|
||||
|
||||
def test_build_body_multipart_with_files_and_fields():
|
||||
kwargs = Stealthii._build_body(
|
||||
{}, data={'field': 'value'}, json=None,
|
||||
files={'upload': ('name.txt', b'content')},
|
||||
)
|
||||
multipart = kwargs['multipart']
|
||||
assert multipart['field'] == 'value'
|
||||
assert multipart['upload']['name'] == 'name.txt'
|
||||
assert multipart['upload']['buffer'] == b'content'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stealth_response_shapes_like_aiohttp():
|
||||
resp = StealthResponse(200, {'Content-Type': 'application/json'}, b'{"ok": true}', 'https://x/')
|
||||
assert resp.status == 200
|
||||
assert await resp.text() == '{"ok": true}'
|
||||
assert await resp.json() == {'ok': True}
|
||||
assert await resp.read() == b'{"ok": true}'
|
||||
async with resp as r:
|
||||
assert r is resp
|
||||
|
||||
|
||||
def test_context_slot_starts_fresh():
|
||||
slot = _ContextSlot(context=object())
|
||||
assert slot.count == 0
|
||||
assert slot.patched is False
|
||||
@@ -0,0 +1,61 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
import pytest
|
||||
|
||||
from stealthii import Stealthii
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_sannysoft_detection_suite_is_clean():
|
||||
"""Regression guard: every check on bot.sannysoft.com must pass under
|
||||
our stealth patches. If this starts failing, a Chromium upgrade likely
|
||||
changed something the STEALTH_INIT_JS patches assume."""
|
||||
s = Stealthii()
|
||||
try:
|
||||
async with s.page() as pg:
|
||||
await pg.goto('https://bot.sannysoft.com/', wait_until='networkidle', timeout=20000)
|
||||
rows = await pg.evaluate(
|
||||
"""() => Array.from(document.querySelectorAll('table tr')).map(tr =>
|
||||
Array.from(tr.querySelectorAll('td')).map(td => td.innerText.trim()).join(' | ')
|
||||
)"""
|
||||
)
|
||||
finally:
|
||||
await s.shutdown()
|
||||
|
||||
failures = [r for r in rows if 'FAIL' in r or 'failed' in r]
|
||||
assert not failures, f'stealth detection regressions: {failures}'
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_with_instance_claims_isolated_concurrent_tabs():
|
||||
"""`async with stealth as pg` must behave like `stealth.page()`, and be
|
||||
safe to use concurrently from multiple tasks on one shared instance."""
|
||||
import asyncio
|
||||
|
||||
s = Stealthii()
|
||||
|
||||
async def worker():
|
||||
async with s as pg:
|
||||
await pg.goto('https://example.com')
|
||||
return await pg.title()
|
||||
|
||||
try:
|
||||
titles = await asyncio.gather(*[worker() for _ in range(3)])
|
||||
finally:
|
||||
await s.shutdown()
|
||||
assert titles == ['Example Domain'] * 3
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_fast_path_uses_real_chromium_fingerprint():
|
||||
"""The no-tab get()/post() fast path must carry a genuine Chromium TLS
|
||||
fingerprint, distinct from a plain Python HTTP stack's."""
|
||||
s = Stealthii()
|
||||
try:
|
||||
info = await s.fingerprint_info()
|
||||
finally:
|
||||
await s.shutdown()
|
||||
assert info['hash']
|
||||
assert info['mode'] == 'ja4'
|
||||
Reference in New Issue
Block a user