"""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.45–1.60): ``browserServerImpl.js`` отсутствует в дистрибутиве → контейнер краш-лупится с MODULE_NOT_FOUND / RuntimeError при запуске через ``launch_server``. Локальный запуск ``AsyncCamoufox`` работает стабильно — проверено. Per-provider модель (#1793): Вместо одного глобального браузера + одного egress-прокси держим ОТДЕЛЬНЫЙ инстанс camoufox на каждого поставщика (avito/cian/yandex/generic). Поставщик определяется по host URL запроса (или явному полю body["provider"]/["source"]). Каждый инстанс ходит через свой 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: # setdefault идемпотентен — вторая корутина под 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, # Скорость: не грузим картинки — нам нужен HTML/JSON, рендер быстрее # (трафик безлимитный, выигрыш именно по времени загрузки). "block_images": True, # Анти-leak: WebRTC может слить реальный IP контейнера мимо прокси → деанон. "block_webrtc": True, # Форсим таймзону +3 МСК. geoip берёт tz одного IP на старте, а БэкКоннект # ротирует IP на каждый запрос (видели Ульяновск UTC+4 при выходе СПб/МСК) → # геолик tz↔IP. Жёсткий Europe/Moscow убирает рассинхрон для РФ-мобильного. "config": {"timezone": "Europe/Moscow"}, } 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. При неудачном lazy-launch (прокси лёг) запускает фоновую retry-задачу для ЭТОГО поставщика: при eager-startup её ставил _on_startup, в lazy-режиме — первый обратившийся /fetch. Поставщики независимы — фоновый retry поднимается только для тех, к кому был запрос, а не для всех сразу. """ if _browsers.get(provider) is not None: return True await _try_launch_browser(provider) if _browsers.get(provider) is not None: return True _start_retry_task(provider) return False 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: # Startup ТОЛЬКО инициализирует guard для ленивого создания локов — браузеры # НЕ поднимаются здесь. Lazy-launch: инстанс конкретного поставщика стартует на # первом /fetch (или /login) к нему через _ensure_browser под _locks[provider]. # # Eager-launch всех 4 на старте давал бы 4 camoufox-инстанса сразу → риск OOM: # до 4 camoufox-инстансов (avito/cian/yandex/generic), lazy; ~0.5-1ГБ RSS # каждый, mem_limit контейнера 5g. Resilience (фоновый retry при недоступном # прокси) запускается при первом обращении к поставщику, не для всех сразу. global _locks_guard _locks_guard = asyncio.Lock() logger.info( "tradein-browser: per-provider concurrency (lazy launch) — " "параллель между %s, ≤1 на поставщика; браузеры стартуют по требованию", list(PROVIDERS), ) 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"]/body["source"] (если валиден) иначе host. Caller (browser_fetcher.py) шлёт {"url","source"} — поэтому explicit-значение берём из "provider" ИЛИ "source", но применяем ТОЛЬКО если оно в PROVIDERS; иначе host-detect; иначе generic. # domclick (source='domklik') не имеет выделенного BROWSER_PROXY_DOMCLICK в prod # → generic. Намеренно. ('domklik' нет в PROVIDERS → fallthrough на host-detect # domclick.ru → generic.) """ explicit = body.get("provider") or body.get("source") 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"]/["source"]). Берётся ТОЛЬКО лок этого поставщика → разные поставщики работают параллельно, внутри поставщика — строго ≤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"]/["source"]). """ 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)