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

+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"]}}