"""server.py — tradein-browser service main process. Этот модуль запускается как точка входа контейнера ``tradein-browser``. Он запускает camoufox **локально** внутри контейнера (AsyncCamoufox) и экспонирует простой HTTP API на базе aiohttp: GET /health → {"status": "ok", "browsers": {"avito": bool, ...}} POST /fetch → {"url","origin"(опц.),"referer"(опц.),"cookies"(опц.)} → {"html": "...", "status": } # status — HTTP-код целевой навигации POST /fetch-json → {"url","method","headers","body","origin"} → {"status","body"} POST /login → {"url": "...", "email": "...", "password": "...", ...} → {"cookies": [...]} Такой подход выбран потому, что ``camoufox.server.launch_server`` (Playwright WS-сервер) несовместим с современными версиями playwright (1.45–1.60): ``browserServerImpl.js`` отсутствует в дистрибутиве → контейнер краш-лупится с MODULE_NOT_FOUND / RuntimeError при запуске через ``launch_server``. Локальный запуск ``AsyncCamoufox`` работает стабильно — проверено. Per-provider модель (#1793): Вместо одного глобального браузера + одного egress-прокси держим ОТДЕЛЬНЫЙ инстанс camoufox на каждого поставщика (avito/cian/yandex/generic). Поставщик определяется по host URL запроса (или явному полю body["provider"]/["source"]). Каждый инстанс имеет geoip=True — фингерпринт привязан к гео прокси, поэтому смешивать источники в одном браузере нельзя. Egress-прокси сейчас общий для всех поставщиков (SCRAPER_PROXY_URL, #2616 шаг 2) — раздельные instance'ы остаются ради geoip-изоляции fingerprint'а и per-provider concurrency (ниже), не прокси. Конкурентность: * МЕЖДУ поставщиками — параллельно (avito/cian/yandex гоняются одновременно); * ВНУТРИ поставщика — строго ≤1 одновременный fetch (per-provider lock). Это решает проблему, когда источники клинили друг друга через единственный egress-прокси / единственный сериализующий лок. Переменные окружения: BROWSER_PORT — TCP-порт HTTP-сервера (default: 3000) BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска, глобальный дефолт для провайдеров без код-дефолта (default: 15). per-provider код-дефолт см. BROWSER_RECYCLE_PAGES_{PROVIDER}. BROWSER_NAV_TIMEOUT_MS — таймаут page.goto в мс (default: 60000) BROWSER_WAIT_MS — ожидание гидрации listings после DOMContentLoaded, мс (default: 6000) BROWSER_CHALLENGE_WAIT_MS — бюджет ожидания QRATOR PoW-челленджа Авито (#3045), мс (default: 30000). Челлендж-страница сама считает proof-of-work в JS, ставит куку pow_solved и через setTimeout(3000) делает window.location = location.href (self-reload, URL не меняется). /fetch опрашивает page.content() пока маркеры челленджа не исчезнут; по истечении бюджета — ChallengeTimeoutError вместо тихой отдачи заглушки. Действует ТОЛЬКО при обнаружении маркеров челленджа в разметке — прочие провайдеры этот путь никогда не задевают. 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_BLOCK_RESOURCES (булев выключатель до #1812) НЕ читается — см. _RETIRED_ENV; в проде он всё ещё выставлен во всех трёх контейнерах и ни на что не влияет. BROWSER_BLOCK_IMAGES — булев глобальный дефолт camoufox block_images (#3185). Дефолт для ВСЕХ провайдеров — True (блокируем). Пробовали выключить для avito по гипотезе «блокировка = сигнал для QRATOR» — замер её не подтвердил, дефолт откатан; разбор и цифры в комментарии у _BLOCK_IMAGES_DEFAULT_BY_PROVIDER. Сама ручка осталась: выключить можно env'ом, без релиза. BROWSER_BLOCK_IMAGES_{PROVIDER} — per-provider override, тот же формат, что у BROWSER_MIN_PAGE_INTERVAL_S_{PROVIDER}: PROVIDER в upper-case (AVITO, CIAN, YANDEX, GENERIC), перебивает и global BROWSER_BLOCK_IMAGES, и код-дефолт per-provider. 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_RECYCLE_PAGES_{PROVIDER} — per-provider override recycle-порога (#3205). PROVIDER в upper-case: AVITO, CIAN, YANDEX, GENERIC, DOMCLICK. Приоритет: per-provider env → глобальный BROWSER_RECYCLE_PAGES → код-дефолт per-provider (_RECYCLE_PAGES_DEFAULT_BY_PROVIDER, сейчас пуст) → общий фолбэк 15. Ручка оставлена для будущих исключений; заведённый в #3205 domclick=1 снят в #3212 — перезапуск процесса уничтожает browser context, а в нём лежит пропуск QRATOR, ради которого всё и затевалось (подробности у _RECYCLE_PAGES_DEFAULT_BY_PROVIDER). SCRAPER_PROXY_URL — http-прокси, ОБЩИЙ для всех поставщиков (avito/cian/ yandex/generic). #2616 шаг 2: per-provider BROWSER_PROXY_AVITO/CIAN/YANDEX и legacy AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — все указывали на закрытые mobileproxy-аккаунты (407/connection refused, проверено вживую #2613). BROWSER_ANCHOR_VIA_SEARCH — CSV провайдеров, для которых якорная вкладка (_ensure_anchor_page) заходит на origin ЧЕРЕЗ реальный поиск yandex.ru, а не голым goto (#3251). Ручная сессия 29.08.2026 показала эталонный человеческий путь: yandex.ru → клик по результату → выдача с Referer yandex.ru. Дефолт "domclick" — авито/циан/яндекс проверяются отдельно в #3251, не включать здесь. Пустая строка = выключено везде (полный откат к прежнему поведению). Провайдер без соответствующего BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поиск тоже не делает — см. ниже. BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} — поисковый запрос на yandex.ru для провайдера, PROVIDER в upper-case (DOMCLICK). Код-дефолт для domclick — "домклик екатеринбург квартиры вторичка" (реалистичный запрос покупателя, ведёт на нужный хост одной из первых органических ссылок). Провайдер в BROWSER_ANCHOR_VIA_SEARCH без запроса (нет ни env, ни код-дефолта) — заход через поиск для него не делается. ENVIRONMENT — "production" в прод-контейнерах, иначе "dev" (дефолт). #2616 шаг 1: прод + нет НИ override в теле, НИ SCRAPER_PROXY_URL → отказ (503, явная причина), а НЕ launch camoufox без proxy (= прямое подключение с IP сервера). В dev отсутствие прокси легитимно (см. _no_live_proxy). Контракт /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 gzip import logging import os from collections.abc import Callable, Mapping from urllib.parse import quote, urlparse from aiohttp import web logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logger = logging.getLogger(__name__) # ── конфигурация из env ──────────────────────────────────────────────────────── # #2616 шаг 1: признак окружения. "production" в прод-контейнерах (ENV: ENVIRONMENT, # см. app.core.config.Settings.environment в основном backend). Дефолт "dev" — как и # везде в проекте (Settings.environment: str = "dev"). ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "dev") IS_PROD: bool = ENVIRONMENT == "production" BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000")) # Глобальной константы recycle больше нет (#3205): порог стал поставщик-зависимым и # живёт в _RECYCLE_PAGES_BY_PROVIDER. Env BROWSER_RECYCLE_PAGES по-прежнему читается — # внутри _resolve_recycle_pages, как один из уровней приоритета. Константу убрали # намеренно: пока она существовала, ~24 теста патчили ЕЁ, чтобы запретить перезапуск # браузера, и после перехода на словарь этот патч перестал на что-либо влиять — # охранник остался в коде, но охранять перестал. 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")) # 30000: цепочка PoW-челленджа Авито — startPow() в JS, затем setTimeout(3000) на # self-reload, затем повторная гидрация страницы. 3с таймера самой площадки — это # ТОЛЬКО задержка перед reload, не бюджет на сам расчёт PoW: под headless-браузером # и egress-прокси решение может занять заметно дольше, чем в обычном браузере # пользователя. Живой замер 2026-08-21 (#3045): без ожидания челленджа 4 из 6 # карточек с органической навигацией отдавали 7891-байтную челлендж-страницу вместо # контента — не бан (403/429 не было), просто уходили раньше, чем страница себя # перезагрузила. 30с — запас с кратным резервом на решение + reload + догидрацию, # не превращающий единичный фетч в минуту ожидания при реальном бане/сетевой пробе. BROWSER_CHALLENGE_WAIT_MS: int = int(os.environ.get("BROWSER_CHALLENGE_WAIT_MS", "30000")) # Через сколько мс безрезультатного опроса перезагрузить страницу САМИМ, не дожидаясь # её самопроизвольного reload'а. Допущение «страница перезагрузит себя сама» (см. # _content_during_navigation) верно НЕ всегда: ручная сессия 2026-08-29 через узел 10 # показала, что после решения PoW выдача Домклика так и осталась на 401, и пропуск # qrator_jsid2 выдался только после ДВУХ перезагрузок, сделанных человеком руками — # 102.7с GET → 401, 115.1с GET → 401, 118.4с GET → 200, и сразу за ним кука-пропуск. # Пока мы только опрашивали content(), такая страница висела до самого таймаута. # Перезагрузка повторяет ровно то, что делает человек, и стоит одну навигацию. BROWSER_CHALLENGE_RELOAD_AFTER_MS: int = int( os.environ.get("BROWSER_CHALLENGE_RELOAD_AFTER_MS", "8000") ) # Сколько таких перезагрузок допустимо за один фетч. Ноль = прежнее поведение (только # опрос). Два — столько понадобилось человеку; больше похоже уже на долбёжку, которая # сама по себе повод отказать. BROWSER_CHALLENGE_MAX_RELOADS: int = int(os.environ.get("BROWSER_CHALLENGE_MAX_RELOADS", "2")) # /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")) # Сколько ждать события `load` на ПОВТОРЕ после гонки «execution context destroyed» # (#2676). Только на повторе: happy-path остаётся на дешёвом FETCH_JSON_SETTLE_MS, # иначе бесконечно дозагружающаяся страница удлиняла бы КАЖДЫЙ запрос. Ожидание # best-effort — по таймауту всё равно пробуем evaluate. FETCH_JSON_LOAD_WAIT_MS: int = int(os.environ.get("FETCH_JSON_LOAD_WAIT_MS", "15000")) # Известные поставщики. "generic" — фолбэк для всех прочих хостов (один общий # инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод. # "domclick" (#3205): собственный инстанс — у площадки свой антибот (QRATOR с # proof-of-work) и свой ритм, и держать её на общем с generic счётчике страниц и # общем браузере значило бы мешать её сессию с чужими запросами. PROVIDERS: tuple[str, ...] = ("avito", "cian", "yandex", "generic", "domclick") 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")) # Провайдеры, для которых якорная вкладка (#3251) заходит на origin ЧЕРЕЗ реальный # поиск yandex.ru, а не голым goto. Дефолт "domclick" — авито/циан/яндекс проверяются # отдельно (#3251), не расширять этот список без замера на них. Пустая строка env = # выключено везде — полный откат на поведение до #3251. _ANCHOR_VIA_SEARCH_PROVIDERS: frozenset[str] = frozenset( p.strip().lower() for p in os.environ.get("BROWSER_ANCHOR_VIA_SEARCH", "domclick").split(",") if p.strip() ) # Код-дефолты поискового запроса per-provider, если BROWSER_ANCHOR_SEARCH_QUERY_ # {PROVIDER} не задан. Запрос подобран так, чтобы органическая выдача Яндекса # реально содержала ссылку на нужный хост, а не только рекламу — проверено вживую # 29.08.2026 ручной сессией с этим же запросом. _ANCHOR_SEARCH_QUERY_DEFAULT_BY_PROVIDER: dict[str, str] = { "domclick": "домклик екатеринбург квартиры вторичка", } # Базовый URL поиска. Константы «Referer визита без клика» здесь СОЗНАТЕЛЬНО нет: # подставлять https://yandex.ru/ по факту одного лишь захода на поиск — это заявить # переход, которого не было. Не нашли ссылку / клик увёл не туда → идём на origin # вообще без Referer (#3251). _YANDEX_SEARCH_URL: str = "https://yandex.ru/search/" # Маркеры SmartCaptcha Яндекса. Мобильные прокси иногда ловят капчу на выдаче — # наблюдалось вживую 29.08.2026. Решать капчу нечем и незачем: детектируем и тихо # откатываемся на прежнее поведение, прогон падать из-за недоступности живого # поиска не должен. _YANDEX_CAPTCHA_MARKERS: tuple[str, ...] = ( "smartcaptcha", "showcaptcha", "подтвердите, что запросы отправляли вы", ) def _anchor_search_query(provider: str) -> str | None: """Запрос для захода на yandex.ru перед origin данного провайдера. Приоритет: BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} (upper-case) → код-дефолт из _ANCHOR_SEARCH_QUERY_DEFAULT_BY_PROVIDER → None. None означает "заход через поиск для этого провайдера не делается" — даже если он есть в _ANCHOR_VIA_SEARCH_PROVIDERS, без запроса открывать нечего. """ default = _ANCHOR_SEARCH_QUERY_DEFAULT_BY_PROVIDER.get(provider) return os.environ.get(f"BROWSER_ANCHOR_SEARCH_QUERY_{provider.upper()}", default) def _looks_like_yandex_captcha(html: str, current_url: str) -> bool: """True, если текущая страница — SmartCaptcha Яндекса, а не выдача. Проверяем и URL (редирект на /showcaptcha), и текст разметки — Яндекс показывает капчу и как отдельную страницу, и как встроенный блок поверх выдачи в зависимости от типа запроса/прокси. """ if "/showcaptcha" in current_url.lower(): return True low_html = html.lower() return any(marker in low_html for marker in _YANDEX_CAPTCHA_MARKERS) async def _find_serp_result_link(page: object, hostname: str) -> object | None: """Ищет в выдаче Яндекса ссылку на ``hostname`` среди всех ``a[href]``. Яндекс часто отдаёт результат как редирект-обёртку (``/redir/...``, ``yandex.ru/clck/...``) — совпадение по одному ``href`` не всегда сработает, поэтому проверяем ещё и видимый текст ссылки (Яндекс показывает реальный хост зелёной строкой под заголовком результата). """ if not hostname: return None try: candidates = await page.query_selector_all("a[href]") # type: ignore[attr-defined] except Exception: return None for element in candidates: try: href = await element.get_attribute("href") # type: ignore[attr-defined] text = await element.text_content() # type: ignore[attr-defined] except Exception: continue haystack = f"{href or ''} {text or ''}".lower() if hostname.lower() in haystack: return element return None async def _navigate_anchor_via_search(page: object, provider: str, origin: str) -> object | None: """Пытается открыть ``origin`` переходом со страницы выдачи yandex.ru вместо голого goto — так это делает реальный пользователь (#3251). ПРИНЦИП (не оптимизировать обратно на "поставить правдоподобный Referer"): либо переход с Яндекса на origin случился НАСТОЯЩИЙ, либо мы честно об этом молчим. До этой правки (#3258) два фолбэка — "ссылка не найдена" и "клик увёл не туда" — подставляли Referer (yandex.ru / URL выдачи) страницам, на которые фактически НЕ переходили с Яндекса. Заявлять переход, которого не было, нельзя. Теперь оба случая возвращают ``None`` без единого referer, и вызывающий делает обычный ``goto(origin)`` без referer — ровно как до #3251, когда захода через поиск не существовало вовсе. Возвращает страницу, на которой origin реально открыт после клика: - ``page`` (тот же объект) — клик по результату выдачи навигировал текущую вкладку; - НОВУЮ страницу — клик открыл origin в отдельной вкладке (частый случай: ссылки в выдаче Яндекса нередко имеют ``target="_blank"``). В этом случае именно новая страница несёт на себе результат клика, поэтому она становится якорной, а исходная вкладка с Яндексом закрывается (держать две вкладки на один провайдер незачем и дороже по памяти). Вызывающий обязан подменить свою ссылку на ``page`` возвращённой — и применить к ней ``_apply_resource_block`` заново, т.к. новая вкладка ничего не наследует от исходной. Возвращает ``None``, если настоящего перехода на origin не случилось: поиск выключен для провайдера / нет запроса, капча на выдаче, упавшая навигация на yandex.ru, ссылки в выдаче нет, клик не удался, либо клик (в текущей вкладке или в новой) увёл не на тот хост. Прогон в любом из этих случаев НЕ должен падать — вызывающий откатывается на прямой goto(origin) без referer. """ if provider not in _ANCHOR_VIA_SEARCH_PROVIDERS: return None query = _anchor_search_query(provider) if query is None: return None hostname = urlparse(origin).hostname or "" search_url = f"{_YANDEX_SEARCH_URL}?text={quote(query)}" try: await page.goto( # type: ignore[attr-defined] search_url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded" ) except Exception as exc: logger.warning( "tradein-browser[%s]: заход на yandex.ru для якоря не удался (%s) — " "откат на прямой goto(origin) без referer", provider, type(exc).__name__, ) return None html = await page.content() # type: ignore[attr-defined] serp_url = str(getattr(page, "url", search_url)) if _looks_like_yandex_captcha(html, serp_url): logger.warning( "tradein-browser[%s]: капча на выдаче Яндекса — откат на прямой " "goto(origin) без referer", provider, ) return None link = await _find_serp_result_link(page, hostname) if link is None: logger.info( "tradein-browser[%s]: ссылка на %s не найдена в выдаче yandex.ru — " "перехода не было, откат на прямой goto(origin) без referer", provider, hostname, ) return None # Снимок открытых вкладок ДО клика — так после клика можно отличить "клик # открыл новую вкладку" (target="_blank", частый случай в живой выдаче # Яндекса) от "клик навигировал текущую". Без этого попап на другой вкладке # остался бы незамеченным: текущая page осталась бы на yandex.ru, проверка # хоста ниже не прошла бы, и мы ушли бы в фолбэк вместо настоящего перехода. context = getattr(page, "context", None) pages_before = list(context.pages) if context is not None else [] # type: ignore[attr-defined] try: await link.click(timeout=BROWSER_NAV_TIMEOUT_MS) # type: ignore[attr-defined] await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] except Exception as exc: logger.warning( "tradein-browser[%s]: клик по ссылке в выдаче не удался (%s) — " "откат на прямой goto(origin) без referer", provider, type(exc).__name__, ) return None popup: object | None = None if context is not None: for candidate in context.pages: # type: ignore[attr-defined] if candidate not in pages_before and candidate is not page: popup = candidate break if popup is not None: try: await popup.wait_for_load_state( # type: ignore[attr-defined] "domcontentloaded", timeout=BROWSER_NAV_TIMEOUT_MS ) except Exception: pass popup_host = urlparse(str(getattr(popup, "url", ""))).hostname or "" if popup_host == hostname: logger.info( "tradein-browser[%s]: клик по ссылке в выдаче открыл НОВУЮ вкладку " "на %s — она становится якорной, исходная с Яндексом закрывается", provider, hostname, ) await _apply_resource_block(popup) try: await page.close() # type: ignore[attr-defined] except Exception: pass return popup logger.warning( "tradein-browser[%s]: клик открыл новую вкладку на %s вместо %s — " "перехода на origin не было, откат на прямой goto(origin) без referer", provider, popup_host, hostname, ) try: await popup.close() # type: ignore[attr-defined] except Exception: pass return None landed_host = urlparse(str(getattr(page, "url", ""))).hostname or "" if landed_host == hostname: return page logger.warning( "tradein-browser[%s]: клик увёл текущую вкладку на %s вместо %s — " "перехода на origin не было, откат на прямой goto(origin) без referer", provider, landed_host, hostname, ) return None 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", "domclick"). 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 PROVIDERS} # Код-дефолт block_images ПО ПРОВАЙДЕРУ. Сейчас пуст — все провайдеры блокируют # картинки, как и до #3185 (см. _BLOCK_IMAGES_DEFAULT_FALLBACK). Ручка из #3185 # остаётся: поменять поведение можно через env, без релиза. # # Почему дефолт откатан (#3185). Гипотеза «блокировка картинок = лишний сигнал для # QRATOR» шла от camoufox'ского LeakWarning, а не от замера. Прямой A/B на сайдкаре её # не подтвердил: 6/8 успехов с блокировкой против 7/8 без — разница в пределах шума. # На проде после выкатки avito=False стало хуже: прогон 5200 (картинки блокировались) — # 43/63 карточки при 32% блоков; прогоны 5206 и 5207 (не блокировались) — 2/22 и 2/13 # при 91% и 77% блоков. Причинность НЕ доказана: между прогонами через тот же пул # прокси прошло ~40 диагностических запросов, репутация пула могла просесть от них. # Но выгоды правка не показала ни разу, поэтому дефолт возвращается к прежнему # поведению; эффект картинок мерить отдельно и на чистом пуле. _BLOCK_IMAGES_DEFAULT_BY_PROVIDER: dict[str, bool] = {} _BLOCK_IMAGES_DEFAULT_FALLBACK: bool = True def _resolve_block_images( provider: str, environ: Mapping[str, str] | None = None, ) -> bool: """Возвращает эффективный флаг block_images для провайдера (#3185). Приоритет (выше — сильнее): per-provider env ``BROWSER_BLOCK_IMAGES_{PROVIDER}`` → глобальный env ``BROWSER_BLOCK_IMAGES`` → код-дефолт per-provider (``_BLOCK_IMAGES_DEFAULT_BY_PROVIDER``; сейчас пуст → все True). Не кидает исключений — невалидный/отсутствующий env тихо проваливается на следующий уровень. Args: provider: имя провайдера ("avito", "cian", "yandex", "generic", "domclick"). environ: env-словарь; None → ``os.environ`` (тестируемость без патча модуля). Returns: True — блокировать image-запросы в camoufox, False — не блокировать. """ env = environ if environ is not None else os.environ provider_default = _BLOCK_IMAGES_DEFAULT_BY_PROVIDER.get( provider, _BLOCK_IMAGES_DEFAULT_FALLBACK ) global_value = _parse_bool(env.get("BROWSER_BLOCK_IMAGES"), provider_default) key = f"BROWSER_BLOCK_IMAGES_{provider.upper()}" return _parse_bool(env.get(key), global_value) # Per-provider эффективные флаги block_images, вычисляются на module-load из os.environ. # Читается в _launch_browser; логируется на старте (_on_startup). _BLOCK_IMAGES_BY_PROVIDER: dict[str, bool] = {p: _resolve_block_images(p) for p in PROVIDERS} # Код-дефолт recycle_pages ПО ПРОВАЙДЕРУ. Пуст: сейчас все поставщики живут на общем # фолбэке, ручка per-provider (env + этот словарь) остаётся для будущих исключений. # # #3205 заводил здесь domclick=1 — перезапуск процесса camoufox после КАЖДОЙ карточки. # #3212 это снял: перезапуск уничтожает browser context, а в контексте лежит пропуск # QRATOR (куки qrator_jsid2 + qrator_jsr), который площадка выдаёт после принятой # валидации /__qrator/validate. То есть #3205 боролся с симптомом, который сам же и # создавал. Замер (прод, прод-прокси, по 6 карточек на условие): общий контекст — 6/6 # и НИ ОДНОГО повторного вызова validate; новый контекст на карточку — 1/6, и на каждой # следующей validate отвечает 403. Вкладки роли не играют, играет общий cookie jar. # Рассуждение #3205 «признак живёт на уровне процесса, свежий контекст отказ не снимает» # было верным наблюдением с неверным выводом: свежий контекст его действительно не # снимает — потому что он и есть причина. _RECYCLE_PAGES_DEFAULT_BY_PROVIDER: dict[str, int] = {} _RECYCLE_PAGES_DEFAULT_FALLBACK: int = 15 def _resolve_recycle_pages( provider: str, environ: Mapping[str, str] | None = None, ) -> int: """Возвращает эффективный recycle_pages threshold для провайдера (#3205). Приоритет (выше — сильнее): per-provider env ``BROWSER_RECYCLE_PAGES_{PROVIDER}`` → глобальный env ``BROWSER_RECYCLE_PAGES`` → код-дефолт per-provider (``_RECYCLE_PAGES_DEFAULT_BY_PROVIDER``; сейчас только domclick=1) → общий фолбэк 15. Невалидное (не int) значение на любом из env-уровней тихо проваливается на следующий уровень приоритета (фолбэк, а не падение). Валидное, но <1 — приводим к 1 (порог меньше единицы бессмысленен: перезапуск на КАЖДОЙ странице — это и есть 1). Args: provider: имя провайдера ("avito", "cian", "yandex", "generic", "domclick"). environ: env-словарь; None → ``os.environ`` (тестируемость без патча модуля). Returns: Эффективный порог recycle (страниц до перезапуска), ≥1. """ env = environ if environ is not None else os.environ def _parse(raw: str | None, fallback: int, env_key: str) -> int: if raw is None: return fallback try: value = int(raw) except ValueError: logger.warning( "tradein-browser: %s=%r невалидно (не int), используем %d", env_key, raw, fallback, ) return fallback if value < 1: logger.warning("tradein-browser: %s=%d < 1, приводим к 1", env_key, value) return 1 return value provider_default = _RECYCLE_PAGES_DEFAULT_BY_PROVIDER.get( provider, _RECYCLE_PAGES_DEFAULT_FALLBACK ) global_value = _parse( env.get("BROWSER_RECYCLE_PAGES"), provider_default, "BROWSER_RECYCLE_PAGES" ) key = f"BROWSER_RECYCLE_PAGES_{provider.upper()}" return _parse(env.get(key), global_value, key) # Per-provider эффективные пороги recycle, вычисляются на module-load из os.environ. # Читаются в _do_fetch/_do_fetch_json/_do_login при сравнении с _page_counters; # логируются на старте (_on_startup). _RECYCLE_PAGES_BY_PROVIDER: dict[str, int] = {p: _resolve_recycle_pages(p) for p in PROVIDERS} def _provider_for_url(url: str) -> str: """Определяет поставщика по host URL. avito/cian/yandex/domclick → одноимённый провайдер; всё прочее → "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" if "domclick" in host: return "domclick" return "generic" def _provider_proxy(provider: str) -> str | None: """Возвращает http-прокси для поставщика из env, иначе None. #2616 шаг 2: per-provider BROWSER_PROXY_AVITO/CIAN/YANDEX и legacy AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — все указывали на закрытые mobileproxy-аккаунты (#2613). SCRAPER_PROXY_URL (ASocks, http-прокси, playwright-совместимый) — единственный источник, общий для ВСЕХ поставщиков (включая generic). `provider` оставлен параметром сигнатуры (вызывающие передают его) — на случай возврата к per-provider egress в будущем. """ return os.environ.get("SCRAPER_PROXY_URL") def _no_live_proxy(provider: str, proxy_override: str | None) -> bool: """True если нет НИ explicit proxy в теле запроса, НИ SCRAPER_PROXY_URL. #2616 шаг 1: используется ТОЛЬКО в prod (IS_PROD) — в dev отсутствие прокси легитимно (прямое подключение для локальной разработки/тестов, см. #2616 issue пункт 2). Отдельная функция от `_provider_proxy`, потому что "нет живого прокси" здесь означает буквально "нет НИКАКОГО прокси" (ни override, ни env) — единственный случай, который код МОЖЕТ проверить без сетевой пробы. Env-переменная, что задана но мертва (407/connection refused), этот guard не триггерит — за живость значения отвечает мониторинг прокси-пула, не этот код. """ return proxy_override is None and _provider_proxy(provider) is None 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__) # provider → переиспользуемый BrowserContext (#3118, opt-in reuse_context). Создаётся # ЛЕНИВО в _get_or_create_context на первом /fetch с reuse_context=True; куки вливаются # ТОЛЬКО в момент создания — дальше cookie-jar эволюционирует сам через Set-Cookie # площадки (browser.new_page() без reuse_context создаёт НОВЫЙ изолированный context на # каждый вызов и убивает живой qrator_jsid2 DomClick, см. #3118). При выключенном флаге # (дефолт) словарь остаётся пустым — поведение прочих провайдеров не меняется. _contexts: dict[str, object] = {} # provider → ЯКОРНАЯ вкладка, живущая всё время жизни context'а и держащая # открытым origin (SERP). До этого origin переоткрывался goto-ом ПЕРЕД каждой # карточкой, и полная навигация по выдаче оплачивалась заново каждый раз. # Замер 29.08.2026 на 131 карточке: 4-5 с на карточку с живой вкладкой против # 17-52 с с переоткрытием, причём время во втором случае росло по ходу прогона. # Живёт только при reuse_context=True: без переиспользуемого context'а держать # вкладку негде и незачем. _anchor_pages: dict[str, object] = {} # provider -> origin, НА КОТОРОМ якорная вкладка стоит СЕЙЧАС. Без этой памяти # _ensure_anchor_page отдавала любую живую вкладку как годную, не глядя на # запрошенный origin, — а origin у обоих caller'ов выводится ИЗ URL карточки и # меняется вместе с городом (Авито: /ekaterinburg/... vs /verhnyaya_pyshma/...; # Домклик: ekaterinburg.domclick.ru vs иной поддомен). Тогда Referer целевой # навигации называл выдачу, которую этот контекст НИКОГДА не открывал: ни куки # её, ни тайминга — площадка видит заявленный переход без единого следа. Ровно # то, что #3258 запретил делать фолбэкам якорного поиска. Origin сменился — # вкладку переоткрываем на новый. _anchor_origins: dict[str, str] = {} _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 (пейсинг) # provider → HTTP-статус ответа ПОСЛЕДНЕЙ целевой навигации (#3196). Пишется в # _fetch_once (сбрасывается в None перед навигацией, чтобы не отдать чужой # протухший статус), читается fetch_handler'ом под тем же _locks[provider] — гонки нет. _last_response_status: dict[str, int | None] = {} # #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, # Значение per-provider (#3185): env BROWSER_BLOCK_IMAGES / # BROWSER_BLOCK_IMAGES_{PROVIDER}, см. docstring модуля. Дефолт для всех — True # (блокируем), почему именно так — у _BLOCK_IMAGES_DEFAULT_BY_PROVIDER. "block_images": _BLOCK_IMAGES_BY_PROVIDER.get(provider, _BLOCK_IMAGES_DEFAULT_FALLBACK), # Анти-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, _RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK), ) 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 _ensure_anchor_page(provider: str, origin: str) -> bool: """Держит вкладку с ``origin`` открытой на всё время жизни context'а провайдера. Возвращает True, если якорь готов (значит вызывающему НЕ надо делать ``goto(origin)`` перед целевой навигацией), и False — если поднять якорь не вышло и нужно откатиться на прежнее поведение. Зачем: до этого origin переоткрывался перед КАЖДОЙ карточкой, и полная навигация по выдаче оплачивалась заново. Замер 29.08.2026 на 131 карточке — 4-5 с на карточку с живой вкладкой против 17-52 с с переоткрытием, причём во втором случае время росло по ходу прогона. Ошибку глотаем намеренно: якорь — оптимизация, а не обязательный шаг. Если вкладка не поднялась, вызывающий сделает goto(origin) как раньше, и запрос отработает штатно, просто медленнее. """ ctx = _contexts.get(provider) if ctx is None: return False page = _anchor_pages.get(provider) if page is not None: try: alive = not page.is_closed() # type: ignore[attr-defined] except Exception: alive = False # Живая вкладка годится ТОЛЬКО если стоит на запрошенном origin: иначе # Referer назовёт выдачу, которой в этом контексте не открывали (см. # комментарий у _anchor_origins). Чужой origin — закрываем и поднимаем # заново; это дороже на одну навигацию, но честно. if alive and _anchor_origins.get(provider) == origin: return True if alive: logger.info( "tradein-browser[%s]: якорь стоял на %s, запрошен %s — переоткрываем", provider, _anchor_origins.get(provider), origin, ) try: await page.close() # type: ignore[attr-defined] except Exception: pass _anchor_pages.pop(provider, None) _anchor_origins.pop(provider, None) try: page = await ctx.new_page() # type: ignore[attr-defined] await _apply_resource_block(page) # #3251/#3258: за origin для включённых провайдеров идём через реальный # поиск yandex.ru, а не голым goto — так это делает человек. anchored_page # не None только если переход на origin был НАСТОЯЩИЙ (см. докстринг # _navigate_anchor_via_search) — тогда это либо та же page, либо новая # вкладка (клик открыл её через target="_blank"), и её нужно взять как # якорную вместо исходной. None — переход не случился ни в каком виде, # откатываемся на прежний прямой goto(origin) БЕЗ referer, ровно как до # #3251: заявлять переход, которого не было, нельзя. anchored_page = await _navigate_anchor_via_search(page, provider, origin) if anchored_page is not None: page = anchored_page # type: ignore[assignment] else: await page.goto( # type: ignore[attr-defined] origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded" ) await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] except Exception as exc: logger.warning( "tradein-browser[%s]: якорная вкладка не поднялась (%s) — откат на " "переоткрытие origin перед каждой навигацией", provider, type(exc).__name__, ) if page is not None: try: await page.close() # type: ignore[attr-defined] except Exception: pass return False _anchor_pages[provider] = page _anchor_origins[provider] = origin logger.info("tradein-browser[%s]: якорная вкладка открыта на %s", provider, origin) return True async def _close_reusable_context(provider: str) -> None: """Закрывает и выбрасывает сохранённый переиспользуемый context провайдера (#3118). No-op если контекста нет (флаг reuse_context никогда не включался или уже сброшен). Best-effort — ошибка закрытия не должна ронять caller'а (reset_context/relaunch/ shutdown идут дальше вне зависимости от исхода close()). """ # Якорная вкладка принадлежит этому context'у и умирает вместе с ним — важно # снять ссылку ДО close(), иначе следующий запрос возьмёт закрытую страницу. _anchor_pages.pop(provider, None) _anchor_origins.pop(provider, None) ctx = _contexts.pop(provider, None) if ctx is None: return try: await ctx.close() # type: ignore[attr-defined] except Exception as exc: logger.warning( "tradein-browser[%s]: ошибка при закрытии переиспользуемого context: %s", provider, type(exc).__name__, ) async def _close_browser(provider: str) -> None: """Закрывает per-provider браузер + переиспользуемый context (если есть, #3118).""" await _close_reusable_context(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) logger.info("tradein-browser: block-images %s", _BLOCK_IMAGES_BY_PROVIDER) logger.info("tradein-browser: recycle-pages %s", _RECYCLE_PAGES_BY_PROVIDER) _warn_retired_env() # Переменные, которые окружение всё ещё задаёт, а код больше не читает. Ставим сюда # ровно те, что реально видели в проде — молчаливо игнорируемая ручка опаснее # отсутствующей: оператор ставит BROWSER_BLOCK_RESOURCES=false, чтобы посмотреть # страницу с ресурсами, ничего не меняется, и он делает вывод не о переменной, # а о блокировке. _RETIRED_ENV: dict[str, str] = { # #1812 заменил булев выключатель на список типов. Блокировка при этом НЕ # ослабла: image глушится camoufox block_images (_launch_browser), font/media — # дефолтом BROWSER_BLOCK_RESOURCE_TYPES. Мёртв только сам выключатель. "BROWSER_BLOCK_RESOURCES": ( "BROWSER_BLOCK_RESOURCE_TYPES (CSV типов; пустая строка = не блокировать)" ), } def _warn_retired_env() -> None: """Предупреждает про заданные env, которые код больше не читает (#2674).""" for name, replacement in _RETIRED_ENV.items(): if os.environ.get(name) is not None: logger.warning( "tradein-browser: %s=%r задана, но НЕ читается кодом с #1812 — " "значение ни на что не влияет. Замена: %s. Уберите переменную из " "окружения, чтобы она не выглядела рабочим выключателем", name, os.environ[name], replacement, ) 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 (#3205): explicit body["source"]="domklik" (опечатка площадки в # caller'е) нет в PROVIDERS дословно → fallthrough на host-detect, а # _provider_for_url узнаёт domclick.ru по host и отдаёт "domclick" — ЭТО # больше НЕ synonim для generic. До #3205 комментарий здесь утверждал, что # generic-путь эквивалентен, потому что все поставщики делят один # SCRAPER_PROXY_URL (#2616 шаг 2) — прокси действительно общий, но с #3205 у # domclick СВОЙ порог перезапуска браузера (recycle_pages=1, см. # _RECYCLE_PAGES_BY_PROVIDER): площадка отдаёт ровно одну карточку на процесс # camoufox, и общий с 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 → инстанс поставщика берёт общий SCRAPER_PROXY_URL (#2616 шаг 2), поведение не меняется (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": "...", "status": } Выполняет навигацию в браузере поставщика и возвращает полный 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) # origin (опционально) — same-site якорь, на который камуфокс зайдёт ПЕРЕД url, # чтобы получить пропуск QRATOR в куки контекста (зеркалит /fetch-json #1917). # Явного auto-derive из url здесь НЕТ (в отличие от fetch-json) — caller не передал # → остаётся None, origin-goto не выполняется, поведение идентично прежнему # (avito/cian/yandex его никогда не передают). origin: str | None = body.get("origin") # referer (опционально, #3247) — HTTP Referer, передаётся ТОЛЬКО в goto() целевого # url (см. _fetch_once). Поле отсутствует → None → поведение идентично прежнему. referer: str | None = body.get("referer") # cookies (опционально) — dict name→value для инъекции в контекст страницы ПЕРЕД # навигацией (обход QRATOR-блока DomClick при валидной test-аккаунт сессии, # эмпирически подтверждено вживую 2026-07-04). Явного auto-derive нет — caller не # передал → остаётся None, инъекции нет, поведение идентично прежнему (avito/cian/ # yandex его никогда не передают). cookies: dict | None = body.get("cookies") # reuse_context/reset_context (#3118) — тёплый переиспользуемый browser-context для # DomClick detail-бэкфилла: browser.new_page() каждый раз создаёт НОВЫЙ изолированный # context, и куки (см. cookies выше), залитые ОДИН РАЗ, не видят Set-Cookie от # площадки — живой qrator_jsid2 (~2.5ч TTL) протухает уже после первого запроса. # reuse_context=True держит per-provider context живым между вызовами (куки вливаются # ТОЛЬКО при его создании, дальше jar эволюционирует сам); reset_context=True # выбрасывает сожжённый context ДО работы (следующее создание будет чистым). Оба # дефолт False — поведение всех прочих provider'ов (avito/cian/yandex/generic) не # меняется ни на байт. reuse_context: bool = bool(body.get("reuse_context", False)) reset_context: bool = bool(body.get("reset_context", False)) # fetch_mode (#3264) — чем забирать ресурс: навигацией (дефолт, прежнее поведение), # запросом контекста или fetch'ем из прогретой страницы. См. FETCH_MODES. fetch_mode: str = str(body.get("fetch_mode") or "navigate") if fetch_mode not in FETCH_MODES: return web.json_response( {"error": f"неизвестный fetch_mode={fetch_mode!r}, " f"допустимы {sorted(FETCH_MODES)}"}, status=400, ) provider = _resolve_provider(body, url) proxy_override = _resolve_proxy_override(body, provider) # #2616 шаг 1: прод + нет НИ override, НИ env-прокси → явный отказ ДО лока/launch'а, # а не тихий заход camoufox напрямую с IP сервера (dev это легитимно, см. _no_live_proxy). if IS_PROD and _no_live_proxy(provider, proxy_override): logger.warning( "tradein-browser[%s]: /fetch отказ — нет прокси в теле и нет env-прокси " "(prod) — не подключаемся напрямую с IP сервера (#2616)", provider, ) return web.json_response( {"error": "no proxy configured — refusing direct connection (prod)"}, status=503 ) 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, origin=origin, referer=referer, cookies=cookies, reuse_context=reuse_context, reset_context=reset_context, fetch_mode=fetch_mode, ) # Читаем статус под тем же локом, что и сам фетч, — иначе следующий # запрос этого провайдера успел бы его перезаписать. status = _last_response_status.get(provider) except Exception as exc: logger.error( "tradein-browser[%s]: fetch error url=%r: %s: %s", provider, url, type(exc).__name__, exc, ) error_body: dict = {"error": f"{type(exc).__name__}: {exc}"} if isinstance(exc, BanPageDetectedError): # #3239: бан-страница — ПОДТВЕРЖДЁННЫЙ маркер-детект, а не сбой # транспорта, и только за первым стоит report_ban на клиенте. # До этой правки оба случая приезжали одинаковой 500-кой, клиент # различить их не мог и настоящий отказ площадки переставал # ротировать узел (регрессия #3237). Код ответа НЕ меняем: на 500 # завязана classify_browser_probe, признак несёт тело. error_body["ban_page"] = True error_body["status"] = _last_response_status.get(provider) return web.json_response(error_body, status=500) # Аддитивно (#3196): ключ "html" на месте и не изменился — клиент, читающий # только его, ничего не заметит. "status" может быть null (goto вернул None). return web.json_response({"html": html, "status": status}) 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) # #2616 шаг 1: см. fetch_handler — прод + нет прокси вообще → отказ, не direct-IP. if IS_PROD and _no_live_proxy(provider, proxy_override): logger.warning( "tradein-browser[%s]: /fetch-json отказ — нет прокси в теле и нет env-прокси " "(prod) — не подключаемся напрямую с IP сервера (#2616)", provider, ) return web.json_response( {"error": "no proxy configured — refusing direct connection (prod)"}, status=503 ) 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() # ── Режимы получения тела (#3264) ──────────────────────────────────────────────── # Сайдкар всю жизнь умел ровно одно: navigate — page.goto(url). Для HTML-страницы это # и есть то, что делает человек. Но два нужных нам ресурса Домклика страницами НЕ # являются: BFF-ручка /api/offers/v1 отдаёт JSON, а карта офферов # (sitemap-offers-1.xml.gz) — вообще gzip-файл. Живой сайт их так и не запрашивает: # перехват сети на выдаче 30.08.2026 показал, что офферы приезжают в SSR-документе, а # к BFF ходят XHR'ы за гео/районами/метро. Навигацией браузера на API-хост мы делаем # то, чего настоящий клиент не делает никогда, — и через узлы пула это упирается в # ChallengeTimeout, тогда как карточки через те же узлы в те же минуты идут. # # Отсюда два дополнительных режима, оба — ПОСЛЕ прогрева origin: # subresource — context.request.get(url): запрос из browser-контекста, куки и прокси # общие со страницей, но это не JS страницы; # page_fetch — fetch(url, {credentials: 'include'}) ИЗ прогретой страницы: ровно то, # что делает SPA, вместе с Origin/Referer/Sec-Fetch-* и cookie jar'ом. # Какой из них проходит через пул — вопрос замера, а не рассуждения, поэтому оба. FETCH_MODES: frozenset[str] = frozenset({"navigate", "subresource", "page_fetch"}) def _decode_body(raw: bytes) -> str: """Тело подзапроса строкой: gzip-файлы распаковываем, прочее декодируем как UTF-8. Карта офферов приезжает с Content-Type: application/gzip — это gzip-ФАЙЛ, а не Content-Encoding, поэтому HTTP-стек его не распаковывает и вызывающий получил бы бинарь в поле "html". Распаковываем здесь по магическим байтам, чтобы контракт /fetch остался прежним — «тело строкой». """ if raw[:2] == bytes((0x1F, 0x8B)): # магия gzip try: raw = gzip.decompress(raw) except Exception: # noqa: BLE001 — битый gzip отдаём как есть, пусть решает caller pass return raw.decode("utf-8", "replace") async def _do_fetch( provider: str, url: str, *, origin: str | None = None, referer: str | None = None, cookies: dict | None = None, reuse_context: bool = False, reset_context: bool = False, fetch_mode: str = "navigate", ) -> str: """Одна попытка навигации; при краше браузера — relaunch и один retry. Caller держит _locks[provider] (нет параллельных страниц на этом инстансе), поэтому relaunch безопасен. Crash-relaunch сохраняет текущий прокси инстанса (_relaunch_browser reuse _launched_proxy) — динамический прокси пула не теряется. Crash-relaunch (через _close_browser) заодно выбрасывает переиспользуемый context провайдера (#3118) — на retry reuse_context=True создаст его заново с чистого листа, а не обратится к context'у уже мёртвого браузера. origin/referer/cookies/reuse_context/reset_context — см. _fetch_once. Дефолты не меняют поведение. """ try: return await _fetch_once( provider, url, origin=origin, referer=referer, cookies=cookies, reuse_context=reuse_context, reset_context=reset_context, fetch_mode=fetch_mode, ) 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, origin=origin, referer=referer, cookies=cookies, reuse_context=reuse_context, reset_context=reset_context, fetch_mode=fetch_mode, ) raise # ── QRATOR PoW-челлендж Авито (#3045) ──────────────────────────────────────────── # Маркеры сняты живьём с challenge-страницы Авито 2026-08-21 (замер: 6 карточек, # органическая навигация из выдачи, 4/6 ушли с челленджа раньше времени). Разметка # площадки может поменяться со временем — при протухании маркеров переснять их # заново вживую, а не гадать по памяти. Форма провайдер-агностична: детектор просто # ищет строки в HTML, другие площадки (cian/yandex/generic) их никогда не отдают, # поэтому ветка ожидания для них не включается. # Признак самого челленджа: JS-функция startPow(), которую страница вызывает в # DOMContentLoaded (см. хвост challenge-скрипта в #3045), либо заголовок блока # «Доступ ограничен: проверка безопасности» — оба встречались на снятых страницах. _CHALLENGE_MARKERS: tuple[str, ...] = ( "startpow", "доступ ограничен: проверка безопасности", ) # Признак БАН-страницы (не челлендж): «Доступ ограничен: проблема с IP» — статика # без PoW-скрипта, приходит с 403/429 и заметно меньше challenge-страницы весом. # Ждать тут бессмысленно — адрес заблокирован, а не временно проверяется. _BAN_MARKERS: tuple[str, ...] = ("доступ ограничен: проблема с ip",) # HTTP-статусы, которые означают отказ площадки, а не временную проверку. Домклик # отдаёт ровно 403 со статической страницей на 26 624 байта, где нет НИ одного # маркера из _CHALLENGE_MARKERS/_BAN_MARKERS (оба сняты с Авито) — замер прода # 28.08.2026, #3196. Такой ответ уезжал наверх как валидный HTML, парсер не находил # состояние страницы, и прогон получал блок неизвестной природы (ban_kind=unknown). # Статус — ДОПОЛНИТЕЛЬНЫЙ сигнал: ветки _is_ban_page/_is_pow_challenge остаются, они # ловят отказ, отданный с кодом 200 (так делает Авито). _REFUSAL_STATUSES: frozenset[int] = frozenset({403, 429}) def _status_of(response: object) -> int | None: """HTTP-статус playwright Response; None, если ответа нет или статус нечитаем. ``page.goto`` штатно возвращает None (редирект на тот же документ, навигационная гонка) — это не ошибка, просто статус неизвестен. """ if response is None: return None status = getattr(response, "status", None) return status if isinstance(status, int) else None class ChallengeTimeoutError(Exception): """PoW-челлендж не снялся за BROWSER_CHALLENGE_WAIT_MS. Caller должен трактовать как временный отказ (retry/backoff), НЕ как валидный контент — раньше caller получал 7891-байтную challenge-страницу и парсер либо падал на ней, либо молча ничего не находил (#3045). """ class BanPageDetectedError(Exception): """Площадка отдала бан-страницу («проблема с IP») вместо контента/челленджа. В отличие от ChallengeTimeoutError ждать здесь бессмысленно: адрес забанен, а не проходит временную проверку — поднимается сразу, без траты BROWSER_CHALLENGE_WAIT_MS. """ def _is_pow_challenge(html: str) -> bool: """True, если HTML — QRATOR PoW-челлендж Авито (см. _CHALLENGE_MARKERS).""" lower = html.lower() return any(marker in lower for marker in _CHALLENGE_MARKERS) def _is_ban_page(html: str) -> bool: """True, если HTML — бан-страница «проблема с IP» (см. _BAN_MARKERS).""" lower = html.lower() return any(marker in lower for marker in _BAN_MARKERS) # ── QRATOR-рукопожатие DomClick (#3196, замер прода 29.08.2026) ───────────────── # У DomClick, в отличие от Авито, у промежуточной (нерендеренной) PoW-страницы НЕТ # ни одного стабильного маркера вообще — ни __qrator, ни startpow, ни текста отказа # (замер: 3 запроса подряд на здоровом узле — 1-й вернул 401/6898 байт без единого # опознаваемого признака, 2-й и 3-й — уже 200/готовая карточка). Строить детектор # «это челлендж» тут не на чем: любой угаданный маркер протухнет на следующей смене # вёрстки, а до тех пор будет ловить призрак несуществующего состояния. # # Поэтому логика инвертирована: опознаём положительно два КРАЙНИХ состояния — # «точно готово» и «точно отказ», а всё остальное (загрузчик рукопожатия на 279 # байт, нерендеренная PoW-страница на ~6.9-7.1 КБ) считаем «рукопожатие ещё идёт» # и уходим в _wait_out_pow_challenge вместо того, чтобы гадать по размеру — # отказ у DomClick тоже статическая страница фиксированного размера (~26.6 КБ), # см. _DOMCLICK_REFUSAL_MARKERS, поэтому размер как единственный признак ненадёжен. # # Признак успеха — JS-глобаль __SSR_STATE__, литеральная строка, которую кладёт # в разметку SSR-рендер готовой карточки (та же строка, из-за отсутствия которой # парсер бэкфилла поднимал ложный «блок», см. #3196). Сайдкар в остальном # провайдер-агностичен и не обязан знать про внутренний контракт парсера, но # альтернатива — «отсутствие маркеров отказа + размер» — здесь СЛАБЕЕ, а не чище: # и загрузчик (279 байт), и промежуточная PoW-страница (~7 КБ), и будущая # перекроенная вёрстка отказа тоже прошли бы такую проверку как «успех». Прямой # маркер успеха — единственный вариант, который не размывается на промежуточных # состояниях. _DOMCLICK_SUCCESS_MARKER = "__SSR_STATE__" # Признак ОТКАЗА площадки (не рукопожатия) — статическая страница «403 | Домклик» # с текстом про подозрительный запрос, ~26.6 КБ, приходит с HTTP 401 (см. # _REFUSAL_STATUSES ниже — статус НЕ используется как сигнал для DomClick, тело # приходит с тем же кодом, что и у здорового рукопожатия и у одной из успешных # страниц в замере). Ждать тут нечего — статическая страница сама себя не # перезагрузит. _DOMCLICK_REFUSAL_MARKERS: tuple[str, ...] = ( "похоже, ваш запрос выглядит необычно", "403 | домклик", ) def _is_domclick_success(html: str) -> bool: """True, если HTML — отрендеренная карточка DomClick (см. _DOMCLICK_SUCCESS_MARKER).""" return _DOMCLICK_SUCCESS_MARKER in html def _is_domclick_refusal(html: str) -> bool: """True, если HTML — статический отказ площадки DomClick (см. _DOMCLICK_REFUSAL_MARKERS).""" lower = html.lower() return any(marker in lower for marker in _DOMCLICK_REFUSAL_MARKERS) # Маркеры исключения playwright «страница прямо сейчас перезагружается». Ловим по # тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит # уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для # этого случая. _NAVIGATION_RACE_MARKERS: tuple[str, ...] = ( "execution context was destroyed", "most likely because of a navigation", "page is navigating", ) async def _content_during_navigation(page: object) -> str | None: """`page.content()`, устойчивый к перезагрузке страницы под руками. PoW-челлендж перезагружает себя сам (`window.location = location.href`), и вызов content(), попавший ровно в этот момент, кидает «Execution context was destroyed». Для нас это НЕ ошибка, а признак того, что перезагрузка — та самая, которую мы ждём, — идёт прямо сейчас. Возвращаем None = «ещё не устоялось, опроси снова», а не роняем фетч на самом успешном исходе. Всё остальное (закрытая страница, упавший браузер) пробрасываем как есть. """ try: return await page.content() # type: ignore[attr-defined] except Exception as exc: text = str(exc).lower() if any(marker in text for marker in _NAVIGATION_RACE_MARKERS): return None raise async def _wait_out_pow_challenge( page: object, provider: str, url: str, *, is_pending: Callable[[str], bool] = _is_pow_challenge, ) -> str: """Опрашивает page.content() пока `is_pending(html)` не станет False. Страница ОБЫЧНО перезагружает себя сама (`window.location = location.href`) после решения PoW — URL не меняется, поэтому page.wait_for_url тут не годится; опрашиваем контент с шагом ~1с вместо этого. По истечении BROWSER_CHALLENGE_WAIT_MS — ChallengeTimeoutError, а не тихая отдача challenge-страницы как будто это валидный контент. «Обычно» — не «всегда», и на этом мы горели: ручная сессия 2026-08-29 показала выдачу Домклика, которая после решения PoW осталась на 401 и отдала контент лишь после двух перезагрузок, сделанных человеком руками. Пока мы только опрашивали content(), такая страница гарантированно доживала до таймаута. Поэтому после BROWSER_CHALLENGE_RELOAD_AFTER_MS безрезультатного опроса перезагружаем сами, до BROWSER_CHALLENGE_MAX_RELOADS раз — ровно то действие, которым человек и вышел из этого состояния. Reload не отменяет ожидания: опрос продолжается в том же бюджете. `is_pending` (дефолт `_is_pow_challenge`, поведение Авито и прочих провайдеров не меняется) — предикат «страница ещё не устоялась, опроси снова». DomClick передаёт свой (см. _fetch_once) — там нет отдельного маркера самого челленджа, есть только «точно успех» / «точно отказ» (#3196), поэтому pending = «ни то, ни другое». После снятия челленджа даём странице догидрироваться тем же BROWSER_WAIT_MS, каким ждём обычную навигацию (второй таймаут не изобретаем). """ poll_interval_ms = 1000 elapsed_ms = 0 reloads = 0 next_reload_at_ms = BROWSER_CHALLENGE_RELOAD_AFTER_MS html: str | None = await _content_during_navigation(page) while (html is None or is_pending(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS: await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined] elapsed_ms += poll_interval_ms if ( reloads < BROWSER_CHALLENGE_MAX_RELOADS and BROWSER_CHALLENGE_RELOAD_AFTER_MS > 0 and elapsed_ms >= next_reload_at_ms ): reloads += 1 next_reload_at_ms = elapsed_ms + BROWSER_CHALLENGE_RELOAD_AFTER_MS logger.info( "tradein-browser[%s]: челлендж висит %dмс — перезагружаю страницу " "(%d/%d) url=%r", provider, elapsed_ms, reloads, BROWSER_CHALLENGE_MAX_RELOADS, url, ) try: await page.reload( # type: ignore[attr-defined] timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded" ) except Exception as exc: # Перезагрузка — попытка помочь, а не обязательный шаг. Упала (гонка # с собственным reload'ом страницы, таймаут навигации) — продолжаем # опрос в прежнем бюджете, а не роняем фетч, который мог бы устояться. logger.warning( "tradein-browser[%s]: перезагрузка челленджа не удалась (%s: %s) — " "продолжаю опрос", provider, type(exc).__name__, exc, ) html = await _content_during_navigation(page) if html is None or is_pending(html): raise ChallengeTimeoutError( f"tradein-browser[{provider}]: PoW-челлендж не снялся за " f"{BROWSER_CHALLENGE_WAIT_MS}мс url={url!r}" ) logger.info( "tradein-browser[%s]: PoW-челлендж снят за ~%dмс, догидрация url=%r", provider, elapsed_ms, url, ) if BROWSER_WAIT_MS > 0: await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined] settled = await _content_during_navigation(page) if settled is None: # Догидрация совпала с ещё одной навигацией — даём один короткий добор # вместо того, чтобы падать: контент уже не challenge, гонка чисто # техническая. await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined] settled = await _content_during_navigation(page) if settled is None: raise ChallengeTimeoutError( f"tradein-browser[{provider}]: челлендж снят, но страница не устоялась " f"(навигация не прекращается) url={url!r}" ) return settled def _cookie_domain(url: str) -> str: """Домен для инъекции кук: РЕГИСТРИРУЕМЫЙ, а не хост из url. Раньше здесь было ``f".{hostname}"``, то есть для карточки ``ekaterinburg.domclick.ru`` куки ложились на ``.ekaterinburg.domclick.ru``. Настоящие куки площадки живут на ``.domclick.ru`` — это видно в записи ручной сессии 29.08.2026 (``.domclick.ruqrator_jsid2``, ``.domclick.ruqrator_jsr``). Куки поддомена родительские НЕ заменяют, а сосуществуют с ними: как только площадка выдаёт свежий ``qrator_jsid2`` на ``.domclick.ru``, браузер шлёт в одном запросе ДВЕ куки с этим именем, и первой — более специфичную, нашу протухшую. QRATOR читает её и держит страницу на 274-байтном загрузчике, рукопожатие не завершается никогда. Замер 29.08 через узел 10, чередование, свежий контекст на пробу: куки на поддомене — 0 успехов из 3 (все три застряли на 274 байтах), те же куки на ``.domclick.ru`` — 3 из 3. Ограничение сознательное: берём два последних лейбла. Для доменов вида ``example.co.uk`` это дало бы ``.co.uk``, но все площадки проекта — в зонах первого уровня (domclick.ru, avito.ru, cian.ru, yandex.ru), а тащить сюда publicsuffix-список ради гипотетического случая дороже, чем он стоит. Хост из одного-двух лейблов возвращаем как есть. """ host = urlparse(url).hostname or "" labels = host.split(".") if len(labels) > 2: host = ".".join(labels[-2:]) return f".{host}" async def _get_or_create_context( provider: str, browser: object, cookies: dict | None, url: str, ) -> object: """Возвращает сохранённый переиспользуемый context провайдера либо создаёт новый (#3118). Куки вливаются ТОЛЬКО в момент создания — в уже существующий (сохранённый) context add_cookies больше НИКОГДА не вызывается: повторная заливка замороженного снимка из БД убивала бы живой qrator_jsid2, который площадка успела выдать через Set-Cookie, — ровно это и было причиной бана DomClick detail-бэкфилла. """ existing = _contexts.get(provider) if existing is not None: return existing context = await browser.new_context() # type: ignore[attr-defined] if cookies: cookie_domain = _cookie_domain(url) await context.add_cookies( # type: ignore[attr-defined] [ {"name": name, "value": value, "domain": cookie_domain, "path": "/"} for name, value in cookies.items() ] ) _contexts[provider] = context logger.info("tradein-browser[%s]: создан переиспользуемый browser context (#3118)", provider) return context async def _fetch_subresource( page: object, url: str, fetch_mode: str, referer: str | None ) -> tuple[bytes, int]: """Забирает *url* подзапросом из прогретой страницы. Возвращает (тело, HTTP-статус). Два режима, отличающихся тем, ЧЬИМ запросом идёт обращение: ``subresource`` — ``context.request.get``: запрос браузерного контекста. Куки, прокси и TLS общие со страницей, но это не JS страницы, поэтому заголовков ``Sec-Fetch-*`` и ``Origin`` у него нет. ``page_fetch`` — ``fetch()``, выполненный ВНУТРИ страницы. Ровно то, что делает SPA: с ``Origin``/``Referer``/``Sec-Fetch-*`` и общим cookie jar'ом. Тело возвращается через base64, потому что ``page.evaluate`` умеет отдавать только JSON-совместимое, а карта офферов — двоичный gzip. """ if fetch_mode == "subresource": headers = {"Referer": referer} if referer else None resp = await page.context.request.get( # type: ignore[attr-defined] url, timeout=BROWSER_NAV_TIMEOUT_MS, headers=headers ) return await resp.body(), int(resp.status) if fetch_mode == "page_fetch": res = await page.evaluate( # type: ignore[attr-defined] """async (u) => { const r = await fetch(u, {credentials: 'include'}); const buf = new Uint8Array(await r.arrayBuffer()); let s = ''; for (let i = 0; i < buf.length; i++) s += String.fromCharCode(buf[i]); return {status: r.status, b64: btoa(s)}; }""", url, ) return base64.b64decode(res["b64"]), int(res["status"]) raise ValueError(f"неизвестный fetch_mode={fetch_mode!r}, допустимы {sorted(FETCH_MODES)}") async def _fetch_once( provider: str, url: str, *, origin: str | None = None, referer: str | None = None, cookies: dict | None = None, reuse_context: bool = False, reset_context: bool = False, fetch_mode: str = "navigate", ) -> str: """Открывает страницу, переходит по URL, ждёт JS, возвращает HTML. Caller держит _locks[provider], поэтому страницы на этом инстансе не параллелятся — recycle через _relaunch_browser безопасен прямо здесь. origin (опционально) — same-site якорь (например SERP), на который камуфокс заходит ПЕРЕД целевым url, чтобы получить пропуск QRATOR в контексте (зеркалит _fetch_json_once, #1917 — DomClick card-fetch, эмпирически подтверждено вживую 2026-07-04). Работает это через КУКИ контекста, поэтому при reuse_context origin поднимается ОДИН раз в якорной вкладке (_ensure_anchor_page), а не переоткрывается перед каждой карточкой: контекст уже прогрет, повторный заход — трата рукопожатия. None (дефолт) → поведение не меняется, ровно один goto(url) как раньше (avito/cian/yandex не передают origin). referer (опционально) — HTTP-заголовок Referer, передаётся playwright'у ТОЛЬКО для целевой навигации ``goto(url, referer=...)`` — площадка видит переход "пришёл из выдачи", а не голый заход прямо на URL карточки (#3247). На origin/якорную вкладку referer НЕ передаётся: туда камуфокс приходит "сам", без источника. None (дефолт) → page.goto(url) без referer, поведение идентично прежнему. cookies (опционально) — dict cookie_name→value для инъекции ДО любой навигации (обход QRATOR-блока DomClick при валидной test-аккаунт сессии, эмпирически подтверждено вживую 2026-07-04). Провайдер-агностично: домен НЕ захардкожен, а выводится из hostname целевого url (с ведущей точкой — покрывает поддомены, зеркалит реальную DomClick cookie-scope ".domclick.ru" для ekaterinburg.domclick.ru/spb.domclick.ru/etc, но работает для любого хоста, не только DomClick) — механизм пригоден для будущих caller'ов (Cian/Avito/Yandex). При reuse_context=False (дефолт) вливаются в КАЖДУЮ новую страницу, как раньше. При reuse_context=True — ТОЛЬКО в момент создания переиспользуемого context'а (см. _get_or_create_context); в уже существующий context повторно не вливаются никогда. None (дефолт) → без инъекции, поведение не меняется. reuse_context (#3118, опционально, дефолт False) — держит per-provider BrowserContext живым между вызовами вместо browser.new_page() (который создаёт НОВЫЙ изолированный context на КАЖДЫЙ запрос и убивает cookie-jar — в т.ч. живой qrator_jsid2 DomClick, ~2.5ч TTL, ротируемый площадкой через Set-Cookie). Страница создаётся ИЗ этого context'а и закрывается после отдачи HTML, сам context остаётся жить. False (дефолт) → browser.new_page() как раньше — avito/cian/yandex/generic его никогда не включают, поведение не меняется ни на байт. reset_context (#3118, опционально, дефолт False) — закрывает и выбрасывает сохранённый context провайдера ДО работы (следующий reuse_context=True создаст его заново, с чистым cookie-jar). Вызывающая сторона использует его ровно один раз на обнаруженный блок, не на каждый последующий запрос. """ browser = _browsers.get(provider) assert browser is not None, "browser not launched" # Гасим статус прошлой навигации ДО работы: если goto упадёт, наверх не должен # уехать статус предыдущей страницы этого же провайдера (#3196). _last_response_status[provider] = None if reset_context: await _close_reusable_context(provider) if reuse_context: context = await _get_or_create_context(provider, browser, cookies, url) page = await context.new_page() # type: ignore[attr-defined] else: page = await browser.new_page() # type: ignore[attr-defined] if cookies: cookie_domain = _cookie_domain(url) await page.context.add_cookies( # type: ignore[attr-defined] [ {"name": name, "value": value, "domain": cookie_domain, "path": "/"} for name, value in cookies.items() ] ) try: await _apply_resource_block(page) await _pace_provider(provider) if origin: anchored = False if reuse_context: # Якорь держим ОТКРЫТОЙ вкладкой, а не переоткрываем перед каждой # карточкой. Так ходит человек: список остаётся открытым, объявления # открываются из него. Замер 29.08.2026 — вчетверо быстрее на карточку. anchored = await _ensure_anchor_page(provider, origin) if not anchored: # Нет переиспользуемого context'а (avito/cian/yandex/generic) либо # якорь не поднялся — поведение ровно как до правки. await page.goto( # type: ignore[attr-defined] origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded" ) await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] if fetch_mode != "navigate": # Ресурс берём ПОДЗАПРОСОМ из уже прогретого контекста, а не навигацией # (#3264). Прогрев origin выше обязателен: именно он проносит рукопожатие # QRATOR в куки контекста, из которого пойдёт подзапрос. body_bytes, status = await _fetch_subresource(page, url, fetch_mode, referer) _last_response_status[provider] = status text = _decode_body(body_bytes) if _is_ban_page(text): raise BanPageDetectedError( f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}" ) if provider == "domclick" and _is_domclick_refusal(text): raise BanPageDetectedError( f"tradein-browser[{provider}]: статический отказ площадки url={url!r}" ) logger.info( "tradein-browser[%s]: %s → HTTP %s, тело %d Б url=%r", provider, fetch_mode, status, len(text), url, ) return text # Статус берём у ЦЕЛЕВОЙ навигации, а не у прогрева origin выше: интересует # ответ площадки на запрошенный url (#3196). goto_kwargs: dict[str, object] = { "timeout": BROWSER_NAV_TIMEOUT_MS, "wait_until": "domcontentloaded", } if referer: goto_kwargs["referer"] = referer response = await page.goto(url, **goto_kwargs) # type: ignore[attr-defined] _last_response_status[provider] = _status_of(response) 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] # Бан-страница («проблема с IP») распознаётся и падает СРАЗУ, без траты # BROWSER_CHALLENGE_WAIT_MS — ждать там нечего, адрес заблокирован (#3045). if _is_ban_page(html): raise BanPageDetectedError( f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}" ) if provider == "domclick": # DomClick — своя ветка (#3196): нет отдельного маркера самого # рукопожатия (см. комментарий у _DOMCLICK_SUCCESS_MARKER), поэтому # статус НЕ смотрим вовсе (в отличие от общей ветки ниже) — 401 # приходит и у здорового рукопожатия, и у отказа, и у успешной # страницы в замере 29.08.2026, различать нужно ТОЛЬКО по телу. if _is_domclick_refusal(html): raise BanPageDetectedError( f"tradein-browser[{provider}]: статический отказ площадки " f"url={url!r}" ) if not _is_domclick_success(html): html = await _wait_out_pow_challenge( page, provider, url, is_pending=lambda h: not _is_domclick_success(h) and not _is_domclick_refusal(h), ) if _is_domclick_refusal(html): # Рукопожатие домоталось до отказа уже ПОСЛЕ начала опроса — # is_pending вернул False (страница устоялась), но устоялась # она в состояние «отказ», а не «успех». Наверх идёт то же # исключение, что и для мгновенного отказа выше. raise BanPageDetectedError( f"tradein-browser[{provider}]: отказ площадки после " f"ожидания рукопожатия url={url!r}" ) # PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его # прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент. elif _is_pow_challenge(html): status = _last_response_status.get(provider) if status in _REFUSAL_STATUSES: # Маркеры челленджа при 403/429 — это отказ, а не проверка: # статическая страница сама себя не перезагрузит, ждать нечего. # Наверх идёт ИСКЛЮЧЕНИЕ, а не заглушка: вернув её как валидный # контент, мы бы воскресили регрессию #3045 — авитовская бан-страница # приходит ровно с 403/429 (см. _BAN_MARKERS выше), и парсер получил # бы 7891-байтный челлендж вместо карточки. Клиент уже трактует # BanPageDetectedError как блок. Статус НЕ добавляем 401 сюда — # у DomClick (единственного, кто отдаёт 401) отказ распознаётся # выше по телу, а не по коду, см. ветку provider == "domclick". raise BanPageDetectedError( f"tradein-browser[{provider}]: HTTP {status} + маркеры челленджа " f"— отказ площадки, ждать нечего url={url!r}" ) html = await _wait_out_pow_challenge(page, provider, url) 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], ) recycle_pages = _RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK) if _page_counters[provider] >= recycle_pages: logger.info( "tradein-browser[%s]: recycle threshold (%d) достигнут, перезапуск браузера", provider, 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 ) # #2676 обрабатывается ВНУТРИ _fetch_json_once (повтор evaluate на той же # странице). Повторять здесь, на свежей странице, бесполезно: пересоздание # воспроизводит ту же первую навигацию origin'а — проверено на проде 06.08, # обе попытки упали одинаково. raise # In-page retry самого fetch() при СЕТЕВОМ throw (TypeError/"NetworkError"), #1917: # первый XHR после навигации иногда падает до готовности стека. Повтор внутри страницы # (~retryDelayMs) дешевле полной ре-навигации (~30-45с/дом). HTTP-статусы (4xx/5xx) — # это успешный resp, НЕ ретраим (решает caller). _IN_PAGE_FETCH_JS = """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; }""" async def _wait_for_load_best_effort(page: object, provider: str) -> None: """Ждёт `load`, но не даёт ожиданию сорвать саму попытку (реклама/трекеры могут держать страницу «загружающейся» бесконечно). Таймаут логируется, не пробрасывается. """ try: await page.wait_for_load_state("load", timeout=FETCH_JSON_LOAD_WAIT_MS) # type: ignore[attr-defined] except Exception as exc: logger.info( "tradein-browser[%s]: load не дождались (%s), пробуем evaluate как есть", provider, type(exc).__name__, ) 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] payload = { "url": url, "method": method, "headers": headers or {}, "body": body, "retries": FETCH_JSON_INPAGE_RETRIES, "retryDelayMs": FETCH_JSON_RETRY_DELAY_MS, } try: result: dict = await page.evaluate(_IN_PAGE_FETCH_JS, payload) # type: ignore[attr-defined] except Exception as exc: if not _is_page_context_lost(exc): raise # #2676, живой прод 06.08: страница origin уходит в клиентскую навигацию # ПОСЛЕ load, поэтому ни settle, ни ожидание load её не опережают, а повтор # с новой страницей воспроизводит ту же первую навигацию (проверено на # проде: две попытки подряд — та же ошибка). Навигация при этом ОДНА: # к моменту исключения она уже случилась и у страницы НОВЫЙ контекст. # Поэтому повторяем evaluate на ЭТОЙ же странице — стоит миллисекунды. logger.warning( "tradein-browser[%s]: контекст страницы пересоздан навигацией, " "повтор evaluate на той же странице: %s", provider, url, ) await _wait_for_load_best_effort(page, provider) await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] result = await page.evaluate(_IN_PAGE_FETCH_JS, payload) # 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-json OK url=%r status=%s pages_since_launch=%d", provider, url, result.get("status"), _page_counters[provider], ) recycle_pages = _RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK) if _page_counters[provider] >= recycle_pages: logger.info( "tradein-browser[%s]: recycle threshold (%d) достигнут, перезапуск браузера", provider, 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 ) # Литерал playwright, а не строка из нашего лога: driver 1.60.0 (в образе сайдкара) # бросает ровно «Execution context was destroyed» / «... , most likely because of a # navigation.» — обе формы начинаются одинаково, поэтому хватает одного маркера. # Проверено grep'ом по playwright/driver/package/lib/coreBundle.js в живом контейнере. _PAGE_CONTEXT_LOST_MARKER = "execution context was destroyed" def _is_page_context_lost(exc: BaseException) -> bool: """Страница потеряла JS-контекст (ушла в навигацию между goto и evaluate), #2676. НЕ краш браузера: инстанс жив, потеряна одна страница. Поэтому обрабатывается отдельно от _is_browser_crash — relaunch здесь стоил бы ~10-20с и тёплый профиль (cookies/фингерпринт инстанса) ради браузера, с которым всё в порядке. """ return _PAGE_CONTEXT_LOST_MARKER in str(exc).lower() # ── 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":

, "interval_s": } → обновляет 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"]) # #2616 шаг 1: см. fetch_handler — прод + нет env-прокси (login override не берёт) → # отказ, не direct-IP. if IS_PROD and _no_live_proxy(provider, None): logger.warning( "tradein-browser[%s]: /login отказ — нет env-прокси (prod) — " "не подключаемся напрямую с IP сервера (#2616)", provider, ) return web.json_response( {"error": "no proxy configured — refusing direct connection (prod)"}, status=503 ) 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 recycle_pages = _RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK) if _page_counters[provider] >= recycle_pages: logger.info( "tradein-browser[%s]: recycle threshold (%d) достигнут после login, перезапуск", provider, 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)