gendesign/tradein-mvp/browser/server.py
bot-backend 77ae4ca779
All checks were successful
CI / changes (pull_request) Successful in 5s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(browser): per-provider concurrency — параллель между поставщиками, ≤1 коннект на поставщика (#1793)
2026-06-19 18:52:55 +03:00

801 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""server.py — tradein-browser service main process.
Этот модуль запускается как точка входа контейнера ``tradein-browser``.
Он запускает camoufox **локально** внутри контейнера (AsyncCamoufox) и
экспонирует простой HTTP API на базе aiohttp:
GET /health → {"status": "ok", "browsers": {"avito": bool, ...}}
POST /fetch → {"url": "..."} → {"html": "..."}
POST /login → {"url": "...", "email": "...", "password": "...", ...} → {"cookies": [...]}
Такой подход выбран потому, что ``camoufox.server.launch_server`` (Playwright
WS-сервер) несовместим с современными версиями playwright (1.451.60):
``browserServerImpl.js`` отсутствует в дистрибутиве → контейнер краш-лупится
с MODULE_NOT_FOUND / RuntimeError при запуске через ``launch_server``.
Локальный запуск ``AsyncCamoufox`` работает стабильно — проверено.
Per-provider модель (#1793):
Вместо одного глобального браузера + одного egress-прокси держим ОТДЕЛЬНЫЙ
инстанс camoufox на каждого поставщика (avito/cian/yandex/generic). Поставщик
определяется по host URL запроса (или явному полю body["provider"]). Каждый
инстанс ходит через свой http-прокси (BROWSER_PROXY_AVITO/CIAN/YANDEX) и имеет
geoip=True — фингерпринт привязан к гео конкретного прокси, поэтому смешивать
источники в одном браузере нельзя.
Конкурентность:
* МЕЖДУ поставщиками — параллельно (avito/cian/yandex гоняются одновременно);
* ВНУТРИ поставщика — строго ≤1 одновременный fetch (per-provider lock).
Это решает проблему, когда источники клинили друг друга через единственный
egress-прокси / единственный сериализующий лок.
Переменные окружения:
BROWSER_PORT — TCP-порт HTTP-сервера (default: 3000)
BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска (default: 15)
BROWSER_NAV_TIMEOUT_MS — таймаут page.goto в мс (default: 60000)
BROWSER_WAIT_MS — ожидание гидрации listings после DOMContentLoaded, мс (default: 6000)
BROWSER_BLOCK_RESOURCES — блокировать тяжёлые ресурсы (картинки/медиа/шрифты) при
навигации, чтобы страница качалась быстрее через медленный
прокси (default: "true"; bandwidth — главный боттлнек).
Данные парсятся из page.content()/__initialData__, поэтому
картинки/шрифты не нужны. CSS/JS/XHR/document НЕ блокируем —
нужны для гидрации listings и не палят stealth.
BROWSER_BLOCKED_RESOURCE_TYPES — CSV типов ресурсов Playwright для блокировки
(default: "image,media,font"). Менять консервативно:
блокировка script/stylesheet/document/xhr сломает выдачу.
BROWSER_PROXY_AVITO — http-прокси для avito (fallback AVITO_PROXY_URL).
BROWSER_PROXY_CIAN — http-прокси для cian (fallback CIAN_PROXY_URL).
BROWSER_PROXY_YANDEX — http-прокси для yandex (fallback YANDEX_PROXY_URL).
SCRAPER_PROXY_URL — http-прокси для generic (fallback AVITO_PROXY_URL).
ВАЖНО: для браузера используем именно BROWSER_PROXY_* (http-прокси). Legacy
CIAN_PROXY_URL/YANDEX_PROXY_URL могут быть socks5 с авторизацией — playwright
их не умеет, поэтому они лишь fallback (если кто-то положил http туда же).
Контракт /login (провалидировано вживую 2026-05-31, Cian email+пароль без SMS):
pre_click_selectors — список селекторов для последовательного клика до формы;
каждый клик non-fatal (пропускается при отсутствии элемента). Двухшаговый
submit: после первого сабмита Cian может показать «Введите пароль» повторно.
Эндпоинт /fetch доступен из Docker-сети как ``http://tradein-browser:3000/fetch``.
"""
import asyncio
import base64
import logging
import os
from urllib.parse import urlparse
from aiohttp import web
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
# ── конфигурация из env ────────────────────────────────────────────────────────
BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000"))
BROWSER_RECYCLE_PAGES: int = int(os.environ.get("BROWSER_RECYCLE_PAGES", "15"))
BROWSER_NAV_TIMEOUT_MS: int = int(os.environ.get("BROWSER_NAV_TIMEOUT_MS", "60000"))
# 6000 (не 2500): avito гидрирует listings client-side ПОСЛЕ domcontentloaded;
# на 2.5с в HTML генерик-шелл без объявлений (0 listings), на 5-6с — полная
# выдача (~50 карточек, 3.2МБ). Подтверждено прод-дебагом 2026-05-31.
BROWSER_WAIT_MS: int = int(os.environ.get("BROWSER_WAIT_MS", "6000"))
# Известные поставщики. "generic" — фолбэк для всех прочих хостов (один общий
# инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод.
PROVIDERS: tuple[str, ...] = ("avito", "cian", "yandex", "generic")
def _parse_bool(raw: str | None, default: bool) -> bool:
"""Парсит булев env-флаг ("true"/"1"/"yes"/"on" → True), fallback на default."""
if raw is None:
return default
return raw.strip().lower() in ("true", "1", "yes", "on")
# Блокировка тяжёлых ресурсов (картинки/медиа/шрифты) при навигации. avito SERP
# ~1.5-2 МБ, в основном медиа; нужные данные — в HTML/__initialData__ (парсятся из
# page.content()). Блокируя image/media/font, страница ~200 КБ (×10 меньше) → быстрее
# качается через медленный прокси (bandwidth — главный боттлнек). CSS/JS/XHR/document
# НЕ трогаем — нужны для гидрации listings, плюс «браузер без ресурсов» спалил бы stealth.
_BLOCK_RESOURCES: bool = _parse_bool(os.environ.get("BROWSER_BLOCK_RESOURCES"), True)
_BLOCKED_TYPES: frozenset[str] = frozenset(
t.strip().lower()
for t in os.environ.get("BROWSER_BLOCKED_RESOURCE_TYPES", "image,media,font").split(",")
if t.strip()
)
def _provider_for_url(url: str) -> str:
"""Определяет поставщика по host URL.
avito/cian/yandex → одноимённый провайдер; всё прочее → "generic".
"""
host = (urlparse(url).hostname or "").lower()
if "avito" in host:
return "avito"
if "cian" in host:
return "cian"
if "yandex" in host:
return "yandex"
return "generic"
def _provider_proxy(provider: str) -> str | None:
"""Возвращает http-прокси для поставщика из env (с фолбэком), иначе None.
Используем BROWSER_PROXY_* как primary — это http-прокси для браузера.
Legacy *_PROXY_URL — лишь fallback: playwright НЕ умеет socks5 с авторизацией,
поэтому socks5-варианты CIAN/YANDEX как primary не годятся.
"""
env_map: dict[str, tuple[str, str]] = {
"avito": ("BROWSER_PROXY_AVITO", "AVITO_PROXY_URL"),
"cian": ("BROWSER_PROXY_CIAN", "CIAN_PROXY_URL"),
"yandex": ("BROWSER_PROXY_YANDEX", "YANDEX_PROXY_URL"),
"generic": ("SCRAPER_PROXY_URL", "AVITO_PROXY_URL"),
}
primary, fallback = env_map.get(provider, env_map["generic"])
return os.environ.get(primary) or os.environ.get(fallback)
def _parse_proxy(proxy_url: str | None) -> dict[str, str] | None:
"""Парсит proxy URL → camoufox proxy dict.
Формат входного URL: ``http://user:pass@host:port`` или ``http://host:port``.
Возвращает None если proxy_url пуст.
Returns:
``{"server": "http://host:port", "username": "...", "password": "..."}``
или None.
"""
if not proxy_url:
return None
parsed = urlparse(proxy_url)
port_part = f":{parsed.port}" if parsed.port else ""
server = f"{parsed.scheme}://{parsed.hostname}{port_part}"
result: dict[str, str] = {"server": server}
if parsed.username:
result["username"] = parsed.username
if parsed.password:
result["password"] = parsed.password
return result
# ── per-provider состояние браузеров ───────────────────────────────────────────
# Каждый словарь keyed по provider (str). Заполняется ЛЕНИВО: инстанс конкретного
# поставщика поднимается на первом /fetch (или /login) к этому поставщику.
#
# _locks[provider] сериализует ВЕСЬ доступ к инстансу этого поставщика
# (launch + relaunch + fetch + login) → внутри поставщика строго ≤1 операция
# одновременно. Разные поставщики держат РАЗНЫЕ локи → работают параллельно.
_browsers: dict[str, object] = {} # provider → Browser
_browser_cms: dict[str, object] = {} # provider → AsyncCamoufox CM (для __aexit__)
_page_counters: dict[str, int] = {} # provider → страниц с момента launch'а
_locks: dict[str, asyncio.Lock] = {} # provider → Lock (весь доступ к инстансу)
_retry_tasks: dict[str, asyncio.Task[None]] = {} # provider → фоновая retry-задача
# Guard на ленивое создание per-provider локов: setdefault на обычном dict из
# разных корутин гонок не даёт (нет await между read-modify-write), но держим
# явный guard на случай будущей сложной инициализации. Создаётся в _on_startup.
_locks_guard: asyncio.Lock | None = None
# Resilience (#crash-storm): если прокси (mproxy.site) лежит, camoufox делает
# eager public_ip(proxy) для geoip-fingerprint и бросает InvalidProxy. Раньше это
# валило app.startup → exit(1) → docker restart → ∞ (RestartCount 3000+). Теперь
# фейл launch'а НЕ фатален: контейнер остаётся Up, а фоновая задача retry'ит
# launch с backoff пока прокси не вернётся. proxy+geoip СОХРАНЕНЫ (анти-бан).
_BROWSER_RETRY_MIN_S: float = 30.0 # стартовый интервал retry launch'а
_BROWSER_RETRY_MAX_S: float = 60.0 # потолок backoff'а
async def _lock_for(provider: str) -> asyncio.Lock:
"""Возвращает (создавая лениво под guard'ом) per-provider лок.
Лок сериализует launch+fetch+login на инстансе поставщика: один поставщик =
один браузер = ≤1 операция за раз. Guard защищает создание лока от гонки
параллельных корутин на первом запросе к новому поставщику.
"""
existing = _locks.get(provider)
if existing is not None:
return existing
assert _locks_guard is not None, "_locks_guard not initialised"
async with _locks_guard:
return _locks.setdefault(provider, asyncio.Lock())
async def _launch_browser(provider: str) -> None:
"""Запускает AsyncCamoufox для поставщика и кладёт в *_…[provider].
Использует _provider_proxy(provider) + geoip=True (фингерпринт привязан к гео
IP конкретного прокси — поэтому отдельный инстанс на каждого поставщика).
"""
from camoufox.async_api import AsyncCamoufox
proxy = _parse_proxy(_provider_proxy(provider))
kwargs: dict[str, object] = {
"headless": True,
"os": "windows",
"locale": "ru-RU",
"geoip": True,
"humanize": True,
}
if proxy is not None:
kwargs["proxy"] = proxy
logger.info(
"tradein-browser[%s]: запуск AsyncCamoufox (proxy=%s, recycle_pages=%d)",
provider,
proxy is not None,
BROWSER_RECYCLE_PAGES,
)
cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type]
browser = await cm.__aenter__()
_browser_cms[provider] = cm
_browsers[provider] = browser
_page_counters[provider] = 0
logger.info("tradein-browser[%s]: браузер запущен", provider)
async def _close_browser(provider: str) -> None:
"""Закрывает per-provider браузер и чистит состояние для поставщика."""
cm = _browser_cms.get(provider)
if cm is not None:
try:
await cm.__aexit__(None, None, None) # type: ignore[attr-defined]
logger.info("tradein-browser[%s]: браузер закрыт", provider)
except Exception as exc:
logger.warning(
"tradein-browser[%s]: ошибка при закрытии браузера: %s",
provider,
type(exc).__name__,
)
_browsers.pop(provider, None)
_browser_cms.pop(provider, None)
_page_counters[provider] = 0
async def _relaunch_browser(provider: str) -> None:
"""Закрывает и заново запускает per-provider браузер (recycle / crash-recovery).
Caller держит _locks[provider], поэтому параллельных страниц на этом инстансе
нет — teardown безопасен.
"""
logger.info("tradein-browser[%s]: перезапуск браузера", provider)
await _close_browser(provider)
await _launch_browser(provider)
async def _try_launch_browser(provider: str) -> bool:
"""Одна попытка launch'а поставщика с санитизированным логом фейла.
НЕ пробрасывает исключение наружу — возвращает True/False. Критично для
resilience: фейл launch'а (прокси лёг → InvalidProxy) не должен валить
startup или фоновую задачу. Креды прокси НЕ логируются: camoufox светит
``user:pass@host`` в тексте своего исключения, поэтому мы логируем только
тип, без ``str(exc)``.
При частичном фейле (CM создан, но __aenter__ не довёл) чистит состояние
через _close_browser, чтобы следующий retry стартовал с чистого листа.
"""
try:
await _launch_browser(provider)
return True
except Exception as exc:
logger.warning(
"tradein-browser[%s]: browser launch failed: proxy unreachable "
"(exc_type=%s) — контейнер остаётся Up, retry на следующем запросе/в фоне",
provider,
type(exc).__name__,
)
await _close_browser(provider)
return False
async def _ensure_browser(provider: str) -> bool:
"""Гарантирует поднятый инстанс поставщика; одна lazy-попытка launch'а.
Caller держит _locks[provider] (весь доступ к инстансу сериализован), поэтому
здесь своей сериализации нет — двух одновременных launch'ей на один provider
быть не может.
Returns:
True если браузер доступен (был или поднялся), False если launch не удался
(прокси лежит) — caller отдаёт 503.
"""
if _browsers.get(provider) is not None:
return True
await _try_launch_browser(provider)
return _browsers.get(provider) is not None
async def _browser_retry_loop(provider: str) -> None:
"""Фоновый retry launch'а поставщика с backoff, пока прокси не вернётся.
Идемпотентна: если инстанс уже поднят — сразу выходит. Иначе ретраит
_try_launch_browser под _locks[provider] с возрастающим интервалом
(_BROWSER_RETRY_MIN_S → _BROWSER_RETRY_MAX_S). Завершается после первого
успешного launch'а. Отменяется в _on_cleanup (CancelledError пробрасывается).
"""
if _browsers.get(provider) is not None:
return
delay = _BROWSER_RETRY_MIN_S
while _browsers.get(provider) is None:
await asyncio.sleep(delay)
lock = await _lock_for(provider)
async with lock:
if _browsers.get(provider) is not None:
return # handler уже поднял инстанс — задача отработала
logger.info("tradein-browser[%s]: фоновый retry launch'а браузера", provider)
launched = await _try_launch_browser(provider)
if launched:
logger.info(
"tradein-browser[%s]: браузер поднят после retry, задача завершена", provider
)
return
delay = min(delay * 1.5, _BROWSER_RETRY_MAX_S)
def _start_retry_task(provider: str) -> None:
"""Запускает (если ещё нет) фоновую retry-задачу для поставщика."""
existing = _retry_tasks.get(provider)
if existing is not None and not existing.done():
return # задача уже бежит — не плодим дубли
_retry_tasks[provider] = asyncio.create_task(_browser_retry_loop(provider))
# ── aiohttp lifecycle hooks ────────────────────────────────────────────────────
async def _on_startup(app: web.Application) -> None:
global _locks_guard
_locks_guard = asyncio.Lock()
logger.info(
"tradein-browser: per-provider concurrency — параллель между %s, ≤1 на поставщика",
list(PROVIDERS),
)
# Eager-launch каждого поставщика. Launch НЕ фатален: если прокси лежит,
# camoufox бросит InvalidProxy. Раньше это валило startup → exit(1) →
# restart-loop. Теперь ловим, остаёмся Up и ретраим в фоне. Поставщики
# независимы: лёгший avito-прокси не мешает подняться cian/yandex.
for provider in PROVIDERS:
lock = await _lock_for(provider)
async with lock:
launched = await _try_launch_browser(provider)
if not launched:
logger.warning(
"tradein-browser[%s]: старт без браузера (launch failed) — запуск фонового retry",
provider,
)
_start_retry_task(provider)
async def _on_cleanup(app: web.Application) -> None:
# Отменяем все фоновые retry-задачи.
for provider, task in list(_retry_tasks.items()):
if task is not None and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception as exc:
logger.warning(
"tradein-browser[%s]: ошибка при отмене retry-задачи: %s",
provider,
type(exc).__name__,
)
_retry_tasks.clear()
# Закрываем все поднятые инстансы.
for provider in list(_browsers.keys()):
await _close_browser(provider)
# ── handlers ───────────────────────────────────────────────────────────────────
def _resolve_provider(body: dict, url: str) -> str:
"""Выбирает поставщика: явный body["provider"] (если валиден) иначе по host URL."""
explicit = body.get("provider")
if isinstance(explicit, str) and explicit in PROVIDERS:
return explicit
return _provider_for_url(url)
async def health_handler(request: web.Request) -> web.Response:
# Всегда 200 — иначе compose healthcheck (если появится) убьёт контейнер,
# хотя процесс жив и ретраит браузеры. browsers — per-provider readiness для
# observability: видно какой поставщик лежит, не теряя контейнер. Текущий prod
# compose НЕ имеет healthcheck на browser (depends_on: service_started).
browsers = {p: _browsers.get(p) is not None for p in PROVIDERS}
return web.json_response({"status": "ok", "browsers": browsers})
async def fetch_handler(request: web.Request) -> web.Response:
"""POST /fetch {"url": "..."} → {"html": "..."}
Выполняет навигацию в браузере поставщика и возвращает полный HTML страницы.
Поставщик определяется по host URL (или явному body["provider"]). Берётся
ТОЛЬКО лок этого поставщика → разные поставщики работают параллельно, внутри
поставщика — строго ≤1 fetch.
"""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON body"}, status=400)
url: str | None = body.get("url")
if not url:
return web.json_response({"error": "missing 'url' field"}, status=400)
provider = _resolve_provider(body, url)
lock = await _lock_for(provider)
async with lock:
# Браузер мог не подняться на старте (прокси лежал). Одна lazy-попытка —
# вдруг прокси уже вернулся. НИКОГДА не фетчим без прокси/браузера: если
# всё ещё None → 503 (caller словит как HTTPStatusError → failed fetch, не
# crash).
if not await _ensure_browser(provider):
logger.warning(
"tradein-browser[%s]: /fetch 503 — браузер недоступен (proxy may be down)",
provider,
)
return web.json_response(
{"error": "browser unavailable (proxy may be down)"}, status=503
)
try:
html = await _do_fetch(provider, url)
except Exception as exc:
logger.error(
"tradein-browser[%s]: fetch error url=%r: %s: %s",
provider,
url,
type(exc).__name__,
exc,
)
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
return web.json_response({"html": html})
async def _apply_resource_block(page: object) -> None:
"""Навешивает route-interception, блокирующую тяжёлые ресурсы (image/media/font).
No-op при BROWSER_BLOCK_RESOURCES=false. Route живёт на странице и снимается
автоматически при page.close(). Блокируем ТОЛЬКО _BLOCKED_TYPES (по умолчанию
картинки/медиа/шрифты) — document/script/stylesheet/xhr/fetch проходят, чтобы
гидрация listings работала и stealth не палился «браузером без ресурсов».
Handler обёрнут в try/except: любой сбой abort/continue (например, route уже
обработан гонкой) деградирует в continue_(), чтобы один битый ресурс не ронял
навигацию. Сам continue_() тоже под try — если страница уже закрыта, молча выходим.
"""
if not _BLOCK_RESOURCES:
return
async def _route_block(route: object) -> None: # type: ignore[no-untyped-def]
try:
if route.request.resource_type in _BLOCKED_TYPES: # type: ignore[attr-defined]
await route.abort() # type: ignore[attr-defined]
else:
await route.continue_() # type: ignore[attr-defined]
except Exception:
# Fallback: не блокируем спорный ресурс, пропускаем его — лучше лишний
# байт, чем сорванная навигация. continue_() под своим try на случай
# уже-закрытой страницы / повторно обработанного route.
try:
await route.continue_() # type: ignore[attr-defined]
except Exception:
pass
await page.route("**/*", _route_block) # type: ignore[attr-defined]
async def _do_fetch(provider: str, url: str) -> str:
"""Одна попытка навигации; при краше браузера — relaunch и один retry.
Caller держит _locks[provider] (нет параллельных страниц на этом инстансе),
поэтому relaunch безопасен — никого не рвёт из-под живых страниц.
"""
try:
return await _fetch_once(provider, url)
except Exception as exc:
if _is_browser_crash(exc):
logger.warning(
"tradein-browser[%s]: краш браузера (%s), перезапуск + retry: %s",
provider,
type(exc).__name__,
url,
)
await _relaunch_browser(provider)
if _browsers.get(provider) is None:
raise
return await _fetch_once(provider, url)
raise
async def _fetch_once(provider: str, url: str) -> str:
"""Открывает СОБСТВЕННУЮ страницу, переходит по URL, ждёт JS, возвращает HTML.
Caller держит _locks[provider], поэтому страницы на этом инстансе не
параллелятся — recycle через _relaunch_browser безопасен прямо здесь.
"""
browser = _browsers.get(provider)
assert browser is not None, "browser not launched"
page = await browser.new_page() # type: ignore[attr-defined]
try:
await _apply_resource_block(page)
await page.goto(url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded") # type: ignore[attr-defined]
if BROWSER_WAIT_MS > 0:
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
html: str = await page.content() # type: ignore[attr-defined]
finally:
await page.close() # type: ignore[attr-defined]
_page_counters[provider] = _page_counters.get(provider, 0) + 1
logger.debug(
"tradein-browser[%s]: fetch OK url=%r pages_since_launch=%d",
provider,
url,
_page_counters[provider],
)
if _page_counters[provider] >= BROWSER_RECYCLE_PAGES:
logger.info(
"tradein-browser[%s]: recycle threshold (%d) достигнут, перезапуск браузера",
provider,
BROWSER_RECYCLE_PAGES,
)
await _relaunch_browser(provider)
return html
def _is_browser_crash(exc: BaseException) -> bool:
"""Проверяет является ли исключение признаком краша / разрыва браузера."""
cls_name = type(exc).__name__
if cls_name in ("TargetClosedError", "BrowserClosedError", "ConnectionClosedError"):
return True
msg = str(exc).lower()
return (
"browser has been closed" in msg
or "target closed" in msg
or "connection closed" in msg
or "browser disconnected" in msg
)
# ── login handler ──────────────────────────────────────────────────────────────
class LoginError(Exception):
"""Исключение с диагностическим payload для /login эндпоинта."""
def __init__(self, payload: dict) -> None:
super().__init__(str(payload))
self.payload = payload
async def login_handler(request: web.Request) -> web.Response:
"""POST /login {...} → {"cookies": [...]}
Выполняет вход на указанную страницу (email + пароль), ждёт появления
success_cookie, возвращает полный список cookies браузерного контекста.
Делит тот же per-provider браузер/лок что и /fetch (поставщик по host URL
или явному body["provider"]).
"""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON body"}, status=400)
missing = [f for f in ("url", "email", "password", "email_selector",
"password_selector", "submit_selector") if not body.get(f)]
if missing:
return web.json_response({"error": f"missing required fields: {missing}"}, status=400)
provider = _resolve_provider(body, body["url"])
lock = await _lock_for(provider)
async with lock:
# Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
# Lazy-попытка, иначе 503 — без прокси не логинимся.
if not await _ensure_browser(provider):
logger.warning(
"tradein-browser[%s]: /login 503 — браузер недоступен (proxy may be down)",
provider,
)
return web.json_response(
{"error": "browser unavailable (proxy may be down)"}, status=503
)
try:
cookies = await _do_login(provider, body)
except LoginError as exc:
return web.json_response(exc.payload, status=502)
except Exception as exc:
logger.error(
"tradein-browser[%s]: login error url=%r: %s: %s",
provider,
body.get("url"),
type(exc).__name__,
exc,
)
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=502)
return web.json_response({"cookies": cookies})
async def _do_login(provider: str, params: dict) -> list[dict]:
"""Одна попытка логина; при краше браузера — relaunch и повтор.
Caller держит _locks[provider], поэтому relaunch безопасен.
"""
try:
return await _login_once(provider, params)
except LoginError:
raise # диагностический payload идёт напрямую в хендлер
except Exception as exc:
if _is_browser_crash(exc):
logger.warning(
"tradein-browser[%s]: краш браузера (%s) при логине, перезапуск + retry",
provider,
type(exc).__name__,
)
await _relaunch_browser(provider)
if _browsers.get(provider) is None:
raise
return await _login_once(provider, params)
raise
async def _wait_cookie(page: object, name: str, tries: int, interval_ms: int) -> bool:
"""Ожидает появления cookie с заданным именем в контексте страницы.
Returns:
True если cookie появился в течение tries × interval_ms мс, иначе False.
"""
for _ in range(tries):
cookies = await page.context.cookies() # type: ignore[attr-defined]
if any(c["name"] == name for c in cookies):
return True
await page.wait_for_timeout(interval_ms) # type: ignore[attr-defined]
return False
async def _login_once(provider: str, params: dict) -> list[dict]:
"""Открывает страницу, заполняет форму, нажимает submit, ждёт success_cookie.
Поддерживает список pre_click_selectors (клик по очереди, каждый non-fatal)
и 2-шаговый submit Cian: после первого сабмита появляется экран
«Введите пароль» — дозаполняем password и кликаем повторно (макс. 2 попытки).
"""
browser = _browsers.get(provider)
assert browser is not None, "browser not launched"
url: str = params["url"]
email: str = params["email"]
password: str = params["password"]
email_selector: str = params["email_selector"]
password_selector: str = params["password_selector"]
submit_selector: str = params["submit_selector"]
pre_click_selectors: list[str] = params.get("pre_click_selectors") or []
success_cookie: str = params.get("success_cookie", "")
wait_ms: int = params.get("wait_ms") or BROWSER_WAIT_MS
# Страница закрывается в finally: asyncio.CancelledError (BaseException, НЕ
# Exception) обходит except-ветку ниже — без finally страница утекала бы при
# отмене таска (например, на shutdown). _fetch_once делает то же.
page = await browser.new_page() # type: ignore[attr-defined]
try:
try:
await page.goto( # type: ignore[attr-defined]
url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
)
await page.wait_for_timeout(wait_ms) # type: ignore[attr-defined]
# Pre-click sequence: каждый клик non-fatal (AnotherAccountBtn на fresh
# headless-сессии отсутствует — молча пропускается).
for sel in pre_click_selectors:
try:
await page.click(sel, timeout=12000) # type: ignore[attr-defined]
await page.wait_for_timeout(1200) # type: ignore[attr-defined]
except Exception:
logger.info("login: pre-click skipped: %s", sel)
await page.fill(email_selector, email, timeout=15000) # type: ignore[attr-defined]
await page.fill(password_selector, password, timeout=15000) # type: ignore[attr-defined]
# 2-шаговый submit: Cian после первого сабмита показывает экран
# «Введите пароль» с тем же password_selector — дозаполняем и повторяем.
cookie_found = False
for attempt in range(2):
await page.click(submit_selector, timeout=15000) # type: ignore[attr-defined]
if success_cookie:
cookie_found = await _wait_cookie(
page, success_cookie, tries=20, interval_ms=500
)
if cookie_found:
break
# 2-step: если поле пароля ещё видимо — дозаполнить и повторить
still_visible = False
try:
still_visible = await page.is_visible(password_selector) # type: ignore[attr-defined]
except Exception:
pass
if attempt == 0 and still_visible:
await page.fill(password_selector, password, timeout=15000) # type: ignore[attr-defined]
await page.wait_for_timeout(800) # type: ignore[attr-defined]
continue
break
logger.info(
"tradein-browser[%s]: login success_cookie=%r present=%s",
provider,
success_cookie,
cookie_found,
)
cookies: list[dict] = await page.context.cookies() # type: ignore[attr-defined]
logger.info(
"tradein-browser[%s]: login OK url=%r email_selector=%r cookie_count=%d",
provider,
url,
email_selector,
len(cookies),
)
except Exception as exc:
screenshot_b64 = ""
try:
screenshot_bytes: bytes = await page.screenshot() # type: ignore[attr-defined]
screenshot_b64 = base64.b64encode(screenshot_bytes).decode()
except Exception:
pass
page_url = ""
try:
page_url = page.url # type: ignore[attr-defined]
except Exception:
pass
raise LoginError({
"error": f"{type(exc).__name__}: {exc}",
"page_url": page_url,
"screenshot_b64": screenshot_b64,
}) from exc
finally:
await page.close() # type: ignore[attr-defined]
_page_counters[provider] = _page_counters.get(provider, 0) + 1
if _page_counters[provider] >= BROWSER_RECYCLE_PAGES:
logger.info(
"tradein-browser[%s]: recycle threshold (%d) достигнут после login, перезапуск",
provider,
BROWSER_RECYCLE_PAGES,
)
await _relaunch_browser(provider)
return cookies
# ── entrypoint ─────────────────────────────────────────────────────────────────
def build_app() -> web.Application:
app = web.Application()
app.on_startup.append(_on_startup)
app.on_cleanup.append(_on_cleanup)
app.router.add_get("/health", health_handler)
app.router.add_post("/fetch", fetch_handler)
app.router.add_post("/login", login_handler)
return app
if __name__ == "__main__":
logger.info("tradein-browser: старт HTTP-сервера на порту %d", BROWSER_PORT)
web.run_app(build_app(), host="0.0.0.0", port=BROWSER_PORT)