gendesign/tradein-mvp/browser/server.py
bot-backend 1200af58f5
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m31s
CI Trade-In / backend-tests (pull_request) Successful in 4m58s
fix(tradein): сайдкар отдаёт подтверждённый бан площадки кодом 403, не 500
Единственный код 500 означал и «площадка забанила», и «сайдкар упал»:
разбор каждого инцидента начинался с ложного следа — в лог провайдера и в
houses.imv_error_reason уезжала httpx-преамбула «Server error '500 Internal
Server Error' for url 'http://tradein-browser:3000/fetch'», то есть текст
ошибки называл гонца, а не виновника.

- browser/server.py: BanPageDetectedError → 403 (доступ ограничен площадкой);
  451 — про юридическую блокировку, это не она. Своих 403 сайдкар не отдаёт
  (400/422/503), код однозначен. classify_browser_probe не задета: у неё любой
  status >= 400 → "sidecar". Тело не меняется — ban_page/status на месте.
- scraper_kit/browser_fetcher.py: текст SidecarBanPageError теперь свой —
  «площадка отдала бан-страницу (upstream 403, ответ сайдкара 403): …».
  Распознавание остаётся по ТЕЛУ и code-agnostic: tradein-browser — отдельный
  образ со своим деплоем, версии штатно расходятся на часы, и гейт по коду в
  этот час уводил бы отказ площадки в инфра-ветку.
- Тесты: 403 → SidecarBanPageError; старый 500 + ban_page → он же; чистый 500
  без ban_page → прежний инфра-диагноз; текст ошибки без «500»/«Server error».

Refs #3288 п.4
2026-09-06 01:58:02 +05:00

2944 lines
180 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""server.py — tradein-browser service main process.
Этот модуль запускается как точка входа контейнера ``tradein-browser``.
Он запускает camoufox **локально** внутри контейнера (AsyncCamoufox) и
экспонирует простой HTTP API на базе aiohttp:
GET /health → {"status": "ok", "browsers": {"avito": bool, ...}}
POST /fetch → {"url","origin"(опц.),"referer"(опц.),"cookies"(опц.)} → {"html": "...",
"status": <int|null>} # 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.451.60):
``browserServerImpl.js`` отсутствует в дистрибутиве → контейнер краш-лупится
с MODULE_NOT_FOUND / RuntimeError при запуске через ``launch_server``.
Локальный запуск ``AsyncCamoufox`` работает стабильно — проверено.
Per-provider модель (#1793):
Вместо одного глобального браузера + одного egress-прокси держим ОТДЕЛЬНЫЙ
инстанс camoufox на каждого поставщика (avito/cian/yandex/generic). Поставщик
определяется по host URL запроса (или явному полю body["provider"]/["source"]). Каждый
инстанс имеет 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
import random
import re
from collections.abc import Callable, Mapping
from typing import NamedTuple
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"))
# ── Имитация чтения человеком (#3283, opt-in через body["behaviour"]) ───────────
# Все константы ниже включаются ТОЛЬКО явным behaviour.scroll/dwell_ms/hop_recommended
# в теле /fetch — по умолчанию (behaviour отсутствует/пуст) ни одна из них не читается
# ни разу, поведение сайдкара байт-в-байт то же, что и до #3283. Это осознанное
# требование задачи: вклад имитации в проходимость анти-бота пока НЕ доказан, есть
# только косвенные основания, и его нужно честно померить прод-A/B "с behaviour vs
# без", а не вкатывать по умолчанию.
#
# Число и разброс прокруток (500-1400px, 3-6 раз) и пауза между ними (700-1800мс) —
# из замера, которым это поведение и мерили (см. PR/issue #3283), не придуманы здесь.
BROWSER_SCROLL_MIN_COUNT: int = int(os.environ.get("BROWSER_SCROLL_MIN_COUNT", "3"))
BROWSER_SCROLL_MAX_COUNT: int = int(os.environ.get("BROWSER_SCROLL_MAX_COUNT", "6"))
BROWSER_SCROLL_MIN_PX: int = int(os.environ.get("BROWSER_SCROLL_MIN_PX", "500"))
BROWSER_SCROLL_MAX_PX: int = int(os.environ.get("BROWSER_SCROLL_MAX_PX", "1400"))
BROWSER_SCROLL_PAUSE_MIN_MS: int = int(os.environ.get("BROWSER_SCROLL_PAUSE_MIN_MS", "700"))
BROWSER_SCROLL_PAUSE_MAX_MS: int = int(os.environ.get("BROWSER_SCROLL_PAUSE_MAX_MS", "1800"))
# hop_recommended (#3283): отдельный, НАМЕРЕННО короткий таймаут навигации на
# рекомендательную ссылку — не наследует BROWSER_NAV_TIMEOUT_MS целиком, чтобы один
# залипший побочный заход не съедал бюджет основного /fetch. И пауза "подержать
# страницу пару секунд" перед закрытием.
BROWSER_HOP_NAV_TIMEOUT_MS: int = int(os.environ.get("BROWSER_HOP_NAV_TIMEOUT_MS", "15000"))
BROWSER_HOP_DWELL_MIN_MS: int = int(os.environ.get("BROWSER_HOP_DWELL_MIN_MS", "1500"))
BROWSER_HOP_DWELL_MAX_MS: int = int(os.environ.get("BROWSER_HOP_DWELL_MAX_MS", "3000"))
# 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")
# Ссылки для behaviour.hop_recommended (#3283) — per-provider, тем же механизмом,
# что PROVIDERS/_provider_for_url, БЕЗ хардкода площадки в общем коде хопа
# (см. _hop_to_recommended_link).
#
# ДВЕ ступени, потому что одной CSS-селектора мало: он сужает до нужного раздела,
# а отличить карточку объявления от рубрики/пагинации/футера умеет только шаблон
# самого URL. Без второй ступени "a[href]" на карточке Авито подберёт навигацию,
# хлебные крошки и футер — то есть хоп уводил бы не на соседнее объявление, а на
# главную, и смысл следа терялся бы.
#
# Шаблон Авито снят на живом замере 31.08.2026 (scratchpad/lifetime.py, 39 карточек):
# ссылка на объявление о квартире — путь с /kvartiry/ и id из 7+ цифр после "_" в
# конце (возможен ?context=...). Домклик — по логам сайдкара того же дня:
# /card/sale__flat__<id>.
#
# cian/yandex/generic шаблона НЕ имеют осознанно: их вёрстку я не снимал, а
# выдуманный шаблон хуже отсутствующего — при None вторая ступень не применяется
# и работает прежнее поведение (любая ссылка того же хоста).
_RECOMMENDATION_LINK_SELECTOR_BY_PROVIDER: dict[str, str] = {p: "a[href]" for p in PROVIDERS}
_RECOMMENDATION_LINK_SELECTOR_BY_PROVIDER["avito"] = 'a[href*="/kvartiry/"]'
_RECOMMENDATION_LINK_SELECTOR_BY_PROVIDER["domclick"] = 'a[href*="/card/"]'
_RECOMMENDATION_LINK_PATTERN_BY_PROVIDER: dict[str, re.Pattern[str]] = {
"avito": re.compile(r"_\d{7,}(?:\?|$)"),
"domclick": re.compile(r"/card/sale__flat__\d+"),
}
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"))
# Джиттер поверх интервала пейсинга (#3283) — сейчас пейсинг всегда ждёт РОВНО
# BROWSER_MIN_PAGE_INTERVAL_S, без разброса, что само по себе регулярный,
# бото-подобный ритм запросов. Дефолт 0 (БЕЗ джиттера) — намеренно: как и
# behaviour.* в /fetch, вклад разброса интервала в проходимость анти-бота отдельно
# не измерен, а включение по умолчанию сломало бы ту же прод-измеримость "с
# джиттером vs без", которую задача защищает для behaviour. Включается явно через
# env, без релиза кода — 0 сохраняет прежнее поведение _pace_provider буквально
# (interval + random.uniform(0, 0) == interval).
BROWSER_PAGE_INTERVAL_JITTER_S: float = float(
os.environ.get("BROWSER_PAGE_INTERVAL_JITTER_S", "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": <int|null>}
Выполняет навигацию в браузере поставщика и возвращает полный 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,
)
# behaviour (опционально, #3283) — имитация чтения страницы человеком: scroll,
# dwell_ms, hop_recommended. Отсутствует/пуст → behaviour остаётся None и ни
# ОДНА строка нового кода (_apply_scroll_behaviour/_apply_dwell_behaviour/
# _hop_to_recommended_link) не исполняется — /fetch ведёт себя байт-в-байт как до
# #3283. Это намеренно: вклад имитации в проходимость анти-бота пока НЕ доказан,
# и его нужно честно померить прод-A/B "с behaviour vs без" — включать что-либо
# из этого по умолчанию значило бы сломать именно этот замер.
behaviour_raw = body.get("behaviour") or {}
if not isinstance(behaviour_raw, dict):
return web.json_response(
{"error": f"'behaviour' должен быть object, получено "
f"{type(behaviour_raw).__name__}"},
status=400,
)
behaviour: _Behaviour | None = None
if behaviour_raw:
dwell_ms_raw = behaviour_raw.get("dwell_ms")
dwell_ms: tuple[int, int] | None = None
if dwell_ms_raw is not None:
is_valid_pair = (
isinstance(dwell_ms_raw, (list, tuple))
and len(dwell_ms_raw) == 2
and all(isinstance(v, int) and not isinstance(v, bool) for v in dwell_ms_raw)
and dwell_ms_raw[0] >= 0
and dwell_ms_raw[1] >= 0
and dwell_ms_raw[0] <= dwell_ms_raw[1]
)
if not is_valid_pair:
return web.json_response(
{"error": f"'behaviour.dwell_ms' должен быть [min, max] — два int "
f">= 0, min <= max, получено {dwell_ms_raw!r}"},
status=400,
)
dwell_ms = (int(dwell_ms_raw[0]), int(dwell_ms_raw[1]))
behaviour = _Behaviour(
scroll=bool(behaviour_raw.get("scroll", False)),
dwell_ms=dwell_ms,
hop_recommended=bool(behaviour_raw.get("hop_recommended", False)),
)
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,
behaviour=behaviour,
)
# Читаем статус под тем же локом, что и сам фетч, — иначе следующий
# запрос этого провайдера успел бы его перезаписать.
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).
#
# #3288 п.4: код ответа теперь РАЗНЫЙ. 500 означало и «площадка
# забанила», и «сайдкар упал», поэтому разбор каждого инцидента
# начинался с ложного следа («Server error 500 for url
# tradein-browser:3000/fetch» в логе провайдера). 403 —
# «в доступе отказано», ровно то, что произошло НА ПЛОЩАДКЕ;
# 451 семантически про юридическую блокировку, это не она.
# Своих 403 сайдкар не отдаёт (auth/валидация — 400/422/503),
# так что код однозначен. classify_browser_probe не задет: у неё
# любой status >= 400 → "sidecar", 403 и 500 там неразличимы.
# Признак ban_page в теле ОСТАЁТСЯ — клиент опознаёт бан по нему,
# а не по коду (сайдкар и backend — разные образы, версии едут
# врозь).
error_body["ban_page"] = True
error_body["status"] = _last_response_status.get(provider)
return web.json_response(error_body, status=403)
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.
Джиттер (#3283) — BROWSER_PAGE_INTERVAL_JITTER_S добавляется К интервалу на КАЖДЫЙ
вызов (``random.uniform(0, JITTER_S)``), а не в остаток ожидания: ровный интервал —
сам по себе регулярный, бото-подобный ритм. Дефолт джиттера 0 → uniform(0, 0) == 0.0
→ interval не меняется, поведение функции идентично прежнему.
"""
base_interval = _MIN_PAGE_INTERVAL_BY_PROVIDER.get(provider, BROWSER_MIN_PAGE_INTERVAL_S)
if base_interval <= 0:
return
interval = base_interval + random.uniform(0, BROWSER_PAGE_INTERVAL_JITTER_S)
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()
class _Behaviour(NamedTuple):
"""Разобранное и провалидированное тело body["behaviour"] (#3283, /fetch).
Собирается ОДИН раз в fetch_handler; None на всём пути ниже (_do_fetch/_fetch_once)
означает «behaviour отсутствовал/пуст» — ни одна из функций _apply_scroll_behaviour/
_apply_dwell_behaviour/_hop_to_recommended_link не вызывается, поведение то же, что
и до #3283.
"""
scroll: bool
dwell_ms: tuple[int, int] | None
hop_recommended: bool
async def _apply_scroll_behaviour(page: object, behaviour: _Behaviour) -> None:
"""3-6 прокруток page.mouse.wheel(0, 500-1400px) с паузами 700-1800мс (behaviour.scroll).
No-op, если behaviour.scroll не выставлен явным True. Числа — именованные модульные
константы BROWSER_SCROLL_* (см. их комментарий рядом с BROWSER_WAIT_MS), не хардкод
здесь. Не оборачиваем в try/except: вызывается ТОЛЬКО при явном opt-in, отказ здесь
(например, страница уже начала закрываться) — тот же класс сбоя навигации, что и
остальной _fetch_once, caller (_do_fetch) уже умеет его обрабатывать.
"""
if not behaviour.scroll:
return
count = random.randint(BROWSER_SCROLL_MIN_COUNT, BROWSER_SCROLL_MAX_COUNT)
for _ in range(count):
delta = random.randint(BROWSER_SCROLL_MIN_PX, BROWSER_SCROLL_MAX_PX)
await page.mouse.wheel(0, delta) # type: ignore[attr-defined]
pause_ms = random.randint(BROWSER_SCROLL_PAUSE_MIN_MS, BROWSER_SCROLL_PAUSE_MAX_MS)
await page.wait_for_timeout(pause_ms) # type: ignore[attr-defined]
async def _apply_dwell_behaviour(page: object, behaviour: _Behaviour) -> None:
"""Дополнительная случайная пауза [min, max] мс перед content() (behaviour.dwell_ms).
No-op, если dwell_ms не передан. Диапазон уже провалидирован в fetch_handler
(два int >= 0, min <= max) — здесь просто random.randint по нему.
"""
if behaviour.dwell_ms is None:
return
low, high = behaviour.dwell_ms
await page.wait_for_timeout(random.randint(low, high)) # type: ignore[attr-defined]
async def _hop_to_recommended_link(page: object, provider: str, url: str) -> None:
"""Заходит на ОДНУ случайную ссылку того же хоста со страницы (behaviour.hop_recommended).
Best-effort и полностью изолирован от основного результата /fetch: любой отказ
(таймаут, ошибка навигации, бан-страница на побочном заходе) глотается и
логируется, наверх ничего не пробрасывается — caller (_fetch_once) вызывает это
ПОСЛЕ того, как основной html уже снят и провалидирован.
Ограничения (см. постановку #3283):
* ссылка — ТОЛЬКО с того же host, что и url (никаких внешних переходов);
* ссылка != url;
* отдельная страница context.new_page() (НЕ переиспользуем ту же page, которую
caller вот-вот закроет и чей html уже зафиксирован в ответе);
* отдельный, короткий таймаут BROWSER_HOP_NAV_TIMEOUT_MS — не наследует
BROWSER_NAV_TIMEOUT_MS целиком, чтобы залипший побочный заход не съедал
бюджет основного /fetch.
Селектор ссылок — per-provider, см. _RECOMMENDATION_LINK_SELECTOR_BY_PROVIDER
рядом с PROVIDERS.
"""
try:
selector = _RECOMMENDATION_LINK_SELECTOR_BY_PROVIDER.get(provider, "a[href]")
hrefs: list[str] = await page.locator(selector).evaluate_all( # type: ignore[attr-defined]
"els => els.map(el => el.href)"
)
target_host = (urlparse(url).hostname or "").lower()
pattern = _RECOMMENDATION_LINK_PATTERN_BY_PROVIDER.get(provider)
candidates = [
href
for href in hrefs
if href
and href != url
and (urlparse(href).hostname or "").lower() == target_host
# Вторая ступень: шаблон URL самой карточки. Нет шаблона для площадки
# → не сужаем, поведение как без него.
and (pattern is None or pattern.search(href) is not None)
]
if not candidates:
logger.debug(
"tradein-browser[%s]: hop_recommended — нет ссылок того же хоста url=%r",
provider,
url,
)
return
target = random.choice(candidates)
hop_page = await page.context.new_page() # type: ignore[attr-defined]
try:
await hop_page.goto( # type: ignore[attr-defined]
target, timeout=BROWSER_HOP_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
)
await hop_page.wait_for_timeout( # type: ignore[attr-defined]
random.randint(BROWSER_HOP_DWELL_MIN_MS, BROWSER_HOP_DWELL_MAX_MS)
)
finally:
await hop_page.close() # type: ignore[attr-defined]
except Exception as exc:
logger.warning(
"tradein-browser[%s]: hop_recommended отказ (%s: %s) — продолжаю без него url=%r",
provider,
type(exc).__name__,
exc,
url,
)
# ── Режимы получения тела (#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",
behaviour: "_Behaviour | None" = None,
) -> 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. Дефолты не
меняют поведение. behaviour (#3283) — None (дефолт) → ни одна из
_apply_scroll_behaviour/_apply_dwell_behaviour/_hop_to_recommended_link не
вызывается, поведение то же, что и до #3283.
"""
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,
behaviour=behaviour,
)
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,
behaviour=behaviour,
)
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, ...] = (
"похоже, ваш запрос выглядит необычно",
"<title>403 | домклик</title>",
)
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,
каким ждём обычную навигацию (второй таймаут не изобретаем).
ПОБОЧНЫЙ ЭФФЕКТ: на успешном выходе сбрасывает `_last_response_status[provider]`
в None — статус challenge-ответа к этому моменту протух и не описывает
устоявшуюся страницу (обоснование — в комментарии у самой строки, #3283).
"""
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}"
)
# #3283: _last_response_status[provider] на этот момент всё ещё хранит статус
# ОТВЕТА НА CHALLENGE-СТРАНИЦУ (goto/подзапрос ДО вызова этой функции, напр.
# 439/401) — сама PoW-страница чинит себя self-reload'ом (`window.location =
# location.href`), Response на который мы не наблюдаем (опрашиваем content(),
# не подписаны на page.on("response")), а наш собственный page.reload() в цикле
# выше (после BROWSER_CHALLENGE_RELOAD_AFTER_MS) — лишь один из нескольких
# возможных источников устоявшегося content(), тоже не гарантированно последний.
# Замер прода (прогон 5606): рукопожатие снималось за 1-9с — БЫСТРЕЕ, чем успевал
# сработать наш reload (порог 8с) — то есть в типичном случае достоверного
# ответа на устоявшийся контент у нас нет вовсе. Отдать наверх протухший статус
# challenge-страницы как статус УСТОЯВШЕГОСЯ content() — воскресить #3283:
# avito/detail.py трактует 403/439 как блок площадки и выбрасывает уже
# вытащенную карточку. None — «статус неизвестен», клиент это НЕ считает блоком
# (см. providers/avito/detail.py: `if status is not None: ...`) и падает на
# html-эвристики, которые на настоящем контенте не сработают. Действует и на
# DomClick-ветку (is_pending=... выше) — там статус на успешном пути и раньше
# не проверялся при классификации блока, только логировался как диагностика.
_last_response_status[provider] = None
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",
behaviour: "_Behaviour | None" = None,
) -> str:
"""Открывает страницу, переходит по URL, ждёт JS, возвращает HTML.
Caller держит _locks[provider], поэтому страницы на этом инстансе не
параллелятся — recycle через _relaunch_browser безопасен прямо здесь.
behaviour (#3283, опционально, дефолт None) — имитация чтения человеком:
scroll (прокрутки перед content()), dwell_ms (доп. пауза перед content()),
hop_recommended (побочный заход на рекомендательную ссылку ПОСЛЕ content(), только
fetch_mode="navigate" и reuse_context=True — иначе контекста для него нет).
None (дефолт) → ни одна из веток ниже не исполняется, поведение то же, что и до
#3283.
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]
# Имитация чтения (#3283) — ПОСЛЕ успешной навигации и settle, ДО content():
# behaviour=None (дефолт) → обе функции no-op, ни одна строка не исполняется.
if behaviour is not None:
await _apply_scroll_behaviour(page, behaviour)
await _apply_dwell_behaviour(page, behaviour)
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)
# hop_recommended (#3283) — ПОСЛЕ того как html снят и провалидирован (бан/
# челлендж уже исключены выше, иначе исключение долетело бы до caller'а раньше
# этой строки). Только reuse_context=True — без него контекста, из которого
# можно open'нуть побочную вкладку не задев основную page, нет. Best-effort:
# см. докстринг _hop_to_recommended_link — отказ на нём не всплывает сюда.
if behaviour is not None and behaviour.hop_recommended and reuse_context:
await _hop_to_recommended_link(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": <p>, "interval_s": <float>} → обновляет in-memory интервал.
Валидация: source ∈ {avito,cian,yandex,generic}, interval_s ≥ 0.
Сброс к env-дефолту происходит при рестарте контейнера (by design).
"""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON body"}, status=400)
source = body.get("source")
if not isinstance(source, str) or source not in PROVIDERS:
return web.json_response(
{"error": f"source must be one of {list(PROVIDERS)}"},
status=400,
)
interval_s = body.get("interval_s")
if not isinstance(interval_s, (int, float)):
return web.json_response(
{"error": "interval_s must be a non-negative number"},
status=422,
)
interval_s = float(interval_s)
if interval_s < 0:
return web.json_response(
{"error": "interval_s must be >= 0"},
status=422,
)
_MIN_PAGE_INTERVAL_BY_PROVIDER[source] = interval_s
logger.info(
"tradein-browser: pacing updated source=%s interval_s=%.2f (resets on restart)",
source,
interval_s,
)
return web.json_response({"ok": True, "source": source, "interval_s": interval_s})
async def login_handler(request: web.Request) -> web.Response:
"""POST /login {...} → {"cookies": [...]}
Выполняет вход на указанную страницу (email + пароль), ждёт появления
success_cookie, возвращает полный список cookies браузерного контекста.
Делит тот же per-provider браузер/лок что и /fetch (поставщик по host URL
или явному body["provider"]/["source"]).
"""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON body"}, status=400)
missing = [f for f in ("url", "email", "password", "email_selector",
"password_selector", "submit_selector") if not body.get(f)]
if missing:
return web.json_response({"error": f"missing required fields: {missing}"}, status=400)
provider = _resolve_provider(body, body["url"])
# #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)