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:
1 parent
a399f7f8cc
commit
58100fa45d
9 files changed
+215
-29
No files matched your search
@@ -5,10 +5,10 @@ WHEELS_DIR := wheels
|
||||
REQUIREMENTS := requirements.txt
|
||||
ver ?= latest
|
||||
|
||||
MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help build release
|
||||
MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help build release fp-check
|
||||
PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS))
|
||||
|
||||
.PHONY: install freeze wheels sync sync-offline clean help venv build release
|
||||
.PHONY: install freeze wheels sync sync-offline clean help venv build release fp-check
|
||||
|
||||
help:
|
||||
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
|
||||
@@ -19,6 +19,7 @@ help:
|
||||
@echo "make clean - удалить $(WHEELS_DIR)/"
|
||||
@echo "make build [ver=latest] - собрать docker-образ robotex:<ver>"
|
||||
@echo "make release ver=<ver> - собрать образ и пакет для деплоя в build/<ver>/ (compose, .env, образ .tar.gz)"
|
||||
@echo "make fp-check [ver=latest] - собрать образ и проверить, что отпечатки профилей различаются и стабильны"
|
||||
|
||||
venv:
|
||||
@test -d $(VENV) || python3 -m venv $(VENV)
|
||||
@@ -53,6 +54,10 @@ build:
|
||||
@echo "build an application"
|
||||
docker build -t robotex:$(ver) .
|
||||
|
||||
fp-check: build
|
||||
docker run --rm --shm-size=2g -v $(CURDIR)/tests:/app/tests:ro robotex:$(ver) \
|
||||
xvfb-run -a --server-args="-screen 0 1920x1080x24" python /app/tests/fingerprint_check.py
|
||||
|
||||
RELEASE_DIR := build/$(ver)
|
||||
|
||||
ifneq ($(filter release,$(MAKECMDGOALS)),)
|
||||
|
||||
@@ -38,12 +38,12 @@ Client → Caddy (TLS, on the host) → FastAPI POST /v1/solve → browser slot
|
||||
reCAPTCHA, hCaptcha) are solved via the client's 2captcha key.
|
||||
- **Proxies**: not managed by the service — the client may supply their own proxy per request. Browser locale and
|
||||
timezone are derived from the proxy's exit IP.
|
||||
- **Sessions**: `session_id` cookie → persistent Chrome profile (cookies, local storage, user agent) on disk.
|
||||
- **Sessions**: `session_id` cookie → persistent Chrome profile on disk. User agent and fingerprint (screen, DPR) are
|
||||
fixed on the first request of a session; cookies passed in a request replace the profile's cookies.
|
||||
- **Logging**: stdout, level from `LOG_LEVEL`; client secrets (proxy credentials, 2captcha key, `fill` values,
|
||||
cookie values) are masked.
|
||||
- **Deployment**: single Docker container; TLS is terminated by Caddy on the host — see
|
||||
[docs/Deployment.md](docs/Deployment.md).
|
||||
- **Canvas fingerprint** is generated anew for every session by design.
|
||||
|
||||
### Project layout
|
||||
|
||||
@@ -51,6 +51,7 @@ Client → Caddy (TLS, on the host) → FastAPI POST /v1/solve → browser slot
|
||||
src/
|
||||
main.py # FastAPI app: /v1/solve, /health, slots, sessions, response
|
||||
api/
|
||||
profile.py # per-profile user agent and generated fingerprint
|
||||
schemas.py # SolveRequest / ActionRequest / SolveResponse + validation
|
||||
actions.py # action registry and execution
|
||||
geoip.py # proxy → locale / timezone
|
||||
@@ -67,6 +68,7 @@ src/
|
||||
docs/ # API, deployment, anti-bot notes
|
||||
tests/
|
||||
loadtest.py # concurrent load test + docker stats
|
||||
fingerprint_check.py # profile fingerprints are applied and stable (make fp-check)
|
||||
test_main.http # manual requests (JetBrains HTTP client)
|
||||
wheels/ # wheels for offline install on Ubuntu Noble
|
||||
Dockerfile
|
||||
|
||||
+13
-3
@@ -40,8 +40,8 @@ Cookie: session_id=<id> # необязательно, см. «Сесси
|
||||
| `html` | bool | `false` | вернуть HTML страницы после выполнения действий |
|
||||
| `screenshot` | bool | `false` | вернуть скриншот видимой области (PNG, base64) после действий — в том числе при ошибке действия или антибота |
|
||||
| `timeout` | int, с | `60` | таймаут навигации |
|
||||
| `user_agent` | string | `null` | свой User-Agent; запоминается в сессии (см. «Сессии»). Без него — UA сессии или UA установленного Chrome |
|
||||
| `cookies` | array | `null` | cookies для установки до навигации, формат Playwright: `{name, value, domain, path, expires, httpOnly, secure, sameSite}` |
|
||||
| `user_agent` | string | `null` | свой User-Agent. Задаётся профилю **один раз** — в первом запросе сессии; в следующих запросах этой сессии поле игнорируется. Без него профилю генерируется отпечаток (см. «Сессии») |
|
||||
| `cookies` | array | `null` | cookies для установки до навигации, формат Playwright: `{name, value, domain, path, expires, httpOnly, secure, sameSite}` или `{name, value, url}`. **Заменяют** cookies профиля: перед установкой все cookies профиля удаляются (`[]` — просто очистить). Без поля cookies профиля сохраняются |
|
||||
| `disable_resources` | bool | `false` | не грузить картинки, шрифты, медиа |
|
||||
| `blocked_domains` | array | `null` | домены, запросы к которым блокируются |
|
||||
| `retries` | int | `1` | попыток навигации |
|
||||
@@ -126,9 +126,19 @@ Cookie: session_id=<id> # необязательно, см. «Сесси
|
||||
## Сессии
|
||||
|
||||
Ответ ставит cookie `session_id` (UUID). Если передать её в следующем запросе, браузер откроется с тем же профилем
|
||||
(`extra/user_data_dir/<session_id>`): cookies, localStorage, отпечаток и `user_agent` сохраняются, повторно проходить Cloudflare
|
||||
(`extra/user_data_dir/<session_id>`): cookies, localStorage, `user_agent` и отпечаток сохраняются, повторно проходить Cloudflare
|
||||
обычно не нужно. Без cookie или с некорректным значением (не UUID) запрос получает новый профиль.
|
||||
|
||||
User-Agent и отпечаток профиля задаются при первом запросе сессии и дальше не меняются (`robotex_session.json` в
|
||||
каталоге профиля):
|
||||
|
||||
- передан `user_agent` — профилю записывается он;
|
||||
- не передан — генерируется отпечаток десктопного Chrome на Linux (browserforge): UA с версией установленного Chrome,
|
||||
размер экрана и `devicePixelRatio` из реального распределения. Применяется нативной эмуляцией Chrome, без
|
||||
JS-подмены (она ломала прохождение Cloudflare). DPR влияет и на хеш canvas. Ограничение: canvas, WebGL и
|
||||
`hardwareConcurrency` у всех профилей одинаковые, поэтому примерно половина профилей совпадает по отпечатку
|
||||
(`make fp-check` показывает долю различий).
|
||||
|
||||
Один профиль нельзя использовать в двух запросах одновременно: Chrome заблокирует каталог профиля.
|
||||
|
||||
## Производительность
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Проверка отпечатков профилей: сгенерированный отпечаток применяется, стабилен внутри профиля,
|
||||
и сколько профилей из выборки различаются.
|
||||
|
||||
Запускается внутри образа (нужны Chrome и Xvfb):
|
||||
|
||||
make fp-check
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
||||
|
||||
from api import profile # noqa: E402
|
||||
from engine import AsyncStealthySession # noqa: E402
|
||||
|
||||
# то, что обычно входит в хеш fingerprint-библиотек и видно странице
|
||||
FP_JS = """async () => {
|
||||
const c = document.createElement('canvas'); c.width = 240; c.height = 60;
|
||||
const x = c.getContext('2d'); x.textBaseline = 'top'; x.font = '16px Arial';
|
||||
x.fillStyle = '#f60'; x.fillRect(10, 10, 100, 30); x.fillStyle = '#069'; x.fillText('Robotex fp', 4, 20);
|
||||
const digest = async s => Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s))))
|
||||
.slice(0, 6).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
const uad = await navigator.userAgentData.getHighEntropyValues(['fullVersionList']);
|
||||
return {
|
||||
ua: navigator.userAgent,
|
||||
ua_ch: uad.fullVersionList.map(b => b.brand + '/' + b.version.split('.')[0]).join(','),
|
||||
screen: [screen.width, screen.height, devicePixelRatio],
|
||||
hwc: navigator.hardwareConcurrency,
|
||||
canvas: await digest(c.toDataURL()),
|
||||
};
|
||||
}"""
|
||||
|
||||
|
||||
async def measure(user_data_dir: Path) -> dict:
|
||||
settings = profile.load_or_create(user_data_dir, None)
|
||||
kwargs = profile.browser_kwargs(settings)
|
||||
async with AsyncStealthySession(headless=False, real_chrome=True, user_data_dir=str(user_data_dir), retries=1, **kwargs) as s:
|
||||
return await s.fetch("https://example.com", page_action=lambda page: page.evaluate(FP_JS))
|
||||
|
||||
|
||||
async def main(count: int = 6) -> int:
|
||||
dirs = [tempfile.TemporaryDirectory() for _ in range(count)]
|
||||
try:
|
||||
fps = [await measure(Path(d.name)) for d in dirs]
|
||||
again = await measure(Path(dirs[0].name))
|
||||
saved = [profile.load(Path(d.name)) for d in dirs]
|
||||
finally:
|
||||
for d in dirs:
|
||||
d.cleanup()
|
||||
|
||||
for i, fp in enumerate(fps):
|
||||
print(f"profile {i} {json.dumps(fp, ensure_ascii=False)}")
|
||||
print(f"profile 0 again {json.dumps(again, ensure_ascii=False)}")
|
||||
|
||||
errors = []
|
||||
if fps[0] != again:
|
||||
errors.append("profile 0 fingerprint changed between launches")
|
||||
major = profile.chrome_major()
|
||||
for i, (fp, st) in enumerate(zip(fps, saved)):
|
||||
want = [st["fingerprint"]["screen"]["width"], st["fingerprint"]["screen"]["height"], st["fingerprint"]["device_scale_factor"]]
|
||||
if fp["screen"] != want:
|
||||
errors.append(f"profile {i}: screen {fp['screen']} != saved {want}")
|
||||
if f"Chrome/{major}." not in fp["ua"] or f"Google Chrome/{major}" not in fp["ua_ch"]:
|
||||
errors.append(f"profile {i}: user agent does not match installed Chrome {major}")
|
||||
|
||||
distinct = len({json.dumps(fp, sort_keys=True) for fp in fps})
|
||||
print(f"distinct fingerprints: {distinct} of {count}")
|
||||
print("\n".join(errors) or "OK: generated fingerprints are applied and stable within a profile")
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
Reference in new issue
Block a user