feature: шум canvas с seed профиля — отпечатки профилей различаются (#89)

Минимальный init-script (src/api/js/canvas_noise.js): младший бит у ~1/64 непрозрачных
пикселей при toDataURL/toBlob/getImageData, детерминирован от seed профиля. Пустой
canvas не меняется, подменённые функции неотличимы по toString и length. Старые
профили получают seed, экран сохраняется.

Проверено: 4 профиля — 4 разных хеша canvas; Cloudflare 6/6 на новых профилях.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-10-13 21:02:21 +04:00
1 parent 4c8394eb4c
commit 9dc2c0b619
6 files changed
+176 -19

No files matched your search

+23 -8
View File
@@ -1,9 +1,11 @@
"""
Отпечаток профиля сессии (рядом с профилем Chrome): экран и devicePixelRatio. Генерируется один раз
при первом запросе сессии и дальше не меняется — реальный человек не меняет монитор между визитами.
Отпечаток профиля сессии (рядом с профилем Chrome): экран, devicePixelRatio и seed шума canvas.
Генерируется один раз при первом запросе сессии и дальше не меняется — реальный человек не меняет
монитор между визитами.
Применяется только нативной эмуляцией Chrome, без JS-инъекций: полная подмена через browserforge
InjectFunction ломала прохождение Cloudflare Turnstile (51a801f).
Экран и 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 — всегда
@@ -11,6 +13,7 @@ navigator.userAgentData и движком, и Cloudflare такой браузе
"""
import json
import logging
import random
from pathlib import Path
from browserforge.fingerprints import FingerprintGenerator, Screen
@@ -20,6 +23,11 @@ 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()
def new_canvas_seed() -> int:
return random.randint(1, 2**31 - 1)
def generate_fingerprint(attempts: int = 10) -> dict:
@@ -42,7 +50,7 @@ def generate_fingerprint(attempts: int = 10) -> dict:
else:
log.warning("browserforge failed %d times, using default screen", attempts)
return {"screen": screen, "device_scale_factor": dpr}
return {"screen": screen, "device_scale_factor": dpr, "canvas_seed": new_canvas_seed()}
def load(user_data_dir: Path) -> dict:
@@ -55,9 +63,13 @@ def load(user_data_dir: Path) -> dict:
def load_or_create(user_data_dir: Path) -> dict:
"""Настройки профиля: сохранённый отпечаток или новый при первом запросе сессии."""
settings = load(user_data_dir)
if "fingerprint" in settings:
if "fingerprint" in settings and "canvas_seed" in settings["fingerprint"]:
return settings
settings = {"fingerprint": generate_fingerprint()}
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
@@ -65,4 +77,7 @@ def load_or_create(user_data_dir: Path) -> dict:
def browser_kwargs(settings: dict) -> dict:
"""kwargs AsyncStealthySession под отпечаток профиля."""
fp = settings["fingerprint"]
return {"additional_args": {"screen": fp["screen"], "device_scale_factor": fp["device_scale_factor"]}}
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"]))),
}