chore: попытка сделать так же как у scrapling
This commit is contained in:
1 parent
8a464391a2
commit
8f8bc62ae7
5 files changed
+142
-14
No files matched your search
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
+54
-13
@@ -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,9 +65,9 @@ async def solve(
|
|||||||
"ok": False,
|
"ok": False,
|
||||||
"error": "No slots available",
|
"error": "No slots available",
|
||||||
}
|
}
|
||||||
else:
|
|
||||||
BROWSER_SLOTS -= 1
|
BROWSER_SLOTS -= 1
|
||||||
|
|
||||||
|
try:
|
||||||
if not session_id:
|
if not session_id:
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
response.set_cookie("session_id", session_id)
|
response.set_cookie("session_id", session_id)
|
||||||
@@ -74,32 +77,41 @@ async def solve(
|
|||||||
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)
|
||||||
|
|
||||||
|
# профиль (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}"
|
user_data_dir = Path(__file__).parent / f"../extra/user_data_dir/{session_id}"
|
||||||
if not user_data_dir.exists():
|
if not user_data_dir.exists():
|
||||||
user_data_dir.mkdir(parents=True)
|
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)
|
geo = await resolve_geo(proxy)
|
||||||
locale = geo.get("locale") if geo else None
|
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
|
||||||
|
# TODO: если есть session_id, то user_agent нужно взять из настроек пользователя
|
||||||
if locale:
|
if locale:
|
||||||
fp_kwargs["locale"] = locale
|
fp_kwargs["locale"] = locale
|
||||||
fingerprint = load_or_create_fingerprint(
|
fingerprint = load_or_create_fingerprint(
|
||||||
user_data_dir,
|
user_data_dir,
|
||||||
# только нижняя граница: реалистичный монитор должен быть не меньше запрошенного окна,
|
# только нижняя граница: реальный монитор физически не может быть
|
||||||
# а точный верхний потолок слишком часто не находится в датасете и тихо "ослабляется"
|
# меньше открытого на нём окна браузера (viewport ниже берём из
|
||||||
# browserforge (актуальный размер окна мы всё равно берём из payload.screen ниже, не отсюда)
|
# payload.screen напрямую, не отсюда). load_or_create_fingerprint
|
||||||
|
# генерирует с strict=True, так что это ограничение либо честно
|
||||||
|
# соблюдается, либо сразу падает ValueError — датасет browserforge
|
||||||
|
# больше не может тихо подсунуть сюда, например, мобильный экран
|
||||||
screen=Screen(min_width=scr_size[0], min_height=scr_size[1]),
|
screen=Screen(min_width=scr_size[0], min_height=scr_size[1]),
|
||||||
**fp_kwargs,
|
**fp_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx_kwargs = {
|
ctx_kwargs = {
|
||||||
# базовые анти-детект опции ниже — fingerprint накладывается поверх и выигрывает
|
# базовые анти-детект опции ниже — fingerprint накладывается поверх
|
||||||
# при конфликте по user_agent/viewport/device_scale_factor
|
# и выигрывает при конфликте по user_agent/viewport/device_scale_factor
|
||||||
**stealth_context_options(),
|
**stealth_context_options(),
|
||||||
**context_options_for(
|
**context_options_for(
|
||||||
fingerprint,
|
fingerprint,
|
||||||
@@ -116,11 +128,17 @@ async def solve(
|
|||||||
permissions.append("geolocation")
|
permissions.append("geolocation")
|
||||||
ctx_kwargs["permissions"] = permissions
|
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:
|
async with async_playwright() as p:
|
||||||
context = await p.chromium.launch_persistent_context(
|
context = await p.chromium.launch_persistent_context(
|
||||||
user_data_dir=str(user_data_dir),
|
user_data_dir=str(user_data_dir),
|
||||||
proxy=proxy,
|
proxy=proxy,
|
||||||
headless=False,
|
headless=True,
|
||||||
channel="chrome",
|
channel="chrome",
|
||||||
args=launch_args(locale=locale),
|
args=launch_args(locale=locale),
|
||||||
ignore_default_args=HARMFUL_ARGS,
|
ignore_default_args=HARMFUL_ARGS,
|
||||||
@@ -131,10 +149,19 @@ async def solve(
|
|||||||
page = context.pages[0] if context.pages else await context.new_page()
|
page = context.pages[0] if context.pages else await context.new_page()
|
||||||
if payload.cookies:
|
if payload.cookies:
|
||||||
await context.add_cookies(payload.cookies)
|
await context.add_cookies(payload.cookies)
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(default_timeout_ms)
|
||||||
page.set_default_navigation_timeout(60_000)
|
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))
|
||||||
|
|
||||||
await page.goto(url=payload.url)
|
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(
|
actions_result = await actions.execute(
|
||||||
page, payload.actions, payload.captcha,
|
page, payload.actions, payload.captcha,
|
||||||
@@ -142,6 +169,8 @@ async def solve(
|
|||||||
success_locator=payload.captcha_success_locator,
|
success_locator=payload.captcha_success_locator,
|
||||||
detect_locator=payload.captcha_detect_locator,
|
detect_locator=payload.captcha_detect_locator,
|
||||||
)
|
)
|
||||||
|
if payload.wait:
|
||||||
|
await page.wait_for_timeout(payload.wait)
|
||||||
|
|
||||||
status = not "error" in actions_result[-1]
|
status = not "error" in actions_result[-1]
|
||||||
error = ["Something error", None][status]
|
error = ["Something error", None][status]
|
||||||
@@ -155,10 +184,22 @@ async def solve(
|
|||||||
# f"{datetime.timestamp(datetime.now())}.png"
|
# 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:
|
finally:
|
||||||
await context.close()
|
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,
|
||||||
|
|||||||
Reference in new issue
Block a user