chore: перетащил обертку над браузером из scrapling
This commit is contained in:
1 parent
8f8bc62ae7
commit
257310b7d5
19 files changed
+4967
-283
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,134 @@
|
||||
import asyncio
|
||||
|
||||
from patchright.async_api import Page
|
||||
|
||||
from antibot.base import click_and_race
|
||||
from antibot.orchestrator import pass_challenges
|
||||
from captcha import load_captcha
|
||||
from api.schemas import ActionRequest
|
||||
|
||||
_actions: dict = {}
|
||||
|
||||
|
||||
def register(fun) -> None:
|
||||
_actions[fun.__name__] = fun
|
||||
return fun
|
||||
|
||||
|
||||
@register
|
||||
async def fill(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
||||
assert act.selector
|
||||
assert act.value
|
||||
await page.fill(act.selector, act.value)
|
||||
|
||||
|
||||
@register
|
||||
async def captcha(page: Page, act: ActionRequest, captcha_key: str, **kwargs) -> str | None:
|
||||
assert act.selector
|
||||
assert act.value
|
||||
assert captcha_key
|
||||
captcha_solver = load_captcha(captcha_key, act.value)
|
||||
await captcha_solver.solve(act.selector, page)
|
||||
|
||||
|
||||
@register
|
||||
async def delay(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
||||
assert act.value
|
||||
await asyncio.sleep(int(act.value))
|
||||
|
||||
|
||||
@register
|
||||
async def mouse_move(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
||||
assert act.selector
|
||||
element = await page.query_selector(act.selector)
|
||||
await element.hover(scroll="auto")
|
||||
|
||||
|
||||
@register
|
||||
async def click(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
||||
assert act.selector
|
||||
element = page.locator(act.selector)
|
||||
await element.click()
|
||||
|
||||
|
||||
@register
|
||||
async def submit(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
||||
assert act.selector
|
||||
element = page.locator(act.selector)
|
||||
|
||||
async with page.expect_event(
|
||||
"framenavigated",
|
||||
predicate=lambda frame: frame == page.main_frame,
|
||||
):
|
||||
await element.click()
|
||||
|
||||
|
||||
@register
|
||||
async def inner_html(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
||||
assert act.selector
|
||||
element = await page.query_selector(act.selector)
|
||||
return await element.inner_html()
|
||||
|
||||
|
||||
@register
|
||||
async def wait_for(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
||||
assert act.selector
|
||||
assert 1 <= act.timeout <= 7200
|
||||
try:
|
||||
await page.wait_for_selector(act.selector, timeout=act.timeout * 1000)
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
|
||||
|
||||
@register
|
||||
async def pass_challenge(
|
||||
page: Page, act: ActionRequest, *,
|
||||
antibot_name, success_locator=None, captcha_key=None, detect_locator=None,
|
||||
**kwargs
|
||||
) -> str | None:
|
||||
result = await pass_challenges(
|
||||
antibot_name, page,
|
||||
captcha=captcha_key,
|
||||
success_locator=act.selector or success_locator,
|
||||
detect_locator=detect_locator
|
||||
)
|
||||
return result.reason
|
||||
|
||||
|
||||
async def execute(
|
||||
page: Page, actions: list[ActionRequest],
|
||||
captcha_key: str | None = None, antibot_name: str | None = None,
|
||||
success_locator: str | None = None,
|
||||
detect_locator: str | None = None) -> list[dict]:
|
||||
"""Выполнение очереди автоматизации"""
|
||||
actions_result = []
|
||||
ctx = {
|
||||
"captcha_key": captcha_key,
|
||||
"antibot_name": antibot_name,
|
||||
"success_locator": success_locator,
|
||||
"detect_locator": detect_locator,
|
||||
}
|
||||
await pass_challenges(
|
||||
antibot_name, page,
|
||||
captcha=captcha_key,
|
||||
success_locator=success_locator,
|
||||
detect_locator=detect_locator,
|
||||
)
|
||||
for act in actions:
|
||||
new_line = {
|
||||
"type": act.type,
|
||||
}
|
||||
try:
|
||||
# TODO: добавить время выполнения
|
||||
result = await _actions[act.type](page, act, **ctx)
|
||||
except Exception as e:
|
||||
new_line["ok"] = False
|
||||
new_line["result"] = str(e)
|
||||
actions_result.append(new_line)
|
||||
break
|
||||
else:
|
||||
new_line["ok"] = True
|
||||
if result:
|
||||
new_line["result"] = result
|
||||
actions_result.append(new_line)
|
||||
return actions_result if len(actions_result) else [{"status": "pass"}]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Генерация и persist согласованного browser fingerprint на профиль (user_data_dir).
|
||||
|
||||
Используем browserforge — ту же библиотеку, которую под капотом использовал Camoufox — но
|
||||
поверх голого Playwright Chromium: генерируем Fingerprint (navigator/screen/WebGL/canvas/шрифты/
|
||||
headers, всё согласовано друг с другом и с выбранным UA) и инжектим его через add_init_script.
|
||||
|
||||
Важно: генерируем именно под browser=['chrome'] — раз движок у нас реальный Chromium, подделывать
|
||||
navigator под Firefox/Safari было бы хуже, чем не подделывать вообще (несоответствие между
|
||||
заявленным UA и реальными JS-фичами движка — сильный сигнал для антибота).
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from browserforge.fingerprints import Fingerprint, FingerprintGenerator, Screen
|
||||
from browserforge.fingerprints.generator import NavigatorFingerprint, ScreenFingerprint, VideoCard
|
||||
from browserforge.injectors.utils import InjectFunction, only_injectable_headers
|
||||
|
||||
FINGERPRINT_FILE = "fingerprint.json"
|
||||
|
||||
|
||||
def _fingerprint_from_dict(data: dict) -> Fingerprint:
|
||||
data = dict(data)
|
||||
data["screen"] = ScreenFingerprint(**data["screen"])
|
||||
data["navigator"] = NavigatorFingerprint(**data["navigator"])
|
||||
if data.get("videoCard"):
|
||||
data["videoCard"] = VideoCard(**data["videoCard"])
|
||||
return Fingerprint(**data)
|
||||
|
||||
|
||||
def load_or_create_fingerprint(user_data_dir: Path, **generator_kwargs) -> Fingerprint:
|
||||
"""Держит fingerprint стабильным для профиля: генерируется один раз при первом запросе
|
||||
этой сессии (user_data_dir) и переиспользуется во всех последующих — так же, как реальный
|
||||
человек не меняет видеокарту/шрифты/UA между визитами на сайт."""
|
||||
fp_file = user_data_dir / FINGERPRINT_FILE
|
||||
if fp_file.exists():
|
||||
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
|
||||
|
||||
|
||||
def browser_kwargs_for(fingerprint: Fingerprint, browser_name: str = "chromium") -> dict:
|
||||
"""kwargs для `AsyncStealthySession(**kwargs)`, полностью выставляющие браузер под fingerprint —
|
||||
UA, заголовки, init-script (подделка navigator/screen/WebGL/codecs/battery) и размер окна
|
||||
(viewport/screen/device_scale_factor).
|
||||
|
||||
Всё уходит обычными kwargs сессии — тем же путём, что и остальная конфигурация
|
||||
(proxy/cookies/locale и т.д.), как это делает сама scrapling — а не отдельным вызовом после
|
||||
запуска контекста (add_init_script/set_extra_http_headers руками требуют context, который
|
||||
есть только после входа в `async with AsyncStealthySession(...)`, то есть после того, как
|
||||
решение о конфигурации уже "должно было" быть принято до запуска браузера).
|
||||
|
||||
:param fingerprint: Сгенерированный/загруженный fingerprint (см. load_or_create_fingerprint).
|
||||
:param browser_name: Имя браузера для фильтрации заголовков (only_injectable_headers) —
|
||||
"chromium", раз движок у нас реальный Chromium (см. docstring модуля).
|
||||
:return: dict с ключами `useragent`, `extra_headers`, `init_script_content`, `additional_args`
|
||||
(viewport/screen/device_scale_factor) — распаковывается в kwargs AsyncStealthySession.
|
||||
"""
|
||||
screen = {"width": fingerprint.screen.width, "height": fingerprint.screen.height}
|
||||
return {
|
||||
"useragent": fingerprint.navigator.userAgent,
|
||||
"extra_headers": only_injectable_headers(fingerprint.headers, browser_name),
|
||||
"init_script_content": InjectFunction(fingerprint),
|
||||
"additional_args": {
|
||||
"viewport": dict(screen),
|
||||
"screen": dict(screen),
|
||||
"device_scale_factor": fingerprint.screen.devicePixelRatio,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Резолвинг геолокации/таймзоны исходящего IP через сам прокси (не локальный IP), с кэшем в файле.
|
||||
|
||||
Раньше это делал Camoufox (geoip=True) под капотом. Смысл: если браузер репортит IP из
|
||||
одной страны/города (через прокси), а navigator.timezone/geolocation — из другой, это
|
||||
явное рассогласование, которое легко ловится антиботом. Поэтому таймзону/координаты нужно
|
||||
брать по тому же IP, что увидит целевой сайт — то есть запрашивать через прокси, а не с
|
||||
локальной машины.
|
||||
|
||||
Кэш нужен по двум причинам: у ipinfo.io есть квота на бесплатные запросы, и результат для
|
||||
одного и того же прокси (пока он не сменил exit-IP) не меняется от запроса к запросу — незачем
|
||||
дёргать внешний сервис на каждый /v1/solve.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
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()
|
||||
|
||||
|
||||
def proxy_settings_to_url(proxy: ProxySettings) -> str:
|
||||
server = proxy["server"]
|
||||
username = proxy.get("username")
|
||||
password = proxy.get("password")
|
||||
if not username:
|
||||
return server
|
||||
scheme, _, rest = server.partition("://")
|
||||
return f"{scheme}://{username}:{password}@{rest}"
|
||||
|
||||
|
||||
def _load_cache(cache_file: Path) -> dict:
|
||||
if not cache_file.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(cache_file.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_cache(cache_file: Path, cache: dict) -> None:
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(json.dumps(cache, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
async def resolve_geo(
|
||||
proxy: ProxySettings,
|
||||
timeout: float = 10.0,
|
||||
cache_file: Path = DEFAULT_CACHE_FILE,
|
||||
ttl: float = DEFAULT_TTL,
|
||||
) -> dict | None:
|
||||
"""Возвращает {"timezone_id", "geolocation": {"latitude", "longitude"}, "country", "ip", "locale"}
|
||||
по IP, который виден снаружи через прокси, или None, если резолв не удался и в кэше
|
||||
тоже ничего нет — в этом случае вызывающий код просто не переопределяет timezone/geolocation/locale.
|
||||
|
||||
"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:
|
||||
cache = _load_cache(cache_file)
|
||||
entry = cache.get(key)
|
||||
if entry and (time.time() - entry["ts"]) < ttl:
|
||||
return entry["data"]
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(proxy=key, timeout=timeout) as client:
|
||||
resp = await client.get(IPINFO_URL)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
log.warning("не удалось резолвнуть geoip через прокси: %r", e)
|
||||
# сеть/квота ipinfo могли отказать разово — лучше отдать протухший кэш, чем ничего,
|
||||
# если прокси тот же самый (скорее всего и IP тот же)
|
||||
if entry:
|
||||
log.info("используем протухший geoip-кэш из-за сбоя запроса")
|
||||
return entry["data"]
|
||||
return None
|
||||
|
||||
loc = data.get("loc") # "lat,lon"
|
||||
geolocation = None
|
||||
if loc and "," in loc:
|
||||
lat_str, lon_str = loc.split(",", 1)
|
||||
try:
|
||||
geolocation = {"latitude": float(lat_str), "longitude": float(lon_str)}
|
||||
except ValueError:
|
||||
geolocation = None
|
||||
|
||||
country = data.get("country") # "RU", "US", ...
|
||||
result = {
|
||||
"ip": data.get("ip"),
|
||||
"country": country,
|
||||
"timezone_id": data.get("timezone"),
|
||||
"geolocation": geolocation,
|
||||
"locale": _COUNTRY_LOCALE.get(country, DEFAULT_LOCALE),
|
||||
}
|
||||
|
||||
async with _cache_lock:
|
||||
cache = _load_cache(cache_file)
|
||||
cache[key] = {"ts": time.time(), "data": result}
|
||||
_save_cache(cache_file, cache)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,45 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ActionRequest(BaseModel):
|
||||
"""
|
||||
Действие автоматизации. Характеризуется типом и производится над селектором.
|
||||
Набор параметров зависит от типа, этот класс перечисляет все параметры.
|
||||
"""
|
||||
type: str
|
||||
selector: str | None = None
|
||||
value: str | None = None
|
||||
timeout: int | None = None
|
||||
|
||||
|
||||
class SolveRequest(BaseModel):
|
||||
"""
|
||||
Объект запроса для автоматизации на сайте.
|
||||
"""
|
||||
url: str
|
||||
proxy: str
|
||||
timeout: int | None = None
|
||||
user_agent: str | None = None
|
||||
cookies: list[dict[str, Any]] | None = None
|
||||
# List[{name: str, value: str, domain: str, path: str, expires: float, httpOnly: bool, secure: bool, sameSite: Union["Lax", "None", "Strict"], partitionKey: Union[str, None]}]
|
||||
|
||||
actions: list[ActionRequest]
|
||||
|
||||
html: bool = False
|
||||
# TODO: screenshot: bool = False
|
||||
|
||||
captcha: str | None = None
|
||||
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
|
||||
Reference in new issue
Block a user