From 8f8bc62ae725c404d86f61b62e855024d15a03cb Mon Sep 17 00:00:00 2001 From: protokey Date: Wed, 23 Sep 2026 14:40:50 +0400 Subject: [PATCH] =?UTF-8?q?chore:=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=81=D0=B4=D0=B5=D0=BB=D0=B0=D1=82=D1=8C=20=D1=82?= =?UTF-8?q?=D0=B0=D0=BA=20=D0=B6=D0=B5=20=D0=BA=D0=B0=D0=BA=20=D1=83=20scr?= =?UTF-8?q?apling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/antibot/known_providers/cloudflare.py | 3 +- src/engine/fingerprint.py | 8 + src/engine/navigation.py | 69 +++++++ src/engine/schemas.py | 9 + src/main.py | 213 +++++++++++++--------- 5 files changed, 215 insertions(+), 87 deletions(-) create mode 100644 src/engine/navigation.py diff --git a/src/antibot/known_providers/cloudflare.py b/src/antibot/known_providers/cloudflare.py index 9da404b..02b8c64 100644 --- a/src/antibot/known_providers/cloudflare.py +++ b/src/antibot/known_providers/cloudflare.py @@ -84,7 +84,8 @@ class CloudflareDetector(Detector): async def detect(self, page: Page, info: ChallengeInfo): log.debug("CloudflareDetector run") - await wait_for_page_stability(page, False, True) + await wait_for_networkidle(page) + log.debug("Страница загружена полностью") info.meta = {} widget_selector = "div.cf-turnstile[data-sitekey], div[data-sitekey].cf-turnstile" diff --git a/src/engine/fingerprint.py b/src/engine/fingerprint.py index 5eeabe8..395e0af 100644 --- a/src/engine/fingerprint.py +++ b/src/engine/fingerprint.py @@ -37,6 +37,14 @@ def load_or_create_fingerprint(user_data_dir: Path, **generator_kwargs) -> Finge return _fingerprint_from_dict(json.loads(fp_file.read_text())) generator_kwargs.setdefault("browser", ["chrome"]) + # strict=True: без него browserforge при отсутствии точного совпадения в датасете + # тихо ослабляет даже нижнюю границу screen (min_width/min_height) — на практике + # так может прилететь мобильный экран под десктопный viewport (screen.availWidth + # меньше самого окна браузера — физически невозможно и легко детектится), а в + # части случаев цикл релаксации виснет на неопределённое время вместо быстрого + # ответа. Со strict=True generate() либо отдаёт честно подходящий сэмпл, либо + # сразу кидает ValueError — и то и другое быстро (~0.1с) + generator_kwargs.setdefault("strict", True) fingerprint = FingerprintGenerator().generate(**generator_kwargs) fp_file.write_text(fingerprint.dumps()) return fingerprint diff --git a/src/engine/navigation.py b/src/engine/navigation.py new file mode 100644 index 0000000..1c4fe00 --- /dev/null +++ b/src/engine/navigation.py @@ -0,0 +1,69 @@ +""" +Мелкие хелперы навигации, портированные из scrapling.engines.toolbelt.navigation +и scrapling.engines.toolbelt.proxy_rotation — адаптированы под patchright/async. + +В отличие от полного Session/PagePool из scrapling.engines._browsers._base, тут +браузер запускается и закрывается на каждый запрос (см. main.py), поэтому +переиспользовать нечего — берём только то, что относится к одной навигации: +блокировку ресурсов/доменов на странице и детект прокси-ошибок для retry. +""" +from typing import Callable, Optional +from urllib.parse import urlparse + +from patchright.async_api import Route + +# ресурсы, которые можно дропать ради скорости — портировано из +# scrapling.engines.constants.EXTRA_RESOURCES +EXTRA_RESOURCES = { + "font", "image", "media", "beacon", "object", + "imageset", "texttrack", "websocket", "csp_report", "stylesheet", +} + +# признаки прокси-ошибки в тексте исключения — портировано из +# scrapling.engines.toolbelt.proxy_rotation._PROXY_ERROR_INDICATORS +_PROXY_ERROR_INDICATORS = ( + "net::err_proxy", "net::err_tunnel", "connection refused", + "connection reset", "connection timed out", "failed to connect", + "could not resolve proxy", +) + + +def is_proxy_error(error: Exception) -> bool: + """Похоже ли исключение на сбой прокси (а не на обычную ошибку навигации).""" + msg = str(error).lower() + return any(indicator in msg for indicator in _PROXY_ERROR_INDICATORS) + + +def _is_domain_blocked(hostname: str, domains: frozenset) -> bool: + """Матчинг хоста и его родительских доменов за O(1) на каждый уровень — + портировано из scrapling.engines.toolbelt.navigation._is_domain_blocked.""" + if hostname in domains: + return True + idx = hostname.find(".") + while idx != -1: + suffix = hostname[idx + 1:] + if "." in suffix and suffix in domains: + return True + idx = hostname.find(".", idx + 1) + return False + + +def create_intercept_handler(disable_resources: bool, blocked_domains: Optional[set] = None) -> Callable: + """Обработчик route, блокирующий типы ресурсов и/или домены — портировано из + scrapling.engines.toolbelt.navigation.create_async_intercept_handler.""" + disabled_resources = EXTRA_RESOURCES if disable_resources else set() + domains = frozenset(blocked_domains) if blocked_domains else frozenset() + + async def handler(route: Route) -> None: + if route.request.resource_type in disabled_resources: + await route.abort() + elif domains: + hostname = urlparse(route.request.url).hostname or "" + if _is_domain_blocked(hostname, domains): + await route.abort() + else: + await route.continue_() + else: + await route.continue_() + + return handler diff --git a/src/engine/schemas.py b/src/engine/schemas.py index 66d1889..5db6d48 100644 --- a/src/engine/schemas.py +++ b/src/engine/schemas.py @@ -35,3 +35,12 @@ class SolveRequest(BaseModel): captcha_detect_locator: str | None = None captcha_success_locator: str | None = None captcha_type: str | None = None + + # ниже — опции навигации, портированные из scrapling.engines._browsers (StealthySession.fetch), + # все выключены/нейтральны по умолчанию, чтобы поведение для текущих вызывающих не менялось + disable_resources: bool = False + blocked_domains: list[str] | None = None + retries: int = 1 + retry_delay: float = 1.0 + wait: int = 0 + google_search: bool = False diff --git a/src/main.py b/src/main.py index fac762e..0008447 100644 --- a/src/main.py +++ b/src/main.py @@ -1,3 +1,4 @@ +import asyncio import json import logging import traceback @@ -14,10 +15,12 @@ from fastapi import Response from patchright.async_api import ProxySettings from patchright.async_api import async_playwright +from antibot.humanize import wait_for_page_stability 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.navigation import create_intercept_handler, is_proxy_error from engine.schemas import SolveRequest logger = logging.getLogger(__name__) @@ -62,103 +65,141 @@ async def solve( "ok": False, "error": "No slots available", } - else: - BROWSER_SLOTS -= 1 + BROWSER_SLOTS -= 1 - if not session_id: - session_id = str(uuid.uuid4()) - response.set_cookie("session_id", session_id) - logger.info("Setup new session with session_id: %s", session_id) + try: + if not session_id: + session_id = str(uuid.uuid4()) + response.set_cookie("session_id", session_id) + logger.info("Setup new session with session_id: %s", session_id) - scr_w, scr_h = payload.screen.split("x") - scr_size = int(scr_w), int(scr_h) - proxy = parse_proxy(payload.proxy) + scr_w, scr_h = payload.screen.split("x") + scr_size = int(scr_w), int(scr_h) + proxy = parse_proxy(payload.proxy) - user_data_dir = Path(__file__).parent / f"../extra/user_data_dir/{session_id}" - if not user_data_dir.exists(): - user_data_dir.mkdir(parents=True) + # профиль (cookies/local storage/fingerprint.json) хранится на диске по + # session_id — если он уже приходил в куках, ниже подгружаем существующий + # fingerprint из этой папки; если нет (новый session_id) — генерируем новый + # и сохраняем туда же (см. engine/fingerprint.py:load_or_create_fingerprint) + user_data_dir = Path(__file__).parent / f"../extra/user_data_dir/{session_id}" + 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 + # 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, - # только нижняя граница: реалистичный монитор должен быть не меньше запрошенного окна, - # а точный верхний потолок слишком часто не находится в датасете и тихо "ослабляется" - # browserforge (актуальный размер окна мы всё равно берём из payload.screen ниже, не отсюда) - screen=Screen(min_width=scr_size[0], min_height=scr_size[1]), - **fp_kwargs, - ) - - 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]}, - ), - } - - 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"] - 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, + fp_kwargs = {} + if payload.user_agent: + fp_kwargs["user_agent"] = payload.user_agent + # TODO: если есть session_id, то user_agent нужно взять из настроек пользователя + if locale: + fp_kwargs["locale"] = locale + fingerprint = load_or_create_fingerprint( + user_data_dir, + # только нижняя граница: реальный монитор физически не может быть + # меньше открытого на нём окна браузера (viewport ниже берём из + # payload.screen напрямую, не отсюда). load_or_create_fingerprint + # генерирует с strict=True, так что это ограничение либо честно + # соблюдается, либо сразу падает ValueError — датасет browserforge + # больше не может тихо подсунуть сюда, например, мобильный экран + screen=Screen(min_width=scr_size[0], min_height=scr_size[1]), + **fp_kwargs, ) - try: - await apply_fingerprint(context, fingerprint) - page = context.pages[0] if context.pages else await context.new_page() - if payload.cookies: - await context.add_cookies(payload.cookies) - page.set_default_timeout(30_000) - page.set_default_navigation_timeout(60_000) - await page.goto(url=payload.url) + 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]}, + ), + } - actions_result = await actions.execute( - page, payload.actions, payload.captcha, - antibot_name=payload.captcha_type, - success_locator=payload.captcha_success_locator, - detect_locator=payload.captcha_detect_locator, + 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"] + permissions.append("geolocation") + ctx_kwargs["permissions"] = permissions + + nav_timeout_ms = payload.timeout * 1000 if payload.timeout else 60_000 + default_timeout_ms = payload.timeout * 1000 if payload.timeout else 30_000 + blocked_domains = set(payload.blocked_domains) if payload.blocked_domains else None + referer = "https://www.google.com/" if payload.google_search else None + retries = max(1, payload.retries) + + async with async_playwright() as p: + context = await p.chromium.launch_persistent_context( + user_data_dir=str(user_data_dir), + proxy=proxy, + headless=True, + channel="chrome", + args=launch_args(locale=locale), + ignore_default_args=HARMFUL_ARGS, + **ctx_kwargs, ) + try: + await apply_fingerprint(context, fingerprint) + page = context.pages[0] if context.pages else await context.new_page() + if payload.cookies: + await context.add_cookies(payload.cookies) + page.set_default_timeout(default_timeout_ms) + page.set_default_navigation_timeout(nav_timeout_ms) + if payload.disable_resources or blocked_domains: + await page.route("**/*", create_intercept_handler(payload.disable_resources, blocked_domains)) - status = not "error" in actions_result[-1] - error = ["Something error", None][status] - cookies = await context.cookies() - html = await page.inner_html('html') - user_agent = await page.evaluate("() => navigator.userAgent") - # TODO: добавить как опцию. передачу скрина в base64. - # await page.screenshot( - # path=( - # f"/home/sokol/PycharmProjects/WebRoboApi/extra/screenshots/" - # f"{datetime.timestamp(datetime.now())}.png" - # ) - # ) - finally: - await context.close() + status = error = user_agent = None + cookies = html = None + actions_result = None + + for attempt in range(retries): + try: + await page.goto(url=payload.url, referer=referer) + await wait_for_page_stability(page, load_dom=True, network_idle=False) + + actions_result = await actions.execute( + page, payload.actions, payload.captcha, + antibot_name=payload.captcha_type, + success_locator=payload.captcha_success_locator, + detect_locator=payload.captcha_detect_locator, + ) + if payload.wait: + await page.wait_for_timeout(payload.wait) + + status = not "error" in actions_result[-1] + error = ["Something error", None][status] + cookies = await context.cookies() + html = await page.inner_html('html') + user_agent = await page.evaluate("() => navigator.userAgent") + # TODO: добавить как опцию. передачу скрина в base64. + # await page.screenshot( + # path=( + # f"/home/sokol/PycharmProjects/WebRoboApi/extra/screenshots/" + # f"{datetime.timestamp(datetime.now())}.png" + # ) + # ) + break + except Exception as e: + if attempt < retries - 1: + kind = "прокси" if is_proxy_error(e) else "запрос" + logger.warning( + "Попытка %s/%s не удалась (%s: %s), retry через %.1fs", + attempt + 1, retries, kind, e, payload.retry_delay, + ) + await asyncio.sleep(payload.retry_delay) + else: + raise + finally: + await context.close() + finally: + BROWSER_SLOTS += 1 - BROWSER_SLOTS += 1 result = { "status": ["err", "ok"][status], "user_agent": user_agent,