diff --git a/README.md b/README.md index 46ee3e0..2bc762d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/API.md b/docs/API.md index 0e4ab00..d0d3da4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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= # необязательно, см. «Сесси | `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= # необязательно, см. «Сесси | `status` | `"ok"` — антибот пройден и все действия выполнены, иначе `"err"` | | `code` | при `"err"`: причина, см. таблицу ниже | | `error` | при `"err"`: текст ошибки; для упавшего действия — `: <текст>` | -| `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= # необязательно, см. «Сесси ## Сессии Ответ ставит cookie `session_id` (UUID). Если передать её в следующем запросе, браузер откроется с тем же профилем -(`extra/user_data_dir/`): cookies, localStorage, `user_agent` и отпечаток сохраняются, повторно проходить Cloudflare +(`extra/user_data_dir/`): 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 заблокирует каталог профиля. diff --git a/docs/Introduction.md b/docs/Introduction.md index 5ff40b5..f4637d0 100644 --- a/docs/Introduction.md +++ b/docs/Introduction.md @@ -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). diff --git a/docs/Usage.md b/docs/Usage.md new file mode 100644 index 0000000..a7b5d19 --- /dev/null +++ b/docs/Usage.md @@ -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 +``` diff --git a/src/api/profile.py b/src/api/profile.py index ba9e54d..60d8a9a 100644 --- a/src/api/profile.py +++ b/src/api/profile.py @@ -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"]}} diff --git a/src/api/schemas.py b/src/api/schemas.py index e5da3c6..def9752 100644 --- a/src/api/schemas.py +++ b/src/api/schemas.py @@ -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: diff --git a/src/main.py b/src/main.py index c1286d5..3658b26 100644 --- a/src/main.py +++ b/src/main.py @@ -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 diff --git a/tests/browser/test_e2e.py b/tests/browser/test_e2e.py index e02c367..6f85e98 100644 --- a/tests/browser/test_e2e.py +++ b/tests/browser/test_e2e.py @@ -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}, diff --git a/tests/unit/test_profile.py b/tests/unit/test_profile.py index 20e01e0..4ce472e 100644 --- a/tests/unit/test_profile.py +++ b/tests/unit/test_profile.py @@ -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}} diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index e883606..96ef0bc 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -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'"): diff --git a/tests/unit/test_solve.py b/tests/unit/test_solve.py index 826dd6d..e66ccec 100644 --- a/tests/unit/test_solve.py +++ b/tests/unit/test_solve.py @@ -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"}