"""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.45–1.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)