All checks were successful
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 32s
Deploy Trade-In / deploy (push) Successful in 1m17s
Deploy Trade-In / build-backend (push) Successful in 50s
Deploy Trade-In / build-browser (push) Successful in 2m8s
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
462 lines
20 KiB
Python
462 lines
20 KiB
Python
"""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_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"))
|
||
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 # страниц с момента последнего (пере)запуска
|
||
_fetch_lock: asyncio.Lock | None = None # сериализация fetch-запросов
|
||
|
||
|
||
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()
|
||
|
||
|
||
# ── aiohttp lifecycle hooks ────────────────────────────────────────────────────
|
||
|
||
|
||
async def _on_startup(app: web.Application) -> None:
|
||
global _fetch_lock
|
||
_fetch_lock = asyncio.Lock()
|
||
await _launch_browser()
|
||
|
||
|
||
async def _on_cleanup(app: web.Application) -> None:
|
||
await _close_browser()
|
||
|
||
|
||
# ── handlers ───────────────────────────────────────────────────────────────────
|
||
|
||
|
||
async def health_handler(request: web.Request) -> web.Response:
|
||
return web.json_response({"status": "ok"})
|
||
|
||
|
||
async def fetch_handler(request: web.Request) -> web.Response:
|
||
"""POST /fetch {"url": "..."} → {"html": "..."}
|
||
|
||
Выполняет навигацию в браузере и возвращает полный HTML страницы.
|
||
Запросы сериализованы через lock (один за раз).
|
||
"""
|
||
global _page_counter
|
||
|
||
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_lock is not None, "_fetch_lock not initialised"
|
||
|
||
async with _fetch_lock:
|
||
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)
|
||
|
||
return web.json_response({"html": html})
|
||
|
||
|
||
async def _do_fetch(url: str) -> str:
|
||
"""Одна попытка навигации; при краше браузера — перезапускает и повторяет один раз."""
|
||
global _page_counter
|
||
|
||
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 _relaunch_browser()
|
||
return await _fetch_once(url)
|
||
raise
|
||
|
||
|
||
async def _fetch_once(url: str) -> str:
|
||
"""Открывает страницу, переходит по URL, ждёт JS, возвращает HTML."""
|
||
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 _relaunch_browser()
|
||
|
||
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 браузерного контекста.
|
||
Запросы сериализованы через lock (один за раз — один браузер).
|
||
"""
|
||
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_lock is not None, "_fetch_lock not initialised"
|
||
|
||
async with _fetch_lock:
|
||
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)
|
||
|
||
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 _relaunch_browser()
|
||
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
|
||
|
||
page = await browser.new_page() # type: ignore[attr-defined]
|
||
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
|
||
await page.close() # type: ignore[attr-defined]
|
||
raise LoginError({
|
||
"error": f"{type(exc).__name__}: {exc}",
|
||
"page_url": page_url,
|
||
"screenshot_b64": screenshot_b64,
|
||
}) from exc
|
||
|
||
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 _relaunch_browser()
|
||
|
||
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)
|