chore: попытка сделать так же как у scrapling

This commit is contained in:
protokey committed 2026-09-23 14:40:50 +04:00
1 parent 8a464391a2
commit 8f8bc62ae7
5 files changed
+215 -87

No files matched your search

+2 -1
View File
@@ -84,7 +84,8 @@ class CloudflareDetector(Detector):
async def detect(self, page: Page, info: ChallengeInfo): async def detect(self, page: Page, info: ChallengeInfo):
log.debug("CloudflareDetector run") log.debug("CloudflareDetector run")
await wait_for_page_stability(page, False, True) await wait_for_networkidle(page)
log.debug("Страница загружена полностью") log.debug("Страница загружена полностью")
info.meta = {} info.meta = {}
widget_selector = "div.cf-turnstile[data-sitekey], div[data-sitekey].cf-turnstile" widget_selector = "div.cf-turnstile[data-sitekey], div[data-sitekey].cf-turnstile"
+8
View File
@@ -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())) return _fingerprint_from_dict(json.loads(fp_file.read_text()))
generator_kwargs.setdefault("browser", ["chrome"]) 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) fingerprint = FingerprintGenerator().generate(**generator_kwargs)
fp_file.write_text(fingerprint.dumps()) fp_file.write_text(fingerprint.dumps())
return fingerprint return fingerprint
+69
View File
@@ -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
+9
View File
@@ -35,3 +35,12 @@ class SolveRequest(BaseModel):
captcha_detect_locator: str | None = None captcha_detect_locator: str | None = None
captcha_success_locator: str | None = None captcha_success_locator: str | None = None
captcha_type: 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
+127 -86
View File
@@ -1,3 +1,4 @@
import asyncio
import json import json
import logging import logging
import traceback import traceback
@@ -14,10 +15,12 @@ from fastapi import Response
from patchright.async_api import ProxySettings from patchright.async_api import ProxySettings
from patchright.async_api import async_playwright from patchright.async_api import async_playwright
from antibot.humanize import wait_for_page_stability
from engine import actions from engine import actions
from engine.browser_launch import HARMFUL_ARGS, launch_args, stealth_context_options 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.fingerprint import apply_fingerprint, context_options_for, load_or_create_fingerprint
from engine.geoip import resolve_geo from engine.geoip import resolve_geo
from engine.navigation import create_intercept_handler, is_proxy_error
from engine.schemas import SolveRequest from engine.schemas import SolveRequest
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -62,103 +65,141 @@ async def solve(
"ok": False, "ok": False,
"error": "No slots available", "error": "No slots available",
} }
else: BROWSER_SLOTS -= 1
BROWSER_SLOTS -= 1
if not session_id: try:
session_id = str(uuid.uuid4()) if not session_id:
response.set_cookie("session_id", session_id) session_id = str(uuid.uuid4())
logger.info("Setup new session with session_id: %s", session_id) 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_w, scr_h = payload.screen.split("x")
scr_size = int(scr_w), int(scr_h) scr_size = int(scr_w), int(scr_h)
proxy = parse_proxy(payload.proxy) proxy = parse_proxy(payload.proxy)
user_data_dir = Path(__file__).parent / f"../extra/user_data_dir/{session_id}" # профиль (cookies/local storage/fingerprint.json) хранится на диске по
if not user_data_dir.exists(): # session_id — если он уже приходил в куках, ниже подгружаем существующий
user_data_dir.mkdir(parents=True) # 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) # geo — до создания fingerprint'а, чтобы производная от прокси locale
# попала в сам fingerprint, а не подменяла его задним числом # (см. engine/geoip.py) попала в сам fingerprint, а не подменяла его
geo = await resolve_geo(proxy) # задним числом
locale = geo.get("locale") if geo else None geo = await resolve_geo(proxy)
locale = geo.get("locale") if geo else None
fp_kwargs = {} fp_kwargs = {}
if payload.user_agent: if payload.user_agent:
fp_kwargs["user_agent"] = payload.user_agent fp_kwargs["user_agent"] = payload.user_agent
if locale: # TODO: если есть session_id, то user_agent нужно взять из настроек пользователя
fp_kwargs["locale"] = locale if locale:
fingerprint = load_or_create_fingerprint( fp_kwargs["locale"] = locale
user_data_dir, fingerprint = load_or_create_fingerprint(
# только нижняя граница: реалистичный монитор должен быть не меньше запрошенного окна, user_data_dir,
# а точный верхний потолок слишком часто не находится в датасете и тихо "ослабляется" # только нижняя граница: реальный монитор физически не может быть
# browserforge (актуальный размер окна мы всё равно берём из payload.screen ниже, не отсюда) # меньше открытого на нём окна браузера (viewport ниже берём из
screen=Screen(min_width=scr_size[0], min_height=scr_size[1]), # payload.screen напрямую, не отсюда). load_or_create_fingerprint
**fp_kwargs, # генерирует с strict=True, так что это ограничение либо честно
) # соблюдается, либо сразу падает ValueError — датасет browserforge
# больше не может тихо подсунуть сюда, например, мобильный экран
ctx_kwargs = { screen=Screen(min_width=scr_size[0], min_height=scr_size[1]),
# базовые анти-детект опции ниже — fingerprint накладывается поверх и выигрывает **fp_kwargs,
# при конфликте по 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,
) )
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( permissions = ["notifications"]
page, payload.actions, payload.captcha, if geo:
antibot_name=payload.captcha_type, if geo.get("timezone_id"):
success_locator=payload.captcha_success_locator, ctx_kwargs["timezone_id"] = geo["timezone_id"]
detect_locator=payload.captcha_detect_locator, 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] status = error = user_agent = None
error = ["Something error", None][status] cookies = html = None
cookies = await context.cookies() actions_result = None
html = await page.inner_html('html')
user_agent = await page.evaluate("() => navigator.userAgent") for attempt in range(retries):
# TODO: добавить как опцию. передачу скрина в base64. try:
# await page.screenshot( await page.goto(url=payload.url, referer=referer)
# path=( await wait_for_page_stability(page, load_dom=True, network_idle=False)
# f"/home/sokol/PycharmProjects/WebRoboApi/extra/screenshots/"
# f"{datetime.timestamp(datetime.now())}.png" actions_result = await actions.execute(
# ) page, payload.actions, payload.captcha,
# ) antibot_name=payload.captcha_type,
finally: success_locator=payload.captcha_success_locator,
await context.close() 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 = { result = {
"status": ["err", "ok"][status], "status": ["err", "ok"][status],
"user_agent": user_agent, "user_agent": user_agent,