"""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": }``. Сайдкар старой сборки ключей не отдаёт → (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