chore: перетащил обертку над браузером из scrapling
This commit is contained in:
1 parent
8f8bc62ae7
commit
257310b7d5
19 files changed
+4967
-283
No files matched your search
+72
-109
@@ -1,7 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
@@ -12,16 +10,13 @@ from fastapi import Cookie
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Request
|
||||
from fastapi import Response
|
||||
from patchright.async_api import ProxySettings
|
||||
from patchright.async_api import async_playwright
|
||||
from patchright.async_api import Page, ProxySettings
|
||||
|
||||
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
|
||||
from api import actions
|
||||
from api.fingerprint import browser_kwargs_for, load_or_create_fingerprint
|
||||
from api.geoip import resolve_geo
|
||||
from api.schemas import SolveRequest
|
||||
from engine import AsyncStealthySession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
@@ -40,6 +35,29 @@ def parse_proxy(proxy_url: str) -> ProxySettings:
|
||||
return settings
|
||||
|
||||
|
||||
def make_page_action(payload: SolveRequest):
|
||||
"""Замыкание для AsyncStealthySession.fetch(page_action=...): выполняет очередь действий
|
||||
(включая прохождение капчи/антибота через api.actions.execute) и достаёт из уже
|
||||
навигированной страницы всё, что нужно для ответа API. Возврат этой функции — то, что
|
||||
вернёт fetch() (см. engine/stealthy.py)."""
|
||||
|
||||
async def page_action(page: Page) -> dict:
|
||||
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,
|
||||
)
|
||||
return {
|
||||
"actions": actions_result,
|
||||
"cookies": await page.context.cookies(),
|
||||
"html": await page.inner_html("html"),
|
||||
"user_agent": await page.evaluate("() => navigator.userAgent"),
|
||||
}
|
||||
|
||||
return page_action
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@@ -73,20 +91,18 @@ async def solve(
|
||||
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)
|
||||
|
||||
# профиль (cookies/local storage/fingerprint.json) хранится на диске по
|
||||
# session_id — если он уже приходил в куках, ниже подгружаем существующий
|
||||
# fingerprint из этой папки; если нет (новый session_id) — генерируем новый
|
||||
# и сохраняем туда же (см. engine/fingerprint.py:load_or_create_fingerprint)
|
||||
# и сохраняем туда же (см. api/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, а не подменяла его
|
||||
# (см. api/geoip.py) попала в сам fingerprint, а не подменяла его
|
||||
# задним числом
|
||||
geo = await resolve_geo(proxy)
|
||||
locale = geo.get("locale") if geo else None
|
||||
@@ -97,118 +113,65 @@ async def solve(
|
||||
# TODO: если есть session_id, то user_agent нужно взять из настроек пользователя
|
||||
if locale:
|
||||
fp_kwargs["locale"] = locale
|
||||
|
||||
scr_size = (1920, 1080)
|
||||
fingerprint = load_or_create_fingerprint(
|
||||
user_data_dir,
|
||||
# только нижняя граница: реальный монитор физически не может быть
|
||||
# меньше открытого на нём окна браузера (viewport ниже берём из
|
||||
# payload.screen напрямую, не отсюда). load_or_create_fingerprint
|
||||
# генерирует с strict=True, так что это ограничение либо честно
|
||||
# соблюдается, либо сразу падает ValueError — датасет browserforge
|
||||
# больше не может тихо подсунуть сюда, например, мобильный экран
|
||||
# fingerprint'а, не отсюда)
|
||||
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]},
|
||||
),
|
||||
}
|
||||
|
||||
# UA/заголовки/init-script (подделка navigator/screen/WebGL/...) /viewport/screen/
|
||||
# device_scale_factor под fingerprint — единым куском kwargs (см.
|
||||
# api/fingerprint.py:browser_kwargs_for), тем же путём, что и остальная конфигурация
|
||||
# сессии (proxy/cookies/locale), а не отдельным вызовом после запуска контекста.
|
||||
fp_browser_kwargs = browser_kwargs_for(fingerprint)
|
||||
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
|
||||
if geo and geo.get("geolocation"):
|
||||
fp_browser_kwargs["additional_args"]["geolocation"] = geo["geolocation"]
|
||||
permissions.append("geolocation")
|
||||
fp_browser_kwargs["additional_args"]["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))
|
||||
kwargs = dict(
|
||||
headless=False,
|
||||
proxy=proxy,
|
||||
user_data_dir=str(user_data_dir),
|
||||
real_chrome=True,
|
||||
locale=locale,
|
||||
timezone_id=geo.get("timezone_id") if geo else None,
|
||||
cookies=payload.cookies,
|
||||
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
|
||||
)
|
||||
|
||||
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()
|
||||
async with AsyncStealthySession(**kwargs) as engine:
|
||||
result = await engine.fetch(payload.url, page_action=make_page_action(payload))
|
||||
finally:
|
||||
BROWSER_SLOTS += 1
|
||||
|
||||
result = {
|
||||
status = not "error" in result["actions"][-1]
|
||||
error = ["Something error", None][status]
|
||||
|
||||
result_payload = {
|
||||
"status": ["err", "ok"][status],
|
||||
"user_agent": user_agent,
|
||||
"cookies": cookies,
|
||||
"actions": actions_result,
|
||||
"user_agent": result["user_agent"],
|
||||
"cookies": result["cookies"],
|
||||
"actions": result["actions"],
|
||||
}
|
||||
if payload.html:
|
||||
result["html"] = html
|
||||
result_payload["html"] = result["html"]
|
||||
if error:
|
||||
result["error"] = error
|
||||
logger.info("Outgoing payload:\n%s", json.dumps(result, indent=2, sort_keys=True, ensure_ascii=True))
|
||||
return result
|
||||
result_payload["error"] = error
|
||||
logger.info("Outgoing payload:\n%s", json.dumps(result_payload, indent=2, sort_keys=True, ensure_ascii=True))
|
||||
return result_payload
|
||||
Reference in new issue
Block a user