gendesign/tradein-mvp/browser/server.py
lekss361 ca473a0012
All checks were successful
Deploy Trade-In / build-browser (push) Successful in 2m13s
Deploy Trade-In / build-backend (push) Successful in 1m18s
Deploy Trade-In / deploy (push) Successful in 2m29s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m32s
feat(proxy-pool): P4 — browser dynamic pool-proxy (acquire→/fetch→relaunch) + orchestration wiring, ship-dark (#2164)
2026-07-02 19:20:29 +00:00

1259 lines
66 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 /fetch-json → {"url","method","headers","body","origin"} → {"status","body"}
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"]/["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_RESOURCE_TYPES — CSV типов ресурсов Playwright, которые abort'ить
через page.route при навигации (default: "font,media").
Снижает число одновременных под-коннектов на страницу →
мобильный прокси не ловит NS_ERROR_PROXY_TOO_MANY_REQUESTS.
image уже глушит camoufox block_images. Пустая строка =
НЕ блокировать ничего. КРИТИЧНО: НЕ добавлять
document/script/stylesheet/xhr/fetch — данные парсятся из
HTML/JS-state (INITIAL_STATE/__preloadedState__), JS нужен
для гидрации, CSS проверяется anti-bot fingerprint.
(legacy alias BROWSER_BLOCKED_RESOURCE_TYPES всё ещё читается
как fallback, если новая переменная не задана.)
BROWSER_MIN_PAGE_INTERVAL_S — минимальный интервал (сек) между последовательными
page.goto ОДНОГО провайдера (default: 2.0). Даёт под-
коннектам предыдущей страницы дренироваться, прежде чем
открыть новую → меньше пиковый параллелизм на прокси.
0 = без пейсинга.
BROWSER_MIN_PAGE_INTERVAL_S_{PROVIDER} — per-provider override интервала пейсинга.
PROVIDER в upper-case: AVITO, CIAN, YANDEX, GENERIC.
Например: BROWSER_MIN_PAGE_INTERVAL_S_CIAN=18 задаёт 18с
только для cian, не затрагивая другие провайдеры. Если env
не задан или содержит невалидное значение — фолбэк на
глобальный BROWSER_MIN_PAGE_INTERVAL_S. Backward-compat:
при незаданных per-provider env поведение идентично
предыдущему (чисто аддитивный override).
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 collections.abc import Mapping
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"))
# /fetch-json settle после goto(origin) перед in-page fetch (#1917). 500мс мало:
# первый XHR иногда ловит `NetworkError when attempting to fetch resource` (anti-bot/
# сетевой стек страницы ещё не готов). Лечился внешним retry (re-navigation ~30-45с/дом).
# Поднимаем дефолт до 1200мс (меньше first-fail) + добавляем дешёвый in-page retry
# самого fetch() (см. FETCH_JSON_INPAGE_RETRIES) — повтор внутри страницы стоит ~доли
# секунды против полной ре-навигации. НЕ трогаем /fetch (SERP): там свой BROWSER_WAIT_MS.
FETCH_JSON_SETTLE_MS: int = int(os.environ.get("FETCH_JSON_SETTLE_MS", "1200"))
# Сколько раз повторить in-page fetch() при сетевом throw (TypeError/NetworkError),
# НЕ при HTTP-статусе (4xx/5xx возвращаются как есть — их решает caller). 0 = выкл.
FETCH_JSON_INPAGE_RETRIES: int = int(os.environ.get("FETCH_JSON_INPAGE_RETRIES", "1"))
# Пауза между in-page попытками fetch(), мс.
FETCH_JSON_RETRY_DELAY_MS: int = int(os.environ.get("FETCH_JSON_RETRY_DELAY_MS", "800"))
# Известные поставщики. "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")
def _parse_block_types(raw: str | None) -> frozenset[str]:
"""Парсит CSV типов ресурсов для блокировки → frozenset (lower/strip).
None (env не задана) → дефолт "font,media". Пустая строка → пустой набор
(ничего не блокируем). Не задаём document/script/stylesheet/xhr/fetch здесь —
дефолт намеренно узкий (font+media), эти типы не нужны для extraction/fingerprint.
"""
if raw is None:
raw = "font,media"
return frozenset(t.strip().lower() for t in raw.split(",") if t.strip())
# Типы под-ресурсов, которые abort'им через page.route на каждой странице. Каждый
# page.goto тянет десятки sub-requests (js/css/xhr/font/media) через прокси; мобильный
# прокси держит ~5 параллельных коннектов → 2-3 быстрых страницы подряд на одном
# аккаунте упираются в NS_ERROR_PROXY_TOO_MANY_REQUESTS/500. Глуша font+media (тяжёлые,
# не нужны ни для extraction, ни для fingerprint), снижаем пиковый fan-out. image уже
# глушит camoufox block_images. КРИТИЧНО: document/script/stylesheet/xhr/fetch НЕ
# блокируем — данные в HTML/JS-state (INITIAL_STATE/__preloadedState__), JS гидрирует
# listings, CSS проверяет anti-bot fingerprint. Legacy alias BROWSER_BLOCKED_RESOURCE_TYPES
# читается fallback'ом, если новая BROWSER_BLOCK_RESOURCE_TYPES не задана.
_BLOCKED_TYPES: frozenset[str] = _parse_block_types(
os.environ.get("BROWSER_BLOCK_RESOURCE_TYPES")
or os.environ.get("BROWSER_BLOCKED_RESOURCE_TYPES")
)
# Минимальный интервал (сек) между последовательными page.goto одного провайдера —
# даёт под-коннектам предыдущей страницы дренироваться (прокси освобождает слоты),
# прежде чем открыть новую страницу. 0 = без пейсинга.
BROWSER_MIN_PAGE_INTERVAL_S: float = float(os.environ.get("BROWSER_MIN_PAGE_INTERVAL_S", "2.0"))
def _resolve_min_interval(
provider: str,
environ: Mapping[str, str] | None = None,
) -> float:
"""Возвращает эффективный интервал пейсинга для провайдера.
Читает per-provider override из ``BROWSER_MIN_PAGE_INTERVAL_S_{PROVIDER}``
(PROVIDER в upper-case). Невалидное или отсутствующее значение → фолбэк на
глобальный ``BROWSER_MIN_PAGE_INTERVAL_S``. Не кидает исключений.
Args:
provider: имя провайдера ("avito", "cian", "yandex", "generic").
environ: env-словарь; None → ``os.environ`` (тестируемость без патча модуля).
Returns:
Эффективный интервал в секундах (≥0).
"""
env = environ if environ is not None else os.environ
key = f"BROWSER_MIN_PAGE_INTERVAL_S_{provider.upper()}"
raw = env.get(key)
if raw is not None:
try:
return float(raw)
except ValueError:
logger.warning(
"tradein-browser: %s=%r невалидно (не float), используем глобал %.1f",
key,
raw,
BROWSER_MIN_PAGE_INTERVAL_S,
)
return BROWSER_MIN_PAGE_INTERVAL_S
# Per-provider эффективные интервалы пейсинга, вычисляются на module-load из os.environ.
# Ключи: все известные провайдеры из PROVIDERS. Логируются на старте (_on_startup).
_MIN_PAGE_INTERVAL_BY_PROVIDER: dict[str, float] = {
p: _resolve_min_interval(p) for p in ("avito", "cian", "yandex", "generic")
}
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-задача
_last_goto_at: dict[str, float] = {} # provider → loop-time последнего page.goto (пейсинг)
# #2164 P4: proxy-url, с которым СЕЙЧАС запущен инстанс провайдера (env или динамический
# из пула, переданный в теле /fetch). Нужен для политики «relaunch ТОЛЬКО при реальной
# смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим,
# если запрошенный proxy совпадает с текущим. None = запущен без прокси (прямое).
_launched_proxy: dict[str, str | None] = {}
# 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, proxy_override: str | None = None) -> None:
"""Запускает AsyncCamoufox для поставщика и кладёт в *_…[provider].
proxy_override (#2164 P4): если задан — запускаем camoufox с ЭТИМ прокси (динамический
из пула, переданный scraper-стороной в теле /fetch). None → env-прокси
_provider_proxy(provider) как раньше. geoip=True (фингерпринт привязан к гео IP
конкретного прокси — поэтому отдельный инстанс на каждого поставщика).
Эффективный proxy-url сохраняется в _launched_proxy[provider] для политики
«relaunch только при реальной смене» (см. _ensure_browser).
"""
from camoufox.async_api import AsyncCamoufox
proxy_url = proxy_override if proxy_override is not None else _provider_proxy(provider)
proxy = _parse_proxy(proxy_url)
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
_launched_proxy[provider] = proxy_url
logger.info(
"tradein-browser[%s]: браузер запущен (proxy_override=%s)",
provider,
proxy_override is not None,
)
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
_last_goto_at.pop(provider, None)
_launched_proxy.pop(provider, None)
async def _relaunch_browser(provider: str, proxy_override: str | None = None) -> None:
"""Закрывает и заново запускает per-provider браузер (recycle / crash-recovery).
proxy_override (#2164 P4): прокинуть конкретный прокси в новый инстанс. None →
СОХРАНИТЬ текущий прокси инстанса (_launched_proxy) — recycle/crash-recovery не должны
терять динамический прокси пула и не должны его менять (это НЕ смена прокси). Если
инстанс ещё не запускался (_launched_proxy пуст) — None → env-прокси в _launch_browser.
Caller держит _locks[provider], поэтому параллельных страниц на этом инстансе нет —
teardown безопасен.
"""
effective = proxy_override if proxy_override is not None else _launched_proxy.get(provider)
logger.info("tradein-browser[%s]: перезапуск браузера", provider)
await _close_browser(provider)
await _launch_browser(provider, proxy_override=effective)
async def _try_launch_browser(provider: str, proxy_override: str | None = None) -> bool:
"""Одна попытка launch'а поставщика с санитизированным логом фейла.
НЕ пробрасывает исключение наружу — возвращает True/False. Критично для
resilience: фейл launch'а (прокси лёг → InvalidProxy) не должен валить
startup или фоновую задачу. Креды прокси НЕ логируются: camoufox светит
``user:pass@host`` в тексте своего исключения, поэтому мы логируем только
тип, без ``str(exc)``.
При частичном фейле (CM создан, но __aenter__ не довёл) чистит состояние
через _close_browser, чтобы следующий retry стартовал с чистого листа.
"""
try:
await _launch_browser(provider, proxy_override=proxy_override)
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, proxy_override: str | None = None) -> bool:
"""Гарантирует поднятый инстанс поставщика с нужным прокси; lazy launch / relaunch.
proxy_override (#2164 P4): желаемый динамический прокси из пула (тело /fetch). None →
желаемый прокси = env (_provider_proxy). Политика «relaunch ТОЛЬКО при реальной смене»:
- инстанса нет → launch с желаемым прокси;
- инстанс есть, но _launched_proxy[provider] != желаемого → close + relaunch;
- инстанс есть и прокси совпадает → no-op (НЕ релончим — camoufox дорого поднимать).
При выключенном флаге на scraper-стороне proxy_override всегда None → желаемый=env=
текущий → релонча нет, поведение идентично прежнему (golden-parity).
Caller держит _locks[provider] (весь доступ к инстансу сериализован), поэтому здесь
своей сериализации нет — двух одновременных launch'ей на один provider быть не может.
Returns:
True если браузер доступен (был или поднялся), False если launch не удался
(прокси лежит) — caller отдаёт 503.
При неудачном launch (прокси лёг) запускает фоновую retry-задачу для ЭТОГО
поставщика (env-прокси). Поставщики независимы — фоновый retry поднимается только
для тех, к кому был запрос, а не для всех сразу.
"""
desired_proxy = proxy_override if proxy_override is not None else _provider_proxy(provider)
if _browsers.get(provider) is not None:
if _launched_proxy.get(provider) == desired_proxy:
return True
# Реальная смена прокси → пересобрать инстанс с новым прокси.
logger.info(
"tradein-browser[%s]: прокси изменился (override=%s) — relaunch перед fetch",
provider,
proxy_override is not None,
)
await _close_browser(provider)
await _try_launch_browser(provider, proxy_override=proxy_override)
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),
)
logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_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"]/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)
def _resolve_proxy_override(body: dict, provider: str) -> str | None:
"""Динамический прокси из тела /fetch (#2164 P4). None → env-прокси (текущее поведение).
Scraper-сторона (BrowserFetcher за флагом use_proxy_pool_browser) кладёт в тело
``{"proxy": "http://user:pass@host:port", "proxy_kind": "http"}`` — прокси, взятый из
пула через acquire(source). Если поля нет / пусто → None → инстанс поставщика берёт
свой env-прокси (BROWSER_PROXY_*), поведение не меняется (ship-dark parity).
camoufox НЕ умеет socks5 с авторизацией → принимаем ТОЛЬКО http/https-прокси; иной
proxy_kind игнорируем (→ None, env-fallback) с предупреждением. Отсутствие proxy_kind
трактуем как http (в пуле kind=http).
"""
raw = body.get("proxy")
if not isinstance(raw, str) or not raw:
return None
kind = body.get("proxy_kind")
if isinstance(kind, str) and kind and kind.lower() not in ("http", "https"):
logger.warning(
"tradein-browser[%s]: proxy_kind=%r не поддерживается camoufox — env-прокси",
provider,
kind,
)
return None
return raw
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)
proxy_override = _resolve_proxy_override(body, provider)
lock = await _lock_for(provider)
async with lock:
# Браузер мог не подняться на старте (прокси лежал). Одна lazy-попытка —
# вдруг прокси уже вернулся. НИКОГДА не фетчим без прокси/браузера: если
# всё ещё None → 503 (caller словит как HTTPStatusError → failed fetch, не
# crash). proxy_override (#2164 P4): relaunch camoufox с прокси из пула, если
# он отличается от текущего launch-прокси инстанса (иначе no-op).
if not await _ensure_browser(provider, proxy_override=proxy_override):
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 fetch_json_handler(request: web.Request) -> web.Response:
"""POST /fetch-json {"url","method","headers","body","origin","source"} → {"status","body"}
Выполняет in-page ``fetch()`` ИЗ ТЁПЛОЙ страницы браузера поставщика: камуфокс
сначала переходит на ``origin`` (same-origin якорь), затем дёргает ``fetch(url)``
с реальным фингерпринтом + контекст-cookies этого инстанса + per-provider прокси.
Возвращает JSON/текст ответа как ``{"status": int, "body": str}``.
Чисто аддитивный путь (#915 Stage 2) — никто из прод-флоу пока не вызывает.
Делит тот же per-provider браузер/лок что и /fetch (поставщик по host URL или
явному body["provider"]/["source"]). Берётся ТОЛЬКО лок этого поставщика →
разные поставщики работают параллельно, внутри поставщика — строго ≤1 операция.
"""
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)
method: str = body.get("method") or "GET"
headers: dict = body.get("headers") or {}
req_body = body.get("body") # None | str (caller сериализует через json.dumps)
# origin — same-origin страница, на которую перейдёт камуфокс перед fetch'ем.
# Явный body["origin"] имеет приоритет; иначе выводим f"{scheme}://{host}/" из url.
origin: str | None = body.get("origin")
if not origin:
parsed = urlparse(url)
origin = f"{parsed.scheme}://{parsed.netloc}/"
provider = _resolve_provider(body, url)
proxy_override = _resolve_proxy_override(body, provider)
lock = await _lock_for(provider)
async with lock:
# Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
# Lazy-попытка, иначе 503 — без прокси/браузера не фетчим. proxy_override
# (#2164 P4): relaunch с прокси из пула при реальной смене.
if not await _ensure_browser(provider, proxy_override=proxy_override):
logger.warning(
"tradein-browser[%s]: /fetch-json 503 — браузер недоступен (proxy may be down)",
provider,
)
return web.json_response(
{"error": "browser unavailable (proxy may be down)"}, status=503
)
try:
result = await _do_fetch_json(
provider, url, method=method, headers=headers, body=req_body, origin=origin
)
except Exception as exc:
logger.error(
"tradein-browser[%s]: fetch-json 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({"status": result["status"], "body": result["body"]})
async def _apply_resource_block(page: object) -> None:
"""Навешивает route-interception, блокирующую тяжёлые под-ресурсы (font/media).
No-op при пустом _BLOCKED_TYPES (BROWSER_BLOCK_RESOURCE_TYPES=""). Route живёт на
странице и снимается автоматически при page.close(). Блокируем ТОЛЬКО _BLOCKED_TYPES
(по умолчанию font/media) — document/script/stylesheet/xhr/fetch проходят, чтобы
гидрация listings работала и stealth не палился «браузером без ресурсов».
Handler обёрнут в try/except: любой сбой abort/continue (например, route уже
обработан гонкой) деградирует в continue_(), чтобы один битый ресурс не ронял
навигацию. Сам continue_() тоже под try — если страница уже закрыта, молча выходим.
"""
if not _BLOCKED_TYPES:
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 _pace_provider(provider: str) -> None:
"""Притормаживает перед page.goto, если с прошлого goto этого провайдера прошло
меньше эффективного интервала — даёт под-коннектам прошлой страницы дренироваться,
снижая пиковый параллелизм на прокси.
Caller держит _locks[provider] → timestamp per-provider читается/пишется без гонки.
Записывает момент ПОСЛЕ возможного sleep (т.е. фактический момент следующего goto),
чтобы интервал считался между реальными навигациями, а не между входами в функцию.
No-op при эффективном интервале провайдера <= 0.
"""
interval = _MIN_PAGE_INTERVAL_BY_PROVIDER.get(provider, BROWSER_MIN_PAGE_INTERVAL_S)
if interval <= 0:
return
now = asyncio.get_event_loop().time()
last = _last_goto_at.get(provider)
if last is not None:
elapsed = now - last
remaining = interval - elapsed
if remaining > 0:
logger.debug(
"tradein-browser[%s]: pacing — ждём %.2fс перед goto", provider, remaining
)
await asyncio.sleep(remaining)
_last_goto_at[provider] = asyncio.get_event_loop().time()
async def _do_fetch(provider: str, url: str) -> str:
"""Одна попытка навигации; при краше браузера — relaunch и один retry.
Caller держит _locks[provider] (нет параллельных страниц на этом инстансе),
поэтому relaunch безопасен. Crash-relaunch сохраняет текущий прокси инстанса
(_relaunch_browser reuse _launched_proxy) — динамический прокси пула не теряется.
"""
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 _pace_provider(provider)
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
async def _do_fetch_json(
provider: str,
url: str,
*,
method: str,
headers: dict,
body: object,
origin: str,
) -> dict:
"""Одна попытка in-page fetch'а; при краше браузера — relaunch и один retry.
Зеркалит _do_fetch: caller держит _locks[provider] (нет параллельных страниц на
этом инстансе), поэтому relaunch безопасен. Crash-relaunch сохраняет текущий прокси
инстанса (_relaunch_browser reuse _launched_proxy).
"""
try:
return await _fetch_json_once(
provider, url, method=method, headers=headers, body=body, origin=origin
)
except Exception as exc:
if _is_browser_crash(exc):
logger.warning(
"tradein-browser[%s]: краш браузера (%s), перезапуск + retry fetch-json: %s",
provider,
type(exc).__name__,
url,
)
await _relaunch_browser(provider)
if _browsers.get(provider) is None:
raise
return await _fetch_json_once(
provider, url, method=method, headers=headers, body=body, origin=origin
)
raise
async def _fetch_json_once(
provider: str,
url: str,
*,
method: str,
headers: dict,
body: object,
origin: str,
) -> dict:
"""Переходит на origin (same-origin якорь) и выполняет in-page fetch(url).
Зеркалит _fetch_once по жизненному циклу страницы. Навигация идёт на ``origin``
(а не на ``url``), чтобы in-page fetch был same-origin и нёс контекст-cookies +
реальный фингерпринт инстанса. Возвращает {"status": int, "body": str}.
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 _pace_provider(provider)
await page.goto(origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded") # type: ignore[attr-defined]
# БЕЗ полного BROWSER_WAIT_MS: нам нужен лишь origin-контекст (cookies +
# same-origin scope для fetch), а не отрендеренные listings. Settle-паузы
# (#1917, FETCH_JSON_SETTLE_MS) хватает, чтобы страница инициализировалась
# перед in-page fetch'ем.
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
# In-page retry самого fetch() при СЕТЕВОМ throw (TypeError/"NetworkError"),
# #1917: первый XHR после навигации иногда падает до готовности стека. Повтор
# внутри страницы (~retryDelayMs) дешевле полной ре-навигации (~30-45с/дом).
# HTTP-статусы (4xx/5xx) — это успешный resp, НЕ ретраим (решает caller).
result: dict = await page.evaluate( # type: ignore[attr-defined]
"""async ({url, method, headers, body, retries, retryDelayMs}) => {
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const resp = await fetch(url, {
method: method || 'GET',
headers: headers || {},
body: (body !== null && body !== undefined) ? body : undefined,
credentials: 'include',
});
const text = await resp.text();
return { status: resp.status, body: text, attempts: attempt + 1 };
} catch (e) {
lastErr = e;
if (attempt < retries) {
await new Promise(r => setTimeout(r, retryDelayMs));
}
}
}
throw lastErr;
}""",
{
"url": url,
"method": method,
"headers": headers or {},
"body": body,
"retries": FETCH_JSON_INPAGE_RETRIES,
"retryDelayMs": FETCH_JSON_RETRY_DELAY_MS,
},
)
finally:
await page.close() # type: ignore[attr-defined]
_page_counters[provider] = _page_counters.get(provider, 0) + 1
logger.debug(
"tradein-browser[%s]: fetch-json OK url=%r status=%s pages_since_launch=%d",
provider,
url,
result.get("status"),
_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 result
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 pacing_get_handler(request: web.Request) -> web.Response:
"""GET /pacing → текущие live-интервалы + env-дефолты для всех провайдеров.
interval_s — живое значение из _MIN_PAGE_INTERVAL_BY_PROVIDER (изменяется PUT).
env_default_s — что вернёт _resolve_min_interval(p) из os.environ (reset при рестарте).
Всегда 200; не требует browser-инстансов (memory-only).
"""
providers = []
for p in PROVIDERS:
providers.append({
"source": p,
"interval_s": _MIN_PAGE_INTERVAL_BY_PROVIDER.get(p, BROWSER_MIN_PAGE_INTERVAL_S),
"env_default_s": _resolve_min_interval(p),
})
return web.json_response({"providers": providers})
async def pacing_put_handler(request: web.Request) -> web.Response:
"""PUT /pacing {"source": <p>, "interval_s": <float>} → обновляет in-memory интервал.
Валидация: source ∈ {avito,cian,yandex,generic}, interval_s ≥ 0.
Сброс к env-дефолту происходит при рестарте контейнера (by design).
"""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON body"}, status=400)
source = body.get("source")
if not isinstance(source, str) or source not in PROVIDERS:
return web.json_response(
{"error": f"source must be one of {list(PROVIDERS)}"},
status=400,
)
interval_s = body.get("interval_s")
if not isinstance(interval_s, (int, float)):
return web.json_response(
{"error": "interval_s must be a non-negative number"},
status=422,
)
interval_s = float(interval_s)
if interval_s < 0:
return web.json_response(
{"error": "interval_s must be >= 0"},
status=422,
)
_MIN_PAGE_INTERVAL_BY_PROVIDER[source] = interval_s
logger.info(
"tradein-browser: pacing updated source=%s interval_s=%.2f (resets on restart)",
source,
interval_s,
)
return web.json_response({"ok": True, "source": source, "interval_s": interval_s})
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("/fetch-json", fetch_json_handler)
app.router.add_post("/login", login_handler)
app.router.add_get("/pacing", pacing_get_handler)
app.router.add_put("/pacing", pacing_put_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)