diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index 0e7e503d..ed40dc63 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -77,6 +77,20 @@ Sticky session lease (browser-путь, живая регрессия 2026-08): на каждый /fetch, чтобы reap_stale_leases не отобрал прокси у многочасового прогона. +Два тракта — два диагноза (#2723): + - ipify-проба (`_probe_proxy`) отвечает на «узел жив вообще» и владеет + consecutive_fails / enabled / exit_ip. Такт — каждый прогон healthcheck (30 мин). + - браузерная проба (`_run_browser_probe` → сайдкар → camoufox с ЭТИМ прокси → + навигация) отвечает на «через узел работает браузерный тракт» и владеет + browser_fail_streak / browser_unfit_since / browser_check_at (миграция 228). + Такт свой, редкий (BROWSER_PROBE_MINUTES) — она стоит запуска camoufox. + Пересечения нет: успешная ipify-проба НЕ обнуляет browser_fail_streak (иначе + дешёвая проба каждые 30 минут стирает вердикт дорогого тракта — узел, мёртвый для + браузера, вечно возвращается в выдачу), провал браузерной пробы НЕ выключает узел + (он жив, просто не для этого тракта). Схлопнуть их в один флаг = повторить #2686. + «Непригоден для браузера» — это НЕ исключение из пула: acquire() лишь отдаёт такой + узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже. + psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. """ @@ -90,9 +104,13 @@ import httpx from sqlalchemy import text from sqlalchemy.orm import Session +from app.core.config import settings as _settings + logger = logging.getLogger(__name__) __all__ = [ + "BROWSER_PROBE_MINUTES", + "BROWSER_UNFIT_THRESHOLD", "DISABLED_RECHECK_MINUTES", "DISABLE_THRESHOLD", "MAX_CONSECUTIVE_FAILS", @@ -105,6 +123,7 @@ __all__ = [ "acquire", "clear_source_bans", "mark_banned", + "mark_browser_health", "mark_health", "reap_stale_leases", "release", @@ -155,6 +174,27 @@ SOURCE_BAN_PURGE_DAYS = 7 _HEALTH_PROBE_URL = "https://api.ipify.org" _HEALTH_PROBE_TIMEOUT_S = 10.0 +# ── браузерная проба узла (#2723) ──────────────────────────────────────────── +# Такт браузерной пробы. Решено по замеру, не по ощущению (прод, 06.08.2026): +# - одна браузерная проба = 8.3с и один запуск camoufox; +# - боевая нагрузка сайдкара = ~42 /fetch и ~8 запусков camoufox в час +# (≈1000 и ≈190 в сутки); +# - такт ipify-пробы = 30 мин → 48 прогонов healthcheck в сутки. +# Гнать браузерную пробу каждым прогоном по 4 узлам = +192 запуска camoufox в сутки, +# то есть УДВОЕНИЕ самой дорогой операции сайдкара ради диагностики. 360 мин даёт +# 4 пробы на узел в сутки: +16 запусков (+8% к запускам, +1.6% к запросам) — цена, +# которую видно только в логе. Отказ, пойманный с задержкой до 6 часов, всё равно +# ловится в разы раньше, чем сейчас (не ловится вовсе). +BROWSER_PROBE_MINUTES = 360 + +# Столько подряд-провалов браузерной пробы (атрибутированных узлу) переводят узел в +# browser_unfit. Не 1: запуск camoufox бывает флаки сам по себе, а пометка — операция +# с последствиями при пуле из 4 узлов. Не 5 (как DISABLE_THRESHOLD): при редком такте +# это были бы сутки. Второе подтверждение приходит на СЛЕДУЮЩЕМ прогоне healthcheck +# (~30 мин), а не через полный такт — browser_check_at на неподтверждённом провале +# намеренно не обновляется (см. mark_browser_health). +BROWSER_UNFIT_THRESHOLD = 2 + # deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для # mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что # в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не @@ -213,7 +253,7 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe db.execute( text( """ - SELECT id, url, kind, rotate_url + SELECT id, url, kind, rotate_url, browser_unfit_since FROM scrape_proxies WHERE enabled AND consecutive_fails < CAST(:max_fails AS integer) @@ -226,7 +266,9 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe AND b.source = :provider AND b.banned_until > now() ) - ORDER BY last_ok_at NULLS LAST, id + -- browser_unfit последним (#2723): узел, живой для HTTP, но не для + -- браузера, из пула НЕ исключается — только уходит в конец очереди. + ORDER BY (browser_unfit_since IS NOT NULL), last_ok_at NULLS LAST, id FOR UPDATE SKIP LOCKED LIMIT 1 """ @@ -247,7 +289,7 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe db.execute( text( """ - SELECT sp.id, sp.url, sp.kind, sp.rotate_url + SELECT sp.id, sp.url, sp.kind, sp.rotate_url, sp.browser_unfit_since FROM scrape_proxies AS sp WHERE sp.enabled AND sp.consecutive_fails < CAST(:max_fails AS integer) @@ -283,7 +325,8 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe ) ) ) - ORDER BY sp.last_ok_at NULLS LAST, sp.id + -- см. ORDER BY основного запроса (#2723) + ORDER BY (sp.browser_unfit_since IS NOT NULL), sp.last_ok_at NULLS LAST, sp.id FOR UPDATE SKIP LOCKED LIMIT 1 """ @@ -323,6 +366,19 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe logger.info( "proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker ) + if row["browser_unfit_since"] is not None: + # Узел помечен непригодным для браузера (#2723), но всё равно выдан — значит + # пригодных свободных не осталось. Голодание хуже работы через плохой узел + # (та же политика, что у защиты последнего узла в mark_banned), но молчать об + # этом нельзя: для браузерного источника это заведомо обречённый прогон. + logger.warning( + "proxy_pool: leased proxy id=%d provider=%s — узел BROWSER-UNFIT с %s " + "(жив для HTTP, браузерный тракт через него не работает). Выдан потому, " + "что пригодных свободных узлов нет — пул надо пополнять (#2638).", + proxy_id, + provider, + row["browser_unfit_since"], + ) return ProxyLease( id=proxy_id, url=str(row["url"]), @@ -480,6 +536,145 @@ def mark_health( ) +def mark_browser_health( + db: Session, + proxy_id: int, + ok: bool, + *, + fail_kind: str | None = None, + detail: str = "", +) -> str: + """Записать результат БРАУЗЕРНОЙ пробы узла (#2723). Returns исход для счётчиков. + + ЧЕМ ОТЛИЧАЕТСЯ ОТ mark_health: тем же, чем «нас забанила площадка» отличается от + «у нас упал сайдкар» (#2686/#2711) — это ДРУГОЙ диагноз, а не другое значение того + же. mark_health отвечает на «узел жив вообще» и владеет + consecutive_fails/enabled/exit_ip. Эта функция отвечает на «через узел работает + браузерный тракт» и владеет browser_fail_streak/browser_unfit_since/ + browser_check_at. Пересечения нет НИ В ОДНУ сторону, и это главное: + + - успешная ipify-проба НЕ обнуляет browser_fail_streak. До #2723 обнуляла бы + (через consecutive_fails=0) — узел, мёртвый для браузера, выходил из карантина + каждые ≤30 минут и снова забирал прогон; + - провал браузерной пробы НЕ инкрементит consecutive_fails и НЕ выключает узел: + он жив, просто не для этого тракта. + + ЧТО СЧИТАЕТСЯ ПРОВАЛОМ УЗЛА: только fail_kind == "proxy" (см. + scraper_kit.browser_fetcher.classify_browser_probe). "sidecar" (сайдкар лежит) и + "page" (площадка отдала пустое) узлу не принадлежат — засчитывать их значило бы + пометить непригодными ВСЕ узлы разом при одной упавшей общей зависимости, то есть + повторить #2686 ещё раз и уже с последствиями для всего пула. + + ТАКТ ПРИ ПРОВАЛЕ: browser_check_at обновляется только когда провал ПОДТВЕРЖДЁН + (streak дошёл до BROWSER_UNFIT_THRESHOLD). На первом, ещё не подтверждённом + провале поле остаётся старым → следующий же прогон healthcheck (~30 мин) повторит + пробу и либо подтвердит отказ, либо снимет подозрение. Иначе подтверждения ждали бы + полный BROWSER_PROBE_MINUTES. + + Returns: "ok" | "refit" (узел был непригоден и починился) | "unfit" (только что + помечен непригодным) | "fail" (провал засчитан, порог не достигнут) | "ignored" + (провал не принадлежит узлу). + """ + if ok: + row = ( + db.execute( + text( + """ + UPDATE scrape_proxies AS sp + SET browser_fail_streak = 0, + browser_unfit_since = NULL, + browser_check_at = now(), + updated_at = now() + -- prev — pre-image строки: RETURNING отдаёт УЖЕ обновлённые + -- значения (browser_unfit_since там всегда NULL), а нам нужно + -- знать, была ли это реанимация непригодного узла. + FROM ( + SELECT id, browser_unfit_since + FROM scrape_proxies + WHERE id = CAST(:id AS bigint) + ) AS prev + WHERE sp.id = prev.id + RETURNING (prev.browser_unfit_since IS NOT NULL) AS was_unfit + """ + ), + {"id": proxy_id}, + ) + .mappings() + .fetchone() + ) + db.commit() + was_unfit = bool(row["was_unfit"]) if row is not None else False + logger.info( + "proxy_pool: browser probe OK id=%d (%s)%s", + proxy_id, + detail, + " — узел снова пригоден для браузера" if was_unfit else "", + ) + return "refit" if was_unfit else "ok" + + if fail_kind != "proxy": + logger.warning( + "proxy_pool: browser probe FAILED id=%d, но отказ НЕ принадлежит узлу " + "(fail_kind=%s): %s — browser_fail_streak не трогаем", + proxy_id, + fail_kind, + detail, + ) + return "ignored" + + row = ( + db.execute( + text( + """ + UPDATE scrape_proxies + SET browser_fail_streak = browser_fail_streak + 1, + browser_unfit_since = CASE + WHEN browser_fail_streak + 1 >= CAST(:threshold AS integer) + AND browser_unfit_since IS NULL + THEN now() ELSE browser_unfit_since + END, + browser_check_at = CASE + WHEN browser_fail_streak + 1 >= CAST(:threshold AS integer) + THEN now() ELSE browser_check_at + END, + updated_at = now() + WHERE id = CAST(:id AS bigint) + RETURNING browser_fail_streak, browser_unfit_since + """ + ), + {"threshold": BROWSER_UNFIT_THRESHOLD, "id": proxy_id}, + ) + .mappings() + .fetchone() + ) + db.commit() + if row is None: + logger.warning("proxy_pool: mark_browser_health id=%d not found — no-op", proxy_id) + return "ignored" + + streak = int(row["browser_fail_streak"]) + if streak >= BROWSER_UNFIT_THRESHOLD: + logger.warning( + "proxy_pool: proxy id=%d BROWSER-UNFIT (browser_fail_streak=%d) — жив для " + "обычного HTTP, но браузерный тракт через него не работает: %s. Узел " + "ОСТАЁТСЯ в пуле (enabled не тронут, curl-путь работает), но acquire() " + "теперь отдаёт его последним (#2723).", + proxy_id, + streak, + detail, + ) + return "unfit" + logger.warning( + "proxy_pool: browser probe FAILED id=%d (browser_fail_streak=%d/%d, порог не " + "достигнут — перепроверим на следующем прогоне): %s", + proxy_id, + streak, + BROWSER_UNFIT_THRESHOLD, + detail, + ) + return "fail" + + def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: """Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2. @@ -645,8 +840,7 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: current = ( db.execute( text( - "SELECT enabled, disabled_reason FROM scrape_proxies " - "WHERE id = CAST(:id AS bigint)" + "SELECT enabled, disabled_reason FROM scrape_proxies WHERE id = CAST(:id AS bigint)" ), {"id": proxy_id}, ) @@ -775,6 +969,29 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No return False, None, None, "other" +async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) -> str: + """Одна браузерная проба узла + запись вердикта. Returns исход mark_browser_health. + + Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет + healthcheck — ipify-часть уже отработала и её результат записан. Диагностика не + имеет права ломать то, что диагностирует. + """ + from scraper_kit.browser_fetcher import probe_proxy_via_browser + + try: + ok, fail_kind, detail = await probe_proxy_via_browser( + _settings.browser_http_endpoint, url, proxy_kind=kind + ) + except Exception: + logger.warning( + "proxy_pool: browser probe crashed for proxy id=%d — вердикт не записан", + proxy_id, + exc_info=True, + ) + return "ignored" + return mark_browser_health(db, proxy_id, ok, fail_kind=fail_kind, detail=detail) + + def _mask(url: str) -> str: """Скрыть пароль в proxy-url для логов (scheme://user:***@host).""" if "@" not in url or "//" not in url: @@ -808,9 +1025,18 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: В конце — purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад (см. комментарий у самого DELETE: отложенность — это и есть сброс ban_count). + БРАУЗЕРНАЯ ПРОБА (#2723): узлам, прошедшим ipify и не проверявшимся браузером + дольше BROWSER_PROBE_MINUTES, дополнительно гоняется проба ЧЕРЕЗ САЙДКАР (тот же + тракт, что у боевого сбора: camoufox стартует с этим прокси, потом навигация на + robots.txt площадки). Её вердикт идёт в ОТДЕЛЬНЫЕ поля (mark_browser_health) и + никогда не смешивается с consecutive_fails/enabled. Гейт — settings. + use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и проба + измеряла бы то, чем никто не пользуется. + Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на один и тот же upstream-endpoint (ipify) не нужен. Returns counters - {reaped, checked, ok, failed, revived, bans_purged}. + {reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok, + browser_unfit, browser_refit}. """ reaped = reap_stale_leases(db) @@ -818,7 +1044,11 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: db.execute( text( """ - SELECT id, url, kind, enabled, disabled_reason + SELECT id, url, kind, enabled, disabled_reason, + (browser_check_at IS NULL + OR browser_check_at < now() - make_interval( + mins => CAST(:browser_probe_minutes AS integer) + )) AS browser_probe_due FROM scrape_proxies WHERE enabled OR last_check_at IS NULL @@ -828,7 +1058,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: ORDER BY id """ ), - {"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES}, + { + "disabled_recheck_minutes": DISABLED_RECHECK_MINUTES, + "browser_probe_minutes": BROWSER_PROBE_MINUTES, + }, ) .mappings() .all() @@ -838,6 +1071,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: ok_count = 0 failed = 0 revived = 0 + browser_checked = 0 + browser_ok = 0 + browser_unfit = 0 + browser_refit = 0 for row in proxies: proxy_id = int(row["id"]) url = str(row["url"]) @@ -860,6 +1097,22 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: else: failed += 1 + # Браузерная проба (#2723) — только если ipify прошла: провалившая ipify нода + # мертва целиком, диагноз уже поставлен, а запуск camoufox через неё — чистая + # трата 8 секунд. Гейт по use_proxy_pool_browser: при выключенном флаге браузер + # ходит мимо пула (через env-прокси сайдкара), и вердикт об узлах пула был бы + # вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет + # не тот узел», из-за которого #2723 и появилась. + if ok and row["browser_probe_due"] and _settings.use_proxy_pool_browser: + outcome = await _run_browser_probe(db, proxy_id, url, str(row["kind"])) + browser_checked += 1 + if outcome in ("ok", "refit"): + browser_ok += 1 + if outcome == "refit": + browser_refit += 1 + elif outcome == "unfit": + browser_unfit += 1 + # Purge ДАВНО истёкших бан-строк (#2600 п.2). Порог — banned_until + SOURCE_BAN_PURGE_DAYS, # НЕ просто `banned_until < now()`: строка после истечения бана ещё ничего не блокирует # (acquire фильтрует по banned_until > now()), но хранит ban_count — память об эскалации. @@ -882,13 +1135,17 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: logger.info( "proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d " - "bans_purged=%d", + "bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d", reaped, checked, ok_count, failed, revived, purged, + browser_checked, + browser_ok, + browser_unfit, + browser_refit, ) return { "reaped": reaped, @@ -897,4 +1154,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: "failed": failed, "revived": revived, "bans_purged": purged, + # Счётчики браузерной пробы (#2723) — намеренно ОТДЕЛЬНЫЕ от checked/ok/failed: + # схлопнув их в общие, мы бы своими руками сделали то, за что чиним этот модуль. + "browser_checked": browser_checked, + "browser_ok": browser_ok, + "browser_unfit": browser_unfit, + "browser_refit": browser_refit, } diff --git a/tradein-mvp/backend/data/sql/228_scrape_proxies_browser_health.sql b/tradein-mvp/backend/data/sql/228_scrape_proxies_browser_health.sql new file mode 100644 index 00000000..71b4cd62 --- /dev/null +++ b/tradein-mvp/backend/data/sql/228_scrape_proxies_browser_health.sql @@ -0,0 +1,66 @@ +-- 228_scrape_proxies_browser_health.sql +-- Здоровье узла ОТДЕЛЬНО для браузерного тракта (#2723). +-- +-- WHY: +-- `run_proxy_healthcheck` гоняет через узел обычный httpx-GET к ipify. Боевой сбор +-- Авито с 02.08 (#2637) ходит через сайдкар браузером: camoufox стартует С ЭТИМ +-- прокси (geoip-lookup на launch), потом навигация. Это разные свойства узла: +-- крошечный GET проходит там, где launch/навигация падает (`browser unavailable +-- (proxy may be down)` — все 90 записанных обрывов сбора именно такие). +-- +-- Хуже того, оба свойства писались в ОДИН счётчик: боевой /fetch репортит +-- mark_health(ok=False) → consecutive_fails++, но следующая (≤30 мин) успешная +-- ipify-проба делает consecutive_fails=0 + enabled=true. Дешёвая проба СТИРАЛА +-- вердикт дорогого тракта, и узел, мёртвый для браузера, вечно возвращался в +-- выдачу. Это ровно ошибка #2686 (схлопывание двух диагнозов в один флаг) в +-- другом месте; разводим её тем же приёмом, что #2711 (`scrape_runs.ban_kind`) — +-- поле РЯДОМ, а не новое значение существующего флага. +-- +-- WHAT (три колонки, ни одна не участвует в enabled/consecutive_fails): +-- browser_fail_streak — подряд-провалы ИМЕННО браузерной пробы, и только те, что +-- атрибутируются узлу (сайдкар лежит / страница пустая — +-- не считаются, см. proxy_pool._classify_browser_probe). +-- Успешная ipify-проба его НЕ обнуляет — в этом весь смысл. +-- browser_unfit_since — момент, когда streak дошёл до порога. NOT NULL = «жив для +-- HTTP, непригоден для браузера». acquire() такой узел НЕ +-- исключает (голодание хуже — #2600/#2638, пул 4 узла), а +-- отправляет в КОНЕЦ очереди выдачи: его возьмут, только +-- если свободных пригодных нет. +-- browser_check_at — когда браузерную пробу гоняли последний раз. Такт у неё +-- свой, редкий (BROWSER_PROBE_MINUTES): она стоит запуска +-- camoufox (~8с замерено на проде), ipify — миллисекунды. +-- +-- IDEMPOTENCY / SAFETY: +-- ADD COLUMN IF NOT EXISTS × 3, аддитивно, без backfill'а: NULL/0 = «браузерную +-- пробу ещё не гоняли», ровно то состояние, в котором пул и находится. Ни одна +-- существующая выборка не меняет результат (все три колонки новые). Повторный +-- прогон — no-op (auto-apply strict на деплое это требует). +-- +-- Dependencies: 157_scrape_proxies.sql + +BEGIN; + +ALTER TABLE scrape_proxies + ADD COLUMN IF NOT EXISTS browser_fail_streak integer NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS browser_unfit_since timestamptz, + ADD COLUMN IF NOT EXISTS browser_check_at timestamptz; + +COMMENT ON COLUMN scrape_proxies.browser_fail_streak IS + 'Подряд-провалы браузерной пробы (сайдкар + camoufox через ЭТОТ узел), ' + 'атрибутированные узлу. НЕ обнуляется успешной ipify-пробой — иначе дешёвая ' + 'проба стирает вердикт дорогого тракта (#2723). Обнуляется успешной браузерной ' + 'пробой. Порог → browser_unfit_since, см. proxy_pool.BROWSER_UNFIT_THRESHOLD.'; + +COMMENT ON COLUMN scrape_proxies.browser_unfit_since IS + 'NOT NULL = узел жив для обычного HTTP, но браузерный тракт через него не ' + 'работает (#2723). Это НЕ enabled=false: узел остаётся в пуле и обслуживает ' + 'curl-путь, а acquire() лишь отдаёт его последним. Полное выключение по-прежнему ' + 'значит «узел мёртв целиком» (серия транспортных сбоев) либо решение оператора.'; + +COMMENT ON COLUMN scrape_proxies.browser_check_at IS + 'Последняя браузерная проба. Такт свой, редкий (proxy_pool.BROWSER_PROBE_MINUTES): ' + 'одна такая проба = запуск camoufox (~8с на проде), против миллисекунд у ipify. ' + 'На неподтверждённом провале НЕ обновляется — чтобы следующий же цикл ' + 'healthcheck подтвердил/опроверг отказ, а не ждал полный такт.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/services/test_proxy_pool.py b/tradein-mvp/backend/tests/services/test_proxy_pool.py index ca25dc81..a62f9886 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_pool.py +++ b/tradein-mvp/backend/tests/services/test_proxy_pool.py @@ -188,9 +188,14 @@ class FakeSession: and _not_banned(r) and (not protects_last_node or _has_backup(r)) ] - # ORDER BY last_ok_at NULLS LAST, id + # ORDER BY (browser_unfit_since IS NOT NULL), last_ok_at NULLS LAST, id. + # Первый ключ гейтим по подстроке самого SQL (как ban-фильтры выше): иначе + # мок сортировал бы «правильно» независимо от боевого запроса и не отличил + # бы код до #2723 от кода после. + deprioritises_unfit = "browser_unfit_since IS NOT NULL" in sql cands.sort( key=lambda r: ( + bool(deprioritises_unfit and r.get("browser_unfit_since") is not None), r["last_ok_at"] is None, r["last_ok_at"] or datetime.min.replace(tzinfo=UTC), r["id"], @@ -250,6 +255,14 @@ class FakeSession: row["enabled"] = True elif "enabled" in sql: row["enabled"] = True + # #2723: если боевой mark_health когда-нибудь снова начнёт обнулять + # ещё и браузерный вердикт (как делал до фикса — тот жил в общем + # consecutive_fails), мок обязан это воспроизвести, иначе + # test_ipify_success_does_not_erase_browser_verdict останется зелёным + # на сломанном коде. + if "browser_fail_streak = 0" in sql: + row["browser_fail_streak"] = 0 + row["browser_unfit_since"] = None if "RETURNING disabled_reason" in sql: return _FakeResult([{"disabled_reason": row.get("disabled_reason")}]) return _FakeResult([]) @@ -272,8 +285,54 @@ class FakeSession: if r["enabled"] or r.get("last_check_at") is None or r["last_check_at"] < cutoff ] rows = sorted(cands, key=lambda r: r["id"]) + # #2723: браузерная проба со своим тактом. Признак считаем, только если + # боевой SQL его реально запрашивает (см. гейты по подстрокам выше). + if "browser_probe_due" in sql: + b_cutoff = datetime.now(UTC) - timedelta(minutes=p["browser_probe_minutes"]) + return _FakeResult( + [ + dict( + r, + browser_probe_due=( + r.get("browser_check_at") is None + or r["browser_check_at"] < b_cutoff + ), + ) + for r in rows + ] + ) return _FakeResult([dict(r) for r in rows]) + if "SET browser_fail_streak = 0" in sql: # mark_browser_health ok (#2723) + row = self._by_id(p["id"]) + if row is None: + return _FakeResult([]) + was_unfit = row.get("browser_unfit_since") is not None + row["browser_fail_streak"] = 0 + row["browser_unfit_since"] = None + row["browser_check_at"] = datetime.now(UTC) + return _FakeResult([{"was_unfit": was_unfit}]) + + if "browser_fail_streak = browser_fail_streak + 1" in sql: # mark_browser_health fail + row = self._by_id(p["id"]) + if row is None: + return _FakeResult([]) + row["browser_fail_streak"] = row.get("browser_fail_streak", 0) + 1 + if row["browser_fail_streak"] >= p["threshold"]: + if row.get("browser_unfit_since") is None: + row["browser_unfit_since"] = datetime.now(UTC) + # такт двигаем ТОЛЬКО на подтверждённом провале — иначе неподтверждённое + # подозрение ждало бы полный BROWSER_PROBE_MINUTES (#2723) + row["browser_check_at"] = datetime.now(UTC) + return _FakeResult( + [ + { + "browser_fail_streak": row["browser_fail_streak"], + "browser_unfit_since": row.get("browser_unfit_since"), + } + ] + ) + if "pg_advisory_xact_lock" in sql: # deep-review fix 2 (#2600) — mark_banned serialize self.advisory_lock_calls.append(p["key"]) return _FakeResult([]) @@ -384,6 +443,9 @@ def _proxy( kind: str = "http", rotate_url: str | None = None, disabled_reason: str | None = None, + browser_unfit_since: datetime | None = None, + browser_fail_streak: int = 0, + browser_check_at: datetime | None = None, ) -> dict[str, Any]: return { "id": pid, @@ -400,6 +462,10 @@ def _proxy( "last_check_at": last_check_at, "exit_ip": None, "latency_ms": None, + # #2723: здоровье браузерного тракта — отдельные поля, миграция 228. + "browser_unfit_since": browser_unfit_since, + "browser_fail_streak": browser_fail_streak, + "browser_check_at": browser_check_at, } diff --git a/tradein-mvp/backend/tests/test_2723_browser_probe.py b/tradein-mvp/backend/tests/test_2723_browser_probe.py new file mode 100644 index 00000000..0bfb8dae --- /dev/null +++ b/tradein-mvp/backend/tests/test_2723_browser_probe.py @@ -0,0 +1,329 @@ +"""#2723 — проба здоровья прокси ходит тем же трактом, что и работа. + +Что сторожится (каждый тест падает на коде origin/main): + + 1. Классификация отказа браузерной пробы: узлу принадлежит ТОЛЬКО отказ прокси + (503 «browser unavailable», 500 NS_ERROR_PROXY_*). Лежащий сайдкар и пустая + страница — не его вина. Без этого одна упавшая общая зависимость пометила бы + непригодными ВСЕ узлы разом — #2686 в третий раз. + 2. Тракт пробы: POST /fetch (одна навигация) на robots.txt, с прокси узла в теле. + Не /fetch-json (тот сначала грузит ГЛАВНУЮ площадки) и не выдача. + 3. Главное: успешная ipify-проба НЕ стирает вердикт браузерного тракта. На коде до + фикса узел, мёртвый для браузера, выходил из карантина каждые ≤30 минут + (mark_health(ok=True) → consecutive_fails=0 + enabled=true) и снова забирал прогон. + 4. Два диагноза разведены в обе стороны: провал браузерной пробы НЕ выключает узел + и НЕ трогает consecutive_fails; провал ipify не пишет ничего в browser-поля. + 5. Пометка непригодности НЕ выводит узел из пула: acquire() отдаёт его последним, + но при отсутствии пригодных всё равно выдаёт (голодание хуже) — пул из 4 узлов. + 6. Реанимация: успешная браузерная проба снимает пометку (browser_refit). + 7. Такт: браузерная проба идёт реже ipify (BROWSER_PROBE_MINUTES) и только по узлам, + прошедшим ipify — иначе на каждый прогон приходился бы запуск camoufox на узел. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import pytest +from scraper_kit.browser_fetcher import classify_browser_probe + +from app.services import proxy_pool +from app.services.proxy_pool import BROWSER_PROBE_MINUTES, BROWSER_UNFIT_THRESHOLD, acquire +from tests.services.test_proxy_pool import FakeSession, _proxy + +# ── 1. классификация отказа ────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("status", "detail", "expected"), + [ + # Ровно тот текст, которым сайдкар отвечал на все 90 записанных обрывов сбора. + (503, '{"error": "browser unavailable (proxy may be down)"}', "proxy"), + (500, '{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY ..."}', "proxy"), + (500, '{"error": "Error: Page.goto: NS_ERROR_UNKNOWN_PROXY_HOST"}', "proxy"), + # Сайдкар не сконфигурирован / лежит / отвечает чем-то ещё — узел ни при чём. + (503, '{"error": "no proxy configured — refusing direct connection (prod)"}', "sidecar"), + (502, "bad gateway", "sidecar"), + (None, "ConnectError: [Errno 111] Connection refused", "sidecar"), + # Тракт сработал, но ответ не похож на страницу — вопрос к площадке, не к пулу. + (200, "", "page"), + ], +) +def test_classify_browser_probe(status: int | None, detail: str, expected: str) -> None: + assert classify_browser_probe(status, detail) == expected + + +def test_sidecar_error_literals_still_exist() -> None: + """Тripwire: классификация опирается на текст отказа сайдкара — сторожим его. + + Если browser/server.py переименует сообщение, «proxy» перестанет распознаваться и + непригодный узел молча останется первосортным. Тест падает СРАЗУ, а не через месяц + зелёных проб (ровно тот сценарий, из-за которого задача и появилась). + """ + server_py = Path(__file__).resolve().parents[2] / "browser" / "server.py" + src = server_py.read_text(encoding="utf-8") + assert "browser unavailable (proxy may be down)" in src + + +# ── 2. тракт пробы ─────────────────────────────────────────────────────────── + + +async def test_probe_goes_through_sidecar_with_node_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + """Проба = POST /fetch на robots.txt с прокси УЗЛА в теле, а не httpx-GET мимо всех.""" + seen: dict[str, Any] = {} + + class _Resp: + status_code = 200 + text = '{"html": "User-agent: *"}' + + @staticmethod + def json() -> dict[str, str]: + return {"html": "User-agent: *"} + + class _Client: + def __init__(self, **kw: Any) -> None: + seen["timeout"] = kw.get("timeout") + + async def __aenter__(self) -> _Client: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def post(self, url: str, json: dict[str, Any]) -> _Resp: + seen["url"] = url + seen["payload"] = json + return _Resp() + + import scraper_kit.browser_fetcher as bf + + monkeypatch.setattr(bf.httpx, "AsyncClient", _Client) + ok, fail_kind, _detail = await bf.probe_proxy_via_browser( + "http://tradein-browser:3000", "http://u:p@node:8080", proxy_kind="http" + ) + + assert ok is True + assert fail_kind is None + # тот же сайдкар и тот же эндпоинт, что у боевого сбора + assert seen["url"] == "http://tradein-browser:3000/fetch" + # НЕ /fetch-json: он делает goto на главную площадки — это уже нагрузка на неё + assert not seen["url"].endswith("/fetch-json") + # прокси проверяемого узла уезжает в тело — иначе camoufox пойдёт через env-прокси + # и проба снова будет измерять не тот узел + assert seen["payload"]["proxy"] == "http://u:p@node:8080" + # адрес — robots.txt площадки, не выдача и не карточка + assert seen["payload"]["url"].endswith("/robots.txt") + assert "avito.ru" in seen["payload"]["url"] + + +# ── 3-4. два диагноза разведены ────────────────────────────────────────────── + + +def test_ipify_success_does_not_erase_browser_verdict() -> None: + """ГЛАВНОЕ: успешная ipify-проба не воскрешает узел, мёртвый для браузера. + + До #2723 браузерный вердикт жил в consecutive_fails, и mark_health(ok=True) + обнулял его каждые ≤30 минут вместе с enabled=true. + """ + db = FakeSession([_proxy(1)]) + for _ in range(BROWSER_UNFIT_THRESHOLD): + proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503") + row = db._by_id(1) + assert row["browser_unfit_since"] is not None + + proxy_pool.mark_health(db, 1, True, exit_ip="1.2.3.4", latency_ms=100) + + row = db._by_id(1) + assert row["consecutive_fails"] == 0 # HTTP-диагноз сброшен, как и раньше + assert row["browser_unfit_since"] is not None # а браузерный — НЕТ + assert row["browser_fail_streak"] >= BROWSER_UNFIT_THRESHOLD + + +def test_browser_failure_does_not_disable_node() -> None: + """Обратная сторона: провал браузерного тракта не выключает живой узел.""" + db = FakeSession([_proxy(1)]) + for _ in range(BROWSER_UNFIT_THRESHOLD + 3): + proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503") + row = db._by_id(1) + assert row["enabled"] is True # узел жив для HTTP — из пула не выводим + assert row["consecutive_fails"] == 0 # и транспортный счётчик не трогаем + assert row["browser_unfit_since"] is not None + + +def test_sidecar_outage_blames_nobody() -> None: + """Лежащий сайдкар не должен пометить непригодными все узлы разом (#2686-класс).""" + db = FakeSession([_proxy(1), _proxy(2)]) + for pid in (1, 2): + for _ in range(BROWSER_UNFIT_THRESHOLD + 1): + outcome = proxy_pool.mark_browser_health( + db, pid, False, fail_kind="sidecar", detail="ConnectError" + ) + assert outcome == "ignored" + for pid in (1, 2): + assert db._by_id(pid)["browser_unfit_since"] is None + assert db._by_id(pid)["browser_fail_streak"] == 0 + + +def test_unconfirmed_failure_keeps_check_at_stale() -> None: + """Первый (неподтверждённый) провал не двигает такт — перепроверка на след. прогоне.""" + db = FakeSession([_proxy(1, browser_check_at=None)]) + proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503") + assert db._by_id(1)["browser_fail_streak"] == 1 + assert db._by_id(1)["browser_check_at"] is None # такт не сдвинут + proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503") + assert db._by_id(1)["browser_unfit_since"] is not None + assert db._by_id(1)["browser_check_at"] is not None # подтверждён → ждём полный такт + + +# ── 5. пометка не выводит узел из пула ─────────────────────────────────────── + + +def test_unfit_node_is_last_in_queue_but_still_reachable() -> None: + old = datetime.now(UTC) - timedelta(hours=5) + db = FakeSession( + [ + # непригодный, но «давно не использованный» → до #2723 выдавался ПЕРВЫМ + _proxy(1, last_ok_at=old, browser_unfit_since=datetime.now(UTC)), + _proxy(2, last_ok_at=datetime.now(UTC)), + ] + ) + lease = acquire(db, "avito") # type: ignore[arg-type] + assert lease is not None + assert lease.id == 2 # пригодный вперёд, несмотря на ORDER BY last_ok_at + + +def test_all_unfit_still_yields_a_proxy() -> None: + """Все узлы непригодны — система НЕ остаётся без прокси (голодание хуже).""" + db = FakeSession( + [ + _proxy(1, browser_unfit_since=datetime.now(UTC)), + _proxy(2, browser_unfit_since=datetime.now(UTC)), + ] + ) + lease = acquire(db, "avito") # type: ignore[arg-type] + assert lease is not None + + +# ── 6-7. healthcheck: такт, гейт, реанимация ───────────────────────────────── + + +def _patch_probes( + monkeypatch: pytest.MonkeyPatch, + *, + http_ok: bool = True, + browser: tuple[bool, str | None, str] = (True, None, "html_len=100"), + calls: list[str] | None = None, +) -> None: + async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]: + return (True, "1.2.3.4", 10, None) if http_ok else (False, None, None, "timeout") + + async def _fake_browser( + endpoint: str, proxy_url: str, **_kw: Any + ) -> tuple[bool, str | None, str]: + if calls is not None: + calls.append(proxy_url) + return browser + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http) + monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True) + import scraper_kit.browser_fetcher as bf + + monkeypatch.setattr(bf, "probe_proxy_via_browser", _fake_browser) + + +async def test_healthcheck_marks_unfit_when_http_green_browser_red( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Исторический случай целиком: ipify зелёная, браузер красный → диагноз ставится.""" + calls: list[str] = [] + _patch_probes( + monkeypatch, + http_ok=True, + browser=(False, "proxy", "503 browser unavailable (proxy may be down)"), + calls=calls, + ) + db = FakeSession([_proxy(1)]) + + first = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert first["ok"] == 1 and first["failed"] == 0 # HTTP-проба по-прежнему зелёная + assert first["browser_checked"] == 1 + assert db._by_id(1)["browser_unfit_since"] is None # один провал ещё не приговор + + second = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert second["browser_unfit"] == 1 + row = db._by_id(1) + assert row["browser_unfit_since"] is not None + assert row["enabled"] is True and row["consecutive_fails"] == 0 + assert len(calls) == 2 + + +async def test_healthcheck_browser_probe_respects_slow_tick( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет.""" + calls: list[str] = [] + _patch_probes(monkeypatch, calls=calls) + db = FakeSession([_proxy(1)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert len(calls) == 1 + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert len(calls) == 1, "браузерная проба обязана идти реже ipify — она стоит camoufox" + + db._by_id(1)["browser_check_at"] = datetime.now(UTC) - timedelta( + minutes=BROWSER_PROBE_MINUTES + 1 + ) + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert len(calls) == 2 + + +async def test_healthcheck_skips_browser_probe_when_http_dead( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Узел, не прошедший ipify, мёртв целиком — жечь на него запуск camoufox незачем.""" + calls: list[str] = [] + _patch_probes(monkeypatch, http_ok=False, calls=calls) + db = FakeSession([_proxy(1)]) + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert counters["failed"] == 1 + assert counters["browser_checked"] == 0 + assert calls == [] + + +async def test_healthcheck_skips_browser_probe_when_pool_not_wired( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Флаг выключен → браузер ходит мимо пула, вердикт об узлах пула бессмыслен.""" + calls: list[str] = [] + _patch_probes(monkeypatch, calls=calls) + monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", False) + db = FakeSession([_proxy(1)]) + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert counters["browser_checked"] == 0 + assert calls == [] + + +async def test_healthcheck_revives_unfit_node(monkeypatch: pytest.MonkeyPatch) -> None: + """Путь обратно: успешная браузерная проба снимает пометку непригодности.""" + _patch_probes(monkeypatch) + db = FakeSession( + [ + _proxy( + 1, + browser_unfit_since=datetime.now(UTC) - timedelta(days=1), + browser_fail_streak=4, + browser_check_at=datetime.now(UTC) - timedelta(minutes=BROWSER_PROBE_MINUTES + 1), + ) + ] + ) + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert counters["browser_refit"] == 1 + row = db._by_id(1) + assert row["browser_unfit_since"] is None + assert row["browser_fail_streak"] == 0 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py index c58ed589..640f7492 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py @@ -36,6 +36,42 @@ 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) та же площадка, что реально отказывает (100% обрывов — avito): TLS-рукопожатие +# и маршрут до её edge, а не до нейтрального хоста; +# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл ~4КБ, который +# автоматическим клиентам читать прямо предписано. НЕ выдача и НЕ карточка. +# Такт пробы редкий (proxy_pool.BROWSER_PROBE_MINUTES) — при 4 узлах это ~16 +# запросов в сутки против ~1000 боевых /fetch (замер на проде 06.08). +_PROXY_PROBE_URL: str = "https://www.avito.ru/robots.txt" +# source='generic' НАМЕРЕННО, хотя адрес авитовский: сайдкар держит по инстансу +# camoufox на провайдера с отдельным локом, и проба с source='avito' забирала бы лок +# боевого инстанса и релончила его (прокси пробы ≠ прокси сессии) — ровно тот +# relaunch-шторм, который лечил sticky-lease фикс. 'generic' — свой инстанс, боевые +# развёртки его не используют. +_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 релончится ТОЛЬКО при реальной @@ -78,6 +114,79 @@ def _raise_for_sidecar_status(resp: httpx.Response) -> None: ) 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" — тракт сработал, но ответ не похож на страницу (пустое тело). + Узел не виноват; повод посмотреть на площадку, не на пул. + """ + 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", + url: str = _PROXY_PROBE_URL, + timeout_s: float = _PROXY_PROBE_TIMEOUT_S, +) -> tuple[bool, str | None, str]: + """Проверить узел ТЕМ ЖЕ трактом, которым идёт работа: сайдкар → camoufox → прокси. + + Standalone (не метод `BrowserFetcher`) и БЕЗ пула: аренда узла здесь не нужна и + вредна — health-checker проверяет узлы, в том числе арендованные, и не должен + конкурировать за lease с боевым прогоном. + + Используется `/fetch` (одна навигация), а НЕ `/fetch-json`: последний сначала + делает goto на origin, т.е. на ГЛАВНУЮ страницу площадки — это уже заметная + нагрузка на неё, ради которой проба и затевалась бы наоборот. + + 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, + "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" + return True, None, f"html_len={len(html)}" + + class BrowserFetcher: """Async context manager: HTTP-клиент к tradein-browser HTTP-сервису.