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
Циан отдаёт капчу (`<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).
1098 lines
70 KiB
Python
1098 lines
70 KiB
Python
"""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
|