feature: UA и отпечаток задаются профилю один раз, cookies запроса заменяют профильные

- 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 <noreply@anthropic.com>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-10-13 17:23:01 +04:00
1 parent a399f7f8cc
commit 58100fa45d
9 files changed
+215 -29

No files matched your search

+104
View File
@@ -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
+2
View File
@@ -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)
+1
View File
@@ -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
+1
View File
@@ -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 = ""
+7 -22
View File
@@ -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: