""" Отпечаток профиля сессии (рядом с профилем Chrome): экран, devicePixelRatio и seed шума canvas. Генерируется один раз при первом запросе сессии и дальше не меняется — реальный человек не меняет монитор между визитами. Экран и DPR применяются нативной эмуляцией Chrome. JS — только минимальный шум canvas (js/canvas_noise.js, #89): полная подмена через browserforge InjectFunction ломала прохождение Cloudflare Turnstile (51a801f). Остальное (WebGL, hardwareConcurrency, шрифты) — родное, как его отдаёт Chrome через Patchright. User-Agent не подменяется: строка UA, отличная от реального Chrome, расходится с navigator.userAgentData и движком, и Cloudflare такой браузер не пропускает (замер в #87). UA — всегда родной UA установленного Chrome, клиент берёт его из ответа. """ import json import logging import os import random from pathlib import Path from browserforge.fingerprints import FingerprintGenerator, Screen log = logging.getLogger(__name__) SETTINGS_FILE = "robotex_session.json" # окно браузера под Xvfb — 1920x1080, монитор физически не может быть меньше окна MIN_SCREEN = (1920, 1080) CANVAS_NOISE_JS = (Path(__file__).parent / "js" / "canvas_noise.js").read_text() # prefs Chrome, которые задаются профилю при каждом запуске (#111): без пузыря «Сохранить пароль?», # предложения перевода и автозаполнения — секреты клиентов не оседают в Login Data/Web Data профиля. # --disable-translate в современных Chrome не действует, поэтому через prefs. На JS-отпечаток не влияют. CHROME_PREFS = { "credentials_enable_service": False, "credentials_enable_autosignin": False, "profile": {"password_manager_enabled": False, "password_manager_leak_detection": False}, "translate": {"enabled": False}, "autofill": {"enabled": False, "profile_enabled": False, "credit_card_enabled": False}, } def new_canvas_seed() -> int: return random.randint(1, 2**31 - 1) 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) return {"screen": screen, "device_scale_factor": dpr, "canvas_seed": new_canvas_seed()} 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) -> dict: """Настройки профиля: сохранённый отпечаток или новый при первом запросе сессии.""" settings = load(user_data_dir) if "fingerprint" in settings and "canvas_seed" in settings["fingerprint"]: return settings if "fingerprint" in settings: # профиль до #89: экран сохраняем, добавляем только seed шума settings = {"fingerprint": settings["fingerprint"] | {"canvas_seed": new_canvas_seed()}} else: 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 под отпечаток профиля.""" fp = settings["fingerprint"] return { "additional_args": {"screen": fp["screen"], "device_scale_factor": fp["device_scale_factor"]}, "init_script_content": CANVAS_NOISE_JS.replace("__SEED__", str(int(fp["canvas_seed"]))), } def _merge(target: dict, source: dict) -> dict: for key, value in source.items(): if isinstance(value, dict) and isinstance(target.get(key), dict): _merge(target[key], value) else: target[key] = value return target def apply_chrome_prefs(user_data_dir: Path) -> None: """Дописать CHROME_PREFS в Default/Preferences профиля, не трогая остальное. Вызывается до запуска Chrome: и для нового профиля, и для существующего — Chrome при выходе сохраняет эти prefs как есть.""" path = user_data_dir / "Default" / "Preferences" try: prefs = json.loads(path.read_text()) if not isinstance(prefs, dict): prefs = {} except (OSError, json.JSONDecodeError): prefs = {} path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_name(path.name + ".robotex") tmp.write_text(json.dumps(_merge(prefs, CHROME_PREFS), ensure_ascii=False)) os.replace(tmp, path)