feature!: параметр user_agent убран, UA всегда родной; docs/Usage.md

- user_agent в запросе → 422 с подсказкой брать UA из ответа: UA, отличный от реального
  Chrome, расходится с userAgentData и движком, Cloudflare такой браузер не пропускает
  (замер: родной 3/3, Chrome 147 и Windows — 0) (#87)
- профиль хранит только отпечаток (экран, DPR), UA не подменяется (#89)
- docs/Usage.md: практики для клиентов API — тот же UA и прокси, что у браузера, сессии,
  cookies, ошибки и повторы, таймауты, пример на httpx
- README, docs/API.md, docs/Introduction.md; тесты под новый контракт

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-10-13 18:19:08 +04:00
1 parent 0ae5b0c83f
commit fe509c25c3
11 files changed
+202 -125

No files matched your search

+6 -3
View File
@@ -38,8 +38,10 @@ Client → Caddy (TLS, on the host) → FastAPI POST /v1/solve → browser slot
reCAPTCHA, hCaptcha) are solved via the client's 2captcha key.
- **Proxies**: not managed by the service — the client may supply their own proxy per request. Browser locale and
timezone are derived from the proxy's exit IP.
- **Sessions**: `session_id` cookie → persistent Chrome profile on disk. User agent and fingerprint (screen, DPR) are
fixed on the first request of a session; cookies passed in a request replace the profile's cookies.
- **Sessions**: `session_id` cookie → persistent Chrome profile on disk. A fingerprint (screen, DPR) is generated on
the first request of a session; cookies passed in a request replace the profile's cookies.
- **Browser identity is not configurable**: user agent and window size are the real browser's own — a spoofed UA
mismatches `userAgentData` and Cloudflare blocks it. Clients take `user_agent` from the response.
- **Logging**: stdout, level from `LOG_LEVEL`; client secrets (proxy credentials, 2captcha key, `fill` values,
cookie values) are masked.
- **Deployment**: single Docker container; TLS is terminated by Caddy on the host — see
@@ -110,7 +112,8 @@ Response:
On failure `status` is `"err"` with a machine-readable `code` (`no_slots`, `request_failed`, `challenge_failed`,
`action_failed`) and a human-readable `error`. Full reference — fields, action types, errors, sessions:
[docs/API.md](docs/API.md).
[docs/API.md](docs/API.md). How to use the API from your client (same UA and proxy as the browser, sessions,
retries) — [docs/Usage.md](docs/Usage.md).
## Roadmap
+11 -11
View File
@@ -1,5 +1,7 @@
# API
Как встраивать API в свой клиент (UA, прокси, сессии, повторы) — [Usage.md](Usage.md).
Основной эндпоинт: `POST /v1/solve`. Открывает страницу в Chrome (через прокси клиента, если он задан), при необходимости проходит
антибот (Cloudflare Turnstile, reCAPTCHA), выполняет список действий и возвращает cookies, результаты действий и,
по запросу, HTML и скриншот.
@@ -40,7 +42,6 @@ Cookie: session_id=<id> # необязательно, см. «Сесси
| `html` | bool | `false` | вернуть HTML страницы после выполнения действий |
| `screenshot` | bool | `false` | вернуть скриншот видимой области (PNG, base64) после действий — в том числе при ошибке действия или антибота |
| `timeout` | int, с | `60` | таймаут навигации |
| `user_agent` | string | `null` | свой User-Agent. Задаётся профилю **один раз** — в первом запросе сессии; в следующих запросах этой сессии поле игнорируется. Без него профилю генерируется отпечаток (см. «Сессии») |
| `cookies` | array | `null` | cookies для установки до навигации, формат Playwright: `{name, value, domain, path, expires, httpOnly, secure, sameSite}` или `{name, value, url}`. **Заменяют** cookies профиля: перед установкой все cookies профиля удаляются (`[]` — просто очистить). Без поля cookies профиля сохраняются |
| `disable_resources` | bool | `false` | не грузить картинки, шрифты, медиа |
| `blocked_domains` | array | `null` | домены, запросы к которым блокируются |
@@ -94,7 +95,7 @@ Cookie: session_id=<id> # необязательно, см. «Сесси
| `status` | `"ok"` — антибот пройден и все действия выполнены, иначе `"err"` |
| `code` | при `"err"`: причина, см. таблицу ниже |
| `error` | при `"err"`: текст ошибки; для упавшего действия — `<type>: <текст>` |
| `user_agent` | UA браузера, с которым проходили антибот (нужен клиенту вместе с `cf_clearance`) |
| `user_agent` | родной UA браузера, с которым проходили антибот. Используйте его в своём HTTP-клиенте вместе с `cf_clearance` (см. [Usage.md](Usage.md)) |
| `cookies` | все cookies контекста |
| `actions` | по элементу на выполненное действие: `type`, `ok`, `result` (результат или текст ошибки), `duration_ms` |
| `html` | только при `"html": true` |
@@ -126,18 +127,17 @@ Cookie: session_id=<id> # необязательно, см. «Сесси
## Сессии
Ответ ставит cookie `session_id` (UUID). Если передать её в следующем запросе, браузер откроется с тем же профилем
(`extra/user_data_dir/<session_id>`): cookies, localStorage, `user_agent` и отпечаток сохраняются, повторно проходить Cloudflare
(`extra/user_data_dir/<session_id>`): cookies, localStorage и отпечаток сохраняются, повторно проходить Cloudflare
обычно не нужно. Без cookie или с некорректным значением (не UUID) запрос получает новый профиль.
User-Agent и отпечаток профиля задаются при первом запросе сессии и дальше не меняются (`robotex_session.json` в
каталоге профиля):
Отпечаток профиля генерируется при первом запросе сессии и дальше не меняется (`robotex_session.json` в каталоге
профиля): размер экрана и `devicePixelRatio` десктопного Chrome на Linux из реального распределения (browserforge).
Применяется нативной эмуляцией Chrome, без JS-подмены (она ломала прохождение Cloudflare); DPR влияет и на хеш
canvas. Ограничение: canvas, WebGL и `hardwareConcurrency` у всех профилей одинаковые, поэтому примерно половина
профилей совпадает по отпечатку.
- передан `user_agent` — профилю записывается он;
- не передан — генерируется отпечаток десктопного Chrome на Linux (browserforge): UA с версией установленного Chrome,
размер экрана и `devicePixelRatio` из реального распределения. Применяется нативной эмуляцией Chrome, без
JS-подмены (она ломала прохождение Cloudflare). DPR влияет и на хеш canvas. Ограничение: canvas, WebGL и
`hardwareConcurrency` у всех профилей одинаковые, поэтому примерно половина профилей совпадает по отпечатку
(`make fp-check` показывает долю различий).
User-Agent и размер окна запросом не задаются — это родные значения браузера (почему — [Usage.md](Usage.md#что-задаёт-сервис-а-не-клиент)).
Поле `user_agent` в запросе даёт `422`.
Один профиль нельзя использовать в двух запросах одновременно: Chrome заблокирует каталог профиля.
+1 -1
View File
@@ -6,4 +6,4 @@
антибот (Cloudflare Turnstile, reCAPTCHA), выполняет действия и возвращает cookies (включая `cf_clearance`),
User-Agent и, по запросу, HTML. С этими cookies и UA клиент продолжает парсинг своим HTTP-клиентом.
Описание запроса и ответа — [API.md](API.md), развёртывание — [Deployment.md](Deployment.md).
Как пользоваться API — [Usage.md](Usage.md), описание запроса и ответа — [API.md](API.md), развёртывание — [Deployment.md](Deployment.md).
+110
View File
@@ -0,0 +1,110 @@
# Использование API: практики для клиентов
Как встроить Robotex в свой парсер или бота, чтобы антибот проходился стабильно, а полученные cookies работали.
Справочник по полям и ответам — [API.md](API.md).
## Основной сценарий
Robotex проходит антибот в настоящем браузере и отдаёт то, с чем клиент дальше ходит на сайт **своим HTTP-клиентом**:
1. `POST /v1/solve` с `url`, своим `proxy` и `captcha_type`.
2. Из ответа взять `cookies` (среди них `cf_clearance`) и **`user_agent`**.
3. Дальше ходить на сайт своим HTTP-клиентом:
- с **тем же User-Agent**, что пришёл в ответе;
- через **тот же прокси** (тот же exit-IP);
- с полученными cookies.
4. Когда сайт снова отвечает челленджем (обычно `403` и страница «Just a moment…») или истекает `cf_clearance`
(поле `expires` у cookie) — повторить `/v1/solve` **в той же сессии** (cookie `session_id`).
`cf_clearance` выдаётся конкретному браузеру: Cloudflare связывает его с User-Agent и, как правило, с IP. С другим UA
или через другой прокси cookie не сработает, и сайт снова покажет челлендж.
## Что задаёт сервис, а не клиент
User-Agent, размер окна и экран, отпечаток браузера **не настраиваются запросом** — их определяет сам браузер.
Клиент подстраивается под ответ, а не наоборот.
Почему так: подменить можно только часть признаков. Строка UA меняется, но `navigator.userAgentData`, client hints и
поведение движка остаются от реального Chrome, и Cloudflare видит расхождение. Замер на Chrome 155 / Linux:
| User-Agent в браузере | Cloudflare |
|---|---|
| родной (Linux Chrome 155) | пройден 3 из 3, 7–9 с |
| Linux Chrome 147 | не пройден, 3 из 3 |
| Windows Chrome 155 | не пройден |
Поэтому параметр `user_agent` убран из запроса (передача даёт `422`). Раньше по той же причине был убран параметр
размера окна. Каждому профилю сервис сам генерирует правдоподобный отпечаток (экран и `devicePixelRatio` из
реального распределения) и применяет его средствами Chrome.
UA в ответе может измениться, когда на сервере обновится Chrome. Не храните UA у себя навсегда — берите его из
последнего ответа.
## Сессии
- Ответ ставит cookie `session_id`. Передавайте её в следующих запросах: профиль браузера сохраняется (cookies,
localStorage, отпечаток), и повторно проходить антибот обычно не нужно — запрос заметно быстрее.
- Одна сессия — один «человек»: держите для неё **один и тот же прокси**. Смена exit-IP внутри сессии выглядит
подозрительно и обнуляет `cf_clearance`.
- Не отправляйте два запроса одной сессии одновременно: профиль Chrome занят первым запросом.
- Для параллельной работы заводите несколько сессий, каждую со своим прокси.
## Cookies
- Переданные в запросе `cookies` **заменяют** cookies профиля целиком. Не передали — профиль сохраняет свои.
- Для постоянных cookies указывайте `expires` (unix-время). Cookie без `expires` — сессионная и пропадёт между
запросами, потому что браузер перезапускается на каждый запрос.
- Обычно передавать cookies не нужно: профиль сам хранит то, что выдал сайт. Передавайте, только если сайт ведёт
себя по-разному в зависимости от cookies (например, авторизация, полученная вне Robotex).
## Ошибки и повторы
| Ответ | Что делать |
|---|---|
| `503`, `code: no_slots` | все браузеры заняты — повторить через `Retry-After` секунд |
| `challenge_failed` | антибот не пройден за отведённое время: повторить в той же сессии; при повторных неудачах — сменить прокси (и сессию) |
| `action_failed` | упало действие: смотрите `error` и `actions[].result`; для отладки повторите с `"screenshot": true` |
| `request_failed` | навигация не удалась: обычно прокси недоступен или сайт не отвечает |
| `422` | ошибка в запросе: описание в `detail` |
Успех — строго `status == "ok"`.
## Таймауты
Прохождение антибота может занять до 2 минут, плюс навигация (`timeout`) и действия. Таймаут вашего HTTP-клиента
к Robotex ставьте не меньше `timeout` + 120 с + сумма таймаутов действий. Так же настройте reverse proxy перед сервисом.
## Пример (Python, httpx)
```python
import httpx
ROBOTEX = "https://app.robotex.sokolab.xyz"
PROXY = "http://user:pass@host:port"
robotex = httpx.Client(base_url=ROBOTEX, timeout=300)
def solve(url: str) -> dict:
resp = robotex.post("/v1/solve", json={
"url": url,
"proxy": PROXY,
"captcha_type": "cloudflare",
}) # session_id сохраняется в cookies клиента robotex и уходит в следующих запросах
data = resp.json()
if data.get("status") != "ok":
raise RuntimeError(f"{data.get('code')}: {data.get('error')}")
return data
solution = solve("https://freelancehunt.com/projects")
site = httpx.Client(
proxy=PROXY, # тот же прокси
headers={"User-Agent": solution["user_agent"]}, # тот же UA
cookies={c["name"]: c["value"] for c in solution["cookies"]},
)
page = site.get("https://freelancehunt.com/projects")
if page.status_code == 403:
solution = solve("https://freelancehunt.com/projects") # та же сессия, обновить cf_clearance
```
+16 -52
View File
@@ -1,19 +1,16 @@
"""
Настройки профиля сессии (рядом с профилем Chrome): User-Agent и отпечаток. Задаются один раз при
первом запросе сессии и дальше не меняются — реальный человек не меняет браузер и монитор между
визитами.
Отпечаток профиля сессии (рядом с профилем Chrome): экран и devicePixelRatio. Генерируется один раз
при первом запросе сессии и дальше не меняется — реальный человек не меняет монитор между визитами.
Отпечаток применяется только нативной эмуляцией Chrome (экран, devicePixelRatio), без JS-инъекций:
полная подмена через browserforge InjectFunction ломала прохождение Cloudflare Turnstile (51a801f).
UA для сгенерированного профиля — шаблон Linux Chrome с версией установленного Chrome: UA другой
версии разошёлся бы с navigator.userAgentData, который Playwright не подменяет.
Применяется только нативной эмуляцией Chrome, без JS-инъекций: полная подмена через browserforge
InjectFunction ломала прохождение Cloudflare Turnstile (51a801f).
User-Agent не подменяется: строка UA, отличная от реального Chrome, расходится с
navigator.userAgentData и движком, и Cloudflare такой браузер не пропускает (замер в #87). UA — всегда
родной UA установленного Chrome, клиент берёт его из ответа.
"""
import json
import logging
import re
import shutil
import subprocess
from functools import cache
from pathlib import Path
from browserforge.fingerprints import FingerprintGenerator, Screen
@@ -21,24 +18,10 @@ from browserforge.fingerprints import FingerprintGenerator, Screen
log = logging.getLogger(__name__)
SETTINGS_FILE = "robotex_session.json"
UA_TEMPLATE = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{major}.0.0.0 Safari/537.36"
# окно браузера под Xvfb — 1920x1080, монитор физически не может быть меньше окна
MIN_SCREEN = (1920, 1080)
@cache
def chrome_major() -> str | None:
for name in ("google-chrome", "google-chrome-stable", "chrome"):
if path := shutil.which(name):
try:
out = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=10).stdout
except (OSError, subprocess.SubprocessError):
continue
if m := re.search(r"(\d+)\.\d+\.\d+\.\d+", out):
return m.group(1)
return None
def generate_fingerprint(attempts: int = 10) -> dict:
"""Правдоподобный отпечаток десктопного Chrome на Linux из датасета browserforge."""
screen, dpr = {"width": MIN_SCREEN[0], "height": MIN_SCREEN[1]}, 1
@@ -59,13 +42,7 @@ def generate_fingerprint(attempts: int = 10) -> dict:
else:
log.warning("browserforge failed %d times, using default screen", attempts)
major = chrome_major()
return {
# версия — от установленного Chrome, а не из датасета (см. docstring модуля)
"user_agent": UA_TEMPLATE.format(major=major) if major else None,
"screen": screen,
"device_scale_factor": dpr,
}
return {"screen": screen, "device_scale_factor": dpr}
def load(user_data_dir: Path) -> dict:
@@ -75,30 +52,17 @@ def load(user_data_dir: Path) -> dict:
return {}
def load_or_create(user_data_dir: Path, user_agent: str | None) -> dict:
"""Настройки профиля. При первом запросе сессии: UA клиента или сгенерированный отпечаток.
У существующего профиля всё берётся из сохранённого, переданный user_agent игнорируется."""
def load_or_create(user_data_dir: Path) -> dict:
"""Настройки профиля: сохранённый отпечаток или новый при первом запросе сессии."""
settings = load(user_data_dir)
if settings:
if user_agent and user_agent != settings.get("user_agent"):
log.info("profile already has user_agent, passed one is ignored")
if "fingerprint" in settings:
return settings
if user_agent:
settings = {"user_agent": user_agent}
else:
settings = {"fingerprint": generate_fingerprint()}
settings["user_agent"] = settings["fingerprint"].pop("user_agent")
settings = {"fingerprint": generate_fingerprint()}
(user_data_dir / SETTINGS_FILE).write_text(json.dumps(settings, ensure_ascii=False))
return settings
def browser_kwargs(settings: dict) -> dict:
"""kwargs AsyncStealthySession под настройки профиля."""
kwargs = {"useragent": settings.get("user_agent")}
if fp := settings.get("fingerprint"):
kwargs["additional_args"] = {
"screen": fp["screen"],
"device_scale_factor": fp["device_scale_factor"],
}
return kwargs
"""kwargs AsyncStealthySession под отпечаток профиля."""
fp = settings["fingerprint"]
return {"additional_args": {"screen": fp["screen"], "device_scale_factor": fp["device_scale_factor"]}}
+11 -1
View File
@@ -66,7 +66,6 @@ class SolveRequest(BaseModel):
url: str
proxy: str | None = None
timeout: int | None = None
user_agent: str | None = None
cookies: list[dict[str, Any]] | None = None
# List[{name: str, value: str, domain: str, path: str, expires: float, httpOnly: bool, secure: bool, sameSite: Union["Lax", "None", "Strict"], partitionKey: Union[str, None]}]
@@ -89,6 +88,17 @@ class SolveRequest(BaseModel):
wait: int = 0
google_search: bool = False
@model_validator(mode="before")
@classmethod
def reject_browser_identity(cls, data):
# UA и размер окна задаёт сам браузер: подмена расходится с userAgentData и движком,
# и Cloudflare такой браузер не пропускает (см. docs/Usage.md)
if isinstance(data, dict) and "user_agent" in data:
raise ValueError(
"user_agent is not supported: take user_agent from the response instead (see docs/Usage.md)"
)
return data
@field_validator("proxy")
@classmethod
def check_proxy(cls, proxy: str | None) -> str | None:
+2 -2
View File
@@ -167,8 +167,8 @@ async def solve(
try:
user_data_dir.mkdir(parents=True, exist_ok=True)
# UA и отпечаток задаются профилю один раз (см. api/profile.py)
profile_kwargs = profile.browser_kwargs(profile.load_or_create(user_data_dir, payload.user_agent))
# отпечаток задаётся профилю один раз (см. api/profile.py)
profile_kwargs = profile.browser_kwargs(profile.load_or_create(user_data_dir))
proxy = parse_proxy(payload.proxy) if payload.proxy else None
+16 -9
View File
@@ -3,6 +3,7 @@
make test-browser
"""
import json
import re
import uuid
import pytest
@@ -63,19 +64,17 @@ def test_action_failed_on_real_page(client):
assert data["error"] == "inner_html: selector not found: #missing"
def test_user_agent_once_and_cookies_replace(client):
def test_cookies_replace_profile_cookies(client):
url = "https://httpbin.org/cookies"
read = [{"type": "inner_html", "selector": "pre"}]
cookie = lambda name: {"name": name, "value": "1", "url": "https://httpbin.org", "expires": 4102444800}
first = solve(client, url=url, user_agent="First/1", cookies=[cookie("a")], actions=read)
first = solve(client, url=url, cookies=[cookie("a")], actions=read)
sid = first["session_id"]
assert first["user_agent"] == "First/1"
assert json.loads(first["actions"][0]["result"])["cookies"] == {"a": "1"}
# повторный UA игнорируется; без cookies в запросе профильные сохраняются
second = solve(client, sid, url=url, user_agent="Second/2", actions=read)
assert second["user_agent"] == "First/1"
# без cookies в запросе профильные сохраняются
second = solve(client, sid, url=url, actions=read)
assert json.loads(second["actions"][0]["result"])["cookies"] == {"a": "1"}
# переданные cookies заменяют профильные
@@ -83,7 +82,7 @@ def test_user_agent_once_and_cookies_replace(client):
assert json.loads(third["actions"][0]["result"])["cookies"] == {"b": "1"}
def test_generated_fingerprint_applied_and_stable(client, monkeypatch):
def test_fingerprint_applied_and_stable(client, monkeypatch):
async def page_fp(page):
await page.evaluate(FP_JS)
return json.loads(await page.inner_html("#fp"))
@@ -113,20 +112,28 @@ def test_generated_fingerprint_applied_and_stable(client, monkeypatch):
sids = [solve(client, url="https://example.com")["session_id"] for _ in range(4)]
solve(client, sids[0], url="https://example.com")
major = profile.chrome_major()
fps = {}
for d, measured in results.items():
saved = profile.load(main.USER_DATA_ROOT / d.rsplit("/", 1)[-1])
fp = saved["fingerprint"]
for m in measured:
assert m["screen"] == [fp["screen"]["width"], fp["screen"]["height"], fp["device_scale_factor"]]
assert f"Chrome/{major}." in m["ua"] and f"Google Chrome/{major}" in m["ua_ch"]
# UA родной: версия в строке UA совпадает с userAgentData
major = re.search(r"Google Chrome/(\d+)", m["ua_ch"]).group(1)
assert f"Chrome/{major}." in m["ua"]
assert all(m == measured[0] for m in measured), "fingerprint changed within a profile"
fps[d] = measured[0]
assert len(fps) == 4
print("distinct fingerprints:", len({json.dumps(f, sort_keys=True) for f in fps.values()}), "of", len(fps))
def test_ua_in_response_is_native(client):
"""user_agent в ответе — родной UA браузера, тот, с которым получен cf_clearance."""
data = solve(client, url="https://example.com")
assert re.fullmatch(r"Mozilla/5\.0 \(X11; Linux x86_64\) .* Chrome/\d+\.0\.0\.0 Safari/537\.36", data["user_agent"])
assert "Headless" not in data["user_agent"]
def test_cloudflare(client):
data = solve(client, url=CF_URL, captcha_type="cloudflare", timeout=120, actions=[
{"type": "wait_for", "selector": CF_SELECTOR, "timeout": 60},
+17 -38
View File
@@ -1,36 +1,24 @@
import json
import pytest
from api import profile
@pytest.fixture(autouse=True)
def chrome_155(monkeypatch):
monkeypatch.setattr(profile, "chrome_major", lambda: "155")
def test_fingerprint_generated_once(tmp_path):
first = profile.load_or_create(tmp_path)
assert profile.load_or_create(tmp_path) == first
assert json.loads((tmp_path / profile.SETTINGS_FILE).read_text()) == first
def test_passed_user_agent_is_saved_once(tmp_path):
first = profile.load_or_create(tmp_path, "First/1")
second = profile.load_or_create(tmp_path, "Second/2")
assert first == second == {"user_agent": "First/1"}
assert json.loads((tmp_path / profile.SETTINGS_FILE).read_text()) == {"user_agent": "First/1"}
def test_generated_fingerprint_without_user_agent(tmp_path):
settings = profile.load_or_create(tmp_path, None)
assert settings["user_agent"] == profile.UA_TEMPLATE.format(major="155")
screen = settings["fingerprint"]["screen"]
screen = first["fingerprint"]["screen"]
assert screen["width"] >= profile.MIN_SCREEN[0] and screen["height"] >= profile.MIN_SCREEN[1]
assert settings["fingerprint"]["device_scale_factor"] >= 1
assert first["fingerprint"]["device_scale_factor"] >= 1
assert "user_agent" not in first
def test_generated_fingerprint_is_stable_and_ignores_later_user_agent(tmp_path):
first = profile.load_or_create(tmp_path, None)
assert profile.load_or_create(tmp_path, None) == first
assert profile.load_or_create(tmp_path, "Later/1") == first
def test_old_profile_settings_get_fingerprint(tmp_path):
"""Профиль из версии, где сохранялся user_agent клиента: UA отбрасывается, отпечаток создаётся."""
(tmp_path / profile.SETTINGS_FILE).write_text(json.dumps({"user_agent": "First/1"}))
settings = profile.load_or_create(tmp_path)
assert set(settings) == {"fingerprint"}
def test_profiles_get_independent_fingerprints(tmp_path):
@@ -39,7 +27,7 @@ def test_profiles_get_independent_fingerprints(tmp_path):
for i in range(30):
d = tmp_path / str(i)
d.mkdir()
fp = profile.load_or_create(d, None)["fingerprint"]
fp = profile.load_or_create(d)["fingerprint"]
variants.add((fp["screen"]["width"], fp["screen"]["height"], fp["device_scale_factor"]))
assert len(variants) > 1
@@ -50,18 +38,9 @@ def test_generate_falls_back_when_browserforge_fails(monkeypatch):
raise ValueError("Cannot generate headers")
monkeypatch.setattr(profile, "FingerprintGenerator", Failing)
fp = profile.generate_fingerprint(attempts=2)
assert fp["screen"] == {"width": 1920, "height": 1080}
assert fp["device_scale_factor"] == 1
assert profile.generate_fingerprint(attempts=2) == {"screen": {"width": 1920, "height": 1080}, "device_scale_factor": 1}
def test_browser_kwargs():
assert profile.browser_kwargs({"user_agent": "UA/1"}) == {"useragent": "UA/1"}
kwargs = profile.browser_kwargs({
"user_agent": "UA/1",
"fingerprint": {"screen": {"width": 2560, "height": 1440}, "device_scale_factor": 1.25},
})
assert kwargs == {
"useragent": "UA/1",
"additional_args": {"screen": {"width": 2560, "height": 1440}, "device_scale_factor": 1.25},
}
def test_browser_kwargs_do_not_touch_user_agent():
kwargs = profile.browser_kwargs({"fingerprint": {"screen": {"width": 2560, "height": 1440}, "device_scale_factor": 1.25}})
assert kwargs == {"additional_args": {"screen": {"width": 2560, "height": 1440}, "device_scale_factor": 1.25}}
+5
View File
@@ -68,6 +68,11 @@ def test_proxy_optional():
assert solve(proxy="").proxy is None
def test_user_agent_rejected():
with pytest.raises(ValidationError, match="user_agent is not supported"):
solve(user_agent="Mozilla/5.0")
def test_captcha_action_requires_key():
action = {"type": "captcha", "selector": "#c", "value": "recaptcha"}
with pytest.raises(ValidationError, match="requires request field 'captcha'"):
+7 -8
View File
@@ -54,7 +54,6 @@ def client(monkeypatch, tmp_path):
monkeypatch.setattr(main, "AsyncStealthySession", FakeSession)
monkeypatch.setattr(main, "USER_DATA_ROOT", tmp_path)
monkeypatch.setattr(main, "_slots", asyncio.Semaphore(2))
monkeypatch.setattr(profile, "chrome_major", lambda: "155")
return TestClient(main.app)
@@ -168,18 +167,18 @@ def test_bad_session_cookie_gets_new_session(client, monkeypatch, tmp_path, bad)
assert [p.name for p in tmp_path.iterdir()] == [sid]
def test_user_agent_set_once_per_profile(client, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute([]))
post(client, user_agent="First/1")
post(client, user_agent="Second/2")
assert [c["useragent"] for c in FakeSession.calls] == ["First/1", "First/1"]
def test_user_agent_param_rejected(client):
resp = post(client, user_agent="First/1")
assert resp.status_code == 422
assert "take user_agent from the response" in resp.text
assert FakeSession.calls == []
def test_generated_fingerprint_passed_to_browser(client, monkeypatch):
def test_fingerprint_passed_to_browser_and_ua_untouched(client, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute([]))
post(client)
kwargs = FakeSession.calls[0]
assert kwargs["useragent"] == profile.UA_TEMPLATE.format(major="155")
assert "useragent" not in kwargs
assert set(kwargs["additional_args"]) == {"screen", "device_scale_factor"}