From 58100fa45d698d33d1fcc1229dce9d05c9589148 Mon Sep 17 00:00:00 2001 From: protokey Date: Tue, 13 Oct 2026 17:23:01 +0400 Subject: [PATCH] =?UTF-8?q?feature:=20UA=20=D0=B8=20=D0=BE=D1=82=D0=BF?= =?UTF-8?q?=D0=B5=D1=87=D0=B0=D1=82=D0=BE=D0=BA=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=8E=D1=82=D1=81=D1=8F=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8E=20=D0=BE=D0=B4=D0=B8=D0=BD=20=D1=80=D0=B0=D0=B7,=20cooki?= =?UTF-8?q?es=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=8F=D1=8E=D1=82=20=D0=BF=D1=80=D0=BE=D1=84?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - user_agent записывается в профиль при первом запросе сессии, дальше игнорируется (#87) - переданные cookies заменяют cookies профиля (clear_cookies перед add_cookies) (#88) - без user_agent профилю генерируется отпечаток browserforge (Linux Chrome): UA с версией установленного Chrome, экран и devicePixelRatio; только нативная эмуляция, без JS (#89) - make fp-check: проверка, что отпечаток применяется и стабилен, и доля различий профилей Co-Authored-By: Claude Opus 5.5 --- Makefile | 9 +++- README.md | 6 ++- docs/API.md | 16 ++++-- src/api/profile.py | 104 ++++++++++++++++++++++++++++++++++++ src/engine/session.py | 2 + src/engine/session_types.py | 1 + src/engine/validators.py | 1 + src/main.py | 29 +++------- tests/fingerprint_check.py | 76 ++++++++++++++++++++++++++ 9 files changed, 215 insertions(+), 29 deletions(-) create mode 100644 src/api/profile.py create mode 100644 tests/fingerprint_check.py diff --git a/Makefile b/Makefile index 4b3eb7b..c33d653 100644 --- a/Makefile +++ b/Makefile @@ -5,10 +5,10 @@ WHEELS_DIR := wheels REQUIREMENTS := requirements.txt ver ?= latest -MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help build release +MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help build release fp-check PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS)) -.PHONY: install freeze wheels sync sync-offline clean help venv build release +.PHONY: install freeze wheels sync sync-offline clean help venv build release fp-check help: @echo "make install [ ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/" @@ -19,6 +19,7 @@ help: @echo "make clean - удалить $(WHEELS_DIR)/" @echo "make build [ver=latest] - собрать docker-образ robotex:" @echo "make release ver= - собрать образ и пакет для деплоя в build// (compose, .env, образ .tar.gz)" + @echo "make fp-check [ver=latest] - собрать образ и проверить, что отпечатки профилей различаются и стабильны" venv: @test -d $(VENV) || python3 -m venv $(VENV) @@ -53,6 +54,10 @@ build: @echo "build an application" docker build -t robotex:$(ver) . +fp-check: build + docker run --rm --shm-size=2g -v $(CURDIR)/tests:/app/tests:ro robotex:$(ver) \ + xvfb-run -a --server-args="-screen 0 1920x1080x24" python /app/tests/fingerprint_check.py + RELEASE_DIR := build/$(ver) ifneq ($(filter release,$(MAKECMDGOALS)),) diff --git a/README.md b/README.md index b742d13..4c59d92 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,12 @@ 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 (cookies, local storage, user agent) on disk. +- **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. - **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 [docs/Deployment.md](docs/Deployment.md). -- **Canvas fingerprint** is generated anew for every session by design. ### Project layout @@ -51,6 +51,7 @@ Client → Caddy (TLS, on the host) → FastAPI POST /v1/solve → browser slot src/ main.py # FastAPI app: /v1/solve, /health, slots, sessions, response api/ + profile.py # per-profile user agent and generated fingerprint schemas.py # SolveRequest / ActionRequest / SolveResponse + validation actions.py # action registry and execution geoip.py # proxy → locale / timezone @@ -67,6 +68,7 @@ src/ docs/ # API, deployment, anti-bot notes tests/ loadtest.py # concurrent load test + docker stats + fingerprint_check.py # profile fingerprints are applied and stable (make fp-check) test_main.http # manual requests (JetBrains HTTP client) wheels/ # wheels for offline install on Ubuntu Noble Dockerfile diff --git a/docs/API.md b/docs/API.md index 561f7e1..0e4ab00 100644 --- a/docs/API.md +++ b/docs/API.md @@ -40,8 +40,8 @@ Cookie: session_id= # необязательно, см. «Сесси | `html` | bool | `false` | вернуть HTML страницы после выполнения действий | | `screenshot` | bool | `false` | вернуть скриншот видимой области (PNG, base64) после действий — в том числе при ошибке действия или антибота | | `timeout` | int, с | `60` | таймаут навигации | -| `user_agent` | string | `null` | свой User-Agent; запоминается в сессии (см. «Сессии»). Без него — UA сессии или UA установленного Chrome | -| `cookies` | array | `null` | cookies для установки до навигации, формат Playwright: `{name, value, domain, path, expires, httpOnly, secure, sameSite}` | +| `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` | домены, запросы к которым блокируются | | `retries` | int | `1` | попыток навигации | @@ -126,9 +126,19 @@ Cookie: session_id= # необязательно, см. «Сесси ## Сессии Ответ ставит cookie `session_id` (UUID). Если передать её в следующем запросе, браузер откроется с тем же профилем -(`extra/user_data_dir/`): cookies, localStorage, отпечаток и `user_agent` сохраняются, повторно проходить Cloudflare +(`extra/user_data_dir/`): cookies, localStorage, `user_agent` и отпечаток сохраняются, повторно проходить Cloudflare обычно не нужно. Без cookie или с некорректным значением (не UUID) запрос получает новый профиль. +User-Agent и отпечаток профиля задаются при первом запросе сессии и дальше не меняются (`robotex_session.json` в +каталоге профиля): + +- передан `user_agent` — профилю записывается он; +- не передан — генерируется отпечаток десктопного Chrome на Linux (browserforge): UA с версией установленного Chrome, + размер экрана и `devicePixelRatio` из реального распределения. Применяется нативной эмуляцией Chrome, без + JS-подмены (она ломала прохождение Cloudflare). DPR влияет и на хеш canvas. Ограничение: canvas, WebGL и + `hardwareConcurrency` у всех профилей одинаковые, поэтому примерно половина профилей совпадает по отпечатку + (`make fp-check` показывает долю различий). + Один профиль нельзя использовать в двух запросах одновременно: Chrome заблокирует каталог профиля. ## Производительность diff --git a/src/api/profile.py b/src/api/profile.py new file mode 100644 index 0000000..ba9e54d --- /dev/null +++ b/src/api/profile.py @@ -0,0 +1,104 @@ +""" +Настройки профиля сессии (рядом с профилем Chrome): User-Agent и отпечаток. Задаются один раз при +первом запросе сессии и дальше не меняются — реальный человек не меняет браузер и монитор между +визитами. + +Отпечаток применяется только нативной эмуляцией Chrome (экран, devicePixelRatio), без JS-инъекций: +полная подмена через browserforge InjectFunction ломала прохождение Cloudflare Turnstile (51a801f). +UA для сгенерированного профиля — шаблон Linux Chrome с версией установленного Chrome: UA другой +версии разошёлся бы с navigator.userAgentData, который Playwright не подменяет. +""" +import json +import logging +import re +import shutil +import subprocess +from functools import cache +from pathlib import Path + +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 + generator = FingerprintGenerator() + for _ in range(attempts): + # strict=True: без него browserforge при отсутствии точного совпадения тихо ослабляет + # нижнюю границу экрана и может отдать экран меньше окна браузера; цена — изредка + # ValueError, тогда просто пробуем ещё раз + try: + fp = generator.generate( + browser=["chrome"], os=["linux"], device="desktop", strict=True, + screen=Screen(min_width=MIN_SCREEN[0], min_height=MIN_SCREEN[1]), + ) + except ValueError: + continue + screen, dpr = {"width": fp.screen.width, "height": fp.screen.height}, fp.screen.devicePixelRatio + break + 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, + } + + +def load(user_data_dir: Path) -> dict: + try: + return json.loads((user_data_dir / SETTINGS_FILE).read_text()) + except (OSError, json.JSONDecodeError): + return {} + + +def load_or_create(user_data_dir: Path, user_agent: str | None) -> dict: + """Настройки профиля. При первом запросе сессии: UA клиента или сгенерированный отпечаток. + У существующего профиля всё берётся из сохранённого, переданный user_agent игнорируется.""" + 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") + return settings + + if user_agent: + settings = {"user_agent": user_agent} + else: + settings = {"fingerprint": generate_fingerprint()} + settings["user_agent"] = settings["fingerprint"].pop("user_agent") + (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 diff --git a/src/engine/session.py b/src/engine/session.py index f5acf8c..2bf6b5f 100644 --- a/src/engine/session.py +++ b/src/engine/session.py @@ -108,6 +108,8 @@ class AsyncSession: if config.init_script_content: await ctx.add_init_script(config.init_script_content) + if config.clear_cookies: + await ctx.clear_cookies() if config.cookies: # pragma: no cover await ctx.add_cookies(config.cookies) diff --git a/src/engine/session_types.py b/src/engine/session_types.py index 06f91b3..7b93e87 100644 --- a/src/engine/session_types.py +++ b/src/engine/session_types.py @@ -28,6 +28,7 @@ class StealthSession(TypedDict, total=False): wait_selector: Optional[str] wait_selector_state: SelectorWaitStates cookies: Sequence[SetCookieParam] | None + clear_cookies: bool google_search: bool wait: int | float timezone_id: str | None diff --git a/src/engine/validators.py b/src/engine/validators.py index 8ef45b5..15ea8e2 100644 --- a/src/engine/validators.py +++ b/src/engine/validators.py @@ -73,6 +73,7 @@ class PlaywrightConfig(Struct, kw_only=True, frozen=False, weakref=True): wait_selector: Optional[str] = None wait_selector_state: SelectorWaitStates = "attached" cookies: Sequence[SetCookieParam] | None = [] + clear_cookies: bool = False # удалить cookies профиля перед установкой `cookies` google_search: bool = True wait: Seconds = 0 timezone_id: str | None = "" diff --git a/src/main.py b/src/main.py index b953eea..c1286d5 100644 --- a/src/main.py +++ b/src/main.py @@ -20,7 +20,7 @@ logging.basicConfig( format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) -from api import actions # noqa: E402 — модули ниже логируют при импорте, конфиг логов должен быть раньше +from api import actions, profile # noqa: E402 — модули ниже логируют при импорте, конфиг логов должен быть раньше from api.geoip import resolve_geo # noqa: E402 from api.schemas import SolveRequest, SolveResponse # noqa: E402 from engine import AsyncStealthySession # noqa: E402 @@ -33,7 +33,6 @@ RETRY_AFTER = os.getenv("NO_SLOTS_RETRY_AFTER", "5") _slots = asyncio.Semaphore(BROWSER_SLOTS) USER_DATA_ROOT = (Path(__file__).parent / "../extra/user_data_dir").resolve() -SESSION_SETTINGS = "robotex_session.json" def parse_proxy(proxy_url: str) -> ProxySettings: @@ -95,17 +94,6 @@ def session_dir(session_id: str | None) -> tuple[str, Path]: return session_id, user_data_dir -def load_session_settings(user_data_dir: Path) -> dict: - try: - return json.loads((user_data_dir / SESSION_SETTINGS).read_text()) - except (OSError, json.JSONDecodeError): - return {} - - -def save_session_settings(user_data_dir: Path, settings: dict) -> None: - (user_data_dir / SESSION_SETTINGS).write_text(json.dumps(settings, ensure_ascii=False)) - - def make_page_action(payload: SolveRequest): """Замыкание для AsyncStealthySession.fetch(page_action=...): выполняет очередь действий (включая прохождение капчи/антибота через api.actions.execute) и достаёт из уже @@ -179,13 +167,8 @@ async def solve( try: user_data_dir.mkdir(parents=True, exist_ok=True) - # user_agent сессии сохраняется рядом с профилем: при повторном запросе с тем же - # session_id без user_agent отпечаток не должен меняться - settings = load_session_settings(user_data_dir) - if payload.user_agent: - settings["user_agent"] = payload.user_agent - save_session_settings(user_data_dir, settings) - user_agent = settings.get("user_agent") + # UA и отпечаток задаются профилю один раз (см. api/profile.py) + profile_kwargs = profile.browser_kwargs(profile.load_or_create(user_data_dir, payload.user_agent)) proxy = parse_proxy(payload.proxy) if payload.proxy else None @@ -201,17 +184,19 @@ async def solve( proxy=proxy, user_data_dir=str(user_data_dir), real_chrome=True, - useragent=user_agent, locale=locale, timezone_id=geo.get("timezone_id") if geo else None, cookies=payload.cookies, + # переданные cookies заменяют cookies профиля, а не дополняют их + clear_cookies=payload.cookies is not None, disable_resources=payload.disable_resources, blocked_domains=blocked_domains, timeout=nav_timeout_ms, retries=max(1, payload.retries), retry_delay=payload.retry_delay, wait=payload.wait, - google_search=payload.google_search + google_search=payload.google_search, + **profile_kwargs, ) async with AsyncStealthySession(**kwargs) as engine: diff --git a/tests/fingerprint_check.py b/tests/fingerprint_check.py new file mode 100644 index 0000000..0bd6c30 --- /dev/null +++ b/tests/fingerprint_check.py @@ -0,0 +1,76 @@ +"""Проверка отпечатков профилей: сгенерированный отпечаток применяется, стабилен внутри профиля, +и сколько профилей из выборки различаются. + +Запускается внутри образа (нужны Chrome и Xvfb): + + make fp-check +""" +import asyncio +import json +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +from api import profile # noqa: E402 +from engine import AsyncStealthySession # noqa: E402 + +# то, что обычно входит в хеш fingerprint-библиотек и видно странице +FP_JS = """async () => { + const c = document.createElement('canvas'); c.width = 240; c.height = 60; + const x = c.getContext('2d'); x.textBaseline = 'top'; x.font = '16px Arial'; + x.fillStyle = '#f60'; x.fillRect(10, 10, 100, 30); x.fillStyle = '#069'; x.fillText('Robotex fp', 4, 20); + const digest = async s => Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)))) + .slice(0, 6).map(b => b.toString(16).padStart(2, '0')).join(''); + const uad = await navigator.userAgentData.getHighEntropyValues(['fullVersionList']); + return { + ua: navigator.userAgent, + ua_ch: uad.fullVersionList.map(b => b.brand + '/' + b.version.split('.')[0]).join(','), + screen: [screen.width, screen.height, devicePixelRatio], + hwc: navigator.hardwareConcurrency, + canvas: await digest(c.toDataURL()), + }; +}""" + + +async def measure(user_data_dir: Path) -> dict: + settings = profile.load_or_create(user_data_dir, None) + kwargs = profile.browser_kwargs(settings) + async with AsyncStealthySession(headless=False, real_chrome=True, user_data_dir=str(user_data_dir), retries=1, **kwargs) as s: + return await s.fetch("https://example.com", page_action=lambda page: page.evaluate(FP_JS)) + + +async def main(count: int = 6) -> int: + dirs = [tempfile.TemporaryDirectory() for _ in range(count)] + try: + fps = [await measure(Path(d.name)) for d in dirs] + again = await measure(Path(dirs[0].name)) + saved = [profile.load(Path(d.name)) for d in dirs] + finally: + for d in dirs: + d.cleanup() + + for i, fp in enumerate(fps): + print(f"profile {i} {json.dumps(fp, ensure_ascii=False)}") + print(f"profile 0 again {json.dumps(again, ensure_ascii=False)}") + + errors = [] + if fps[0] != again: + errors.append("profile 0 fingerprint changed between launches") + major = profile.chrome_major() + for i, (fp, st) in enumerate(zip(fps, saved)): + want = [st["fingerprint"]["screen"]["width"], st["fingerprint"]["screen"]["height"], st["fingerprint"]["device_scale_factor"]] + if fp["screen"] != want: + errors.append(f"profile {i}: screen {fp['screen']} != saved {want}") + if f"Chrome/{major}." not in fp["ua"] or f"Google Chrome/{major}" not in fp["ua_ch"]: + errors.append(f"profile {i}: user agent does not match installed Chrome {major}") + + distinct = len({json.dumps(fp, sort_keys=True) for fp in fps}) + print(f"distinct fingerprints: {distinct} of {count}") + print("\n".join(errors) or "OK: generated fingerprints are applied and stable within a profile") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main()))