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
+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:
|
||||
|
||||
Reference in new issue
Block a user