gendesign/tradein-mvp/browser/server.py
bot-backend a6717705b9
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(browser): reset page_counter on recycle + close login page in finally (#796 review)
2026-06-18 00:06:50 +03:00

701 lines
34 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"}
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`` работает стабильно — проверено.
Переменные окружения:
BROWSER_PORT — TCP-порт HTTP-сервера (default: 3000)
BROWSER_CONCURRENCY — сколько /fetch обрабатывается параллельно (default: 4, clamp >=1).
Один браузер, N параллельных страниц (по странице на запрос).
BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска (default: 15)
BROWSER_NAV_TIMEOUT_MS — таймаут page.goto в мс (default: 60000)
BROWSER_WAIT_MS — ожидание гидрации listings после DOMContentLoaded, мс (default: 6000)
AVITO_PROXY_URL — прокси ``http://user:pass@host:port`` (mobileproxy, #623);
SCRAPER_PROXY_URL — generic-fallback. Без него — soft-block.
Контракт /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"))
def _parse_concurrency(raw: str | None) -> int:
"""Парсит BROWSER_CONCURRENCY → int, clamp >=1, fallback 4 при мусоре.
Ops поднимает значение в .env.runtime после смоука. Невалидный ввод
(нечисловой / пустой) → дефолт 4, чтобы кривой конфиг не валил сервис.
"""
if raw is None:
return 4
try:
value = int(raw)
except (TypeError, ValueError):
logger.warning(
"tradein-browser: невалидный BROWSER_CONCURRENCY=%r — дефолт 4", raw
)
return 4
return max(1, value)
# Сколько /fetch обрабатывается параллельно. Один браузер, N параллельных
# страниц (по странице на запрос). Дефолт 4 — консервативно; ops поднимет до 8
# после смоука (firefox-страницы прожорливы по RAM, mem_limit поднят до 5g).
BROWSER_CONCURRENCY: int = _parse_concurrency(os.environ.get("BROWSER_CONCURRENCY"))
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"))
# Прокси: основной источник — AVITO_PROXY_URL (mobileproxy, #623, лежит в
# backend/.env.runtime, тот же что у curl_cffi-avito). SCRAPER_PROXY_URL —
# опциональный generic-fallback на будущее (cian/yandex). Без прокси camoufox
# фетчит с датацентр-IP → avito отдаёт soft-block заглушку.
SCRAPER_PROXY_URL: str | None = os.environ.get("AVITO_PROXY_URL") or os.environ.get(
"SCRAPER_PROXY_URL"
)
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
# ── состояние браузера ─────────────────────────────────────────────────────────
_browser: object | None = None # текущий экземпляр Browser
_browser_cm: object | None = None # AsyncCamoufox context manager (для __aexit__)
_page_counter: int = 0 # страниц с момента последнего (пере)запуска
# _launch_lock сериализует ТОЛЬКО launch/relaunch/recycle браузера (один __aenter__
# за раз на общий глобал _browser — иначе два launch'а затрут друг друга). НЕ
# сериализует сами /fetch — это делает семафор ниже.
_launch_lock: asyncio.Lock | None = None
# _fetch_sem ограничивает число ПАРАЛЛЕЛЬНЫХ /fetch (и /login) до BROWSER_CONCURRENCY.
# Один браузер, N параллельных страниц. Заменяет старый _fetch_lock (сериализация 1-за-раз).
_fetch_sem: asyncio.Semaphore | None = None
# Число fetch/login в полёте прямо сейчас. Нужно чтобы recycle/crash-relaunch НЕ
# рвал общий браузер из-под живых параллельных страниц (teardown под сиблингами →
# каскад крашей). Relaunch выполняется только когда in-flight == 1 (мы единственные).
_inflight: int = 0
# 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'а
_browser_retry_task: asyncio.Task[None] | None = None # handle фоновой retry-задачи
async def _launch_browser() -> None:
"""Запускает AsyncCamoufox и сохраняет browser + CM в модульных переменных."""
global _browser, _browser_cm, _page_counter
from camoufox.async_api import AsyncCamoufox
proxy = _parse_proxy(SCRAPER_PROXY_URL)
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: запуск AsyncCamoufox (proxy=%s, recycle_pages=%d)",
proxy is not None,
BROWSER_RECYCLE_PAGES,
)
cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type]
browser = await cm.__aenter__()
_browser_cm = cm
_browser = browser
_page_counter = 0
logger.info("tradein-browser: браузер запущен")
async def _close_browser() -> None:
"""Закрывает текущий экземпляр браузера."""
global _browser, _browser_cm, _page_counter
cm = _browser_cm
if cm is not None:
try:
await cm.__aexit__(None, None, None) # type: ignore[attr-defined]
logger.info("tradein-browser: браузер закрыт")
except Exception as exc:
logger.warning("tradein-browser: ошибка при закрытии браузера: %s", exc)
finally:
_browser = None
_browser_cm = None
_page_counter = 0
async def _relaunch_browser() -> None:
"""Закрывает текущий браузер и запускает новый (recycle / crash-recovery)."""
logger.info("tradein-browser: перезапуск браузера")
await _close_browser()
await _launch_browser()
async def _try_launch_browser() -> bool:
"""Одна попытка launch'а с санитизированным логом фейла.
НЕ пробрасывает исключение наружу — возвращает True/False. Критично для
resilience: фейл launch'а (прокси лёг → InvalidProxy) не должен валить
startup или фоновую задачу. Креды прокси НЕ логируются: camoufox светит
``user:pass@host`` в тексте своего исключения, поэтому мы логируем только
тип + санитизированное сообщение, без ``str(exc)``.
При частичном фейле (CM создан, но __aenter__ не довёл) чистит состояние
через _close_browser, чтобы следующий retry стартовал с чистого листа.
"""
try:
await _launch_browser()
return True
except Exception as exc:
logger.warning(
"tradein-browser: browser launch failed: proxy unreachable "
"(exc_type=%s) — контейнер остаётся Up, retry в фоне",
type(exc).__name__,
)
# Подчистить возможный частичный CM перед следующей попыткой.
await _close_browser()
return False
async def _ensure_browser() -> bool:
"""Гарантирует поднятый браузер; одна lazy-попытка launch'а под launch-локом.
Идемпотентна и конкурентно-безопасна: несколько параллельных /fetch могут
вызвать её одновременно — лок сериализует launch, а double-check внутри лока
не даёт второму launch'у затереть уже поднятый первым браузер.
Returns:
True если браузер доступен (был или поднялся), False если launch не удался
(прокси лежит) — caller отдаёт 503.
"""
if _browser is not None:
return True
assert _launch_lock is not None, "_launch_lock not initialised"
async with _launch_lock:
if _browser is not None:
return True
await _try_launch_browser()
return _browser is not None
async def _safe_relaunch(reason: str) -> None:
"""Перезапуск браузера под launch-локом ТОЛЬКО когда мы единственный in-flight.
Под конкуренцией recycle/crash-relaunch не должен рвать общий браузер из-под
живых параллельных страниц (teardown под сиблингами → каскад крашей). Поэтому:
relaunch выполняется лишь если ``_inflight <= 1`` (мы — единственный активный
запрос). Иначе relaunch ПРОПУСКАЕТСЯ — браузер переиспользуется, а recycle
случится позже, когда параллельная нагрузка спадёт. Crash-recovery при этом не
теряется: per-page краш изолирован (своя страница), а реально мёртвый браузер
поднимет _ensure_browser на следующем запросе.
Сброс ``_page_counter = 0`` происходит АТОМАРНО с реальным relaunch'ем (под
launch-локом, только когда guard inflight<=1 пройден). Иначе под конкуренцией
N>1 несколько корутин видят counter >= порога, все откладывают relaunch (guard
inflight>1), но counter НИКОГДА не сбрасывается → recycle-check срабатывает на
каждом fetch вечно, а реальный recycle голодает. При отложенном relaunch'е
counter НЕ трогаем — следующий запрос (когда нагрузка спадёт) дожмёт recycle.
"""
global _page_counter
assert _launch_lock is not None, "_launch_lock not initialised"
async with _launch_lock:
if _inflight > 1:
logger.info(
"tradein-browser: relaunch (%s) отложен — %d параллельных запросов в полёте",
reason,
_inflight,
)
return
logger.info("tradein-browser: relaunch (%s)", reason)
_page_counter = 0
await _relaunch_browser()
async def _browser_retry_loop() -> None:
"""Фоновый retry launch'а браузера с backoff, пока прокси не вернётся.
Идемпотентна: если браузер уже поднят (_browser не None) — сразу выходит.
Иначе ретраит _try_launch_browser с возрастающим интервалом
(_BROWSER_RETRY_MIN_S → _BROWSER_RETRY_MAX_S). Завершается после первого
успешного launch'а. Отменяется в _on_cleanup (CancelledError пробрасывается).
"""
if _browser is not None:
return
delay = _BROWSER_RETRY_MIN_S
while _browser is None:
await asyncio.sleep(delay)
# Launch-лок сериализует launch с in-flight lazy-launch и recycle — иначе
# два __aenter__ на один глобал _browser затрут друг друга.
assert _launch_lock is not None, "_launch_lock not initialised"
async with _launch_lock:
if _browser is not None:
return # handler уже поднял браузер — задача отработала
logger.info("tradein-browser: фоновый retry launch'а браузера")
launched = await _try_launch_browser()
if launched:
logger.info("tradein-browser: браузер поднят после retry, задача завершена")
return
delay = min(delay * 1.5, _BROWSER_RETRY_MAX_S)
def _start_retry_task(app: web.Application) -> None:
"""Запускает (если ещё нет) фоновую retry-задачу и кладёт handle в app."""
global _browser_retry_task
existing = _browser_retry_task
if existing is not None and not existing.done():
return # задача уже бежит — не плодим дубли
task = asyncio.create_task(_browser_retry_loop())
_browser_retry_task = task
app["browser_retry_task"] = task
# ── aiohttp lifecycle hooks ────────────────────────────────────────────────────
async def _on_startup(app: web.Application) -> None:
global _launch_lock, _fetch_sem
_launch_lock = asyncio.Lock()
_fetch_sem = asyncio.Semaphore(BROWSER_CONCURRENCY)
logger.info(
"tradein-browser: BROWSER_CONCURRENCY=%d (параллельных /fetch)", BROWSER_CONCURRENCY
)
# Launch НЕ фатален: если прокси лежит, camoufox бросит InvalidProxy. Раньше
# это валило startup → exit(1) → restart-loop. Теперь ловим, остаёмся Up и
# ретраим в фоне. proxy+geoip сохранены — никакого direct-fetch fallback.
if not await _try_launch_browser():
logger.warning(
"tradein-browser: старт без браузера (launch failed) — запуск фонового retry"
)
_start_retry_task(app)
async def _on_cleanup(app: web.Application) -> None:
global _browser_retry_task
task = _browser_retry_task
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: ошибка при отмене retry-задачи: %s", type(exc).__name__
)
_browser_retry_task = None
await _close_browser()
# ── handlers ───────────────────────────────────────────────────────────────────
async def health_handler(request: web.Request) -> web.Response:
# Всегда 200 — иначе compose healthcheck (если появится) убьёт контейнер,
# хотя процесс жив и ретраит браузер. browser_ready — для observability:
# видно лежит ли прокси, не теряя контейнер. Текущий prod compose НЕ имеет
# healthcheck на browser (depends_on: condition: service_started), так что
# 200-без-браузера никого не рестартит.
return web.json_response({"status": "ok", "browser_ready": _browser is not None})
async def fetch_handler(request: web.Request) -> web.Response:
"""POST /fetch {"url": "..."} → {"html": "..."}
Выполняет навигацию в браузере и возвращает полный HTML страницы.
До BROWSER_CONCURRENCY запросов обрабатываются параллельно (семафор), у
каждого — своя страница (изоляция крашей).
"""
global _inflight
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)
assert _fetch_sem is not None, "_fetch_sem not initialised"
async with _fetch_sem:
# Браузер мог не подняться на старте (прокси лежал). Одна lazy-попытка
# под launch-локом — вдруг прокси уже вернулся. НИКОГДА не фетчим без
# прокси/браузера: если всё ещё None → 503 (caller словит как
# HTTPStatusError и обработает как failed fetch, а не как crash).
if not await _ensure_browser():
logger.warning("tradein-browser: /fetch 503 — браузер недоступен (proxy may be down)")
return web.json_response(
{"error": "browser unavailable (proxy may be down)"}, status=503
)
_inflight += 1
try:
html = await _do_fetch(url)
except Exception as exc:
logger.error(
"tradein-browser: fetch error url=%r: %s: %s", url, type(exc).__name__, exc
)
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
finally:
_inflight -= 1
return web.json_response({"html": html})
async def _do_fetch(url: str) -> str:
"""Одна попытка навигации; при краше браузера — перезапускает и повторяет один раз.
Per-page краш изолирован (своя страница на запрос); _safe_relaunch рвёт общий
браузер лишь когда нет параллельных in-flight запросов, иначе пропускает (мёртвый
браузер поднимет _ensure_browser на следующем запросе).
"""
try:
return await _fetch_once(url)
except Exception as exc:
if _is_browser_crash(exc):
logger.warning(
"tradein-browser: краш браузера (%s), перезапуск + retry: %s",
type(exc).__name__,
url,
)
await _safe_relaunch("crash-recovery")
if not await _ensure_browser():
raise
return await _fetch_once(url)
raise
async def _fetch_once(url: str) -> str:
"""Открывает СОБСТВЕННУЮ страницу, переходит по URL, ждёт JS, возвращает HTML.
Каждый параллельный /fetch держит свою page (никакого шеринга — общая page под
конкуренцией ломает навигацию), закрывает её в finally.
"""
global _page_counter
browser = _browser
assert browser is not None, "browser not launched"
page = await browser.new_page() # type: ignore[attr-defined]
try:
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_counter += 1
logger.debug("tradein-browser: fetch OK url=%r pages_since_launch=%d", url, _page_counter)
if _page_counter >= BROWSER_RECYCLE_PAGES:
logger.info(
"tradein-browser: recycle threshold (%d) достигнут, перезапуск браузера",
BROWSER_RECYCLE_PAGES,
)
await _safe_relaunch("recycle")
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 браузерного контекста.
Делит тот же семафор/браузер что и /fetch (своя страница на запрос).
"""
global _inflight
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)
assert _fetch_sem is not None, "_fetch_sem not initialised"
async with _fetch_sem:
# Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
# Lazy-попытка под launch-локом, иначе 503 — без прокси не логинимся.
if not await _ensure_browser():
logger.warning("tradein-browser: /login 503 — браузер недоступен (proxy may be down)")
return web.json_response(
{"error": "browser unavailable (proxy may be down)"}, status=503
)
_inflight += 1
try:
cookies = await _do_login(body)
except LoginError as exc:
return web.json_response(exc.payload, status=502)
except Exception as exc:
logger.error(
"tradein-browser: login error url=%r: %s: %s",
body.get("url"),
type(exc).__name__,
exc,
)
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=502)
finally:
_inflight -= 1
return web.json_response({"cookies": cookies})
async def _do_login(params: dict) -> list[dict]:
"""Одна попытка логина; при краше браузера — перезапускает и повторяет."""
try:
return await _login_once(params)
except LoginError:
raise # диагностический payload идёт напрямую в хендлер
except Exception as exc:
if _is_browser_crash(exc):
logger.warning(
"tradein-browser: краш браузера (%s) при логине, перезапуск + retry",
type(exc).__name__,
)
await _safe_relaunch("login-crash-recovery")
if not await _ensure_browser():
raise
return await _login_once(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(params: dict) -> list[dict]:
"""Открывает страницу, заполняет форму, нажимает submit, ждёт success_cookie.
Поддерживает список pre_click_selectors (клик по очереди, каждый non-fatal)
и 2-шаговый submit Cian: после первого сабмита появляется экран
«Введите пароль» — дозаполняем password и кликаем повторно (макс. 2 попытки).
"""
global _page_counter
browser = _browser
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: login success_cookie=%r present=%s",
success_cookie,
cookie_found,
)
cookies: list[dict] = await page.context.cookies() # type: ignore[attr-defined]
logger.info(
"tradein-browser: login OK url=%r email_selector=%r cookie_count=%d",
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_counter += 1
if _page_counter >= BROWSER_RECYCLE_PAGES:
logger.info(
"tradein-browser: recycle threshold (%d) достигнут после login, перезапуск браузера",
BROWSER_RECYCLE_PAGES,
)
await _safe_relaunch("recycle-after-login")
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)