diff --git a/requirements.txt b/requirements.txt index f1d02a1..c489c3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -44,8 +44,8 @@ mozversion==2.4.0 multidict==6.7.1 numpy==2.5.3 orjson==3.12.0 +patchright==1.63.0 platformdirs==4.11.7 -playwright==1.62.0 propcache==0.5.2 psutil==7.2.2 pydantic==2.13.5 diff --git a/src/antibot/base.py b/src/antibot/base.py index 71ded06..87f0849 100644 --- a/src/antibot/base.py +++ b/src/antibot/base.py @@ -11,8 +11,8 @@ from dataclasses import dataclass from enum import Enum from typing import Type -from playwright.async_api import Page -from playwright.async_api import TimeoutError as PWTimeoutError +from patchright.async_api import Page +from patchright.async_api import TimeoutError as PWTimeoutError async def click_and_race(page, element, challenge_locator, timeout=30_000): diff --git a/src/antibot/humanize.py b/src/antibot/humanize.py index 6af2d4b..24b0ffb 100644 --- a/src/antibot/humanize.py +++ b/src/antibot/humanize.py @@ -10,8 +10,8 @@ import random import string from typing import Optional -from playwright.async_api import TimeoutError -from playwright.async_api import Page +from patchright.async_api import TimeoutError +from patchright.async_api import Page # последняя известная позиция курсора на страницу — Playwright не даёт её прочитать из браузера, # поэтому считаем сами, чтобы следующее движение начиналось не из (0, 0), а оттуда, где реально @@ -103,11 +103,14 @@ async def click_human( """move_mouse_human + клик с человеческой задержкой между наведением и down/up, вместо мгновенного page.mouse.click(x, y).""" await move_mouse_human(page, x, y, **move_kwargs) + print('wait for timeout') await page.wait_for_timeout(random.uniform(50, 180)) + print('key down') await page.mouse.down(button=button) + print('sleep') await page.wait_for_timeout(random.uniform(40, 99)) + print('key up') await page.mouse.up(button=button) - await move_mouse_human(page, x-50, y+50, **move_kwargs) async def wait_for_networkidle(page: Page, timeout: Optional[int] = None): diff --git a/src/antibot/known_providers/cloudflare.py b/src/antibot/known_providers/cloudflare.py index 5bf714f..b31ac85 100644 --- a/src/antibot/known_providers/cloudflare.py +++ b/src/antibot/known_providers/cloudflare.py @@ -63,7 +63,7 @@ import re import time from random import randint -from playwright.async_api import Page +from patchright.async_api import Page from ..base import Detector, ChallengeInfo, AntiBotProvider, Solver from ..base import Stage @@ -140,9 +140,10 @@ __CF_PATTERN__ = re.compile(r"^https://challenges\.cloudflare\.com/cdn-cgi/chall class CloudflareSolver(Solver): async def solve(self, page: Page, info: ChallengeInfo): + print("start solving") iframe = page.frame(url=__CF_PATTERN__) if iframe: - await page.wait_for_timeout(randint(4000, 7000)) # дать чекбоксу домонтироваться в closed shadow root + await page.wait_for_timeout(randint(500, 1000)) frame_el = await iframe.frame_element() box = await frame_el.bounding_box() else: @@ -152,8 +153,6 @@ class CloudflareSolver(Solver): # # Calculate the Captcha coordinates for any viewport captcha_x, captcha_y = box["x"] + randint(26, 28), box["y"] + randint(25, 27) - # await page.mouse.click(captcha_x, captcha_y, delay=randint(100, 200), button="left") - await click_human(page, captcha_x, captcha_y) await wait_for_networkidle(page) @@ -186,6 +185,7 @@ class CloudflareSolver(Solver): return return else: + captcha_solver = self._opt.get("captcha_solver") if not captcha_solver: raise RuntimeError("Нужен captcha_solver (2captcha) чтобы решить Turnstile") diff --git a/src/antibot/known_providers/recaptcha.py b/src/antibot/known_providers/recaptcha.py index ece9423..d0967dd 100644 --- a/src/antibot/known_providers/recaptcha.py +++ b/src/antibot/known_providers/recaptcha.py @@ -1,7 +1,7 @@ import json import logging -from playwright.async_api import Page +from patchright.async_api import Page from captcha.base import Captcha diff --git a/src/antibot/registry.py b/src/antibot/registry.py index abff98e..b549dde 100644 --- a/src/antibot/registry.py +++ b/src/antibot/registry.py @@ -1,7 +1,7 @@ """ реестр детекторов, порядок проверки """ -from playwright.async_api import Page +from patchright.async_api import Page from .base import AntiBotProvider from .exceptions import ProviderNotDetected diff --git a/src/captcha/base.py b/src/captcha/base.py index 73bee1c..52c5683 100644 --- a/src/captcha/base.py +++ b/src/captcha/base.py @@ -4,7 +4,7 @@ import hashlib import time from pathlib import Path -from playwright.async_api import Page +from patchright.async_api import Page from twocaptcha import TwoCaptcha diff --git a/src/engine/actions.py b/src/engine/actions.py index cefe961..250abcd 100644 --- a/src/engine/actions.py +++ b/src/engine/actions.py @@ -1,6 +1,6 @@ import asyncio -from playwright.async_api import Page +from patchright.async_api import Page from antibot.base import click_and_race from antibot.orchestrator import pass_challenges diff --git a/src/engine/browser_launch.py b/src/engine/browser_launch.py new file mode 100644 index 0000000..3039318 --- /dev/null +++ b/src/engine/browser_launch.py @@ -0,0 +1,120 @@ +""" +Launch/context-флаги для Chromium, снижающие детект автоматизации на уровне ниже +JS-фингерпринта (который уже покрывает browserforge, см. engine/fingerprint.py). +Портировано из scrapling.engines.constants / _browsers._base.BaseSessionMixin. +""" + +HARMFUL_ARGS = ( + # Playwright сам добавляет эти флаги по умолчанию — они одни из самых дешёвых + # и надёжных сигналов автоматизации, поэтому их нужно гасить через ignore_default_args + "--enable-automation", + "--disable-popup-blocking", + "--disable-component-update", + "--disable-default-apps", + "--disable-extensions", +) + +DEFAULT_ARGS = ( + "--no-pings", + "--no-first-run", + "--disable-infobars", + "--disable-breakpad", + "--no-service-autorun", + "--homepage=about:blank", + "--password-store=basic", + "--disable-hang-monitor", + "--no-default-browser-check", + "--disable-session-crashed-bubble", + "--disable-search-engine-choice-screen", +) + +STEALTH_ARGS = ( + "--test-type", + "--mute-audio", + "--disable-sync", + "--hide-scrollbars", + "--disable-logging", + "--start-maximized", # обход headless-детекта по размеру окна + "--enable-async-dns", + "--use-mock-keychain", + "--disable-translate", + "--disable-voice-input", + "--window-position=0,0", + "--disable-wake-on-wifi", + "--ignore-gpu-blocklist", + "--enable-tcp-fast-open", + "--enable-web-bluetooth", + "--disable-cloud-import", + "--disable-print-preview", + "--disable-dev-shm-usage", + "--metrics-recording-only", + "--disable-crash-reporter", + "--disable-partial-raster", + "--disable-gesture-typing", + "--disable-checker-imaging", + "--disable-prompt-on-repost", + "--force-color-profile=srgb", + "--font-render-hinting=none", + "--aggressive-cache-discard", + "--disable-cookie-encryption", + "--disable-domain-reliability", + "--disable-threaded-animation", + "--disable-threaded-scrolling", + "--enable-simple-cache-backend", + "--disable-background-networking", + "--enable-surface-synchronization", + "--disable-image-animation-resync", + "--disable-renderer-backgrounding", + "--disable-ipc-flooding-protection", + "--prerender-from-omnibox=disabled", + "--safebrowsing-disable-auto-update", + "--disable-offer-upload-credit-cards", + "--disable-background-timer-throttling", + "--disable-new-content-rendering-timeout", + "--run-all-compositor-stages-before-draw", + "--disable-client-side-phishing-detection", + "--disable-backgrounding-occluded-windows", + "--disable-layer-tree-host-memory-pressure", + "--autoplay-policy=user-gesture-required", + "--disable-offer-store-unmasked-wallet-cards", + "--disable-blink-features=AutomationControlled", + "--disable-component-extensions-with-background-pages", + "--enable-features=NetworkService,NetworkServiceInProcess,TrustTokens,TrustTokensAlwaysAllowIssuance", + "--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4", + "--disable-features=AudioServiceOutOfProcess,TranslateUI,BlinkGenPropertyTrees", +) + +_WEBRTC_ARGS = ( + "--webrtc-ip-handling-policy=disable_non_proxied_udp", + "--force-webrtc-ip-handling-policy", +) + + +def launch_args(locale: str | None = None, block_webrtc: bool = True) -> list[str]: + """Флаги запуска Chromium: скорость + анти-детект + (опционально) защита от WebRTC-утечки + реального IP мимо прокси + (если передана locale) выставление языка браузера на уровне + лаунча, а не только через JS-контекст — иначе Web Workers/Intl внутри браузера остаются + на языке хоста, даже когда browserforge уже подменил navigator.language на странице.""" + args = list(DEFAULT_ARGS) + list(STEALTH_ARGS) + + if block_webrtc: + args += list(_WEBRTC_ARGS) + + if locale: + base_lang = locale.split("-")[0].lower() + accept_lang = f"{locale},{base_lang}" if base_lang != locale.lower() else locale + args += [f"--lang={locale}", f"--accept-lang={accept_lang}"] + + return args + + +def stealth_context_options() -> dict: + """Context-опции, не пересекающиеся с тем, что уже выставляет browserforge + (user_agent/viewport/device_scale_factor) — их сюда специально не кладём, чтобы + fingerprint оставался единственным источником истины для этих полей.""" + return { + "color_scheme": "dark", # обходит проверку prefersLightColor в creepjs + "is_mobile": False, + "has_touch": False, + "ignore_https_errors": True, + } diff --git a/src/engine/geoip.py b/src/engine/geoip.py index 9016fb2..f8a0f4f 100644 --- a/src/engine/geoip.py +++ b/src/engine/geoip.py @@ -18,13 +18,32 @@ import time from pathlib import Path import httpx -from playwright.async_api import ProxySettings +from patchright.async_api import ProxySettings log = logging.getLogger(__name__) IPINFO_URL = "https://ipinfo.io/json" DEFAULT_CACHE_FILE = Path(__file__).resolve().parent.parent.parent / "extra" / "geoip_cache.json" DEFAULT_TTL = 24 * 3600 # прокси может сменить exit-IP — не кэшируем навечно +DEFAULT_LOCALE = "en-US" + +# Страна exit-IP -> основная локаль. Раньше это (как и timezone/geolocation) под капотом +# делал Camoufox(geoip=True); теперь резолвим сами и прокидываем в browserforge (locale +# согласованно выставляет navigator.language/languages и Accept-Language header) и в +# launch-флаги Chromium --lang=/--accept-lang= (engine/browser_launch.py). Не более чем +# точечная таблица под реально используемые прокси-гео — не претендует на полноту ISO-3166. +_COUNTRY_LOCALE: dict[str, str] = { + "RU": "ru-RU", "BY": "ru-BY", "KZ": "ru-KZ", "UA": "uk-UA", + "US": "en-US", "GB": "en-GB", "CA": "en-CA", "AU": "en-AU", "IE": "en-IE", "IN": "en-IN", + "DE": "de-DE", "AT": "de-AT", "CH": "de-CH", + "FR": "fr-FR", "BE": "fr-BE", + "ES": "es-ES", "MX": "es-MX", "AR": "es-AR", + "IT": "it-IT", "PT": "pt-PT", "BR": "pt-BR", "NL": "nl-NL", + "PL": "pl-PL", "CZ": "cs-CZ", "SK": "sk-SK", "RO": "ro-RO", "HU": "hu-HU", + "SE": "sv-SE", "NO": "nb-NO", "FI": "fi-FI", "DK": "da-DK", + "TR": "tr-TR", "GR": "el-GR", + "CN": "zh-CN", "TW": "zh-TW", "JP": "ja-JP", "KR": "ko-KR", +} _cache_lock = asyncio.Lock() @@ -59,14 +78,16 @@ async def resolve_geo( cache_file: Path = DEFAULT_CACHE_FILE, ttl: float = DEFAULT_TTL, ) -> dict | None: - """Возвращает {"timezone_id", "geolocation": {"latitude", "longitude"}, "country", "ip"} + """Возвращает {"timezone_id", "geolocation": {"latitude", "longitude"}, "country", "ip", "locale"} по IP, который виден снаружи через прокси, или None, если резолв не удался и в кэше - тоже ничего нет — в этом случае вызывающий код просто не переопределяет timezone/geolocation. + тоже ничего нет — в этом случае вызывающий код просто не переопределяет timezone/geolocation/locale. - Язык (locale) сюда намеренно не подмешиваем: Accept-Language — это предпочтение пользователя, - а не производная от геолокации IP, и его уже согласованно генерирует fingerprint (см. - engine/fingerprint.py). Подменять его на "en-" было бы отдельным, никак не - связанным с реальным поведением допущением.""" + "locale" — основная локаль страны exit-IP (см. _COUNTRY_LOCALE), с фолбэком DEFAULT_LOCALE + для неизвестных/отсутствующих кодов страны. Реальный человек с российским прокси не сидит + с английской раскладкой браузера, поэтому locale — такой же производный от IP сигнал, как + timezone/geolocation, а не независимое "предпочтение пользователя". Прокидывается вызывающим + кодом в browserforge (согласованно выставляет navigator.language/languages и Accept-Language, + см. engine/fingerprint.py) и в launch-флаги Chromium (engine/browser_launch.py).""" key = proxy_settings_to_url(proxy) async with _cache_lock: @@ -98,11 +119,13 @@ async def resolve_geo( except ValueError: geolocation = None + country = data.get("country") # "RU", "US", ... result = { "ip": data.get("ip"), - "country": data.get("country"), # "RU", "US", ... + "country": country, "timezone_id": data.get("timezone"), "geolocation": geolocation, + "locale": _COUNTRY_LOCALE.get(country, DEFAULT_LOCALE), } async with _cache_lock: diff --git a/src/main.py b/src/main.py index c741062..fac762e 100644 --- a/src/main.py +++ b/src/main.py @@ -11,10 +11,11 @@ from fastapi import Cookie from fastapi import FastAPI from fastapi import Request from fastapi import Response -from playwright.async_api import ProxySettings -from playwright.async_api import async_playwright +from patchright.async_api import ProxySettings +from patchright.async_api import async_playwright from engine import actions +from engine.browser_launch import HARMFUL_ARGS, launch_args, stealth_context_options from engine.fingerprint import apply_fingerprint, context_options_for, load_or_create_fingerprint from engine.geoip import resolve_geo from engine.schemas import SolveRequest @@ -77,9 +78,16 @@ async def solve( if not user_data_dir.exists(): user_data_dir.mkdir(parents=True) + # geo — до создания fingerprint'а, чтобы производная от прокси locale (см. engine/geoip.py) + # попала в сам fingerprint, а не подменяла его задним числом + geo = await resolve_geo(proxy) + locale = geo.get("locale") if geo else None + fp_kwargs = {} if payload.user_agent: fp_kwargs["user_agent"] = payload.user_agent + if locale: + fp_kwargs["locale"] = locale fingerprint = load_or_create_fingerprint( user_data_dir, # только нижняя граница: реалистичный монитор должен быть не меньше запрошенного окна, @@ -89,24 +97,33 @@ async def solve( **fp_kwargs, ) - ctx_kwargs = context_options_for( - fingerprint, - viewport={"width": scr_size[0], "height": scr_size[1]}, - ) + ctx_kwargs = { + # базовые анти-детект опции ниже — fingerprint накладывается поверх и выигрывает + # при конфликте по user_agent/viewport/device_scale_factor + **stealth_context_options(), + **context_options_for( + fingerprint, + viewport={"width": scr_size[0], "height": scr_size[1]}, + ), + } - geo = await resolve_geo(proxy) + permissions = ["notifications"] if geo: if geo.get("timezone_id"): ctx_kwargs["timezone_id"] = geo["timezone_id"] if geo.get("geolocation"): ctx_kwargs["geolocation"] = geo["geolocation"] - ctx_kwargs["permissions"] = ["geolocation"] + permissions.append("geolocation") + ctx_kwargs["permissions"] = permissions async with async_playwright() as p: context = await p.chromium.launch_persistent_context( user_data_dir=str(user_data_dir), proxy=proxy, headless=False, + channel="chrome", + args=launch_args(locale=locale), + ignore_default_args=HARMFUL_ARGS, **ctx_kwargs, ) try: