All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m19s
Deploy Trade-In / test (push) Successful in 4m11s
Deploy Trade-In / build-backend (push) Successful in 2m9s
Deploy Trade-In / deploy (push) Successful in 1m50s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 13s
2659 lines
160 KiB
Python
2659 lines
160 KiB
Python
"""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.45–1.60):
|
||
``browserServerImpl.js`` отсутствует в дистрибутиве → контейнер краш-лупится
|
||
с MODULE_NOT_FOUND / RuntimeError при запуске через ``launch_server``.
|
||
Локальный запуск ``AsyncCamoufox`` работает стабильно — проверено.
|
||
|
||
Per-provider модель (#1793):
|
||
Вместо одного глобального браузера + одного egress-прокси держим ОТДЕЛЬНЫЙ
|
||
инстанс camoufox на каждого поставщика (avito/cian/yandex/generic). Поставщик
|
||
определяется по host URL запроса (или явному полю body["provider"]/["source"]). Каждый
|
||
инстанс имеет geoip=True — фингерпринт привязан к гео прокси, поэтому смешивать
|
||
источники в одном браузере нельзя. Egress-прокси сейчас общий для всех
|
||
поставщиков (SCRAPER_PROXY_URL, #2616 шаг 2) — раздельные instance'ы остаются
|
||
ради geoip-изоляции fingerprint'а и per-provider concurrency (ниже), не прокси.
|
||
|
||
Конкурентность:
|
||
* МЕЖДУ поставщиками — параллельно (avito/cian/yandex гоняются одновременно);
|
||
* ВНУТРИ поставщика — строго ≤1 одновременный fetch (per-provider lock).
|
||
|
||
Это решает проблему, когда источники клинили друг друга через единственный
|
||
egress-прокси / единственный сериализующий лок.
|
||
|
||
Переменные окружения:
|
||
BROWSER_PORT — TCP-порт HTTP-сервера (default: 3000)
|
||
BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска, глобальный
|
||
дефолт для провайдеров без код-дефолта (default: 15).
|
||
per-provider код-дефолт см. BROWSER_RECYCLE_PAGES_{PROVIDER}.
|
||
BROWSER_NAV_TIMEOUT_MS — таймаут page.goto в мс (default: 60000)
|
||
BROWSER_WAIT_MS — ожидание гидрации listings после DOMContentLoaded, мс (default: 6000)
|
||
BROWSER_CHALLENGE_WAIT_MS — бюджет ожидания QRATOR PoW-челленджа Авито (#3045),
|
||
мс (default: 30000). Челлендж-страница сама считает
|
||
proof-of-work в JS, ставит куку pow_solved и через
|
||
setTimeout(3000) делает window.location = location.href
|
||
(self-reload, URL не меняется). /fetch опрашивает
|
||
page.content() пока маркеры челленджа не исчезнут; по
|
||
истечении бюджета — ChallengeTimeoutError вместо тихой
|
||
отдачи заглушки. Действует ТОЛЬКО при обнаружении
|
||
маркеров челленджа в разметке — прочие провайдеры этот
|
||
путь никогда не задевают.
|
||
BROWSER_BLOCK_RESOURCE_TYPES — CSV типов ресурсов Playwright, которые abort'ить
|
||
через page.route при навигации (default: "font,media").
|
||
Снижает число одновременных под-коннектов на страницу →
|
||
мобильный прокси не ловит NS_ERROR_PROXY_TOO_MANY_REQUESTS.
|
||
image уже глушит camoufox block_images. Пустая строка =
|
||
НЕ блокировать ничего. КРИТИЧНО: НЕ добавлять
|
||
document/script/stylesheet/xhr/fetch — данные парсятся из
|
||
HTML/JS-state (INITIAL_STATE/__preloadedState__), JS нужен
|
||
для гидрации, CSS проверяется anti-bot fingerprint.
|
||
(legacy alias BROWSER_BLOCKED_RESOURCE_TYPES всё ещё читается
|
||
как fallback, если новая переменная не задана.)
|
||
⚠ BROWSER_BLOCK_RESOURCES (булев выключатель до #1812)
|
||
НЕ читается — см. _RETIRED_ENV; в проде он всё ещё
|
||
выставлен во всех трёх контейнерах и ни на что не влияет.
|
||
BROWSER_BLOCK_IMAGES — булев глобальный дефолт camoufox block_images (#3185).
|
||
Дефолт для ВСЕХ провайдеров — True (блокируем). Пробовали
|
||
выключить для avito по гипотезе «блокировка = сигнал для
|
||
QRATOR» — замер её не подтвердил, дефолт откатан; разбор
|
||
и цифры в комментарии у _BLOCK_IMAGES_DEFAULT_BY_PROVIDER.
|
||
Сама ручка осталась: выключить можно env'ом, без релиза.
|
||
BROWSER_BLOCK_IMAGES_{PROVIDER} — per-provider override, тот же формат, что у
|
||
BROWSER_MIN_PAGE_INTERVAL_S_{PROVIDER}: PROVIDER в
|
||
upper-case (AVITO, CIAN, YANDEX, GENERIC), перебивает и
|
||
global BROWSER_BLOCK_IMAGES, и код-дефолт per-provider.
|
||
BROWSER_MIN_PAGE_INTERVAL_S — минимальный интервал (сек) между последовательными
|
||
page.goto ОДНОГО провайдера (default: 2.0). Даёт под-
|
||
коннектам предыдущей страницы дренироваться, прежде чем
|
||
открыть новую → меньше пиковый параллелизм на прокси.
|
||
0 = без пейсинга.
|
||
BROWSER_MIN_PAGE_INTERVAL_S_{PROVIDER} — per-provider override интервала пейсинга.
|
||
PROVIDER в upper-case: AVITO, CIAN, YANDEX, GENERIC.
|
||
Например: BROWSER_MIN_PAGE_INTERVAL_S_CIAN=18 задаёт 18с
|
||
только для cian, не затрагивая другие провайдеры. Если env
|
||
не задан или содержит невалидное значение — фолбэк на
|
||
глобальный BROWSER_MIN_PAGE_INTERVAL_S. Backward-compat:
|
||
при незаданных per-provider env поведение идентично
|
||
предыдущему (чисто аддитивный override).
|
||
BROWSER_RECYCLE_PAGES_{PROVIDER} — per-provider override recycle-порога (#3205).
|
||
PROVIDER в upper-case: AVITO, CIAN, YANDEX, GENERIC,
|
||
DOMCLICK. Приоритет: per-provider env → глобальный
|
||
BROWSER_RECYCLE_PAGES → код-дефолт per-provider
|
||
(_RECYCLE_PAGES_DEFAULT_BY_PROVIDER, сейчас пуст) →
|
||
общий фолбэк 15. Ручка оставлена для будущих исключений;
|
||
заведённый в #3205 domclick=1 снят в #3212 — перезапуск
|
||
процесса уничтожает browser context, а в нём лежит пропуск
|
||
QRATOR, ради которого всё и затевалось (подробности у
|
||
_RECYCLE_PAGES_DEFAULT_BY_PROVIDER).
|
||
SCRAPER_PROXY_URL — http-прокси, ОБЩИЙ для всех поставщиков (avito/cian/
|
||
yandex/generic). #2616 шаг 2: per-provider
|
||
BROWSER_PROXY_AVITO/CIAN/YANDEX и legacy
|
||
AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты —
|
||
все указывали на закрытые mobileproxy-аккаунты
|
||
(407/connection refused, проверено вживую #2613).
|
||
BROWSER_ANCHOR_VIA_SEARCH — CSV провайдеров, для которых якорная вкладка
|
||
(_ensure_anchor_page) заходит на origin ЧЕРЕЗ реальный
|
||
поиск yandex.ru, а не голым goto (#3251). Ручная сессия
|
||
29.08.2026 показала эталонный человеческий путь:
|
||
yandex.ru → клик по результату → выдача с Referer
|
||
yandex.ru. Дефолт "domclick" — авито/циан/яндекс
|
||
проверяются отдельно в #3251, не включать здесь.
|
||
Пустая строка = выключено везде (полный откат к
|
||
прежнему поведению). Провайдер без соответствующего
|
||
BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поиск тоже не
|
||
делает — см. ниже.
|
||
BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} — поисковый запрос на yandex.ru для
|
||
провайдера, PROVIDER в upper-case (DOMCLICK). Код-дефолт
|
||
для domclick — "домклик екатеринбург квартиры вторичка"
|
||
(реалистичный запрос покупателя, ведёт на нужный хост
|
||
одной из первых органических ссылок). Провайдер в
|
||
BROWSER_ANCHOR_VIA_SEARCH без запроса (нет ни env, ни
|
||
код-дефолта) — заход через поиск для него не делается.
|
||
|
||
ENVIRONMENT — "production" в прод-контейнерах, иначе "dev" (дефолт).
|
||
#2616 шаг 1: прод + нет НИ override в теле, НИ
|
||
SCRAPER_PROXY_URL → отказ (503, явная причина), а НЕ
|
||
launch camoufox без proxy (= прямое подключение с IP
|
||
сервера). В dev отсутствие прокси легитимно (см.
|
||
_no_live_proxy).
|
||
|
||
Контракт /login (провалидировано вживую 2026-05-31, Cian email+пароль без SMS):
|
||
pre_click_selectors — список селекторов для последовательного клика до формы;
|
||
каждый клик non-fatal (пропускается при отсутствии элемента). Двухшаговый
|
||
submit: после первого сабмита Cian может показать «Введите пароль» повторно.
|
||
|
||
Эндпоинт /fetch доступен из Docker-сети как ``http://tradein-browser:3000/fetch``.
|
||
"""
|
||
|
||
import asyncio
|
||
import base64
|
||
import gzip
|
||
import logging
|
||
import os
|
||
from collections.abc import Callable, Mapping
|
||
from urllib.parse import quote, urlparse
|
||
|
||
from aiohttp import web
|
||
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── конфигурация из env ────────────────────────────────────────────────────────
|
||
|
||
# #2616 шаг 1: признак окружения. "production" в прод-контейнерах (ENV: ENVIRONMENT,
|
||
# см. app.core.config.Settings.environment в основном backend). Дефолт "dev" — как и
|
||
# везде в проекте (Settings.environment: str = "dev").
|
||
ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "dev")
|
||
IS_PROD: bool = ENVIRONMENT == "production"
|
||
|
||
BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000"))
|
||
# Глобальной константы recycle больше нет (#3205): порог стал поставщик-зависимым и
|
||
# живёт в _RECYCLE_PAGES_BY_PROVIDER. Env BROWSER_RECYCLE_PAGES по-прежнему читается —
|
||
# внутри _resolve_recycle_pages, как один из уровней приоритета. Константу убрали
|
||
# намеренно: пока она существовала, ~24 теста патчили ЕЁ, чтобы запретить перезапуск
|
||
# браузера, и после перехода на словарь этот патч перестал на что-либо влиять —
|
||
# охранник остался в коде, но охранять перестал.
|
||
BROWSER_NAV_TIMEOUT_MS: int = int(os.environ.get("BROWSER_NAV_TIMEOUT_MS", "60000"))
|
||
# 6000 (не 2500): avito гидрирует listings client-side ПОСЛЕ domcontentloaded;
|
||
# на 2.5с в HTML генерик-шелл без объявлений (0 listings), на 5-6с — полная
|
||
# выдача (~50 карточек, 3.2МБ). Подтверждено прод-дебагом 2026-05-31.
|
||
BROWSER_WAIT_MS: int = int(os.environ.get("BROWSER_WAIT_MS", "6000"))
|
||
|
||
# 30000: цепочка PoW-челленджа Авито — startPow() в JS, затем setTimeout(3000) на
|
||
# self-reload, затем повторная гидрация страницы. 3с таймера самой площадки — это
|
||
# ТОЛЬКО задержка перед reload, не бюджет на сам расчёт PoW: под headless-браузером
|
||
# и egress-прокси решение может занять заметно дольше, чем в обычном браузере
|
||
# пользователя. Живой замер 2026-08-21 (#3045): без ожидания челленджа 4 из 6
|
||
# карточек с органической навигацией отдавали 7891-байтную челлендж-страницу вместо
|
||
# контента — не бан (403/429 не было), просто уходили раньше, чем страница себя
|
||
# перезагрузила. 30с — запас с кратным резервом на решение + reload + догидрацию,
|
||
# не превращающий единичный фетч в минуту ожидания при реальном бане/сетевой пробе.
|
||
BROWSER_CHALLENGE_WAIT_MS: int = int(os.environ.get("BROWSER_CHALLENGE_WAIT_MS", "30000"))
|
||
|
||
# Через сколько мс безрезультатного опроса перезагрузить страницу САМИМ, не дожидаясь
|
||
# её самопроизвольного reload'а. Допущение «страница перезагрузит себя сама» (см.
|
||
# _content_during_navigation) верно НЕ всегда: ручная сессия 2026-08-29 через узел 10
|
||
# показала, что после решения PoW выдача Домклика так и осталась на 401, и пропуск
|
||
# qrator_jsid2 выдался только после ДВУХ перезагрузок, сделанных человеком руками —
|
||
# 102.7с GET → 401, 115.1с GET → 401, 118.4с GET → 200, и сразу за ним кука-пропуск.
|
||
# Пока мы только опрашивали content(), такая страница висела до самого таймаута.
|
||
# Перезагрузка повторяет ровно то, что делает человек, и стоит одну навигацию.
|
||
BROWSER_CHALLENGE_RELOAD_AFTER_MS: int = int(
|
||
os.environ.get("BROWSER_CHALLENGE_RELOAD_AFTER_MS", "8000")
|
||
)
|
||
# Сколько таких перезагрузок допустимо за один фетч. Ноль = прежнее поведение (только
|
||
# опрос). Два — столько понадобилось человеку; больше похоже уже на долбёжку, которая
|
||
# сама по себе повод отказать.
|
||
BROWSER_CHALLENGE_MAX_RELOADS: int = int(os.environ.get("BROWSER_CHALLENGE_MAX_RELOADS", "2"))
|
||
|
||
# /fetch-json settle после goto(origin) перед in-page fetch (#1917). 500мс мало:
|
||
# первый XHR иногда ловит `NetworkError when attempting to fetch resource` (anti-bot/
|
||
# сетевой стек страницы ещё не готов). Лечился внешним retry (re-navigation ~30-45с/дом).
|
||
# Поднимаем дефолт до 1200мс (меньше first-fail) + добавляем дешёвый in-page retry
|
||
# самого fetch() (см. FETCH_JSON_INPAGE_RETRIES) — повтор внутри страницы стоит ~доли
|
||
# секунды против полной ре-навигации. НЕ трогаем /fetch (SERP): там свой BROWSER_WAIT_MS.
|
||
FETCH_JSON_SETTLE_MS: int = int(os.environ.get("FETCH_JSON_SETTLE_MS", "1200"))
|
||
# Сколько раз повторить in-page fetch() при сетевом throw (TypeError/NetworkError),
|
||
# НЕ при HTTP-статусе (4xx/5xx возвращаются как есть — их решает caller). 0 = выкл.
|
||
FETCH_JSON_INPAGE_RETRIES: int = int(os.environ.get("FETCH_JSON_INPAGE_RETRIES", "1"))
|
||
# Пауза между in-page попытками fetch(), мс.
|
||
FETCH_JSON_RETRY_DELAY_MS: int = int(os.environ.get("FETCH_JSON_RETRY_DELAY_MS", "800"))
|
||
# Сколько ждать события `load` на ПОВТОРЕ после гонки «execution context destroyed»
|
||
# (#2676). Только на повторе: happy-path остаётся на дешёвом FETCH_JSON_SETTLE_MS,
|
||
# иначе бесконечно дозагружающаяся страница удлиняла бы КАЖДЫЙ запрос. Ожидание
|
||
# best-effort — по таймауту всё равно пробуем evaluate.
|
||
FETCH_JSON_LOAD_WAIT_MS: int = int(os.environ.get("FETCH_JSON_LOAD_WAIT_MS", "15000"))
|
||
|
||
# Известные поставщики. "generic" — фолбэк для всех прочих хостов (один общий
|
||
# инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод.
|
||
# "domclick" (#3205): собственный инстанс — у площадки свой антибот (QRATOR с
|
||
# proof-of-work) и свой ритм, и держать её на общем с generic счётчике страниц и
|
||
# общем браузере значило бы мешать её сессию с чужими запросами.
|
||
PROVIDERS: tuple[str, ...] = ("avito", "cian", "yandex", "generic", "domclick")
|
||
|
||
|
||
def _parse_bool(raw: str | None, default: bool) -> bool:
|
||
"""Парсит булев env-флаг ("true"/"1"/"yes"/"on" → True), fallback на default."""
|
||
if raw is None:
|
||
return default
|
||
return raw.strip().lower() in ("true", "1", "yes", "on")
|
||
|
||
|
||
def _parse_block_types(raw: str | None) -> frozenset[str]:
|
||
"""Парсит CSV типов ресурсов для блокировки → frozenset (lower/strip).
|
||
|
||
None (env не задана) → дефолт "font,media". Пустая строка → пустой набор
|
||
(ничего не блокируем). Не задаём document/script/stylesheet/xhr/fetch здесь —
|
||
дефолт намеренно узкий (font+media), эти типы не нужны для extraction/fingerprint.
|
||
"""
|
||
if raw is None:
|
||
raw = "font,media"
|
||
return frozenset(t.strip().lower() for t in raw.split(",") if t.strip())
|
||
|
||
|
||
# Типы под-ресурсов, которые abort'им через page.route на каждой странице. Каждый
|
||
# page.goto тянет десятки sub-requests (js/css/xhr/font/media) через прокси; мобильный
|
||
# прокси держит ~5 параллельных коннектов → 2-3 быстрых страницы подряд на одном
|
||
# аккаунте упираются в NS_ERROR_PROXY_TOO_MANY_REQUESTS/500. Глуша font+media (тяжёлые,
|
||
# не нужны ни для extraction, ни для fingerprint), снижаем пиковый fan-out. image уже
|
||
# глушит camoufox block_images. КРИТИЧНО: document/script/stylesheet/xhr/fetch НЕ
|
||
# блокируем — данные в HTML/JS-state (INITIAL_STATE/__preloadedState__), JS гидрирует
|
||
# listings, CSS проверяет anti-bot fingerprint. Legacy alias BROWSER_BLOCKED_RESOURCE_TYPES
|
||
# читается fallback'ом, если новая BROWSER_BLOCK_RESOURCE_TYPES не задана.
|
||
_BLOCKED_TYPES: frozenset[str] = _parse_block_types(
|
||
os.environ.get("BROWSER_BLOCK_RESOURCE_TYPES")
|
||
or os.environ.get("BROWSER_BLOCKED_RESOURCE_TYPES")
|
||
)
|
||
|
||
# Минимальный интервал (сек) между последовательными page.goto одного провайдера —
|
||
# даёт под-коннектам предыдущей страницы дренироваться (прокси освобождает слоты),
|
||
# прежде чем открыть новую страницу. 0 = без пейсинга.
|
||
BROWSER_MIN_PAGE_INTERVAL_S: float = float(os.environ.get("BROWSER_MIN_PAGE_INTERVAL_S", "2.0"))
|
||
|
||
# Провайдеры, для которых якорная вкладка (#3251) заходит на origin ЧЕРЕЗ реальный
|
||
# поиск yandex.ru, а не голым goto. Дефолт "domclick" — авито/циан/яндекс проверяются
|
||
# отдельно (#3251), не расширять этот список без замера на них. Пустая строка env =
|
||
# выключено везде — полный откат на поведение до #3251.
|
||
_ANCHOR_VIA_SEARCH_PROVIDERS: frozenset[str] = frozenset(
|
||
p.strip().lower()
|
||
for p in os.environ.get("BROWSER_ANCHOR_VIA_SEARCH", "domclick").split(",")
|
||
if p.strip()
|
||
)
|
||
|
||
# Код-дефолты поискового запроса per-provider, если BROWSER_ANCHOR_SEARCH_QUERY_
|
||
# {PROVIDER} не задан. Запрос подобран так, чтобы органическая выдача Яндекса
|
||
# реально содержала ссылку на нужный хост, а не только рекламу — проверено вживую
|
||
# 29.08.2026 ручной сессией с этим же запросом.
|
||
_ANCHOR_SEARCH_QUERY_DEFAULT_BY_PROVIDER: dict[str, str] = {
|
||
"domclick": "домклик екатеринбург квартиры вторичка",
|
||
}
|
||
|
||
# Базовый URL поиска. Константы «Referer визита без клика» здесь СОЗНАТЕЛЬНО нет:
|
||
# подставлять https://yandex.ru/ по факту одного лишь захода на поиск — это заявить
|
||
# переход, которого не было. Не нашли ссылку / клик увёл не туда → идём на origin
|
||
# вообще без Referer (#3251).
|
||
_YANDEX_SEARCH_URL: str = "https://yandex.ru/search/"
|
||
|
||
# Маркеры SmartCaptcha Яндекса. Мобильные прокси иногда ловят капчу на выдаче —
|
||
# наблюдалось вживую 29.08.2026. Решать капчу нечем и незачем: детектируем и тихо
|
||
# откатываемся на прежнее поведение, прогон падать из-за недоступности живого
|
||
# поиска не должен.
|
||
_YANDEX_CAPTCHA_MARKERS: tuple[str, ...] = (
|
||
"smartcaptcha",
|
||
"showcaptcha",
|
||
"подтвердите, что запросы отправляли вы",
|
||
)
|
||
|
||
|
||
def _anchor_search_query(provider: str) -> str | None:
|
||
"""Запрос для захода на yandex.ru перед origin данного провайдера.
|
||
|
||
Приоритет: BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} (upper-case) → код-дефолт
|
||
из _ANCHOR_SEARCH_QUERY_DEFAULT_BY_PROVIDER → None. None означает "заход через
|
||
поиск для этого провайдера не делается" — даже если он есть в
|
||
_ANCHOR_VIA_SEARCH_PROVIDERS, без запроса открывать нечего.
|
||
"""
|
||
default = _ANCHOR_SEARCH_QUERY_DEFAULT_BY_PROVIDER.get(provider)
|
||
return os.environ.get(f"BROWSER_ANCHOR_SEARCH_QUERY_{provider.upper()}", default)
|
||
|
||
|
||
def _looks_like_yandex_captcha(html: str, current_url: str) -> bool:
|
||
"""True, если текущая страница — SmartCaptcha Яндекса, а не выдача.
|
||
|
||
Проверяем и URL (редирект на /showcaptcha), и текст разметки — Яндекс
|
||
показывает капчу и как отдельную страницу, и как встроенный блок поверх
|
||
выдачи в зависимости от типа запроса/прокси.
|
||
"""
|
||
if "/showcaptcha" in current_url.lower():
|
||
return True
|
||
low_html = html.lower()
|
||
return any(marker in low_html for marker in _YANDEX_CAPTCHA_MARKERS)
|
||
|
||
|
||
async def _find_serp_result_link(page: object, hostname: str) -> object | None:
|
||
"""Ищет в выдаче Яндекса ссылку на ``hostname`` среди всех ``a[href]``.
|
||
|
||
Яндекс часто отдаёт результат как редирект-обёртку (``/redir/...``,
|
||
``yandex.ru/clck/...``) — совпадение по одному ``href`` не всегда сработает,
|
||
поэтому проверяем ещё и видимый текст ссылки (Яндекс показывает реальный
|
||
хост зелёной строкой под заголовком результата).
|
||
"""
|
||
if not hostname:
|
||
return None
|
||
try:
|
||
candidates = await page.query_selector_all("a[href]") # type: ignore[attr-defined]
|
||
except Exception:
|
||
return None
|
||
for element in candidates:
|
||
try:
|
||
href = await element.get_attribute("href") # type: ignore[attr-defined]
|
||
text = await element.text_content() # type: ignore[attr-defined]
|
||
except Exception:
|
||
continue
|
||
haystack = f"{href or ''} {text or ''}".lower()
|
||
if hostname.lower() in haystack:
|
||
return element
|
||
return None
|
||
|
||
|
||
async def _navigate_anchor_via_search(page: object, provider: str, origin: str) -> object | None:
|
||
"""Пытается открыть ``origin`` переходом со страницы выдачи yandex.ru вместо
|
||
голого goto — так это делает реальный пользователь (#3251).
|
||
|
||
ПРИНЦИП (не оптимизировать обратно на "поставить правдоподобный Referer"):
|
||
либо переход с Яндекса на origin случился НАСТОЯЩИЙ, либо мы честно об этом
|
||
молчим. До этой правки (#3258) два фолбэка — "ссылка не найдена" и "клик
|
||
увёл не туда" — подставляли Referer (yandex.ru / URL выдачи) страницам, на
|
||
которые фактически НЕ переходили с Яндекса. Заявлять переход, которого не
|
||
было, нельзя. Теперь оба случая возвращают ``None`` без единого referer, и
|
||
вызывающий делает обычный ``goto(origin)`` без referer — ровно как до
|
||
#3251, когда захода через поиск не существовало вовсе.
|
||
|
||
Возвращает страницу, на которой origin реально открыт после клика:
|
||
- ``page`` (тот же объект) — клик по результату выдачи навигировал текущую
|
||
вкладку;
|
||
- НОВУЮ страницу — клик открыл origin в отдельной вкладке (частый случай:
|
||
ссылки в выдаче Яндекса нередко имеют ``target="_blank"``). В этом случае
|
||
именно новая страница несёт на себе результат клика, поэтому она
|
||
становится якорной, а исходная вкладка с Яндексом закрывается (держать
|
||
две вкладки на один провайдер незачем и дороже по памяти). Вызывающий
|
||
обязан подменить свою ссылку на ``page`` возвращённой — и применить к
|
||
ней ``_apply_resource_block`` заново, т.к. новая вкладка ничего не
|
||
наследует от исходной.
|
||
|
||
Возвращает ``None``, если настоящего перехода на origin не случилось:
|
||
поиск выключен для провайдера / нет запроса, капча на выдаче, упавшая
|
||
навигация на yandex.ru, ссылки в выдаче нет, клик не удался, либо клик
|
||
(в текущей вкладке или в новой) увёл не на тот хост. Прогон в любом из
|
||
этих случаев НЕ должен падать — вызывающий откатывается на прямой
|
||
goto(origin) без referer.
|
||
"""
|
||
if provider not in _ANCHOR_VIA_SEARCH_PROVIDERS:
|
||
return None
|
||
query = _anchor_search_query(provider)
|
||
if query is None:
|
||
return None
|
||
|
||
hostname = urlparse(origin).hostname or ""
|
||
search_url = f"{_YANDEX_SEARCH_URL}?text={quote(query)}"
|
||
|
||
try:
|
||
await page.goto( # type: ignore[attr-defined]
|
||
search_url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
|
||
)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"tradein-browser[%s]: заход на yandex.ru для якоря не удался (%s) — "
|
||
"откат на прямой goto(origin) без referer",
|
||
provider,
|
||
type(exc).__name__,
|
||
)
|
||
return None
|
||
|
||
html = await page.content() # type: ignore[attr-defined]
|
||
serp_url = str(getattr(page, "url", search_url))
|
||
if _looks_like_yandex_captcha(html, serp_url):
|
||
logger.warning(
|
||
"tradein-browser[%s]: капча на выдаче Яндекса — откат на прямой "
|
||
"goto(origin) без referer",
|
||
provider,
|
||
)
|
||
return None
|
||
|
||
link = await _find_serp_result_link(page, hostname)
|
||
if link is None:
|
||
logger.info(
|
||
"tradein-browser[%s]: ссылка на %s не найдена в выдаче yandex.ru — "
|
||
"перехода не было, откат на прямой goto(origin) без referer",
|
||
provider,
|
||
hostname,
|
||
)
|
||
return None
|
||
|
||
# Снимок открытых вкладок ДО клика — так после клика можно отличить "клик
|
||
# открыл новую вкладку" (target="_blank", частый случай в живой выдаче
|
||
# Яндекса) от "клик навигировал текущую". Без этого попап на другой вкладке
|
||
# остался бы незамеченным: текущая page осталась бы на yandex.ru, проверка
|
||
# хоста ниже не прошла бы, и мы ушли бы в фолбэк вместо настоящего перехода.
|
||
context = getattr(page, "context", None)
|
||
pages_before = list(context.pages) if context is not None else [] # type: ignore[attr-defined]
|
||
|
||
try:
|
||
await link.click(timeout=BROWSER_NAV_TIMEOUT_MS) # type: ignore[attr-defined]
|
||
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"tradein-browser[%s]: клик по ссылке в выдаче не удался (%s) — "
|
||
"откат на прямой goto(origin) без referer",
|
||
provider,
|
||
type(exc).__name__,
|
||
)
|
||
return None
|
||
|
||
popup: object | None = None
|
||
if context is not None:
|
||
for candidate in context.pages: # type: ignore[attr-defined]
|
||
if candidate not in pages_before and candidate is not page:
|
||
popup = candidate
|
||
break
|
||
|
||
if popup is not None:
|
||
try:
|
||
await popup.wait_for_load_state( # type: ignore[attr-defined]
|
||
"domcontentloaded", timeout=BROWSER_NAV_TIMEOUT_MS
|
||
)
|
||
except Exception:
|
||
pass
|
||
popup_host = urlparse(str(getattr(popup, "url", ""))).hostname or ""
|
||
if popup_host == hostname:
|
||
logger.info(
|
||
"tradein-browser[%s]: клик по ссылке в выдаче открыл НОВУЮ вкладку "
|
||
"на %s — она становится якорной, исходная с Яндексом закрывается",
|
||
provider,
|
||
hostname,
|
||
)
|
||
await _apply_resource_block(popup)
|
||
try:
|
||
await page.close() # type: ignore[attr-defined]
|
||
except Exception:
|
||
pass
|
||
return popup
|
||
logger.warning(
|
||
"tradein-browser[%s]: клик открыл новую вкладку на %s вместо %s — "
|
||
"перехода на origin не было, откат на прямой goto(origin) без referer",
|
||
provider,
|
||
popup_host,
|
||
hostname,
|
||
)
|
||
try:
|
||
await popup.close() # type: ignore[attr-defined]
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
landed_host = urlparse(str(getattr(page, "url", ""))).hostname or ""
|
||
if landed_host == hostname:
|
||
return page
|
||
logger.warning(
|
||
"tradein-browser[%s]: клик увёл текущую вкладку на %s вместо %s — "
|
||
"перехода на origin не было, откат на прямой goto(origin) без referer",
|
||
provider,
|
||
landed_host,
|
||
hostname,
|
||
)
|
||
return None
|
||
|
||
|
||
def _resolve_min_interval(
|
||
provider: str,
|
||
environ: Mapping[str, str] | None = None,
|
||
) -> float:
|
||
"""Возвращает эффективный интервал пейсинга для провайдера.
|
||
|
||
Читает per-provider override из ``BROWSER_MIN_PAGE_INTERVAL_S_{PROVIDER}``
|
||
(PROVIDER в upper-case). Невалидное или отсутствующее значение → фолбэк на
|
||
глобальный ``BROWSER_MIN_PAGE_INTERVAL_S``. Не кидает исключений.
|
||
|
||
Args:
|
||
provider: имя провайдера ("avito", "cian", "yandex", "generic", "domclick").
|
||
environ: env-словарь; None → ``os.environ`` (тестируемость без патча модуля).
|
||
|
||
Returns:
|
||
Эффективный интервал в секундах (≥0).
|
||
"""
|
||
env = environ if environ is not None else os.environ
|
||
key = f"BROWSER_MIN_PAGE_INTERVAL_S_{provider.upper()}"
|
||
raw = env.get(key)
|
||
if raw is not None:
|
||
try:
|
||
return float(raw)
|
||
except ValueError:
|
||
logger.warning(
|
||
"tradein-browser: %s=%r невалидно (не float), используем глобал %.1f",
|
||
key,
|
||
raw,
|
||
BROWSER_MIN_PAGE_INTERVAL_S,
|
||
)
|
||
return BROWSER_MIN_PAGE_INTERVAL_S
|
||
|
||
|
||
# Per-provider эффективные интервалы пейсинга, вычисляются на module-load из os.environ.
|
||
# Ключи: все известные провайдеры из PROVIDERS. Логируются на старте (_on_startup).
|
||
_MIN_PAGE_INTERVAL_BY_PROVIDER: dict[str, float] = {p: _resolve_min_interval(p) for p in PROVIDERS}
|
||
|
||
# Код-дефолт block_images ПО ПРОВАЙДЕРУ. Сейчас пуст — все провайдеры блокируют
|
||
# картинки, как и до #3185 (см. _BLOCK_IMAGES_DEFAULT_FALLBACK). Ручка из #3185
|
||
# остаётся: поменять поведение можно через env, без релиза.
|
||
#
|
||
# Почему дефолт откатан (#3185). Гипотеза «блокировка картинок = лишний сигнал для
|
||
# QRATOR» шла от camoufox'ского LeakWarning, а не от замера. Прямой A/B на сайдкаре её
|
||
# не подтвердил: 6/8 успехов с блокировкой против 7/8 без — разница в пределах шума.
|
||
# На проде после выкатки avito=False стало хуже: прогон 5200 (картинки блокировались) —
|
||
# 43/63 карточки при 32% блоков; прогоны 5206 и 5207 (не блокировались) — 2/22 и 2/13
|
||
# при 91% и 77% блоков. Причинность НЕ доказана: между прогонами через тот же пул
|
||
# прокси прошло ~40 диагностических запросов, репутация пула могла просесть от них.
|
||
# Но выгоды правка не показала ни разу, поэтому дефолт возвращается к прежнему
|
||
# поведению; эффект картинок мерить отдельно и на чистом пуле.
|
||
_BLOCK_IMAGES_DEFAULT_BY_PROVIDER: dict[str, bool] = {}
|
||
_BLOCK_IMAGES_DEFAULT_FALLBACK: bool = True
|
||
|
||
|
||
def _resolve_block_images(
|
||
provider: str,
|
||
environ: Mapping[str, str] | None = None,
|
||
) -> bool:
|
||
"""Возвращает эффективный флаг block_images для провайдера (#3185).
|
||
|
||
Приоритет (выше — сильнее): per-provider env ``BROWSER_BLOCK_IMAGES_{PROVIDER}``
|
||
→ глобальный env ``BROWSER_BLOCK_IMAGES`` → код-дефолт per-provider
|
||
(``_BLOCK_IMAGES_DEFAULT_BY_PROVIDER``; сейчас пуст → все True). Не кидает
|
||
исключений — невалидный/отсутствующий env тихо проваливается на следующий уровень.
|
||
|
||
Args:
|
||
provider: имя провайдера ("avito", "cian", "yandex", "generic", "domclick").
|
||
environ: env-словарь; None → ``os.environ`` (тестируемость без патча модуля).
|
||
|
||
Returns:
|
||
True — блокировать image-запросы в camoufox, False — не блокировать.
|
||
"""
|
||
env = environ if environ is not None else os.environ
|
||
provider_default = _BLOCK_IMAGES_DEFAULT_BY_PROVIDER.get(
|
||
provider, _BLOCK_IMAGES_DEFAULT_FALLBACK
|
||
)
|
||
global_value = _parse_bool(env.get("BROWSER_BLOCK_IMAGES"), provider_default)
|
||
key = f"BROWSER_BLOCK_IMAGES_{provider.upper()}"
|
||
return _parse_bool(env.get(key), global_value)
|
||
|
||
|
||
# Per-provider эффективные флаги block_images, вычисляются на module-load из os.environ.
|
||
# Читается в _launch_browser; логируется на старте (_on_startup).
|
||
_BLOCK_IMAGES_BY_PROVIDER: dict[str, bool] = {p: _resolve_block_images(p) for p in PROVIDERS}
|
||
|
||
# Код-дефолт recycle_pages ПО ПРОВАЙДЕРУ. Пуст: сейчас все поставщики живут на общем
|
||
# фолбэке, ручка per-provider (env + этот словарь) остаётся для будущих исключений.
|
||
#
|
||
# #3205 заводил здесь domclick=1 — перезапуск процесса camoufox после КАЖДОЙ карточки.
|
||
# #3212 это снял: перезапуск уничтожает browser context, а в контексте лежит пропуск
|
||
# QRATOR (куки qrator_jsid2 + qrator_jsr), который площадка выдаёт после принятой
|
||
# валидации /__qrator/validate. То есть #3205 боролся с симптомом, который сам же и
|
||
# создавал. Замер (прод, прод-прокси, по 6 карточек на условие): общий контекст — 6/6
|
||
# и НИ ОДНОГО повторного вызова validate; новый контекст на карточку — 1/6, и на каждой
|
||
# следующей validate отвечает 403. Вкладки роли не играют, играет общий cookie jar.
|
||
# Рассуждение #3205 «признак живёт на уровне процесса, свежий контекст отказ не снимает»
|
||
# было верным наблюдением с неверным выводом: свежий контекст его действительно не
|
||
# снимает — потому что он и есть причина.
|
||
_RECYCLE_PAGES_DEFAULT_BY_PROVIDER: dict[str, int] = {}
|
||
_RECYCLE_PAGES_DEFAULT_FALLBACK: int = 15
|
||
|
||
|
||
def _resolve_recycle_pages(
|
||
provider: str,
|
||
environ: Mapping[str, str] | None = None,
|
||
) -> int:
|
||
"""Возвращает эффективный recycle_pages threshold для провайдера (#3205).
|
||
|
||
Приоритет (выше — сильнее): per-provider env ``BROWSER_RECYCLE_PAGES_{PROVIDER}``
|
||
→ глобальный env ``BROWSER_RECYCLE_PAGES`` → код-дефолт per-provider
|
||
(``_RECYCLE_PAGES_DEFAULT_BY_PROVIDER``; сейчас только domclick=1) → общий фолбэк 15.
|
||
Невалидное (не int) значение на любом из env-уровней тихо проваливается на
|
||
следующий уровень приоритета (фолбэк, а не падение). Валидное, но <1 — приводим к 1
|
||
(порог меньше единицы бессмысленен: перезапуск на КАЖДОЙ странице — это и есть 1).
|
||
|
||
Args:
|
||
provider: имя провайдера ("avito", "cian", "yandex", "generic", "domclick").
|
||
environ: env-словарь; None → ``os.environ`` (тестируемость без патча модуля).
|
||
|
||
Returns:
|
||
Эффективный порог recycle (страниц до перезапуска), ≥1.
|
||
"""
|
||
env = environ if environ is not None else os.environ
|
||
|
||
def _parse(raw: str | None, fallback: int, env_key: str) -> int:
|
||
if raw is None:
|
||
return fallback
|
||
try:
|
||
value = int(raw)
|
||
except ValueError:
|
||
logger.warning(
|
||
"tradein-browser: %s=%r невалидно (не int), используем %d",
|
||
env_key,
|
||
raw,
|
||
fallback,
|
||
)
|
||
return fallback
|
||
if value < 1:
|
||
logger.warning("tradein-browser: %s=%d < 1, приводим к 1", env_key, value)
|
||
return 1
|
||
return value
|
||
|
||
provider_default = _RECYCLE_PAGES_DEFAULT_BY_PROVIDER.get(
|
||
provider, _RECYCLE_PAGES_DEFAULT_FALLBACK
|
||
)
|
||
global_value = _parse(
|
||
env.get("BROWSER_RECYCLE_PAGES"), provider_default, "BROWSER_RECYCLE_PAGES"
|
||
)
|
||
key = f"BROWSER_RECYCLE_PAGES_{provider.upper()}"
|
||
return _parse(env.get(key), global_value, key)
|
||
|
||
|
||
# Per-provider эффективные пороги recycle, вычисляются на module-load из os.environ.
|
||
# Читаются в _do_fetch/_do_fetch_json/_do_login при сравнении с _page_counters;
|
||
# логируются на старте (_on_startup).
|
||
_RECYCLE_PAGES_BY_PROVIDER: dict[str, int] = {p: _resolve_recycle_pages(p) for p in PROVIDERS}
|
||
|
||
|
||
def _provider_for_url(url: str) -> str:
|
||
"""Определяет поставщика по host URL.
|
||
|
||
avito/cian/yandex/domclick → одноимённый провайдер; всё прочее → "generic".
|
||
"""
|
||
host = (urlparse(url).hostname or "").lower()
|
||
if "avito" in host:
|
||
return "avito"
|
||
if "cian" in host:
|
||
return "cian"
|
||
if "yandex" in host:
|
||
return "yandex"
|
||
if "domclick" in host:
|
||
return "domclick"
|
||
return "generic"
|
||
|
||
|
||
def _provider_proxy(provider: str) -> str | None:
|
||
"""Возвращает http-прокси для поставщика из env, иначе None.
|
||
|
||
#2616 шаг 2: per-provider BROWSER_PROXY_AVITO/CIAN/YANDEX и legacy
|
||
AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — все указывали на
|
||
закрытые mobileproxy-аккаунты (#2613). SCRAPER_PROXY_URL (ASocks, http-прокси,
|
||
playwright-совместимый) — единственный источник, общий для ВСЕХ поставщиков
|
||
(включая generic). `provider` оставлен параметром сигнатуры (вызывающие
|
||
передают его) — на случай возврата к per-provider egress в будущем.
|
||
"""
|
||
return os.environ.get("SCRAPER_PROXY_URL")
|
||
|
||
|
||
def _no_live_proxy(provider: str, proxy_override: str | None) -> bool:
|
||
"""True если нет НИ explicit proxy в теле запроса, НИ SCRAPER_PROXY_URL.
|
||
|
||
#2616 шаг 1: используется ТОЛЬКО в prod (IS_PROD) — в dev отсутствие прокси
|
||
легитимно (прямое подключение для локальной разработки/тестов, см. #2616 issue
|
||
пункт 2). Отдельная функция от `_provider_proxy`, потому что "нет живого прокси"
|
||
здесь означает буквально "нет НИКАКОГО прокси" (ни override, ни env) — единственный
|
||
случай, который код МОЖЕТ проверить без сетевой пробы. Env-переменная, что задана
|
||
но мертва (407/connection refused), этот guard не триггерит — за живость значения
|
||
отвечает мониторинг прокси-пула, не этот код.
|
||
"""
|
||
return proxy_override is None and _provider_proxy(provider) is None
|
||
|
||
|
||
def _parse_proxy(proxy_url: str | None) -> dict[str, str] | None:
|
||
"""Парсит proxy URL → camoufox proxy dict.
|
||
|
||
Формат входного URL: ``http://user:pass@host:port`` или ``http://host:port``.
|
||
Возвращает None если proxy_url пуст.
|
||
|
||
Returns:
|
||
``{"server": "http://host:port", "username": "...", "password": "..."}``
|
||
или None.
|
||
"""
|
||
if not proxy_url:
|
||
return None
|
||
|
||
parsed = urlparse(proxy_url)
|
||
port_part = f":{parsed.port}" if parsed.port else ""
|
||
server = f"{parsed.scheme}://{parsed.hostname}{port_part}"
|
||
|
||
result: dict[str, str] = {"server": server}
|
||
if parsed.username:
|
||
result["username"] = parsed.username
|
||
if parsed.password:
|
||
result["password"] = parsed.password
|
||
|
||
return result
|
||
|
||
|
||
# ── per-provider состояние браузеров ───────────────────────────────────────────
|
||
# Каждый словарь keyed по provider (str). Заполняется ЛЕНИВО: инстанс конкретного
|
||
# поставщика поднимается на первом /fetch (или /login) к этому поставщику.
|
||
#
|
||
# _locks[provider] сериализует ВЕСЬ доступ к инстансу этого поставщика
|
||
# (launch + relaunch + fetch + login) → внутри поставщика строго ≤1 операция
|
||
# одновременно. Разные поставщики держат РАЗНЫЕ локи → работают параллельно.
|
||
_browsers: dict[str, object] = {} # provider → Browser
|
||
_browser_cms: dict[str, object] = {} # provider → AsyncCamoufox CM (для __aexit__)
|
||
# provider → переиспользуемый BrowserContext (#3118, opt-in reuse_context). Создаётся
|
||
# ЛЕНИВО в _get_or_create_context на первом /fetch с reuse_context=True; куки вливаются
|
||
# ТОЛЬКО в момент создания — дальше cookie-jar эволюционирует сам через Set-Cookie
|
||
# площадки (browser.new_page() без reuse_context создаёт НОВЫЙ изолированный context на
|
||
# каждый вызов и убивает живой qrator_jsid2 DomClick, см. #3118). При выключенном флаге
|
||
# (дефолт) словарь остаётся пустым — поведение прочих провайдеров не меняется.
|
||
_contexts: dict[str, object] = {}
|
||
# provider → ЯКОРНАЯ вкладка, живущая всё время жизни context'а и держащая
|
||
# открытым origin (SERP). До этого origin переоткрывался goto-ом ПЕРЕД каждой
|
||
# карточкой, и полная навигация по выдаче оплачивалась заново каждый раз.
|
||
# Замер 29.08.2026 на 131 карточке: 4-5 с на карточку с живой вкладкой против
|
||
# 17-52 с с переоткрытием, причём время во втором случае росло по ходу прогона.
|
||
# Живёт только при reuse_context=True: без переиспользуемого context'а держать
|
||
# вкладку негде и незачем.
|
||
_anchor_pages: dict[str, object] = {}
|
||
# provider -> origin, НА КОТОРОМ якорная вкладка стоит СЕЙЧАС. Без этой памяти
|
||
# _ensure_anchor_page отдавала любую живую вкладку как годную, не глядя на
|
||
# запрошенный origin, — а origin у обоих caller'ов выводится ИЗ URL карточки и
|
||
# меняется вместе с городом (Авито: /ekaterinburg/... vs /verhnyaya_pyshma/...;
|
||
# Домклик: ekaterinburg.domclick.ru vs иной поддомен). Тогда Referer целевой
|
||
# навигации называл выдачу, которую этот контекст НИКОГДА не открывал: ни куки
|
||
# её, ни тайминга — площадка видит заявленный переход без единого следа. Ровно
|
||
# то, что #3258 запретил делать фолбэкам якорного поиска. Origin сменился —
|
||
# вкладку переоткрываем на новый.
|
||
_anchor_origins: dict[str, str] = {}
|
||
_page_counters: dict[str, int] = {} # provider → страниц с момента launch'а
|
||
_locks: dict[str, asyncio.Lock] = {} # provider → Lock (весь доступ к инстансу)
|
||
_retry_tasks: dict[str, asyncio.Task[None]] = {} # provider → фоновая retry-задача
|
||
_last_goto_at: dict[str, float] = {} # provider → loop-time последнего page.goto (пейсинг)
|
||
# provider → HTTP-статус ответа ПОСЛЕДНЕЙ целевой навигации (#3196). Пишется в
|
||
# _fetch_once (сбрасывается в None перед навигацией, чтобы не отдать чужой
|
||
# протухший статус), читается fetch_handler'ом под тем же _locks[provider] — гонки нет.
|
||
_last_response_status: dict[str, int | None] = {}
|
||
# #2164 P4: proxy-url, с которым СЕЙЧАС запущен инстанс провайдера (env или динамический
|
||
# из пула, переданный в теле /fetch). Нужен для политики «relaunch ТОЛЬКО при реальной
|
||
# смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим,
|
||
# если запрошенный proxy совпадает с текущим. None = запущен без прокси (прямое).
|
||
_launched_proxy: dict[str, str | None] = {}
|
||
|
||
# Guard на ленивое создание per-provider локов: setdefault на обычном dict из
|
||
# разных корутин гонок не даёт (нет await между read-modify-write), но держим
|
||
# явный guard на случай будущей сложной инициализации. Создаётся в _on_startup.
|
||
_locks_guard: asyncio.Lock | None = None
|
||
|
||
# Resilience (#crash-storm): если прокси (mproxy.site) лежит, camoufox делает
|
||
# eager public_ip(proxy) для geoip-fingerprint и бросает InvalidProxy. Раньше это
|
||
# валило app.startup → exit(1) → docker restart → ∞ (RestartCount 3000+). Теперь
|
||
# фейл launch'а НЕ фатален: контейнер остаётся Up, а фоновая задача retry'ит
|
||
# launch с backoff пока прокси не вернётся. proxy+geoip СОХРАНЕНЫ (анти-бан).
|
||
_BROWSER_RETRY_MIN_S: float = 30.0 # стартовый интервал retry launch'а
|
||
_BROWSER_RETRY_MAX_S: float = 60.0 # потолок backoff'а
|
||
|
||
|
||
async def _lock_for(provider: str) -> asyncio.Lock:
|
||
"""Возвращает (создавая лениво под guard'ом) per-provider лок.
|
||
|
||
Лок сериализует launch+fetch+login на инстансе поставщика: один поставщик =
|
||
один браузер = ≤1 операция за раз. Guard защищает создание лока от гонки
|
||
параллельных корутин на первом запросе к новому поставщику.
|
||
"""
|
||
existing = _locks.get(provider)
|
||
if existing is not None:
|
||
return existing
|
||
assert _locks_guard is not None, "_locks_guard not initialised"
|
||
async with _locks_guard:
|
||
# setdefault идемпотентен — вторая корутина под guard получит уже созданный лок.
|
||
return _locks.setdefault(provider, asyncio.Lock())
|
||
|
||
|
||
async def _launch_browser(provider: str, proxy_override: str | None = None) -> None:
|
||
"""Запускает AsyncCamoufox для поставщика и кладёт в *_…[provider].
|
||
|
||
proxy_override (#2164 P4): если задан — запускаем camoufox с ЭТИМ прокси (динамический
|
||
из пула, переданный scraper-стороной в теле /fetch). None → env-прокси
|
||
_provider_proxy(provider) как раньше. geoip=True (фингерпринт привязан к гео IP
|
||
конкретного прокси — поэтому отдельный инстанс на каждого поставщика).
|
||
|
||
Эффективный proxy-url сохраняется в _launched_proxy[provider] для политики
|
||
«relaunch только при реальной смене» (см. _ensure_browser).
|
||
"""
|
||
from camoufox.async_api import AsyncCamoufox
|
||
|
||
proxy_url = proxy_override if proxy_override is not None else _provider_proxy(provider)
|
||
proxy = _parse_proxy(proxy_url)
|
||
kwargs: dict[str, object] = {
|
||
"headless": True,
|
||
"os": "windows",
|
||
"locale": "ru-RU",
|
||
"geoip": True,
|
||
"humanize": True,
|
||
# Значение per-provider (#3185): env BROWSER_BLOCK_IMAGES /
|
||
# BROWSER_BLOCK_IMAGES_{PROVIDER}, см. docstring модуля. Дефолт для всех — True
|
||
# (блокируем), почему именно так — у _BLOCK_IMAGES_DEFAULT_BY_PROVIDER.
|
||
"block_images": _BLOCK_IMAGES_BY_PROVIDER.get(provider, _BLOCK_IMAGES_DEFAULT_FALLBACK),
|
||
# Анти-leak: WebRTC может слить реальный IP контейнера мимо прокси → деанон.
|
||
"block_webrtc": True,
|
||
# Форсим таймзону +3 МСК. geoip берёт tz одного IP на старте, а БэкКоннект
|
||
# ротирует IP на каждый запрос (видели Ульяновск UTC+4 при выходе СПб/МСК) →
|
||
# геолик tz↔IP. Жёсткий Europe/Moscow убирает рассинхрон для РФ-мобильного.
|
||
"config": {"timezone": "Europe/Moscow"},
|
||
}
|
||
if proxy is not None:
|
||
kwargs["proxy"] = proxy
|
||
|
||
logger.info(
|
||
"tradein-browser[%s]: запуск AsyncCamoufox (proxy=%s, recycle_pages=%d)",
|
||
provider,
|
||
proxy is not None,
|
||
_RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK),
|
||
)
|
||
cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type]
|
||
browser = await cm.__aenter__()
|
||
_browser_cms[provider] = cm
|
||
_browsers[provider] = browser
|
||
_page_counters[provider] = 0
|
||
_launched_proxy[provider] = proxy_url
|
||
logger.info(
|
||
"tradein-browser[%s]: браузер запущен (proxy_override=%s)",
|
||
provider,
|
||
proxy_override is not None,
|
||
)
|
||
|
||
|
||
async def _ensure_anchor_page(provider: str, origin: str) -> bool:
|
||
"""Держит вкладку с ``origin`` открытой на всё время жизни context'а провайдера.
|
||
|
||
Возвращает True, если якорь готов (значит вызывающему НЕ надо делать
|
||
``goto(origin)`` перед целевой навигацией), и False — если поднять якорь не
|
||
вышло и нужно откатиться на прежнее поведение.
|
||
|
||
Зачем: до этого origin переоткрывался перед КАЖДОЙ карточкой, и полная
|
||
навигация по выдаче оплачивалась заново. Замер 29.08.2026 на 131 карточке —
|
||
4-5 с на карточку с живой вкладкой против 17-52 с с переоткрытием, причём во
|
||
втором случае время росло по ходу прогона.
|
||
|
||
Ошибку глотаем намеренно: якорь — оптимизация, а не обязательный шаг. Если
|
||
вкладка не поднялась, вызывающий сделает goto(origin) как раньше, и запрос
|
||
отработает штатно, просто медленнее.
|
||
"""
|
||
ctx = _contexts.get(provider)
|
||
if ctx is None:
|
||
return False
|
||
|
||
page = _anchor_pages.get(provider)
|
||
if page is not None:
|
||
try:
|
||
alive = not page.is_closed() # type: ignore[attr-defined]
|
||
except Exception:
|
||
alive = False
|
||
# Живая вкладка годится ТОЛЬКО если стоит на запрошенном origin: иначе
|
||
# Referer назовёт выдачу, которой в этом контексте не открывали (см.
|
||
# комментарий у _anchor_origins). Чужой origin — закрываем и поднимаем
|
||
# заново; это дороже на одну навигацию, но честно.
|
||
if alive and _anchor_origins.get(provider) == origin:
|
||
return True
|
||
if alive:
|
||
logger.info(
|
||
"tradein-browser[%s]: якорь стоял на %s, запрошен %s — переоткрываем",
|
||
provider,
|
||
_anchor_origins.get(provider),
|
||
origin,
|
||
)
|
||
try:
|
||
await page.close() # type: ignore[attr-defined]
|
||
except Exception:
|
||
pass
|
||
_anchor_pages.pop(provider, None)
|
||
_anchor_origins.pop(provider, None)
|
||
|
||
try:
|
||
page = await ctx.new_page() # type: ignore[attr-defined]
|
||
await _apply_resource_block(page)
|
||
# #3251/#3258: за origin для включённых провайдеров идём через реальный
|
||
# поиск yandex.ru, а не голым goto — так это делает человек. anchored_page
|
||
# не None только если переход на origin был НАСТОЯЩИЙ (см. докстринг
|
||
# _navigate_anchor_via_search) — тогда это либо та же page, либо новая
|
||
# вкладка (клик открыл её через target="_blank"), и её нужно взять как
|
||
# якорную вместо исходной. None — переход не случился ни в каком виде,
|
||
# откатываемся на прежний прямой goto(origin) БЕЗ referer, ровно как до
|
||
# #3251: заявлять переход, которого не было, нельзя.
|
||
anchored_page = await _navigate_anchor_via_search(page, provider, origin)
|
||
if anchored_page is not None:
|
||
page = anchored_page # type: ignore[assignment]
|
||
else:
|
||
await page.goto( # type: ignore[attr-defined]
|
||
origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
|
||
)
|
||
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"tradein-browser[%s]: якорная вкладка не поднялась (%s) — откат на "
|
||
"переоткрытие origin перед каждой навигацией",
|
||
provider,
|
||
type(exc).__name__,
|
||
)
|
||
if page is not None:
|
||
try:
|
||
await page.close() # type: ignore[attr-defined]
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
_anchor_pages[provider] = page
|
||
_anchor_origins[provider] = origin
|
||
logger.info("tradein-browser[%s]: якорная вкладка открыта на %s", provider, origin)
|
||
return True
|
||
|
||
|
||
async def _close_reusable_context(provider: str) -> None:
|
||
"""Закрывает и выбрасывает сохранённый переиспользуемый context провайдера (#3118).
|
||
|
||
No-op если контекста нет (флаг reuse_context никогда не включался или уже сброшен).
|
||
Best-effort — ошибка закрытия не должна ронять caller'а (reset_context/relaunch/
|
||
shutdown идут дальше вне зависимости от исхода close()).
|
||
"""
|
||
# Якорная вкладка принадлежит этому context'у и умирает вместе с ним — важно
|
||
# снять ссылку ДО close(), иначе следующий запрос возьмёт закрытую страницу.
|
||
_anchor_pages.pop(provider, None)
|
||
_anchor_origins.pop(provider, None)
|
||
ctx = _contexts.pop(provider, None)
|
||
if ctx is None:
|
||
return
|
||
try:
|
||
await ctx.close() # type: ignore[attr-defined]
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"tradein-browser[%s]: ошибка при закрытии переиспользуемого context: %s",
|
||
provider,
|
||
type(exc).__name__,
|
||
)
|
||
|
||
|
||
async def _close_browser(provider: str) -> None:
|
||
"""Закрывает per-provider браузер + переиспользуемый context (если есть, #3118)."""
|
||
await _close_reusable_context(provider)
|
||
cm = _browser_cms.get(provider)
|
||
if cm is not None:
|
||
try:
|
||
await cm.__aexit__(None, None, None) # type: ignore[attr-defined]
|
||
logger.info("tradein-browser[%s]: браузер закрыт", provider)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"tradein-browser[%s]: ошибка при закрытии браузера: %s",
|
||
provider,
|
||
type(exc).__name__,
|
||
)
|
||
_browsers.pop(provider, None)
|
||
_browser_cms.pop(provider, None)
|
||
_page_counters[provider] = 0
|
||
_last_goto_at.pop(provider, None)
|
||
_launched_proxy.pop(provider, None)
|
||
|
||
|
||
async def _relaunch_browser(provider: str, proxy_override: str | None = None) -> None:
|
||
"""Закрывает и заново запускает per-provider браузер (recycle / crash-recovery).
|
||
|
||
proxy_override (#2164 P4): прокинуть конкретный прокси в новый инстанс. None →
|
||
СОХРАНИТЬ текущий прокси инстанса (_launched_proxy) — recycle/crash-recovery не должны
|
||
терять динамический прокси пула и не должны его менять (это НЕ смена прокси). Если
|
||
инстанс ещё не запускался (_launched_proxy пуст) — None → env-прокси в _launch_browser.
|
||
Caller держит _locks[provider], поэтому параллельных страниц на этом инстансе нет —
|
||
teardown безопасен.
|
||
"""
|
||
effective = proxy_override if proxy_override is not None else _launched_proxy.get(provider)
|
||
logger.info("tradein-browser[%s]: перезапуск браузера", provider)
|
||
await _close_browser(provider)
|
||
await _launch_browser(provider, proxy_override=effective)
|
||
|
||
|
||
async def _try_launch_browser(provider: str, proxy_override: str | None = None) -> bool:
|
||
"""Одна попытка launch'а поставщика с санитизированным логом фейла.
|
||
|
||
НЕ пробрасывает исключение наружу — возвращает True/False. Критично для
|
||
resilience: фейл launch'а (прокси лёг → InvalidProxy) не должен валить
|
||
startup или фоновую задачу. Креды прокси НЕ логируются: camoufox светит
|
||
``user:pass@host`` в тексте своего исключения, поэтому мы логируем только
|
||
тип, без ``str(exc)``.
|
||
|
||
При частичном фейле (CM создан, но __aenter__ не довёл) чистит состояние
|
||
через _close_browser, чтобы следующий retry стартовал с чистого листа.
|
||
"""
|
||
try:
|
||
await _launch_browser(provider, proxy_override=proxy_override)
|
||
return True
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"tradein-browser[%s]: browser launch failed: proxy unreachable "
|
||
"(exc_type=%s) — контейнер остаётся Up, retry на следующем запросе/в фоне",
|
||
provider,
|
||
type(exc).__name__,
|
||
)
|
||
await _close_browser(provider)
|
||
return False
|
||
|
||
|
||
async def _ensure_browser(provider: str, proxy_override: str | None = None) -> bool:
|
||
"""Гарантирует поднятый инстанс поставщика с нужным прокси; lazy launch / relaunch.
|
||
|
||
proxy_override (#2164 P4): желаемый динамический прокси из пула (тело /fetch). None →
|
||
желаемый прокси = env (_provider_proxy). Политика «relaunch ТОЛЬКО при реальной смене»:
|
||
- инстанса нет → launch с желаемым прокси;
|
||
- инстанс есть, но _launched_proxy[provider] != желаемого → close + relaunch;
|
||
- инстанс есть и прокси совпадает → no-op (НЕ релончим — camoufox дорого поднимать).
|
||
При выключенном флаге на scraper-стороне proxy_override всегда None → желаемый=env=
|
||
текущий → релонча нет, поведение идентично прежнему (golden-parity).
|
||
|
||
Caller держит _locks[provider] (весь доступ к инстансу сериализован), поэтому здесь
|
||
своей сериализации нет — двух одновременных launch'ей на один provider быть не может.
|
||
|
||
Returns:
|
||
True если браузер доступен (был или поднялся), False если launch не удался
|
||
(прокси лежит) — caller отдаёт 503.
|
||
|
||
При неудачном launch (прокси лёг) запускает фоновую retry-задачу для ЭТОГО
|
||
поставщика (env-прокси). Поставщики независимы — фоновый retry поднимается только
|
||
для тех, к кому был запрос, а не для всех сразу.
|
||
"""
|
||
desired_proxy = proxy_override if proxy_override is not None else _provider_proxy(provider)
|
||
if _browsers.get(provider) is not None:
|
||
if _launched_proxy.get(provider) == desired_proxy:
|
||
return True
|
||
# Реальная смена прокси → пересобрать инстанс с новым прокси.
|
||
logger.info(
|
||
"tradein-browser[%s]: прокси изменился (override=%s) — relaunch перед fetch",
|
||
provider,
|
||
proxy_override is not None,
|
||
)
|
||
await _close_browser(provider)
|
||
|
||
await _try_launch_browser(provider, proxy_override=proxy_override)
|
||
if _browsers.get(provider) is not None:
|
||
return True
|
||
_start_retry_task(provider)
|
||
return False
|
||
|
||
|
||
async def _browser_retry_loop(provider: str) -> None:
|
||
"""Фоновый retry launch'а поставщика с backoff, пока прокси не вернётся.
|
||
|
||
Идемпотентна: если инстанс уже поднят — сразу выходит. Иначе ретраит
|
||
_try_launch_browser под _locks[provider] с возрастающим интервалом
|
||
(_BROWSER_RETRY_MIN_S → _BROWSER_RETRY_MAX_S). Завершается после первого
|
||
успешного launch'а. Отменяется в _on_cleanup (CancelledError пробрасывается).
|
||
"""
|
||
if _browsers.get(provider) is not None:
|
||
return
|
||
|
||
delay = _BROWSER_RETRY_MIN_S
|
||
while _browsers.get(provider) is None:
|
||
await asyncio.sleep(delay)
|
||
lock = await _lock_for(provider)
|
||
async with lock:
|
||
if _browsers.get(provider) is not None:
|
||
return # handler уже поднял инстанс — задача отработала
|
||
logger.info("tradein-browser[%s]: фоновый retry launch'а браузера", provider)
|
||
launched = await _try_launch_browser(provider)
|
||
if launched:
|
||
logger.info(
|
||
"tradein-browser[%s]: браузер поднят после retry, задача завершена", provider
|
||
)
|
||
return
|
||
delay = min(delay * 1.5, _BROWSER_RETRY_MAX_S)
|
||
|
||
|
||
def _start_retry_task(provider: str) -> None:
|
||
"""Запускает (если ещё нет) фоновую retry-задачу для поставщика."""
|
||
existing = _retry_tasks.get(provider)
|
||
if existing is not None and not existing.done():
|
||
return # задача уже бежит — не плодим дубли
|
||
_retry_tasks[provider] = asyncio.create_task(_browser_retry_loop(provider))
|
||
|
||
|
||
# ── aiohttp lifecycle hooks ────────────────────────────────────────────────────
|
||
|
||
|
||
async def _on_startup(app: web.Application) -> None:
|
||
# Startup ТОЛЬКО инициализирует guard для ленивого создания локов — браузеры
|
||
# НЕ поднимаются здесь. Lazy-launch: инстанс конкретного поставщика стартует на
|
||
# первом /fetch (или /login) к нему через _ensure_browser под _locks[provider].
|
||
#
|
||
# Eager-launch всех 4 на старте давал бы 4 camoufox-инстанса сразу → риск OOM:
|
||
# до 4 camoufox-инстансов (avito/cian/yandex/generic), lazy; ~0.5-1ГБ RSS
|
||
# каждый, mem_limit контейнера 5g. Resilience (фоновый retry при недоступном
|
||
# прокси) запускается при первом обращении к поставщику, не для всех сразу.
|
||
global _locks_guard
|
||
_locks_guard = asyncio.Lock()
|
||
logger.info(
|
||
"tradein-browser: per-provider concurrency (lazy launch) — "
|
||
"параллель между %s, ≤1 на поставщика; браузеры стартуют по требованию",
|
||
list(PROVIDERS),
|
||
)
|
||
logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER)
|
||
logger.info("tradein-browser: block-images %s", _BLOCK_IMAGES_BY_PROVIDER)
|
||
logger.info("tradein-browser: recycle-pages %s", _RECYCLE_PAGES_BY_PROVIDER)
|
||
_warn_retired_env()
|
||
|
||
|
||
# Переменные, которые окружение всё ещё задаёт, а код больше не читает. Ставим сюда
|
||
# ровно те, что реально видели в проде — молчаливо игнорируемая ручка опаснее
|
||
# отсутствующей: оператор ставит BROWSER_BLOCK_RESOURCES=false, чтобы посмотреть
|
||
# страницу с ресурсами, ничего не меняется, и он делает вывод не о переменной,
|
||
# а о блокировке.
|
||
_RETIRED_ENV: dict[str, str] = {
|
||
# #1812 заменил булев выключатель на список типов. Блокировка при этом НЕ
|
||
# ослабла: image глушится camoufox block_images (_launch_browser), font/media —
|
||
# дефолтом BROWSER_BLOCK_RESOURCE_TYPES. Мёртв только сам выключатель.
|
||
"BROWSER_BLOCK_RESOURCES": (
|
||
"BROWSER_BLOCK_RESOURCE_TYPES (CSV типов; пустая строка = не блокировать)"
|
||
),
|
||
}
|
||
|
||
|
||
def _warn_retired_env() -> None:
|
||
"""Предупреждает про заданные env, которые код больше не читает (#2674)."""
|
||
for name, replacement in _RETIRED_ENV.items():
|
||
if os.environ.get(name) is not None:
|
||
logger.warning(
|
||
"tradein-browser: %s=%r задана, но НЕ читается кодом с #1812 — "
|
||
"значение ни на что не влияет. Замена: %s. Уберите переменную из "
|
||
"окружения, чтобы она не выглядела рабочим выключателем",
|
||
name,
|
||
os.environ[name],
|
||
replacement,
|
||
)
|
||
|
||
|
||
async def _on_cleanup(app: web.Application) -> None:
|
||
# Отменяем все фоновые retry-задачи.
|
||
for provider, task in list(_retry_tasks.items()):
|
||
if task is not None and not task.done():
|
||
task.cancel()
|
||
try:
|
||
await task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"tradein-browser[%s]: ошибка при отмене retry-задачи: %s",
|
||
provider,
|
||
type(exc).__name__,
|
||
)
|
||
_retry_tasks.clear()
|
||
|
||
# Закрываем все поднятые инстансы.
|
||
for provider in list(_browsers.keys()):
|
||
await _close_browser(provider)
|
||
|
||
|
||
# ── handlers ───────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _resolve_provider(body: dict, url: str) -> str:
|
||
"""Выбирает поставщика: явный body["provider"]/body["source"] (если валиден) иначе host.
|
||
|
||
Caller (browser_fetcher.py) шлёт {"url","source"} — поэтому explicit-значение
|
||
берём из "provider" ИЛИ "source", но применяем ТОЛЬКО если оно в PROVIDERS;
|
||
иначе host-detect; иначе generic.
|
||
|
||
# domclick (#3205): explicit body["source"]="domklik" (опечатка площадки в
|
||
# caller'е) нет в PROVIDERS дословно → fallthrough на host-detect, а
|
||
# _provider_for_url узнаёт domclick.ru по host и отдаёт "domclick" — ЭТО
|
||
# больше НЕ synonim для generic. До #3205 комментарий здесь утверждал, что
|
||
# generic-путь эквивалентен, потому что все поставщики делят один
|
||
# SCRAPER_PROXY_URL (#2616 шаг 2) — прокси действительно общий, но с #3205 у
|
||
# domclick СВОЙ порог перезапуска браузера (recycle_pages=1, см.
|
||
# _RECYCLE_PAGES_BY_PROVIDER): площадка отдаёт ровно одну карточку на процесс
|
||
# camoufox, и общий с generic счётчик страниц пачкал бы чужие запросы чужим
|
||
# порогом.
|
||
"""
|
||
explicit = body.get("provider") or body.get("source")
|
||
if isinstance(explicit, str) and explicit in PROVIDERS:
|
||
return explicit
|
||
return _provider_for_url(url)
|
||
|
||
|
||
def _resolve_proxy_override(body: dict, provider: str) -> str | None:
|
||
"""Динамический прокси из тела /fetch (#2164 P4). None → env-прокси (текущее поведение).
|
||
|
||
Scraper-сторона (BrowserFetcher за флагом use_proxy_pool_browser) кладёт в тело
|
||
``{"proxy": "http://user:pass@host:port", "proxy_kind": "http"}`` — прокси, взятый из
|
||
пула через acquire(source). Если поля нет / пусто → None → инстанс поставщика берёт
|
||
общий SCRAPER_PROXY_URL (#2616 шаг 2), поведение не меняется (ship-dark parity).
|
||
|
||
camoufox НЕ умеет socks5 с авторизацией → принимаем ТОЛЬКО http/https-прокси; иной
|
||
proxy_kind игнорируем (→ None, env-fallback) с предупреждением. Отсутствие proxy_kind
|
||
трактуем как http (в пуле kind=http).
|
||
"""
|
||
raw = body.get("proxy")
|
||
if not isinstance(raw, str) or not raw:
|
||
return None
|
||
kind = body.get("proxy_kind")
|
||
if isinstance(kind, str) and kind and kind.lower() not in ("http", "https"):
|
||
logger.warning(
|
||
"tradein-browser[%s]: proxy_kind=%r не поддерживается camoufox — env-прокси",
|
||
provider,
|
||
kind,
|
||
)
|
||
return None
|
||
return raw
|
||
|
||
|
||
async def health_handler(request: web.Request) -> web.Response:
|
||
# Всегда 200 — иначе compose healthcheck (если появится) убьёт контейнер,
|
||
# хотя процесс жив и ретраит браузеры. browsers — per-provider readiness для
|
||
# observability: видно какой поставщик лежит, не теряя контейнер. Текущий prod
|
||
# compose НЕ имеет healthcheck на browser (depends_on: service_started).
|
||
browsers = {p: _browsers.get(p) is not None for p in PROVIDERS}
|
||
return web.json_response({"status": "ok", "browsers": browsers})
|
||
|
||
|
||
async def fetch_handler(request: web.Request) -> web.Response:
|
||
"""POST /fetch {"url": "..."} → {"html": "...", "status": <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,
|
||
)
|
||
|
||
provider = _resolve_provider(body, url)
|
||
proxy_override = _resolve_proxy_override(body, provider)
|
||
|
||
# #2616 шаг 1: прод + нет НИ override, НИ env-прокси → явный отказ ДО лока/launch'а,
|
||
# а не тихий заход camoufox напрямую с IP сервера (dev это легитимно, см. _no_live_proxy).
|
||
if IS_PROD and _no_live_proxy(provider, proxy_override):
|
||
logger.warning(
|
||
"tradein-browser[%s]: /fetch отказ — нет прокси в теле и нет env-прокси "
|
||
"(prod) — не подключаемся напрямую с IP сервера (#2616)",
|
||
provider,
|
||
)
|
||
return web.json_response(
|
||
{"error": "no proxy configured — refusing direct connection (prod)"}, status=503
|
||
)
|
||
|
||
lock = await _lock_for(provider)
|
||
async with lock:
|
||
# Браузер мог не подняться на старте (прокси лежал). Одна lazy-попытка —
|
||
# вдруг прокси уже вернулся. НИКОГДА не фетчим без прокси/браузера: если
|
||
# всё ещё None → 503 (caller словит как HTTPStatusError → failed fetch, не
|
||
# crash). proxy_override (#2164 P4): relaunch camoufox с прокси из пула, если
|
||
# он отличается от текущего launch-прокси инстанса (иначе no-op).
|
||
if not await _ensure_browser(provider, proxy_override=proxy_override):
|
||
logger.warning(
|
||
"tradein-browser[%s]: /fetch 503 — браузер недоступен (proxy may be down)",
|
||
provider,
|
||
)
|
||
return web.json_response(
|
||
{"error": "browser unavailable (proxy may be down)"}, status=503
|
||
)
|
||
|
||
try:
|
||
html = await _do_fetch(
|
||
provider,
|
||
url,
|
||
origin=origin,
|
||
referer=referer,
|
||
cookies=cookies,
|
||
reuse_context=reuse_context,
|
||
reset_context=reset_context,
|
||
fetch_mode=fetch_mode,
|
||
)
|
||
# Читаем статус под тем же локом, что и сам фетч, — иначе следующий
|
||
# запрос этого провайдера успел бы его перезаписать.
|
||
status = _last_response_status.get(provider)
|
||
except Exception as exc:
|
||
logger.error(
|
||
"tradein-browser[%s]: fetch error url=%r: %s: %s",
|
||
provider,
|
||
url,
|
||
type(exc).__name__,
|
||
exc,
|
||
)
|
||
error_body: dict = {"error": f"{type(exc).__name__}: {exc}"}
|
||
if isinstance(exc, BanPageDetectedError):
|
||
# #3239: бан-страница — ПОДТВЕРЖДЁННЫЙ маркер-детект, а не сбой
|
||
# транспорта, и только за первым стоит report_ban на клиенте.
|
||
# До этой правки оба случая приезжали одинаковой 500-кой, клиент
|
||
# различить их не мог и настоящий отказ площадки переставал
|
||
# ротировать узел (регрессия #3237). Код ответа НЕ меняем: на 500
|
||
# завязана classify_browser_probe, признак несёт тело.
|
||
error_body["ban_page"] = True
|
||
error_body["status"] = _last_response_status.get(provider)
|
||
return web.json_response(error_body, status=500)
|
||
|
||
# Аддитивно (#3196): ключ "html" на месте и не изменился — клиент, читающий
|
||
# только его, ничего не заметит. "status" может быть null (goto вернул None).
|
||
return web.json_response({"html": html, "status": status})
|
||
|
||
|
||
async def fetch_json_handler(request: web.Request) -> web.Response:
|
||
"""POST /fetch-json {"url","method","headers","body","origin","source"} → {"status","body"}
|
||
|
||
Выполняет in-page ``fetch()`` ИЗ ТЁПЛОЙ страницы браузера поставщика: камуфокс
|
||
сначала переходит на ``origin`` (same-origin якорь), затем дёргает ``fetch(url)``
|
||
с реальным фингерпринтом + контекст-cookies этого инстанса + per-provider прокси.
|
||
Возвращает JSON/текст ответа как ``{"status": int, "body": str}``.
|
||
|
||
Чисто аддитивный путь (#915 Stage 2) — никто из прод-флоу пока не вызывает.
|
||
Делит тот же per-provider браузер/лок что и /fetch (поставщик по host URL или
|
||
явному body["provider"]/["source"]). Берётся ТОЛЬКО лок этого поставщика →
|
||
разные поставщики работают параллельно, внутри поставщика — строго ≤1 операция.
|
||
"""
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
return web.json_response({"error": "invalid JSON body"}, status=400)
|
||
|
||
url: str | None = body.get("url")
|
||
if not url:
|
||
return web.json_response({"error": "missing 'url' field"}, status=400)
|
||
|
||
method: str = body.get("method") or "GET"
|
||
headers: dict = body.get("headers") or {}
|
||
req_body = body.get("body") # None | str (caller сериализует через json.dumps)
|
||
# origin — same-origin страница, на которую перейдёт камуфокс перед fetch'ем.
|
||
# Явный body["origin"] имеет приоритет; иначе выводим f"{scheme}://{host}/" из url.
|
||
origin: str | None = body.get("origin")
|
||
if not origin:
|
||
parsed = urlparse(url)
|
||
origin = f"{parsed.scheme}://{parsed.netloc}/"
|
||
|
||
provider = _resolve_provider(body, url)
|
||
proxy_override = _resolve_proxy_override(body, provider)
|
||
|
||
# #2616 шаг 1: см. fetch_handler — прод + нет прокси вообще → отказ, не direct-IP.
|
||
if IS_PROD and _no_live_proxy(provider, proxy_override):
|
||
logger.warning(
|
||
"tradein-browser[%s]: /fetch-json отказ — нет прокси в теле и нет env-прокси "
|
||
"(prod) — не подключаемся напрямую с IP сервера (#2616)",
|
||
provider,
|
||
)
|
||
return web.json_response(
|
||
{"error": "no proxy configured — refusing direct connection (prod)"}, status=503
|
||
)
|
||
|
||
lock = await _lock_for(provider)
|
||
async with lock:
|
||
# Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
|
||
# Lazy-попытка, иначе 503 — без прокси/браузера не фетчим. proxy_override
|
||
# (#2164 P4): relaunch с прокси из пула при реальной смене.
|
||
if not await _ensure_browser(provider, proxy_override=proxy_override):
|
||
logger.warning(
|
||
"tradein-browser[%s]: /fetch-json 503 — браузер недоступен (proxy may be down)",
|
||
provider,
|
||
)
|
||
return web.json_response(
|
||
{"error": "browser unavailable (proxy may be down)"}, status=503
|
||
)
|
||
|
||
try:
|
||
result = await _do_fetch_json(
|
||
provider, url, method=method, headers=headers, body=req_body, origin=origin
|
||
)
|
||
except Exception as exc:
|
||
logger.error(
|
||
"tradein-browser[%s]: fetch-json error url=%r: %s: %s",
|
||
provider,
|
||
url,
|
||
type(exc).__name__,
|
||
exc,
|
||
)
|
||
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
|
||
|
||
return web.json_response({"status": result["status"], "body": result["body"]})
|
||
|
||
|
||
async def _apply_resource_block(page: object) -> None:
|
||
"""Навешивает route-interception, блокирующую тяжёлые под-ресурсы (font/media).
|
||
|
||
No-op при пустом _BLOCKED_TYPES (BROWSER_BLOCK_RESOURCE_TYPES=""). Route живёт на
|
||
странице и снимается автоматически при page.close(). Блокируем ТОЛЬКО _BLOCKED_TYPES
|
||
(по умолчанию font/media) — document/script/stylesheet/xhr/fetch проходят, чтобы
|
||
гидрация listings работала и stealth не палился «браузером без ресурсов».
|
||
|
||
Handler обёрнут в try/except: любой сбой abort/continue (например, route уже
|
||
обработан гонкой) деградирует в continue_(), чтобы один битый ресурс не ронял
|
||
навигацию. Сам continue_() тоже под try — если страница уже закрыта, молча выходим.
|
||
"""
|
||
if not _BLOCKED_TYPES:
|
||
return
|
||
|
||
async def _route_block(route: object) -> None: # type: ignore[no-untyped-def]
|
||
try:
|
||
if route.request.resource_type in _BLOCKED_TYPES: # type: ignore[attr-defined]
|
||
await route.abort() # type: ignore[attr-defined]
|
||
else:
|
||
await route.continue_() # type: ignore[attr-defined]
|
||
except Exception:
|
||
# Fallback: не блокируем спорный ресурс, пропускаем его — лучше лишний
|
||
# байт, чем сорванная навигация. continue_() под своим try на случай
|
||
# уже-закрытой страницы / повторно обработанного route.
|
||
try:
|
||
await route.continue_() # type: ignore[attr-defined]
|
||
except Exception:
|
||
pass
|
||
|
||
await page.route("**/*", _route_block) # type: ignore[attr-defined]
|
||
|
||
|
||
async def _pace_provider(provider: str) -> None:
|
||
"""Притормаживает перед page.goto, если с прошлого goto этого провайдера прошло
|
||
меньше эффективного интервала — даёт под-коннектам прошлой страницы дренироваться,
|
||
снижая пиковый параллелизм на прокси.
|
||
|
||
Caller держит _locks[provider] → timestamp per-provider читается/пишется без гонки.
|
||
Записывает момент ПОСЛЕ возможного sleep (т.е. фактический момент следующего goto),
|
||
чтобы интервал считался между реальными навигациями, а не между входами в функцию.
|
||
No-op при эффективном интервале провайдера <= 0.
|
||
"""
|
||
interval = _MIN_PAGE_INTERVAL_BY_PROVIDER.get(provider, BROWSER_MIN_PAGE_INTERVAL_S)
|
||
if interval <= 0:
|
||
return
|
||
now = asyncio.get_event_loop().time()
|
||
last = _last_goto_at.get(provider)
|
||
if last is not None:
|
||
elapsed = now - last
|
||
remaining = interval - elapsed
|
||
if remaining > 0:
|
||
logger.debug(
|
||
"tradein-browser[%s]: pacing — ждём %.2fс перед goto", provider, remaining
|
||
)
|
||
await asyncio.sleep(remaining)
|
||
_last_goto_at[provider] = asyncio.get_event_loop().time()
|
||
|
||
|
||
# ── Режимы получения тела (#3264) ────────────────────────────────────────────────
|
||
# Сайдкар всю жизнь умел ровно одно: navigate — page.goto(url). Для HTML-страницы это
|
||
# и есть то, что делает человек. Но два нужных нам ресурса Домклика страницами НЕ
|
||
# являются: BFF-ручка /api/offers/v1 отдаёт JSON, а карта офферов
|
||
# (sitemap-offers-1.xml.gz) — вообще gzip-файл. Живой сайт их так и не запрашивает:
|
||
# перехват сети на выдаче 30.08.2026 показал, что офферы приезжают в SSR-документе, а
|
||
# к BFF ходят XHR'ы за гео/районами/метро. Навигацией браузера на API-хост мы делаем
|
||
# то, чего настоящий клиент не делает никогда, — и через узлы пула это упирается в
|
||
# ChallengeTimeout, тогда как карточки через те же узлы в те же минуты идут.
|
||
#
|
||
# Отсюда два дополнительных режима, оба — ПОСЛЕ прогрева origin:
|
||
# subresource — context.request.get(url): запрос из browser-контекста, куки и прокси
|
||
# общие со страницей, но это не JS страницы;
|
||
# page_fetch — fetch(url, {credentials: 'include'}) ИЗ прогретой страницы: ровно то,
|
||
# что делает SPA, вместе с Origin/Referer/Sec-Fetch-* и cookie jar'ом.
|
||
# Какой из них проходит через пул — вопрос замера, а не рассуждения, поэтому оба.
|
||
FETCH_MODES: frozenset[str] = frozenset({"navigate", "subresource", "page_fetch"})
|
||
|
||
|
||
def _decode_body(raw: bytes) -> str:
|
||
"""Тело подзапроса строкой: gzip-файлы распаковываем, прочее декодируем как UTF-8.
|
||
|
||
Карта офферов приезжает с Content-Type: application/gzip — это gzip-ФАЙЛ, а не
|
||
Content-Encoding, поэтому HTTP-стек его не распаковывает и вызывающий получил бы
|
||
бинарь в поле "html". Распаковываем здесь по магическим байтам, чтобы контракт
|
||
/fetch остался прежним — «тело строкой».
|
||
"""
|
||
if raw[:2] == bytes((0x1F, 0x8B)): # магия gzip
|
||
try:
|
||
raw = gzip.decompress(raw)
|
||
except Exception: # noqa: BLE001 — битый gzip отдаём как есть, пусть решает caller
|
||
pass
|
||
return raw.decode("utf-8", "replace")
|
||
|
||
|
||
async def _do_fetch(
|
||
provider: str,
|
||
url: str,
|
||
*,
|
||
origin: str | None = None,
|
||
referer: str | None = None,
|
||
cookies: dict | None = None,
|
||
reuse_context: bool = False,
|
||
reset_context: bool = False,
|
||
fetch_mode: str = "navigate",
|
||
) -> str:
|
||
"""Одна попытка навигации; при краше браузера — relaunch и один retry.
|
||
|
||
Caller держит _locks[provider] (нет параллельных страниц на этом инстансе),
|
||
поэтому relaunch безопасен. Crash-relaunch сохраняет текущий прокси инстанса
|
||
(_relaunch_browser reuse _launched_proxy) — динамический прокси пула не теряется.
|
||
Crash-relaunch (через _close_browser) заодно выбрасывает переиспользуемый context
|
||
провайдера (#3118) — на retry reuse_context=True создаст его заново с чистого листа,
|
||
а не обратится к context'у уже мёртвого браузера.
|
||
|
||
origin/referer/cookies/reuse_context/reset_context — см. _fetch_once. Дефолты не
|
||
меняют поведение.
|
||
"""
|
||
try:
|
||
return await _fetch_once(
|
||
provider,
|
||
url,
|
||
origin=origin,
|
||
referer=referer,
|
||
cookies=cookies,
|
||
reuse_context=reuse_context,
|
||
reset_context=reset_context,
|
||
fetch_mode=fetch_mode,
|
||
)
|
||
except Exception as exc:
|
||
if _is_browser_crash(exc):
|
||
logger.warning(
|
||
"tradein-browser[%s]: краш браузера (%s), перезапуск + retry: %s",
|
||
provider,
|
||
type(exc).__name__,
|
||
url,
|
||
)
|
||
await _relaunch_browser(provider)
|
||
if _browsers.get(provider) is None:
|
||
raise
|
||
return await _fetch_once(
|
||
provider,
|
||
url,
|
||
origin=origin,
|
||
referer=referer,
|
||
cookies=cookies,
|
||
reuse_context=reuse_context,
|
||
reset_context=reset_context,
|
||
fetch_mode=fetch_mode,
|
||
)
|
||
raise
|
||
|
||
|
||
# ── QRATOR PoW-челлендж Авито (#3045) ────────────────────────────────────────────
|
||
# Маркеры сняты живьём с challenge-страницы Авито 2026-08-21 (замер: 6 карточек,
|
||
# органическая навигация из выдачи, 4/6 ушли с челленджа раньше времени). Разметка
|
||
# площадки может поменяться со временем — при протухании маркеров переснять их
|
||
# заново вживую, а не гадать по памяти. Форма провайдер-агностична: детектор просто
|
||
# ищет строки в HTML, другие площадки (cian/yandex/generic) их никогда не отдают,
|
||
# поэтому ветка ожидания для них не включается.
|
||
|
||
# Признак самого челленджа: JS-функция startPow(), которую страница вызывает в
|
||
# DOMContentLoaded (см. хвост challenge-скрипта в #3045), либо заголовок блока
|
||
# «Доступ ограничен: проверка безопасности» — оба встречались на снятых страницах.
|
||
_CHALLENGE_MARKERS: tuple[str, ...] = (
|
||
"startpow",
|
||
"доступ ограничен: проверка безопасности",
|
||
)
|
||
|
||
# Признак БАН-страницы (не челлендж): «Доступ ограничен: проблема с IP» — статика
|
||
# без PoW-скрипта, приходит с 403/429 и заметно меньше challenge-страницы весом.
|
||
# Ждать тут бессмысленно — адрес заблокирован, а не временно проверяется.
|
||
_BAN_MARKERS: tuple[str, ...] = ("доступ ограничен: проблема с ip",)
|
||
|
||
|
||
# HTTP-статусы, которые означают отказ площадки, а не временную проверку. Домклик
|
||
# отдаёт ровно 403 со статической страницей на 26 624 байта, где нет НИ одного
|
||
# маркера из _CHALLENGE_MARKERS/_BAN_MARKERS (оба сняты с Авито) — замер прода
|
||
# 28.08.2026, #3196. Такой ответ уезжал наверх как валидный HTML, парсер не находил
|
||
# состояние страницы, и прогон получал блок неизвестной природы (ban_kind=unknown).
|
||
# Статус — ДОПОЛНИТЕЛЬНЫЙ сигнал: ветки _is_ban_page/_is_pow_challenge остаются, они
|
||
# ловят отказ, отданный с кодом 200 (так делает Авито).
|
||
_REFUSAL_STATUSES: frozenset[int] = frozenset({403, 429})
|
||
|
||
|
||
def _status_of(response: object) -> int | None:
|
||
"""HTTP-статус playwright Response; None, если ответа нет или статус нечитаем.
|
||
|
||
``page.goto`` штатно возвращает None (редирект на тот же документ, навигационная
|
||
гонка) — это не ошибка, просто статус неизвестен.
|
||
"""
|
||
if response is None:
|
||
return None
|
||
status = getattr(response, "status", None)
|
||
return status if isinstance(status, int) else None
|
||
|
||
|
||
class ChallengeTimeoutError(Exception):
|
||
"""PoW-челлендж не снялся за BROWSER_CHALLENGE_WAIT_MS.
|
||
|
||
Caller должен трактовать как временный отказ (retry/backoff), НЕ как валидный
|
||
контент — раньше caller получал 7891-байтную challenge-страницу и парсер либо
|
||
падал на ней, либо молча ничего не находил (#3045).
|
||
"""
|
||
|
||
|
||
class BanPageDetectedError(Exception):
|
||
"""Площадка отдала бан-страницу («проблема с IP») вместо контента/челленджа.
|
||
|
||
В отличие от ChallengeTimeoutError ждать здесь бессмысленно: адрес забанен, а
|
||
не проходит временную проверку — поднимается сразу, без траты
|
||
BROWSER_CHALLENGE_WAIT_MS.
|
||
"""
|
||
|
||
|
||
def _is_pow_challenge(html: str) -> bool:
|
||
"""True, если HTML — QRATOR PoW-челлендж Авито (см. _CHALLENGE_MARKERS)."""
|
||
lower = html.lower()
|
||
return any(marker in lower for marker in _CHALLENGE_MARKERS)
|
||
|
||
|
||
def _is_ban_page(html: str) -> bool:
|
||
"""True, если HTML — бан-страница «проблема с IP» (см. _BAN_MARKERS)."""
|
||
lower = html.lower()
|
||
return any(marker in lower for marker in _BAN_MARKERS)
|
||
|
||
|
||
# ── QRATOR-рукопожатие DomClick (#3196, замер прода 29.08.2026) ─────────────────
|
||
# У DomClick, в отличие от Авито, у промежуточной (нерендеренной) PoW-страницы НЕТ
|
||
# ни одного стабильного маркера вообще — ни __qrator, ни startpow, ни текста отказа
|
||
# (замер: 3 запроса подряд на здоровом узле — 1-й вернул 401/6898 байт без единого
|
||
# опознаваемого признака, 2-й и 3-й — уже 200/готовая карточка). Строить детектор
|
||
# «это челлендж» тут не на чем: любой угаданный маркер протухнет на следующей смене
|
||
# вёрстки, а до тех пор будет ловить призрак несуществующего состояния.
|
||
#
|
||
# Поэтому логика инвертирована: опознаём положительно два КРАЙНИХ состояния —
|
||
# «точно готово» и «точно отказ», а всё остальное (загрузчик рукопожатия на 279
|
||
# байт, нерендеренная PoW-страница на ~6.9-7.1 КБ) считаем «рукопожатие ещё идёт»
|
||
# и уходим в _wait_out_pow_challenge вместо того, чтобы гадать по размеру —
|
||
# отказ у DomClick тоже статическая страница фиксированного размера (~26.6 КБ),
|
||
# см. _DOMCLICK_REFUSAL_MARKERS, поэтому размер как единственный признак ненадёжен.
|
||
#
|
||
# Признак успеха — JS-глобаль __SSR_STATE__, литеральная строка, которую кладёт
|
||
# в разметку SSR-рендер готовой карточки (та же строка, из-за отсутствия которой
|
||
# парсер бэкфилла поднимал ложный «блок», см. #3196). Сайдкар в остальном
|
||
# провайдер-агностичен и не обязан знать про внутренний контракт парсера, но
|
||
# альтернатива — «отсутствие маркеров отказа + размер» — здесь СЛАБЕЕ, а не чище:
|
||
# и загрузчик (279 байт), и промежуточная PoW-страница (~7 КБ), и будущая
|
||
# перекроенная вёрстка отказа тоже прошли бы такую проверку как «успех». Прямой
|
||
# маркер успеха — единственный вариант, который не размывается на промежуточных
|
||
# состояниях.
|
||
_DOMCLICK_SUCCESS_MARKER = "__SSR_STATE__"
|
||
|
||
# Признак ОТКАЗА площадки (не рукопожатия) — статическая страница «403 | Домклик»
|
||
# с текстом про подозрительный запрос, ~26.6 КБ, приходит с HTTP 401 (см.
|
||
# _REFUSAL_STATUSES ниже — статус НЕ используется как сигнал для DomClick, тело
|
||
# приходит с тем же кодом, что и у здорового рукопожатия и у одной из успешных
|
||
# страниц в замере). Ждать тут нечего — статическая страница сама себя не
|
||
# перезагрузит.
|
||
_DOMCLICK_REFUSAL_MARKERS: tuple[str, ...] = (
|
||
"похоже, ваш запрос выглядит необычно",
|
||
"<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,
|
||
каким ждём обычную навигацию (второй таймаут не изобретаем).
|
||
"""
|
||
poll_interval_ms = 1000
|
||
elapsed_ms = 0
|
||
reloads = 0
|
||
next_reload_at_ms = BROWSER_CHALLENGE_RELOAD_AFTER_MS
|
||
html: str | None = await _content_during_navigation(page)
|
||
while (html is None or is_pending(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS:
|
||
await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined]
|
||
elapsed_ms += poll_interval_ms
|
||
if (
|
||
reloads < BROWSER_CHALLENGE_MAX_RELOADS
|
||
and BROWSER_CHALLENGE_RELOAD_AFTER_MS > 0
|
||
and elapsed_ms >= next_reload_at_ms
|
||
):
|
||
reloads += 1
|
||
next_reload_at_ms = elapsed_ms + BROWSER_CHALLENGE_RELOAD_AFTER_MS
|
||
logger.info(
|
||
"tradein-browser[%s]: челлендж висит %dмс — перезагружаю страницу "
|
||
"(%d/%d) url=%r",
|
||
provider,
|
||
elapsed_ms,
|
||
reloads,
|
||
BROWSER_CHALLENGE_MAX_RELOADS,
|
||
url,
|
||
)
|
||
try:
|
||
await page.reload( # type: ignore[attr-defined]
|
||
timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
|
||
)
|
||
except Exception as exc:
|
||
# Перезагрузка — попытка помочь, а не обязательный шаг. Упала (гонка
|
||
# с собственным reload'ом страницы, таймаут навигации) — продолжаем
|
||
# опрос в прежнем бюджете, а не роняем фетч, который мог бы устояться.
|
||
logger.warning(
|
||
"tradein-browser[%s]: перезагрузка челленджа не удалась (%s: %s) — "
|
||
"продолжаю опрос",
|
||
provider,
|
||
type(exc).__name__,
|
||
exc,
|
||
)
|
||
html = await _content_during_navigation(page)
|
||
|
||
if html is None or is_pending(html):
|
||
raise ChallengeTimeoutError(
|
||
f"tradein-browser[{provider}]: PoW-челлендж не снялся за "
|
||
f"{BROWSER_CHALLENGE_WAIT_MS}мс url={url!r}"
|
||
)
|
||
|
||
logger.info(
|
||
"tradein-browser[%s]: PoW-челлендж снят за ~%dмс, догидрация url=%r",
|
||
provider,
|
||
elapsed_ms,
|
||
url,
|
||
)
|
||
if BROWSER_WAIT_MS > 0:
|
||
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
|
||
settled = await _content_during_navigation(page)
|
||
if settled is None:
|
||
# Догидрация совпала с ещё одной навигацией — даём один короткий добор
|
||
# вместо того, чтобы падать: контент уже не challenge, гонка чисто
|
||
# техническая.
|
||
await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined]
|
||
settled = await _content_during_navigation(page)
|
||
if settled is None:
|
||
raise ChallengeTimeoutError(
|
||
f"tradein-browser[{provider}]: челлендж снят, но страница не устоялась "
|
||
f"(навигация не прекращается) url={url!r}"
|
||
)
|
||
return settled
|
||
|
||
|
||
def _cookie_domain(url: str) -> str:
|
||
"""Домен для инъекции кук: РЕГИСТРИРУЕМЫЙ, а не хост из url.
|
||
|
||
Раньше здесь было ``f".{hostname}"``, то есть для карточки
|
||
``ekaterinburg.domclick.ru`` куки ложились на ``.ekaterinburg.domclick.ru``.
|
||
Настоящие куки площадки живут на ``.domclick.ru`` — это видно в записи ручной
|
||
сессии 29.08.2026 (``.domclick.ruqrator_jsid2``, ``.domclick.ruqrator_jsr``).
|
||
|
||
Куки поддомена родительские НЕ заменяют, а сосуществуют с ними: как только
|
||
площадка выдаёт свежий ``qrator_jsid2`` на ``.domclick.ru``, браузер шлёт в
|
||
одном запросе ДВЕ куки с этим именем, и первой — более специфичную, нашу
|
||
протухшую. QRATOR читает её и держит страницу на 274-байтном загрузчике,
|
||
рукопожатие не завершается никогда. Замер 29.08 через узел 10, чередование,
|
||
свежий контекст на пробу: куки на поддомене — 0 успехов из 3 (все три
|
||
застряли на 274 байтах), те же куки на ``.domclick.ru`` — 3 из 3.
|
||
|
||
Ограничение сознательное: берём два последних лейбла. Для доменов вида
|
||
``example.co.uk`` это дало бы ``.co.uk``, но все площадки проекта — в зонах
|
||
первого уровня (domclick.ru, avito.ru, cian.ru, yandex.ru), а тащить сюда
|
||
publicsuffix-список ради гипотетического случая дороже, чем он стоит.
|
||
Хост из одного-двух лейблов возвращаем как есть.
|
||
"""
|
||
host = urlparse(url).hostname or ""
|
||
labels = host.split(".")
|
||
if len(labels) > 2:
|
||
host = ".".join(labels[-2:])
|
||
return f".{host}"
|
||
|
||
|
||
async def _get_or_create_context(
|
||
provider: str,
|
||
browser: object,
|
||
cookies: dict | None,
|
||
url: str,
|
||
) -> object:
|
||
"""Возвращает сохранённый переиспользуемый context провайдера либо создаёт новый (#3118).
|
||
|
||
Куки вливаются ТОЛЬКО в момент создания — в уже существующий (сохранённый) context
|
||
add_cookies больше НИКОГДА не вызывается: повторная заливка замороженного снимка
|
||
из БД убивала бы живой qrator_jsid2, который площадка успела выдать через
|
||
Set-Cookie, — ровно это и было причиной бана DomClick detail-бэкфилла.
|
||
"""
|
||
existing = _contexts.get(provider)
|
||
if existing is not None:
|
||
return existing
|
||
context = await browser.new_context() # type: ignore[attr-defined]
|
||
if cookies:
|
||
cookie_domain = _cookie_domain(url)
|
||
await context.add_cookies( # type: ignore[attr-defined]
|
||
[
|
||
{"name": name, "value": value, "domain": cookie_domain, "path": "/"}
|
||
for name, value in cookies.items()
|
||
]
|
||
)
|
||
_contexts[provider] = context
|
||
logger.info("tradein-browser[%s]: создан переиспользуемый browser context (#3118)", provider)
|
||
return context
|
||
|
||
|
||
async def _fetch_subresource(
|
||
page: object, url: str, fetch_mode: str, referer: str | None
|
||
) -> tuple[bytes, int]:
|
||
"""Забирает *url* подзапросом из прогретой страницы. Возвращает (тело, HTTP-статус).
|
||
|
||
Два режима, отличающихся тем, ЧЬИМ запросом идёт обращение:
|
||
|
||
``subresource`` — ``context.request.get``: запрос браузерного контекста. Куки, прокси
|
||
и TLS общие со страницей, но это не JS страницы, поэтому заголовков ``Sec-Fetch-*`` и
|
||
``Origin`` у него нет.
|
||
|
||
``page_fetch`` — ``fetch()``, выполненный ВНУТРИ страницы. Ровно то, что делает SPA:
|
||
с ``Origin``/``Referer``/``Sec-Fetch-*`` и общим cookie jar'ом. Тело возвращается
|
||
через base64, потому что ``page.evaluate`` умеет отдавать только JSON-совместимое,
|
||
а карта офферов — двоичный gzip.
|
||
"""
|
||
if fetch_mode == "subresource":
|
||
headers = {"Referer": referer} if referer else None
|
||
resp = await page.context.request.get( # type: ignore[attr-defined]
|
||
url, timeout=BROWSER_NAV_TIMEOUT_MS, headers=headers
|
||
)
|
||
return await resp.body(), int(resp.status)
|
||
|
||
if fetch_mode == "page_fetch":
|
||
res = await page.evaluate( # type: ignore[attr-defined]
|
||
"""async (u) => {
|
||
const r = await fetch(u, {credentials: 'include'});
|
||
const buf = new Uint8Array(await r.arrayBuffer());
|
||
let s = '';
|
||
for (let i = 0; i < buf.length; i++) s += String.fromCharCode(buf[i]);
|
||
return {status: r.status, b64: btoa(s)};
|
||
}""",
|
||
url,
|
||
)
|
||
return base64.b64decode(res["b64"]), int(res["status"])
|
||
|
||
raise ValueError(f"неизвестный fetch_mode={fetch_mode!r}, допустимы {sorted(FETCH_MODES)}")
|
||
|
||
|
||
async def _fetch_once(
|
||
provider: str,
|
||
url: str,
|
||
*,
|
||
origin: str | None = None,
|
||
referer: str | None = None,
|
||
cookies: dict | None = None,
|
||
reuse_context: bool = False,
|
||
reset_context: bool = False,
|
||
fetch_mode: str = "navigate",
|
||
) -> str:
|
||
"""Открывает страницу, переходит по URL, ждёт JS, возвращает HTML.
|
||
|
||
Caller держит _locks[provider], поэтому страницы на этом инстансе не
|
||
параллелятся — recycle через _relaunch_browser безопасен прямо здесь.
|
||
|
||
origin (опционально) — same-site якорь (например SERP), на который камуфокс
|
||
заходит ПЕРЕД целевым url, чтобы получить пропуск QRATOR в контексте (зеркалит
|
||
_fetch_json_once, #1917 — DomClick card-fetch, эмпирически подтверждено вживую
|
||
2026-07-04). Работает это через КУКИ контекста, поэтому при reuse_context origin
|
||
поднимается ОДИН раз в якорной вкладке (_ensure_anchor_page), а не переоткрывается
|
||
перед каждой карточкой: контекст уже прогрет, повторный заход — трата рукопожатия.
|
||
None (дефолт) → поведение не меняется, ровно один goto(url) как раньше
|
||
(avito/cian/yandex не передают origin).
|
||
|
||
referer (опционально) — HTTP-заголовок Referer, передаётся playwright'у ТОЛЬКО
|
||
для целевой навигации ``goto(url, referer=...)`` — площадка видит переход "пришёл
|
||
из выдачи", а не голый заход прямо на URL карточки (#3247). На origin/якорную
|
||
вкладку referer НЕ передаётся: туда камуфокс приходит "сам", без источника.
|
||
None (дефолт) → page.goto(url) без referer, поведение идентично прежнему.
|
||
|
||
cookies (опционально) — dict cookie_name→value для инъекции ДО любой навигации
|
||
(обход QRATOR-блока DomClick при валидной test-аккаунт сессии, эмпирически
|
||
подтверждено вживую 2026-07-04). Провайдер-агностично: домен НЕ захардкожен, а
|
||
выводится из hostname целевого url (с ведущей точкой — покрывает поддомены,
|
||
зеркалит реальную DomClick cookie-scope ".domclick.ru" для
|
||
ekaterinburg.domclick.ru/spb.domclick.ru/etc, но работает для любого хоста, не
|
||
только DomClick) — механизм пригоден для будущих caller'ов (Cian/Avito/Yandex).
|
||
При reuse_context=False (дефолт) вливаются в КАЖДУЮ новую страницу, как раньше.
|
||
При reuse_context=True — ТОЛЬКО в момент создания переиспользуемого context'а
|
||
(см. _get_or_create_context); в уже существующий context повторно не вливаются
|
||
никогда. None (дефолт) → без инъекции, поведение не меняется.
|
||
|
||
reuse_context (#3118, опционально, дефолт False) — держит per-provider
|
||
BrowserContext живым между вызовами вместо browser.new_page() (который создаёт
|
||
НОВЫЙ изолированный context на КАЖДЫЙ запрос и убивает cookie-jar — в т.ч. живой
|
||
qrator_jsid2 DomClick, ~2.5ч TTL, ротируемый площадкой через Set-Cookie). Страница
|
||
создаётся ИЗ этого context'а и закрывается после отдачи HTML, сам context остаётся
|
||
жить. False (дефолт) → browser.new_page() как раньше — avito/cian/yandex/generic
|
||
его никогда не включают, поведение не меняется ни на байт.
|
||
|
||
reset_context (#3118, опционально, дефолт False) — закрывает и выбрасывает
|
||
сохранённый context провайдера ДО работы (следующий reuse_context=True создаст его
|
||
заново, с чистым cookie-jar). Вызывающая сторона использует его ровно один раз на
|
||
обнаруженный блок, не на каждый последующий запрос.
|
||
"""
|
||
browser = _browsers.get(provider)
|
||
assert browser is not None, "browser not launched"
|
||
|
||
# Гасим статус прошлой навигации ДО работы: если goto упадёт, наверх не должен
|
||
# уехать статус предыдущей страницы этого же провайдера (#3196).
|
||
_last_response_status[provider] = None
|
||
|
||
if reset_context:
|
||
await _close_reusable_context(provider)
|
||
|
||
if reuse_context:
|
||
context = await _get_or_create_context(provider, browser, cookies, url)
|
||
page = await context.new_page() # type: ignore[attr-defined]
|
||
else:
|
||
page = await browser.new_page() # type: ignore[attr-defined]
|
||
if cookies:
|
||
cookie_domain = _cookie_domain(url)
|
||
await page.context.add_cookies( # type: ignore[attr-defined]
|
||
[
|
||
{"name": name, "value": value, "domain": cookie_domain, "path": "/"}
|
||
for name, value in cookies.items()
|
||
]
|
||
)
|
||
try:
|
||
await _apply_resource_block(page)
|
||
await _pace_provider(provider)
|
||
if origin:
|
||
anchored = False
|
||
if reuse_context:
|
||
# Якорь держим ОТКРЫТОЙ вкладкой, а не переоткрываем перед каждой
|
||
# карточкой. Так ходит человек: список остаётся открытым, объявления
|
||
# открываются из него. Замер 29.08.2026 — вчетверо быстрее на карточку.
|
||
anchored = await _ensure_anchor_page(provider, origin)
|
||
if not anchored:
|
||
# Нет переиспользуемого context'а (avito/cian/yandex/generic) либо
|
||
# якорь не поднялся — поведение ровно как до правки.
|
||
await page.goto( # type: ignore[attr-defined]
|
||
origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
|
||
)
|
||
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
|
||
if fetch_mode != "navigate":
|
||
# Ресурс берём ПОДЗАПРОСОМ из уже прогретого контекста, а не навигацией
|
||
# (#3264). Прогрев origin выше обязателен: именно он проносит рукопожатие
|
||
# QRATOR в куки контекста, из которого пойдёт подзапрос.
|
||
body_bytes, status = await _fetch_subresource(page, url, fetch_mode, referer)
|
||
_last_response_status[provider] = status
|
||
text = _decode_body(body_bytes)
|
||
if _is_ban_page(text):
|
||
raise BanPageDetectedError(
|
||
f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}"
|
||
)
|
||
if provider == "domclick" and _is_domclick_refusal(text):
|
||
raise BanPageDetectedError(
|
||
f"tradein-browser[{provider}]: статический отказ площадки url={url!r}"
|
||
)
|
||
logger.info(
|
||
"tradein-browser[%s]: %s → HTTP %s, тело %d Б url=%r",
|
||
provider,
|
||
fetch_mode,
|
||
status,
|
||
len(text),
|
||
url,
|
||
)
|
||
return text
|
||
|
||
# Статус берём у ЦЕЛЕВОЙ навигации, а не у прогрева origin выше: интересует
|
||
# ответ площадки на запрошенный url (#3196).
|
||
goto_kwargs: dict[str, object] = {
|
||
"timeout": BROWSER_NAV_TIMEOUT_MS,
|
||
"wait_until": "domcontentloaded",
|
||
}
|
||
if referer:
|
||
goto_kwargs["referer"] = referer
|
||
response = await page.goto(url, **goto_kwargs) # type: ignore[attr-defined]
|
||
_last_response_status[provider] = _status_of(response)
|
||
if BROWSER_WAIT_MS > 0:
|
||
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
|
||
html: str = await page.content() # type: ignore[attr-defined]
|
||
|
||
# Бан-страница («проблема с IP») распознаётся и падает СРАЗУ, без траты
|
||
# BROWSER_CHALLENGE_WAIT_MS — ждать там нечего, адрес заблокирован (#3045).
|
||
if _is_ban_page(html):
|
||
raise BanPageDetectedError(
|
||
f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}"
|
||
)
|
||
if provider == "domclick":
|
||
# DomClick — своя ветка (#3196): нет отдельного маркера самого
|
||
# рукопожатия (см. комментарий у _DOMCLICK_SUCCESS_MARKER), поэтому
|
||
# статус НЕ смотрим вовсе (в отличие от общей ветки ниже) — 401
|
||
# приходит и у здорового рукопожатия, и у отказа, и у успешной
|
||
# страницы в замере 29.08.2026, различать нужно ТОЛЬКО по телу.
|
||
if _is_domclick_refusal(html):
|
||
raise BanPageDetectedError(
|
||
f"tradein-browser[{provider}]: статический отказ площадки "
|
||
f"url={url!r}"
|
||
)
|
||
if not _is_domclick_success(html):
|
||
html = await _wait_out_pow_challenge(
|
||
page,
|
||
provider,
|
||
url,
|
||
is_pending=lambda h: not _is_domclick_success(h)
|
||
and not _is_domclick_refusal(h),
|
||
)
|
||
if _is_domclick_refusal(html):
|
||
# Рукопожатие домоталось до отказа уже ПОСЛЕ начала опроса —
|
||
# is_pending вернул False (страница устоялась), но устоялась
|
||
# она в состояние «отказ», а не «успех». Наверх идёт то же
|
||
# исключение, что и для мгновенного отказа выше.
|
||
raise BanPageDetectedError(
|
||
f"tradein-browser[{provider}]: отказ площадки после "
|
||
f"ожидания рукопожатия url={url!r}"
|
||
)
|
||
# PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его
|
||
# прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент.
|
||
elif _is_pow_challenge(html):
|
||
status = _last_response_status.get(provider)
|
||
if status in _REFUSAL_STATUSES:
|
||
# Маркеры челленджа при 403/429 — это отказ, а не проверка:
|
||
# статическая страница сама себя не перезагрузит, ждать нечего.
|
||
# Наверх идёт ИСКЛЮЧЕНИЕ, а не заглушка: вернув её как валидный
|
||
# контент, мы бы воскресили регрессию #3045 — авитовская бан-страница
|
||
# приходит ровно с 403/429 (см. _BAN_MARKERS выше), и парсер получил
|
||
# бы 7891-байтный челлендж вместо карточки. Клиент уже трактует
|
||
# BanPageDetectedError как блок. Статус НЕ добавляем 401 сюда —
|
||
# у DomClick (единственного, кто отдаёт 401) отказ распознаётся
|
||
# выше по телу, а не по коду, см. ветку provider == "domclick".
|
||
raise BanPageDetectedError(
|
||
f"tradein-browser[{provider}]: HTTP {status} + маркеры челленджа "
|
||
f"— отказ площадки, ждать нечего url={url!r}"
|
||
)
|
||
html = await _wait_out_pow_challenge(page, provider, url)
|
||
finally:
|
||
await page.close() # type: ignore[attr-defined]
|
||
|
||
_page_counters[provider] = _page_counters.get(provider, 0) + 1
|
||
logger.debug(
|
||
"tradein-browser[%s]: fetch OK url=%r pages_since_launch=%d",
|
||
provider,
|
||
url,
|
||
_page_counters[provider],
|
||
)
|
||
|
||
recycle_pages = _RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK)
|
||
if _page_counters[provider] >= recycle_pages:
|
||
logger.info(
|
||
"tradein-browser[%s]: recycle threshold (%d) достигнут, перезапуск браузера",
|
||
provider,
|
||
recycle_pages,
|
||
)
|
||
await _relaunch_browser(provider)
|
||
|
||
return html
|
||
|
||
|
||
async def _do_fetch_json(
|
||
provider: str,
|
||
url: str,
|
||
*,
|
||
method: str,
|
||
headers: dict,
|
||
body: object,
|
||
origin: str,
|
||
) -> dict:
|
||
"""Одна попытка in-page fetch'а; при краше браузера — relaunch и один retry.
|
||
|
||
Зеркалит _do_fetch: caller держит _locks[provider] (нет параллельных страниц на
|
||
этом инстансе), поэтому relaunch безопасен. Crash-relaunch сохраняет текущий прокси
|
||
инстанса (_relaunch_browser reuse _launched_proxy).
|
||
"""
|
||
try:
|
||
return await _fetch_json_once(
|
||
provider, url, method=method, headers=headers, body=body, origin=origin
|
||
)
|
||
except Exception as exc:
|
||
if _is_browser_crash(exc):
|
||
logger.warning(
|
||
"tradein-browser[%s]: краш браузера (%s), перезапуск + retry fetch-json: %s",
|
||
provider,
|
||
type(exc).__name__,
|
||
url,
|
||
)
|
||
await _relaunch_browser(provider)
|
||
if _browsers.get(provider) is None:
|
||
raise
|
||
return await _fetch_json_once(
|
||
provider, url, method=method, headers=headers, body=body, origin=origin
|
||
)
|
||
# #2676 обрабатывается ВНУТРИ _fetch_json_once (повтор evaluate на той же
|
||
# странице). Повторять здесь, на свежей странице, бесполезно: пересоздание
|
||
# воспроизводит ту же первую навигацию origin'а — проверено на проде 06.08,
|
||
# обе попытки упали одинаково.
|
||
raise
|
||
|
||
|
||
# In-page retry самого fetch() при СЕТЕВОМ throw (TypeError/"NetworkError"), #1917:
|
||
# первый XHR после навигации иногда падает до готовности стека. Повтор внутри страницы
|
||
# (~retryDelayMs) дешевле полной ре-навигации (~30-45с/дом). HTTP-статусы (4xx/5xx) —
|
||
# это успешный resp, НЕ ретраим (решает caller).
|
||
_IN_PAGE_FETCH_JS = """async ({url, method, headers, body, retries, retryDelayMs}) => {
|
||
let lastErr;
|
||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||
try {
|
||
const resp = await fetch(url, {
|
||
method: method || 'GET',
|
||
headers: headers || {},
|
||
body: (body !== null && body !== undefined) ? body : undefined,
|
||
credentials: 'include',
|
||
});
|
||
const text = await resp.text();
|
||
return { status: resp.status, body: text, attempts: attempt + 1 };
|
||
} catch (e) {
|
||
lastErr = e;
|
||
if (attempt < retries) {
|
||
await new Promise(r => setTimeout(r, retryDelayMs));
|
||
}
|
||
}
|
||
}
|
||
throw lastErr;
|
||
}"""
|
||
|
||
|
||
async def _wait_for_load_best_effort(page: object, provider: str) -> None:
|
||
"""Ждёт `load`, но не даёт ожиданию сорвать саму попытку (реклама/трекеры могут
|
||
держать страницу «загружающейся» бесконечно). Таймаут логируется, не пробрасывается.
|
||
"""
|
||
try:
|
||
await page.wait_for_load_state("load", timeout=FETCH_JSON_LOAD_WAIT_MS) # type: ignore[attr-defined]
|
||
except Exception as exc:
|
||
logger.info(
|
||
"tradein-browser[%s]: load не дождались (%s), пробуем evaluate как есть",
|
||
provider,
|
||
type(exc).__name__,
|
||
)
|
||
|
||
|
||
async def _fetch_json_once(
|
||
provider: str,
|
||
url: str,
|
||
*,
|
||
method: str,
|
||
headers: dict,
|
||
body: object,
|
||
origin: str,
|
||
) -> dict:
|
||
"""Переходит на origin (same-origin якорь) и выполняет in-page fetch(url).
|
||
|
||
Зеркалит _fetch_once по жизненному циклу страницы. Навигация идёт на ``origin``
|
||
(а не на ``url``), чтобы in-page fetch был same-origin и нёс контекст-cookies +
|
||
реальный фингерпринт инстанса. Возвращает {"status": int, "body": str}.
|
||
|
||
Caller держит _locks[provider], поэтому страницы на этом инстансе не
|
||
параллелятся — recycle через _relaunch_browser безопасен прямо здесь.
|
||
"""
|
||
browser = _browsers.get(provider)
|
||
assert browser is not None, "browser not launched"
|
||
|
||
page = await browser.new_page() # type: ignore[attr-defined]
|
||
try:
|
||
await _apply_resource_block(page)
|
||
await _pace_provider(provider)
|
||
await page.goto(origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded") # type: ignore[attr-defined]
|
||
# БЕЗ полного BROWSER_WAIT_MS: нам нужен лишь origin-контекст (cookies +
|
||
# same-origin scope для fetch), а не отрендеренные listings. Settle-паузы
|
||
# (#1917, FETCH_JSON_SETTLE_MS) хватает, чтобы страница инициализировалась
|
||
# перед in-page fetch'ем.
|
||
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
|
||
payload = {
|
||
"url": url,
|
||
"method": method,
|
||
"headers": headers or {},
|
||
"body": body,
|
||
"retries": FETCH_JSON_INPAGE_RETRIES,
|
||
"retryDelayMs": FETCH_JSON_RETRY_DELAY_MS,
|
||
}
|
||
try:
|
||
result: dict = await page.evaluate(_IN_PAGE_FETCH_JS, payload) # type: ignore[attr-defined]
|
||
except Exception as exc:
|
||
if not _is_page_context_lost(exc):
|
||
raise
|
||
# #2676, живой прод 06.08: страница origin уходит в клиентскую навигацию
|
||
# ПОСЛЕ load, поэтому ни settle, ни ожидание load её не опережают, а повтор
|
||
# с новой страницей воспроизводит ту же первую навигацию (проверено на
|
||
# проде: две попытки подряд — та же ошибка). Навигация при этом ОДНА:
|
||
# к моменту исключения она уже случилась и у страницы НОВЫЙ контекст.
|
||
# Поэтому повторяем evaluate на ЭТОЙ же странице — стоит миллисекунды.
|
||
logger.warning(
|
||
"tradein-browser[%s]: контекст страницы пересоздан навигацией, "
|
||
"повтор evaluate на той же странице: %s",
|
||
provider,
|
||
url,
|
||
)
|
||
await _wait_for_load_best_effort(page, provider)
|
||
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
|
||
result = await page.evaluate(_IN_PAGE_FETCH_JS, payload) # type: ignore[attr-defined]
|
||
finally:
|
||
await page.close() # type: ignore[attr-defined]
|
||
|
||
_page_counters[provider] = _page_counters.get(provider, 0) + 1
|
||
logger.debug(
|
||
"tradein-browser[%s]: fetch-json OK url=%r status=%s pages_since_launch=%d",
|
||
provider,
|
||
url,
|
||
result.get("status"),
|
||
_page_counters[provider],
|
||
)
|
||
|
||
recycle_pages = _RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK)
|
||
if _page_counters[provider] >= recycle_pages:
|
||
logger.info(
|
||
"tradein-browser[%s]: recycle threshold (%d) достигнут, перезапуск браузера",
|
||
provider,
|
||
recycle_pages,
|
||
)
|
||
await _relaunch_browser(provider)
|
||
|
||
return result
|
||
|
||
|
||
def _is_browser_crash(exc: BaseException) -> bool:
|
||
"""Проверяет является ли исключение признаком краша / разрыва браузера."""
|
||
cls_name = type(exc).__name__
|
||
if cls_name in ("TargetClosedError", "BrowserClosedError", "ConnectionClosedError"):
|
||
return True
|
||
msg = str(exc).lower()
|
||
return (
|
||
"browser has been closed" in msg
|
||
or "target closed" in msg
|
||
or "connection closed" in msg
|
||
or "browser disconnected" in msg
|
||
)
|
||
|
||
|
||
# Литерал playwright, а не строка из нашего лога: driver 1.60.0 (в образе сайдкара)
|
||
# бросает ровно «Execution context was destroyed» / «... , most likely because of a
|
||
# navigation.» — обе формы начинаются одинаково, поэтому хватает одного маркера.
|
||
# Проверено grep'ом по playwright/driver/package/lib/coreBundle.js в живом контейнере.
|
||
_PAGE_CONTEXT_LOST_MARKER = "execution context was destroyed"
|
||
|
||
|
||
def _is_page_context_lost(exc: BaseException) -> bool:
|
||
"""Страница потеряла JS-контекст (ушла в навигацию между goto и evaluate), #2676.
|
||
|
||
НЕ краш браузера: инстанс жив, потеряна одна страница. Поэтому обрабатывается
|
||
отдельно от _is_browser_crash — relaunch здесь стоил бы ~10-20с и тёплый профиль
|
||
(cookies/фингерпринт инстанса) ради браузера, с которым всё в порядке.
|
||
"""
|
||
return _PAGE_CONTEXT_LOST_MARKER in str(exc).lower()
|
||
|
||
|
||
# ── login handler ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
class LoginError(Exception):
|
||
"""Исключение с диагностическим payload для /login эндпоинта."""
|
||
|
||
def __init__(self, payload: dict) -> None:
|
||
super().__init__(str(payload))
|
||
self.payload = payload
|
||
|
||
|
||
async def pacing_get_handler(request: web.Request) -> web.Response:
|
||
"""GET /pacing → текущие live-интервалы + env-дефолты для всех провайдеров.
|
||
|
||
interval_s — живое значение из _MIN_PAGE_INTERVAL_BY_PROVIDER (изменяется PUT).
|
||
env_default_s — что вернёт _resolve_min_interval(p) из os.environ (reset при рестарте).
|
||
Всегда 200; не требует browser-инстансов (memory-only).
|
||
"""
|
||
providers = []
|
||
for p in PROVIDERS:
|
||
providers.append({
|
||
"source": p,
|
||
"interval_s": _MIN_PAGE_INTERVAL_BY_PROVIDER.get(p, BROWSER_MIN_PAGE_INTERVAL_S),
|
||
"env_default_s": _resolve_min_interval(p),
|
||
})
|
||
return web.json_response({"providers": providers})
|
||
|
||
|
||
async def pacing_put_handler(request: web.Request) -> web.Response:
|
||
"""PUT /pacing {"source": <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)
|