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:
1 parent
0ae5b0c83f
commit
fe509c25c3
11 files changed
+202
-125
No files matched your search
@@ -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
@@ -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 заблокирует каталог профиля.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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}}
|
||||
@@ -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'"):
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user