gendesign/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py
bot-backend ff979d4afd
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m28s
CI Trade-In / backend-tests (pull_request) Successful in 5m11s
fix(tradein/cian): капча Циана с HTTP 200 — отказ площадки в обоих слоях (#3402)
Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`, 44 КБ) и страницу
ошибки (`<title>Ошибка - Циан`, 374 КБ) с кодом 200. Детектор сайдкара их не знал
(_REFUSAL_STATUSES {403,429} + маркеры Авито/Домклика), HTML уезжал клиенту как
успех, extract_state возвращал None и провайдер печатал «defaultState extraction
failed» — отказ ПЛОЩАДКИ читался как дрейф НАШЕЙ разметки. Аренда при этом не
менялась: fetch() уже отрапортовал mark_health(ok=True), fail-streak обнулялся, и
один капча-узел сжигал батч целиком (6200: 0/210; 6123/6091/6052/6032/6010/5981:
0/400 — против 161/162 через здоровый узел на прогоне 13).

Два слоя, потому что образы backend и browser деплоятся раздельно и расходятся
на часы:
* сайдкар (browser/server.py) — детект по <title> на обоих путях (navigate и
  подзапрос) → BanPageDetectedError → прежний путь #3288/#3379: 403 + ban_page +
  ЧЕСТНЫЙ upstream-статус 200;
* kit (providers/cian/detail.py) — при провале extract_state те же маркеры →
  CianBlockedError вместо тихого None, плюс report_platform_ban по живому lease.
  Там же ветка SidecarBanPageError: отказ, опознанный сайдкаром, больше не
  гасится общим `except` в «не смогли разобрать».

Слово `captcha` признаком быть не может: в нормальной карточке оно встречается 11
раз, на капче 17. Детект по <title> с нормализацией тире.

`_report_platform_ban` → `report_platform_ban` (публичный): тем же путём обязан
идти отказ, распознанный не сайдкаром, а провайдером. report_ban один только
пишет бан пары «узел×источник» — сменить сожжённую аренду ВНУТРИ батча позволяет
только fail-streak (_LEASE_ROTATE_AFTER_FAILS).
2026-09-06 14:10:57 +05:00

1098 lines
70 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

"""browser_fetcher.py — HTTP-клиент к tradein-browser сервису (#884/#905).
Тонкий клиент над tradein-browser контейнером, который запускает AsyncCamoufox
локально и экспонирует HTTP API (POST /fetch).
Публичный интерфейс не изменился:
async with BrowserFetcher() as fetcher:
html = await fetcher.fetch("https://example.com")
Внутреннее устройство: httpx.AsyncClient + POST к settings.browser_http_endpoint.
Recycle, crash-recovery и управление браузером живут на стороне сервера (server.py).
При HTTPError / ConnectError делает одну повторную попытку после короткой паузы,
затем пробрасывает исключение.
Архитектура выбрана потому, что playwright WS-сервер (launch_server) несовместим
с playwright >=1.45: ``browserServerImpl.js`` отсутствует → MODULE_NOT_FOUND.
Локальный AsyncCamoufox + HTTP — работающая альтернатива.
"""
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING
import httpx
from scraper_kit.proxy_errors import NoProxyAvailableError
if TYPE_CHECKING:
from scraper_kit.contracts import ProxyLease, ProxyProvider
logger = logging.getLogger(__name__)
_RETRY_SLEEP_S: float = 1.0
_HTTP_TIMEOUT_S: float = 120.0 # навигация медленная → щедрый таймаут
# ── проба узла ПО БРАУЗЕРНОМУ ТРАКТУ (#2723) ─────────────────────────────────
# Адрес пробы. Требования к нему ровно три, и robots.txt им отвечает:
# 1) тот же тракт, что у работы — сайдкар, camoufox, ЭТОТ прокси, настоящая
# навигация. Все 90 записанных обрывов сбора («browser unavailable (proxy may
# be down)») рождались на launch'е camoufox с прокси — проба обязана его делать;
# 2) та же площадка, что реально отказывает: TLS-рукопожатие и маршрут до ЕЁ edge,
# а не до нейтрального хоста;
# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл, который
# автоматическим клиентам читать прямо предписано. НЕ выдача и НЕ карточка.
#
# АДРЕС НА КАЖДЫЙ ИСТОЧНИК, а не один зашитый (#2800). До этого проба всех узлов
# ходила на Авито, и её зелёный ответ читался как «узел годен», хотя означал
# «годен для Авито». Замер на проде 09.08.2026 показал ровно ту цену: узел id=1
# отдавал 200 на Авито/Яндексе и 500 NS_ERROR_PROXY_BAD_GATEWAY на Домклике, имея
# при этом browser_fail_streak=0 и свежую пробу.
#
# ХОСТ — РАБОЧИЙ, А НЕ APEX-ДОМЕН. Тот же замер: через узел id=1
# `domclick.ru/robots.txt` отдаёт 200, а `bff-search-web.domclick.ru/robots.txt` —
# 500. Боевой сбор Домклика ходит именно на bff (providers/domclick/serp.py::
# _BFF_BASE), поэтому проба по apex была бы зелёной и бесполезной — та же ошибка
# «проба идёт не рабочим путём», что и #2723, на третьей оси.
_PROBE_URLS: dict[str, str] = {
"avito": "https://www.avito.ru/robots.txt",
"cian": "https://ekb.cian.ru/robots.txt", # рабочий хост — providers/cian/serp.py base_url
"yandex": "https://realty.yandex.ru/robots.txt", # providers/yandex/serp.py::_GATE_URL
# #2855: боевой ЗАЩИЩЁННЫЙ путь, а не robots.txt того же хоста. QRATOR закрывает
# /api/offers/*, а robots.txt отдаётся свободно — проба по нему возвращала 200 ровно
# тогда, когда свип с того же узла получал блок-страницу. Замер 13.08: за сутки
# 64 проверки пар и 5 банов на ВСЕ площадки, при этом свип Домклика блокировался
# каждые сутки; 04:30 проба «pair_banned=0» → 05:02 свип «QRATOR block».
# Взят count-эндпоинт: та же семья путей, что у работы (providers/domclick/serp.py
# ::_build_count_url), но ответ — одно число, без пагинации и без выдачи, то есть
# проба остаётся дешёвой для площадки (тот же довод, по которому здесь /fetch, а
# не /fetch-json). Параметры продублированы намеренно: импорт провайдера сюда дал
# бы цикл (providers/* уже импортируют browser_fetcher).
"domclick": (
"https://bff-search-web.domclick.ru/api/offers/count/v1"
"?address=0d475b79-88de-4054-818c-37d8f9d0d440&aids=20561"
"&deal_type=sale&category=living&offer_type=flat&rooms=st"
"&sort=qi&sort_dir=desc"
),
}
_PROXY_PROBE_URL: str = _PROBE_URLS["avito"]
# Источники, по которым вообще есть что спрашивать. Публичный кортеж — proxy_pool
# перебирает его, чтобы не заводить второй список площадок на стороне backend'а.
PROBE_SOURCES: tuple[str, ...] = tuple(_PROBE_URLS)
# HTTP 200 + непустой HTML ещё не значит «площадка ответила»: замер 09.08 — Циан
# через узел id=1 отдаёт 200 и 374 КБ HTML со страницей «Ошибка — Циан» вместо
# robots.txt. Такой ответ проба до #2800 засчитывала как успех. Маркер ниже есть в
# robots.txt всех четырёх рабочих хостов (проверено вживую) и отсутствует в
# странице-заглушке — самый дешёвый способ отличить «отдали ресурс» от «отдали
# отказ с кодом 200».
_PROBE_CONTENT_MARKER: str = "User-agent"
# #2855: источник, чья проба идёт НЕ по robots.txt, требует своего признака «ресурс
# отдан». У count-эндпоинта Домклика это ключ ответа `snippetsCount` — он есть в живом
# JSON и отсутствует в блок-странице QRATOR. Держим отдельной картой, а не условием в
# теле: добавить площадку с боевым путём должно быть одной строкой, а не веткой.
_PROBE_CONTENT_MARKERS: dict[str, str] = {"domclick": "snippetsCount"}
# source='generic' В ТЕЛЕ /fetch НАМЕРЕННО, какой бы площадке ни принадлежал адрес:
# сайдкар держит по инстансу camoufox на провайдера с отдельным локом, и проба с
# source='avito' забирала бы лок боевого инстанса и релончила его (прокси пробы ≠
# прокси сессии) — ровно тот relaunch-шторм, который лечил sticky-lease фикс.
# 'generic' — свой инстанс, боевые развёртки его не используют. Аргумент `source` у
# probe_proxy_via_browser выбирает АДРЕС (какую площадку спрашиваем), а не инстанс.
_PROXY_PROBE_SOURCE: str = "generic"
# Щедрее ipify-пробы (10с) на порядок: сюда входит холодный запуск camoufox — 8.3с
# замерено на проде вместе с релончем, плюс запас на медленный узел.
_PROXY_PROBE_TIMEOUT_S: float = 90.0
# Маркеры отказов, которые сайдкар порождает ИМЕННО из-за прокси (browser/server.py:
# fetch_handler 503 после _ensure_browser → camoufox не поднялся с этим прокси;
# 500 с NS_ERROR_PROXY_* → навигация не прошла через прокси). Всё остальное —
# не про узел (сайдкар недоступен, конфиг сайдкара, пустая страница).
# ponytail: подстроки, а не машинный код отказа — сайдкар не отдаёт поле причины.
# Тест test_2723_browser_probe.py::test_sidecar_error_literals_still_exist сторожит
# расхождение с исходником сайдкара; при следующей правке browser/server.py дешевле
# добавить туда {"fail_kind": "proxy"} и читать его здесь.
_PROXY_FAIL_MARKERS: tuple[str, ...] = (
"browser unavailable (proxy may be down)",
"NS_ERROR_PROXY",
"NS_ERROR_UNKNOWN_PROXY_HOST",
)
# Живая регрессия 2026-08: после скольких подряд провалившихся /fetch ТЕКУЩИЙ session-lease
# считается плохим (бан/сетевая труха) и ОСОЗНАННО меняется один раз (release+acquire), вместо
# того чтобы менять прокси на каждый /fetch как раньше. Camoufox релончится ТОЛЬКО при реальной
# смене желаемого прокси (см. tradein-browser server.py::_ensure_browser) — так что смена lease
# здесь стоит РОВНО один relaunch, а не N. Значение зеркалит proxy_pool.MAX_CONSECUTIVE_FAILS
# (тот же порог, за которым acquire() перестаёт выдавать узел) — kit намеренно не импортирует
# app.services.proxy_pool (contracts-граница), поэтому константа продублирована локально.
_LEASE_ROTATE_AFTER_FAILS: int = 3
class SidecarBanPageError(httpx.HTTPStatusError):
"""Сайдкар подтвердил бан-страницу по маркерам её тела (#3239).
Подкласс ``HTTPStatusError``, а не самостоятельный тип: ловля по
``httpx.HTTPError`` у всех прочих поставщиков и retry-политика
``fetch()`` продолжают работать не зная о нём. Отличать его нужно ровно
там, где решается судьба узла: за подтверждённым маркером стоит
``report_ban`` (площадка отказала этому IP), за обычной 500-кой — нет
(сбой транспорта, #2600 п.4).
``upstream_status`` — HTTP-код САМОЙ целевой навигации, а не ответа
сайдкара. На error-пути ``fetch()`` обнуляет ``last_response_status``,
поэтому иначе диагноз узнать неоткуда: у DomClick отказ приходит с 401
и без него классификатор ставит 'unknown' вместо 'platform'.
"""
def __init__(
self,
message: str,
*,
request: httpx.Request,
response: httpx.Response,
upstream_status: int | None,
) -> None:
super().__init__(message, request=request, response=response)
self.upstream_status = upstream_status
def _sidecar_ban_page_status(resp: httpx.Response) -> tuple[bool, int | None]:
"""(это бан-страница?, апстрим-статус) из тела ошибки сайдкара (#3239).
Тело — ``{"error": ..., "ban_page": true, "status": <int|null>}``. Сайдкар
старой сборки ключей не отдаёт → (False, None), поведение как до правки.
``bool`` отсекаем явно: он подтип ``int`` и JSON ``true`` уехал бы статусом.
Признак — ТЕЛО, а не код ответа, и проверка намеренно остаётся
code-agnostic: с #3288 сайдкар отвечает на бан 403, до него отвечал 500, а
``tradein-browser`` — ОТДЕЛЬНЫЙ образ (``gendesign-tradein-browser``,
docker-compose.prod.yml) со своим деплоем, т.е. версии сайдкара и backend
штатно разъезжаются на несколько часов. Оба кода дают одинаковый диагноз,
гейта по коду тут нет и заводить его не нужно: он ничего не добавит (свой
``ban_page`` умеет писать только наш сайдкар), а в час рассинхрона снова
уводил бы отказ площадки в инфра-ветку. Снимать «совместимость» поэтому
нечего — 500-ветка исчезнет сама, когда сайдкар перестанет её отдавать.
"""
try:
body = resp.json()
except Exception:
return False, None
if not isinstance(body, dict) or body.get("ban_page") is not True:
return False, None
raw = body.get("status")
return True, raw if isinstance(raw, int) and not isinstance(raw, bool) else None
def _raise_for_sidecar_status(resp: httpx.Response) -> None:
"""`raise_for_status()`, но с ПРИЧИНОЙ отказа из тела ответа сайдкара в тексте ошибки.
tradein-browser кладёт причину отказа в тело: 503 ``{"error": "no proxy configured —
refusing direct connection (prod)"}`` / ``{"error": "browser unavailable (proxy may be
down)"}``, 500 ``{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY ..."}``
(browser/server.py, fetch_handler + fetch_json_handler). До #2698 тело выбрасывалось:
httpx.HTTPStatusError печатает только «Server error '503 Service Unavailable' for url
'http://tradein-browser:3000/fetch-json'» — и ровно эта строка 34 дня лежала в
houses.imv_error_reason у 1240 домов. Отказ был виден, причина — нет.
Тип исключения не меняется (HTTPStatusError ⊂ HTTPError), поэтому retry-политика
fetch()/fetch_json() и обработка у вызывающих остаются прежними.
"""
try:
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
try:
detail = " ".join((resp.text or "").split())[:300]
except Exception:
# Тело не прочиталось/не декодируется — причина не обязана быть; отдаём
# исходную ошибку, а не роняем вызывающего на разборе тела.
raise exc from None
is_ban_page, upstream_status = _sidecar_ban_page_status(resp)
if is_ban_page:
# #3239: тип несёт диагноз наверх — подстрокой в тексте его искать
# нельзя, detail обрезан до 300 символов и формулировка отказа
# менялась дважды за месяц.
#
# #3288 п.4: текст СВОЙ, без httpx-преамбулы `{exc}`. Она печатала
# «Server error '500 Internal Server Error' for url
# 'http://tradein-browser:3000/fetch'», и этой строкой отказ ПЛОЩАДКИ
# читался как падение НАШЕГО сайдкара — разбор инцидента каждый раз
# начинался с ложного следа («текст ошибки называет гонца»).
# Куда текст реально доезжает: провайдер подставляет его в свой лог и
# в сообщение доменного исключения (providers/avito/serp.py — logger
# «avito page=%d sidecar ban page» + AvitoBlockedError,
# providers/domclick/detail.py — DomClickBlockedError «...: {exc}»),
# а задача кладёт `str(exc)[:1000]` в `scrape_runs.error` через
# `mark_failed` (например domclick_detail_backfill.py). В
# `houses.imv_error_reason` он НЕ попадает: IMV ходит только через
# `/fetch-json`, а `fetch_json_handler` (browser/server.py) `ban_page`
# в тело не пишет — SidecarBanPageError там не возникает вовсе.
# Код ответа сайдкара оставляем в тексте отдельным числом: он
# отличает 403 нового сайдкара от 500 старого, но больше ничего не
# значит.
raise SidecarBanPageError(
f"tradein-browser: площадка отдала бан-страницу "
f"(upstream {upstream_status if upstream_status is not None else '?'}, "
f"ответ сайдкара {resp.status_code}): {detail or 'ban page'}",
request=exc.request,
response=exc.response,
upstream_status=upstream_status,
) from exc
if not detail:
raise
raise httpx.HTTPStatusError(
f"{exc} | tradein-browser: {detail}",
request=exc.request,
response=exc.response,
) from exc
def classify_browser_probe(status: int | None, detail: str) -> str:
"""Кому принадлежит отказ браузерной пробы: узлу, сайдкару или странице (#2723).
Разведение обязательно, иначе повторяется #2686 в третий раз: лежащий сайдкар
пометил бы НЕПРИГОДНЫМИ ВСЕ узлы разом, хотя ни один из них не при чём.
- "proxy" — отказ порождён прокси: camoufox не поднялся с ним (503 «browser
unavailable (proxy may be down)») либо навигация не прошла через
него (500 NS_ERROR_PROXY_*). ТОЛЬКО этот исход копит
browser_fail_streak.
- "sidecar" — сайдкар недоступен/не сконфигурирован (connect error, таймаут,
503 «no proxy configured», прочие 5xx). Узел не виноват.
- "page" — тракт сработал, но ответ не похож на запрошенный ресурс (пустое
тело либо 200 со страницей-заглушкой вместо robots.txt, #2800).
Браузерный тракт узла исправен — не годится ПАРА «узел ×
площадка»: этому exit-IP площадка ресурс не отдала.
"""
if status is None:
return "sidecar" # до ответа не дошло — сайдкар/сеть контейнера
if any(marker in detail for marker in _PROXY_FAIL_MARKERS):
return "proxy"
if status >= 400:
return "sidecar"
return "page"
async def probe_proxy_via_browser(
endpoint: str,
proxy_url: str,
*,
proxy_kind: str = "http",
source: str = "avito",
url: str | None = None,
timeout_s: float = _PROXY_PROBE_TIMEOUT_S,
) -> tuple[bool, str | None, str]:
"""Проверить ПАРУ «узел × площадка» тем же трактом, что и работа: сайдкар → camoufox → прокси.
Standalone (не метод `BrowserFetcher`) и БЕЗ пула: аренда узла здесь не нужна и
вредна — health-checker проверяет узлы, в том числе арендованные, и не должен
конкурировать за lease с боевым прогоном.
Используется `/fetch` (одна навигация), а НЕ `/fetch-json`: последний сначала
делает goto на origin, т.е. на ГЛАВНУЮ страницу площадки — это уже заметная
нагрузка на неё, ради которой проба и затевалась бы наоборот.
`source` выбирает АДРЕС пробы (`_PROBE_URLS`, #2800) — рабочий хост именно этой
площадки. Прежняя сигнатура спрашивала только Авито, и её зелёный ответ означал
«узел годен для Авито», а читался как «узел годен». `url` (явный адрес) остаётся
для тестов и разовых проверок и перекрывает `source`.
Returns:
(ok, fail_kind, detail). ok=True → fail_kind=None. Иначе fail_kind —
"proxy" / "sidecar" / "page" (см. classify_browser_probe), detail —
обрезанный текст для лога.
"""
payload: dict[str, object] = {
"url": url or _PROBE_URLS.get(source, _PROXY_PROBE_URL),
"source": _PROXY_PROBE_SOURCE,
"proxy": proxy_url,
"proxy_kind": proxy_kind,
}
try:
async with httpx.AsyncClient(timeout=timeout_s) as client:
resp = await client.post(f"{endpoint}/fetch", json=payload)
except Exception as exc:
detail = f"{type(exc).__name__}: {str(exc)[:200]}"
return False, classify_browser_probe(None, detail), detail
detail = " ".join((resp.text or "").split())[:300]
if resp.status_code != 200:
return False, classify_browser_probe(resp.status_code, detail), detail
try:
html = resp.json().get("html") or ""
except Exception:
html = ""
if not html:
return False, classify_browser_probe(resp.status_code, detail), "empty html"
marker = _PROBE_CONTENT_MARKERS.get(source, _PROBE_CONTENT_MARKER)
if marker not in html:
# 200 и непустое тело, но это не запрошенный ресурс — площадка отдала заглушку
# ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»;
# 13.08: Домклик отдаёт блок-страницу QRATOR на /api/offers/*, #2855).
# Тракт узла исправен, негодна пара — отсюда "page", а не "proxy".
return (
False,
"page",
f"no marker {marker!r} (html_len={len(html)}): {' '.join(html.split())[:120]}",
)
return True, None, f"html_len={len(html)}"
# ── классификация отказа по HTTP-статусу (#3196) ─────────────────────────────
# Значения совпадают с CHECK-ограничением scrape_runs.ban_kind: допустимы только
# "platform", "infra", "unknown" либо NULL. Здесь "unknown" НЕ возвращается
# намеренно: статус, который ни о чём не говорит (200/404/None), не повод объявлять
# блок — решение остаётся за вызывающим кодом, который знает остальной контекст.
_BAN_KIND_REFUSAL_STATUSES: frozenset[int] = frozenset({403, 429})
def ban_kind_from_status(status: int | None) -> str | None:
"""Раскладывает HTTP-статус ответа площадки в ``scrape_runs.ban_kind``.
403/429 → "platform" (площадка отказала осознанно: WAF/rate-limit);
5xx → "infra" (сбой на стороне площадки/прокси, не адресный бан);
всё остальное и None → None (статус ничего не сообщает о блоке).
Args:
status: HTTP-код целевой навигации (``BrowserFetcher.last_response_status``)
либо None, если сайдкар статус не отдал (старая версия / goto вернул None).
Returns:
"platform" | "infra" | None — значение, пригодное для записи в
``scrape_runs.ban_kind`` как есть.
"""
if status is None:
return None
if status in _BAN_KIND_REFUSAL_STATUSES:
return "platform"
if 500 <= status <= 599:
return "infra"
return None
class BrowserFetcher:
"""Async context manager: HTTP-клиент к tradein-browser HTTP-сервису.
Использование::
async with BrowserFetcher() as fetcher:
html = await fetcher.fetch("https://example.com")
"""
def __init__(
self,
source: str = "avito",
fetch_timeout_s: float = _HTTP_TIMEOUT_S,
*,
endpoint: str,
proxy_provider: ProxyProvider | None = None,
use_pool: bool = False,
environment: str = "dev",
reuse_context: bool = False,
) -> None:
# source — логический источник ("avito"/"cian"/"yandex"/"domclick"). Сервер
# роутит /fetch по нему на отдельный браузер+прокси, когда включён
# FEATURE_BROWSER_POOL_ENABLED (Phase 1). При выключенном флаге source
# игнорируется — поведение не меняется.
# fetch_timeout_s — таймаут httpx-клиента для POST /fetch. Yandex-путь передаёт
# 30s чтобы один проблемный combo занимал ≤30s×retries вместо 120s×retries.
# endpoint — HTTP-эндпоинт tradein-browser сервиса. Инжектируется вызывающей
# стороной (продуктовый код передаёт settings.browser_http_endpoint /
# ScraperConfig.browser_http_endpoint) — kit НЕ импортирует app.core.config.
#
# #2164 P4 (ship-dark за use_pool=config.use_proxy_pool_browser), фикс живой
# регрессии 2026-08: lease берётся ОДИН раз в __aenter__ на весь жизненный цикл
# фетчера (весь прогон, часы) — НЕ на каждый /fetch. Раньше acquire()/release()
# шли на каждый /fetch: при N>=2 живых узлах пула это гарантированно меняло
# прокси между соседними запросами (acquire ORDER BY last_ok_at NULLS LAST —
# «давно не использованный первый»), а tradein-browser релончит camoufox при
# каждой смене желаемого прокси — 17 relaunch'ей за 15 минут в проде. lease.url
# кладётся в тело КАЖДОГО /fetch ({"proxy": ...}), но остаётся тем же между
# вызовами → relaunch происходит только один раз (на старте сессии) плюс
# осознанная ротация при N подряд провалах (см. _report_fetch_result).
# mark_health вызывается на каждый /fetch (не только по итогу сессии) — тонкая
# health-грануляция proxy_pool (DISABLE_THRESHOLD считает consecutive_fails по
# попыткам) не должна огрубляться; release — один раз в __aexit__ (finally,
# lease не течёт). use_pool=False (дефолт) ИЛИ пустой пул → proxy в теле не
# шлём, браузер юзает свой env-прокси (BROWSER_PROXY_*), поведение не меняется.
#
# environment (#2616 шаг 1): "production" в прод-контейнерах (ScraperConfig.
# environment, ENV ENVIRONMENT). Пул реально задействован (use_pool+provider) и
# acquire() вернул None/упал (initial acquire в __aenter__ ИЛИ ре-acquire на
# осознанной ротации в _report_fetch_result) + environment=="production" → НЕ
# падаем на env-прокси (все мертвы, #2613) — NoProxyAvailableError вместо тела
# без "proxy" (см. _acquire_lease). Дефолт "dev" — легитимный fallback на env
# для dev/test, поведение не меняется.
# reuse_context (#3118) — сессионный флаг: держать тёплый переиспользуемый
# browser-context сайдкара (tradein-browser server.py::_get_or_create_context)
# вместо browser.new_page() на каждый /fetch. Нужен, потому что new_page()
# создаёт НОВЫЙ изолированный context на каждый вызов и убивает cookie-jar — в
# т.ч. живой qrator_jsid2 DomClick, который куки-инъекция (см. fetch()) не
# успевает подхватить повторно. False (дефолт) → payload /fetch остаётся БЕЗ
# ключа reuse_context (см. _post_fetch) — сайдкар старой версии, не знающий
# этого поля, ничего не заметит; поведение всех прочих providers не меняется.
self._reuse_context = reuse_context
# Отложенный запрос сброса context'а сайдкара (#3118) — выставляется
# request_context_reset() (caller не имеет прямого доступа к call-сайту
# fetch(), напр. domclick_detail_backfill вызывает его через fetch_detail(),
# который reset_context НЕ прокидывает) и потребляется РОВНО одним следующим
# fetch() (см. fetch()), затем сбрасывается — один флаг на один блок, не на
# каждый последующий запрос.
self._context_reset_pending: bool = False
self._source = source
self._fetch_timeout_s = fetch_timeout_s
self._client: httpx.AsyncClient | None = None
self._endpoint: str | None = endpoint
self._proxy_provider = proxy_provider
self._use_pool = use_pool
self._environment = environment
self._lease: ProxyLease | None = None
self._lease_fail_streak: int = 0
# id lease'а, по которому бан этому источнику УЖЕ отрапортован (#3288). Одно
# событие бана доезжает до report_ban несколько раз: _post_fetch на самой
# SidecarBanPageError, ретрай fetch() вторым POST'ом и провайдер сверху
# (providers/avito/detail.py). mark_banned на каждый вызов растит ban_count,
# то есть ОДИН факт удлинял бы отдых пары «узел×источник» кратно (docstring
# proxy_pool.mark_banned: 6ч→12ч). Сбрасывается при взятии нового lease.
self._banned_lease_id: int | None = None
# HTTP-статус ПОСЛЕДНЕЙ целевой навигации сайдкара (#3196). Публичный
# атрибут, а не возврат fetch(): поток управления менять нельзя — fetch()
# по-прежнему отдаёт str и по-прежнему не бросает там, где не бросал.
# Обновляется КАЖДЫМ _post_fetch (в т.ч. на неудаче — сбрасывается в None),
# читается сразу после await fetch(...). None = статуса нет: сайдкар старой
# версии без ключа "status", goto вернул None, либо фетч упал транспортом.
self.last_response_status: int | None = None
# ── lifecycle ──────────────────────────────────────────────────────────────
async def __aenter__(self) -> BrowserFetcher:
assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"
self._client = httpx.AsyncClient(timeout=self._fetch_timeout_s)
try:
self._lease = self._acquire_lease()
except Exception:
# _acquire_lease() может поднять NoProxyAvailableError (#2616 шаг 1, prod +
# пул пуст) — __aenter__ падает ДО return self, значит `async with` НЕ
# вызовет __aexit__ → клиент закрываем сами, иначе течёт httpx.AsyncClient.
await self._client.aclose()
self._client = None
raise
logger.info(
"BrowserFetcher: клиент создан, endpoint=%s proxy_lease_id=%s",
self._endpoint,
self._lease.id if self._lease else None,
)
return self
async def __aexit__(self, *_: object) -> None:
try:
if self._client is not None:
await self._client.aclose()
finally:
self._client = None
self._release_lease()
logger.debug("BrowserFetcher: клиент закрыт")
# ── public API ─────────────────────────────────────────────────────────────
async def fetch(
self,
url: str,
*,
origin: str | None = None,
referer: str | None = None,
cookies: dict[str, str] | None = None,
reset_context: bool = False,
fetch_mode: str | None = None,
) -> str:
"""Запрашивает HTML страницы через tradein-browser HTTP-сервис.
fetch_mode (#3264) — чем сайдкар заберёт ресурс: ``None``/``"navigate"`` —
обычная навигация браузера (дефолт, прежнее поведение), ``"subresource"`` —
запрос из прогретого browser-контекста. Второй режим нужен ресурсам, которые
страницами не являются: BFF-ручка Домклика отдаёт JSON, и навигацией браузера
на неё через мобильные прокси приходит только ChallengeTimeout, тогда как
подзапросом — HTTP 200 за 6 секунд (замер 30.08.2026, все 4 узла пула, все 6
комнатных корзин). Требует непустого origin: рукопожатие QRATOR попадает в
куки контекста именно при прогреве.
origin — same-site якорь, на который камуфокс зайдёт ПЕРЕД url (прогревает
cookies контекста ДО холодного goto на url), см. /fetch ``origin`` в
server.py. None (дефолт) → поведение не меняется (ровно один goto(url), как
раньше) — таков путь всех providers кроме domclick detail.
referer (#3247) — HTTP-заголовок Referer для целевой навигации ``goto(url,
referer=...)`` на сайдкаре: без него голый ``page.goto()`` Referer не шлёт
вовсе, и площадка видит переход прямо на URL карточки без источника. None
(дефолт) → поле не кладётся в payload, поведение не меняется.
cookies — dict cookie_name→value для инъекции в browser-контекст ПЕРЕД
навигацией (обходит QRATOR-блок DomClick при валидной test-аккаунт
сессии, empирически подтверждено вживую 2026-07-04, см. app.services.
domclick_session). None (дефолт) → поведение не меняется, инъекции нет —
таков путь всех providers кроме domclick detail-debug.
reset_context (#3118) — просит сайдкар выбросить сохранённый переиспользуемый
context ЭТОГО источника ДО фетча (см. reuse_context в __init__). Имеет смысл
только вместе с reuse_context=True; при reuse_context=False payload остаётся
неизменным (см. _post_fetch) — параметр молча игнорируется сайдкаром.
Фактически применяемое значение — ``reset_context OR`` отложенный флаг из
request_context_reset() (потребляется здесь ровно один раз).
При HTTPError или ConnectError делает одну повторную попытку после
короткой паузы. Остальные исключения всплывают к вызывающему коду.
Побочный эффект (#3196): после возврата ``self.last_response_status`` содержит
HTTP-код целевой навигации (или None, если сайдкар его не отдал). Читать
СРАЗУ после await — следующий fetch() его перезапишет. Классифицировать
помогает ``ban_kind_from_status`` (в этом же модуле).
Returns:
Полный HTML-контент страницы.
"""
assert self._client is not None, "BrowserFetcher: используй как async context manager"
assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"
# Отложенный флаг потребляется РОВНО здесь и сразу гасится — следующий fetch()
# уже не сбрасывает контекст (один сброс на один блок). На retry ниже тот же
# effective_reset уходит вторым POST'ом ОСОЗНАННО: сброс на стороне сайдкара
# идемпотентен (pop с дефолтом), а исход первого POST'а неизвестен — он мог
# упасть транспортом как до, так и после пересоздания контекста. Цена лишнего
# срабатывания — один new_context()+add_cookies(), цена пропущенного — прогон
# доживает на сожжённом контексте, поэтому выбран заведомо безопасный вариант.
effective_reset = reset_context or self._context_reset_pending
self._context_reset_pending = False
try:
return await self._post_fetch(
url, origin, cookies, effective_reset, referer, fetch_mode=fetch_mode
)
except SidecarBanPageError:
# #3288: бан-страница — ОТВЕТ площадки, а не блип сайдкара; ретраить нечего.
# Ветка обязана стоять ДО httpx.HTTPError (SidecarBanPageError — его подкласс,
# см. test_sidecar_ban_page_is_a_subclass_of_httpx_error): иначе один бан
# давал второй POST тем же узлом и ВТОРОЙ инкремент _lease_fail_streak —
# ротация наступала вдвое раньше, чем задумано (_LEASE_ROTATE_AFTER_FAILS).
raise
except (httpx.HTTPError, httpx.TransportError) as exc:
logger.warning(
"BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s",
type(exc).__name__,
_RETRY_SLEEP_S,
url,
)
await asyncio.sleep(_RETRY_SLEEP_S)
return await self._post_fetch(
url, origin, cookies, effective_reset, referer, fetch_mode=fetch_mode
)
async def fetch_json(
self,
url: str,
*,
method: str = "GET",
headers: dict[str, str] | None = None,
body: str | None = None,
origin: str | None = None,
) -> dict:
"""In-page fetch() через sidecar /fetch-json. Возвращает {"status": int, "body": str}.
body — уже сериализованная строка (caller делает json.dumps для POST).
origin — same-origin страница, на которую перейдёт камуфокс перед fetch.
При HTTPError / TransportError делает одну повторную попытку после короткой
паузы. Остальные исключения всплывают к вызывающему коду.
"""
assert self._client is not None, "BrowserFetcher: используй как async context manager"
assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"
try:
return await self._post_fetch_json(url, method, headers, body, origin)
except (httpx.HTTPError, httpx.TransportError) as exc:
logger.warning(
"BrowserFetcher: ошибка fetch-json запроса (%s), retry через %.1fs: %s",
type(exc).__name__,
_RETRY_SLEEP_S,
url,
)
await asyncio.sleep(_RETRY_SLEEP_S)
return await self._post_fetch_json(url, method, headers, body, origin)
async def login(
self,
*,
url: str,
email: str,
password: str,
email_selector: str,
password_selector: str,
submit_selector: str,
success_cookie: str,
pre_click_selectors: list[str] | None = None,
wait_ms: int | None = None,
) -> dict[str, str]:
"""Логинится через tradein-browser /login и возвращает cookies как dict name→value.
Использует увеличенный таймаут (60s) — логин медленнее обычного fetch.
При HTTPError / TransportError делает одну повторную попытку.
Returns:
Плоский словарь {cookie_name: cookie_value}.
"""
assert self._client is not None, "BrowserFetcher: используй как async context manager"
assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"
body: dict[str, object] = {
"url": url,
"email": email,
"password": password,
"email_selector": email_selector,
"password_selector": password_selector,
"submit_selector": submit_selector,
"success_cookie": success_cookie,
"pre_click_selectors": pre_click_selectors or [],
}
if wait_ms is not None:
body["wait_ms"] = wait_ms
try:
return await self._post_login(body)
except (httpx.HTTPError, httpx.TransportError) as exc:
logger.warning(
"BrowserFetcher: ошибка login-запроса (%s), retry через %.1fs",
type(exc).__name__,
_RETRY_SLEEP_S,
)
await asyncio.sleep(_RETRY_SLEEP_S)
return await self._post_login(body)
# ── internal: session-lease lifecycle (#2164 P4 + sticky-session fix 2026-08) ──
def _acquire_lease(self) -> ProxyLease | None:
"""Взять lease: initial acquire из __aenter__ ИЛИ ре-acquire из
_report_fetch_result при осознанной ротации после N подряд провалов — оба
call-site'а идут через этот метод, guard ниже общий для обоих (#2616 шаг 1).
use_pool=False (дефолт) ИЛИ proxy_provider=None → None, proxy в тело /fetch не
кладётся — browser юзает свой env-прокси (BROWSER_PROXY_*), поведение не
меняется (легитимный dev/no-op путь). Пул пуст/ошибка acquire +
environment != "production" → None, НЕ падаем (fallback на env, легитимно для
dev/test). Пул пуст/ошибка acquire + environment == "production" (#2616 шаг 1)
→ env-прокси мертвы (#2613) — поднимаем `NoProxyAvailableError` ДО HTTP POST
/fetch, а не заходим через мёртвый узел (ни на старте сессии, ни mid-run).
Raises:
NoProxyAvailableError: прод + пул реально задействован (use_pool+provider)
и пуст/сломан.
"""
use_pool = self._use_pool and self._proxy_provider is not None
lease: ProxyLease | None = None
if use_pool:
assert self._proxy_provider is not None # type-narrowing (use_pool гарантирует)
try:
lease = self._proxy_provider.acquire(self._source)
except Exception:
logger.warning(
"BrowserFetcher: proxy_pool acquire(%s) failed — fallback to env proxy",
self._source,
exc_info=True,
)
lease = None
if lease is None and use_pool and self._environment == "production":
# Пул реально задействован (прод) и пуст/сломан — env-прокси мертвы, НЕ
# идём на них молча. Явный отказ ДО POST /fetch (#2616 шаг 1).
logger.warning(
"BrowserFetcher: proxy_pool acquire(%s) empty in production — refusing "
"(no HTTP request), NOT falling back to dead env proxy (#2616)",
self._source,
)
raise NoProxyAvailableError(self._source)
# Новый lease — новый узел, признак «бан уже отрапортован» к нему не относится.
self._banned_lease_id = None
return lease
def _release_lease(self) -> None:
"""Отпустить текущий lease (вызывается из __aexit__, ОБЯЗАТЕЛЬНО в finally там).
Идемпотентно/best-effort — проблема пула не должна ронять сбор ни на входе,
ни на выходе.
"""
if self._lease is None or self._proxy_provider is None:
return
lease, self._lease = self._lease, None
try:
self._proxy_provider.release(lease)
except Exception:
logger.warning(
"BrowserFetcher: proxy_pool release failed for %s", self._source, exc_info=True
)
def request_context_reset(self) -> None:
"""Просит сбросить переиспользуемый sidecar-context на СЛЕДУЮЩЕМ fetch() (#3118).
Нужен вызывающему коду, который не дёргает fetch() напрямую (например,
domclick_detail_backfill идёт через scraper_kit.providers.domclick.detail.
fetch_detail(), который reset_context не прокидывает) — сюда можно
сигнализировать «контекст сожжён блоком» без изменения того промежуточного
слоя. Потребляется ровно один раз: следующий fetch() применит сброс и
обнулит флаг сам, дальнейшие запросы идут как обычно (никакого «сброс на
каждый последующий»). No-op, если reuse_context=False — тогда fetch() всё
равно не кладёт reuse_context/reset_context в payload.
"""
self._context_reset_pending = True
def report_ban(self, reason: str) -> None:
"""Пометить ТЕКУЩИЙ lease забаненным площадкой (#2600 п.1, п.2).
Вызывать из точки детекта бана (заглушка HTTP 200 / капча / QRATOR-маркер),
ПОКА lease ещё держится (до `__aexit__`/`_release_lease`) — `fetch()` уже
отрапортовал `mark_health(ok=True)` за этот запрос (HTTP-уровень был успешен,
бан распознаётся ПОЗЖЕ, при разборе содержимого) — этот вызов ЯВНО переопределяет
тот ошибочный сигнал корректным «узел забанен», вместо того чтобы полагаться на
мягкий ipify-health-check, который бан площадки не видит (issue #2600 root cause).
No-op если lease нет (env-fallback путь, use_pool=False) или proxy_provider не
подключён — best-effort, как touch/mark_health/release: проблема пула не должна
ронять сбор. Lease НЕ освобождается и НЕ ротируется здесь — вызывающий код обычно
сразу поднимает исключение и завершает сессию (release произойдёт как обычно в
`__aexit__`); бан переживает release — с #2600 п.2 это строка в
`scrape_proxy_source_bans` для пары (узел, `self._source`), и `acquire(source)`
её фильтрует, так что свежий lease ЭТОГО источника узел больше не возьмёт. Узел
при этом остаётся `enabled` и продолжает работать на другие источники: площадка
забанила IP, а не сломала прокси.
"""
if self._lease is None or self._proxy_provider is None:
return
lease = self._lease
if self._banned_lease_id == lease.id:
# Тот же lease уже помечен забаненным для этого источника (#3288) —
# второй рапорт того же факта только эскалирует ban_count. Новый факт
# возможен лишь на НОВОМ lease, а его взятие сбрасывает признак.
logger.debug(
"BrowserFetcher: lease id=%d (%s) уже помечен баном — пропускаем: %s",
lease.id,
self._source,
reason,
)
return
self._banned_lease_id = lease.id
logger.warning(
"BrowserFetcher: lease id=%d (%s) BANNED — reporting to pool: %s",
lease.id,
self._source,
reason,
)
try:
self._proxy_provider.mark_banned(lease, source=self._source)
except Exception:
logger.warning(
"BrowserFetcher: proxy_pool mark_banned failed for %s", self._source, exc_info=True
)
def _current_proxy(self) -> tuple[str | None, str | None]:
"""Прокси текущей session-lease (или (None, None) — env-прокси браузера)."""
if self._lease is None:
return None, None
return self._lease.url, self._lease.kind
@property
def lease_id(self) -> int | None:
"""id ТЕКУЩЕГО session-lease (`scrape_proxies.id`) — None, если lease нет
(env-fallback путь, use_pool=False, или proxy_provider не подключён).
Минимальный read-only доступ для вызывающего кода, которому нужен proxy_id
арендованного прокси (например, для app.services.proxy_rotation.rotate_proxy)
БЕЗ прямого доступа к приватному `_lease` и без изменения lease-механики.
"""
return self._lease.id if self._lease is not None else None
def _report_fetch_result(self, ok: bool, *, health: bool = True) -> None:
"""Учесть исход ОДНОГО /fetch в здоровье текущего session-lease.
Вызывать на каждый /fetch (успешный и неуспешный) — best-effort, не бросает:
- `touch()` heartbeat всегда (см. proxy_pool.touch — продлевает leased_at,
чтобы reap_stale_leases не отобрал прокси у многочасовой сессии);
- `mark_health(ok)` — та же грануляция «на каждый /fetch», что была
до фикса (mark_health решает про DISABLE_THRESHOLD битого узла глобально
для пула, это НЕ session-locale решение и не должно огрубляться до
«одна оценка на всю сессию»). `health=False` (#3288) выключает ТОЛЬКО этот
шаг — так отказ ПЛОЩАДКИ (подтверждённая бан-страница) не копит глобальный
счётчик здоровья узла: он исправен, его отбил конкретный источник, и его
судьбу решает `mark_banned(source=...)` по паре «узел×источник»
(см. `report_platform_ban`). До #3288 узел с тремя бан-страницами Авито
уходил из выдачи ВСЕМ источникам — прод-замер 31.08: yandex/cian/domclick
получали ProxyPoolExhaustedError при banned_for_source=0 и трёх живых узлах;
- ok=False копит `_lease_fail_streak`; после `_LEASE_ROTATE_AFTER_FAILS`
подряд lease считается плохим (бан/сетевая труха) — ОСОЗНАННО меняется
один раз (release старого + acquire нового), счётчик обнуляется. Следующий
/fetch пошлёт НОВЫЙ proxy-url → camoufox перелончится РОВНО один раз
(server.py релончит только при реальной смене) — контролируемая, редкая
смена вместо прежнего «на каждый запрос».
"""
if self._lease is None or self._proxy_provider is None:
return
lease = self._lease
try:
self._proxy_provider.touch(lease)
except Exception:
logger.warning(
"BrowserFetcher: proxy_pool touch failed for %s", self._source, exc_info=True
)
if health:
try:
self._proxy_provider.mark_health(lease, ok)
except Exception:
logger.warning(
"BrowserFetcher: proxy_pool mark_health failed for %s",
self._source,
exc_info=True,
)
if ok:
self._lease_fail_streak = 0
return
self._lease_fail_streak += 1
if self._lease_fail_streak < _LEASE_ROTATE_AFTER_FAILS:
return
logger.warning(
"BrowserFetcher: lease id=%d (%s) провалил %d /fetch подряд — меняем прокси "
"один раз (не на каждый запрос)",
lease.id,
self._source,
self._lease_fail_streak,
)
self._lease_fail_streak = 0
# #2616 шаг 1: обнуляем ДО re-acquire — если _acquire_lease() ниже поднимет
# NoProxyAvailableError (prod, пул опустел mid-run), __aexit__ не должен потом
# попытаться release() уже отпущенный lease ещё раз (self._lease уже None).
self._lease = None
try:
self._proxy_provider.release(lease)
except Exception:
logger.warning(
"BrowserFetcher: proxy_pool release (rotate) failed for %s",
self._source,
exc_info=True,
)
self._lease = self._acquire_lease()
def report_platform_ban(self, reason: str) -> None:
"""Исход /fetch, который опознан как бан-страница площадки (#3288).
Публичный метод (#3402): тем же путём обязан идти отказ, распознанный не
сайдкаром, а провайдером — по телу ответа с HTTP 200 (капча Циана). Разница с
`report_ban` существенна: тот только пишет бан пары «узел×источник», а сменить
сожжённую аренду ВНУТРИ батча позволяет только fail-streak ниже.
Отличается от обычного провала РОВНО одним: узел не получает `mark_health(False)`.
Бан — приговор паре «узел×источник» (`mark_banned`, строка в
`scrape_proxy_source_bans`, которую фильтрует `acquire(source)`), а не оценка
исправности узла: прод-замер 31.08 показал три узла на потолке
MAX_CONSECUTIVE_FAILS при banned_for_source=0 — Авито выбивало их из выдачи и
Яндексу, и Циану, и Домклику, хотя живая проба тех же узлов проходила.
Порядок вызовов существенен: `report_ban` работает по ТЕКУЩЕМУ lease, а
`_report_fetch_result` может его сменить (ротация после N подряд провалов).
Fail-streak копим по-прежнему — сменить сожжённый площадкой адрес полезно, и
новый lease забаненный узел уже не вернёт (`acquire` фильтрует бан по source.)
Ротация внутри `_report_fetch_result` — best-effort: её `_acquire_lease()` может
поднять `NoProxyAvailableError` (прод, пул опустел mid-run), и та вылетела бы
ВМЕСТО `SidecarBanPageError`, ради которой мы сюда попали, — прогон получил бы
диагноз 'infra' на подтверждённом отказе площадки. Глотаем ровно её и ровно
здесь: бан уже отрапортован (`report_ban` выше), а «нечем ходить дальше» caller
узнает на следующем /fetch, где lease действительно нужен.
"""
self.report_ban(reason)
try:
self._report_fetch_result(False, health=False)
except NoProxyAvailableError:
logger.warning(
"BrowserFetcher: пул пуст при ротации после бана (%s) — бан отрапортован, "
"поднимаем исходную ошибку площадки",
self._source,
)
async def _post_fetch(
self,
url: str,
origin: str | None = None,
cookies: dict[str, str] | None = None,
reset_context: bool = False,
referer: str | None = None,
fetch_mode: str | None = None,
) -> str:
"""Один HTTP POST к /fetch эндпоинту сервиса.
origin/cookies всегда кладём в payload (даже None) — зеркалит
_post_fetch_json, сервер (body.get("origin")/body.get("cookies"))
корректно обрабатывает оба случая.
referer (#3247) кладётся в payload ТОЛЬКО когда задан — старый сайдкар без
этого поля в схеме не должен получать неожиданный ключ, а поведение при
referer=None (дефолт) остаётся байт-в-байт прежним.
reuse_context/reset_context (#3118) кладутся в payload ТОЛЬКО когда сессионный
self._reuse_context включён — при выключенном флаге (дефолт) payload остаётся
БАЙТ-В-БАЙТ таким же, как раньше: сайдкар старой версии (без этих полей в
схеме) не должен получать неожиданные ключи.
proxy — из ТЕКУЩЕГО session-lease (_current_proxy), НЕ acquire на каждый вызов
(#2164 P4 sticky-session fix, живая регрессия 2026-08). Исход репортится в lease
через _report_fetch_result (touch-heartbeat + mark_health + осознанная ротация
при N подряд провалах) — best-effort, саму ошибку не глотает (re-raise).
Побочный эффект (#3196): выставляет ``self.last_response_status`` — HTTP-код
целевой навигации из тела ответа (``{"html": ..., "status": ...}``). На любой
неудаче сбрасывает в None, чтобы не отдать статус прошлого запроса.
"""
assert self._client is not None
assert self._endpoint is not None
proxy_url, proxy_kind = self._current_proxy()
payload: dict = {
"url": url,
"source": self._source,
"origin": origin,
"cookies": cookies,
}
if self._reuse_context:
payload["reuse_context"] = True
payload["reset_context"] = reset_context
if referer:
payload["referer"] = referer
# fetch_mode кладём ТОЛЬКО когда задан и не дефолтный: сайдкар старой версии
# (без этого поля в схеме) не должен получать неожиданный ключ — тот же приём,
# что с referer выше.
if fetch_mode and fetch_mode != "navigate":
payload["fetch_mode"] = fetch_mode
if proxy_url:
payload["proxy"] = proxy_url
if proxy_kind:
payload["proxy_kind"] = proxy_kind
try:
resp = await self._client.post(f"{self._endpoint}/fetch", json=payload)
_raise_for_sidecar_status(resp) # #2698: причина отказа из тела, не только код
data: dict = resp.json()
html = data["html"]
except SidecarBanPageError as exc:
# ПОРЯДОК ВЕТОК ВАЖЕН (#3288): SidecarBanPageError — подкласс
# httpx.HTTPStatusError, и общий `except Exception` ниже забирал её себе,
# отправляя подтверждённый отказ ПЛОЩАДКИ в глобальный счётчик здоровья узла.
self.last_response_status = None
self.report_platform_ban(f"sidecar ban page (upstream={exc.upstream_status}) for {url}")
raise
except Exception:
# NoProxyAvailableError (пустой пул) сюда НЕ приходит: он поднимается в
# _acquire_lease ДО этого POST'а — узла, о здоровье которого можно судить,
# в этот момент просто нет. Отдельной ветки на него здесь поэтому не
# заводим: она была бы защитой, которая не может сработать (#3288 п.4).
self.last_response_status = None
self._report_fetch_result(False)
raise
# Аддитивно: ключа "status" может не быть (сайдкар старой версии) и он
# может быть null — оба случая дают None, исключений не бросаем.
# bool отсекаем явно: он подтип int, поэтому JSON true прошёл бы проверку
# и уехал бы наверх статусом True, который дальше сравнивается с 403/5xx
# как 1 — молчаливо неверный диагноз вместо честного «статуса нет».
raw_status = data.get("status")
has_status = isinstance(raw_status, int) and not isinstance(raw_status, bool)
self.last_response_status = raw_status if has_status else None
self._report_fetch_result(True)
logger.debug("BrowserFetcher: fetch OK url=%r html_len=%d", url, len(html))
return html
async def _post_fetch_json(
self,
url: str,
method: str,
headers: dict[str, str] | None,
body: str | None,
origin: str | None,
) -> dict:
"""Один HTTP POST к /fetch-json эндпоинту сервиса.
proxy — из ТЕКУЩЕГО session-lease (_current_proxy), см. _post_fetch docstring.
"""
assert self._client is not None
assert self._endpoint is not None
proxy_url, proxy_kind = self._current_proxy()
payload: dict = {
"url": url,
"source": self._source,
"method": method,
"headers": headers or {},
"body": body,
"origin": origin,
}
if proxy_url:
payload["proxy"] = proxy_url
if proxy_kind:
payload["proxy_kind"] = proxy_kind
try:
resp = await self._client.post(f"{self._endpoint}/fetch-json", json=payload)
_raise_for_sidecar_status(resp) # #2698: причина отказа из тела, не только код
data: dict = resp.json()
except SidecarBanPageError as exc:
# Ветка ДО общего except по той же причине, что в _post_fetch (#3288):
# бан-страница — отказ площадки, а не отказ узла.
self.report_platform_ban(
f"sidecar ban page on fetch-json (upstream={exc.upstream_status}) for {url}"
)
raise
except Exception:
self._report_fetch_result(False)
raise
self._report_fetch_result(True)
# Defensive: контракт сервера — {"status": int, "body": str} (зеркалит как
# _post_fetch читает data["html"]). Если ключи пропали (несовместимый сервер /
# прокинутый error-payload) — падаем с понятной ошибкой, а не KeyError ниже по
# стеку у адаптера, который ждёт r["status"]/r["body"].
if "status" not in data or "body" not in data:
raise RuntimeError(
f"BrowserFetcher: /fetch-json вернул некорректный ответ "
f"(нет 'status'/'body'): keys={sorted(data)} url={url!r}"
)
logger.debug(
"BrowserFetcher: fetch-json OK url=%r status=%s body_len=%d",
url,
data.get("status"),
len(data.get("body") or ""),
)
return data
async def _post_login(self, body: dict[str, object]) -> dict[str, str]:
"""Один HTTP POST к /login эндпоинту сервиса."""
assert self._client is not None
assert self._endpoint is not None
resp = await self._client.post(
f"{self._endpoint}/login",
json=body,
timeout=httpx.Timeout(60.0),
)
if resp.status_code == 502:
data = resp.json()
has_screenshot = bool(data.get("screenshot_b64"))
logger.debug(
"BrowserFetcher: login 502 — has_screenshot=%s page_url=%r",
has_screenshot,
data.get("page_url"),
)
raise RuntimeError(
f"browser login failed: {data.get('error')} url={data.get('page_url')}"
)
resp.raise_for_status()
data = resp.json()
raw: list[dict[str, object]] = data["cookies"]
result = {str(c["name"]): str(c["value"]) for c in raw}
logger.info("BrowserFetcher: login OK cookie_count=%d", len(result))
return result