diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index 5e2cb9c5..bf1f26e7 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -91,6 +91,28 @@ Sticky session lease (browser-путь, живая регрессия 2026-08): «Непригоден для браузера» — это НЕ исключение из пула: acquire() лишь отдаёт такой узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже. +Проба на ПАРУ «узел × источник» (#2800, продолжение #2723): + - #2723 починил ТРАНСПОРТ пробы (ходить браузером, как работа). Ходила она при этом + для всех узлов на один зашитый адрес — robots.txt Авито. Прокси-узел не «жив/мёртв» + вообще: замер на проде 09.08.2026 — узел id=1 отдаёт 200 на Авито и Яндексе и 500 + NS_ERROR_PROXY_BAD_GATEWAY на рабочем хосте Домклика, имея browser_fail_streak=0 и + свежую пробу. Зелёная проба означала «годен для Авито», а читалась как «годен». + - Теперь каждый узел за такт опрашивается по КАЖДОМУ источнику, который ему может + достаться (browser_fetcher.PROBE_SOURCES ∩ affinity), по РАБОЧЕМУ хосту площадки + (apex-домен не годится: `domclick.ru` через узел id=1 отвечает 200, а + `bff-search-web.domclick.ru`, куда ходит сбор, — 500). + - Вердикт пары пишется В СУЩЕСТВУЮЩУЮ таблицу scrape_proxy_source_bans (новой + сущности не заводим — эта ровно про пару и её уже читает acquire): подтверждённый + отказ → строка бана с reason=_PROBE_BAN_REASON, успех → снятие СВОЕЙ строки. + Чужие строки (бан, распознанный боевым сбором) проба не трогает — robots.txt + площадка отдаёт и забаненному IP, так что дешёвый успех не имеет права стирать + дорогой вердикт живого сбора (тот же принцип, что «ipify не стирает браузерный»). + - Узловые поля (browser_fail_streak/browser_unfit_since) сохраняют своё значение + «браузерный тракт через узел не работает ВООБЩЕ» и обновляются по итогу ВСЕГО + креста: хоть одна зелёная площадка → ok; все красные транспортом → провал узла. + Отказ одной площадки узел глобально не пятнает — иначе мы бы своими руками + вернули то самое схлопывание диагнозов. + psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. """ @@ -125,6 +147,7 @@ __all__ = [ "mark_banned", "mark_browser_health", "mark_health", + "mark_source_probe", "reap_stale_leases", "release", "run_proxy_healthcheck", @@ -195,6 +218,26 @@ BROWSER_PROBE_MINUTES = 360 # намеренно не обновляется (см. mark_browser_health). BROWSER_UNFIT_THRESHOLD = 2 +# ── проба на пару «узел × источник» (#2800) ────────────────────────────────── +# ЦЕНА, посчитанная до правки (замер 09.08.2026, тот же тракт): +# - было: 4 узла × 1 адрес / 360 мин = 16 навигаций в сутки, все на Авито; +# - стало: 4 узла × 4 источника / 360 мин = 64 навигации в сутки, то есть +# 16 robots.txt НА ПЛОЩАДКУ в сутки против ~1000 боевых /fetch; +# - одна проба 9–18 с (замерено) → такт с крестом ~3 мин против ~50 с; прогонов +# healthcheck с браузерной пробой по-прежнему 4 в сутки (гейт browser_check_at). +# Запусков camoufox НЕ прибавляется пропорционально: сайдкар релончит браузер при +# смене ЖЕЛАЕМОГО прокси, а крест идёт узел-за-узлом — 4 релонча за такт, как и было. +# Разрежённая схема (по одному источнику за такт, round-robin) рассматривалась и +# отвергнута: вердикт пары протухал бы до 24 ч при бане в 6 ч — окно, в котором +# acquire снова выдаёт узел, не спросив. +# +# Причина в scrape_proxy_source_bans, которой владеет ИМЕННО проба. Отличает её +# вердикт от бана, распознанного боевым сбором (mark_banned из report_ban): успешная +# проба снимает ТОЛЬКО свои строки. Без этого дешёвый robots.txt, который площадка +# отдаёт и забаненному IP, стирал бы дорогой вердикт живого сбора — ровно ошибка +# #2723 («дешёвая проба стирает вердикт дорогого тракта»), только на паре. +_PROBE_BAN_REASON = "probe:browser" + # deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для # mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что # в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не @@ -682,9 +725,14 @@ def mark_browser_health( return "fail" -def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: +def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> None: """Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2. + `reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по + умолчанию 'banned:' (бан распознан боевым сбором), у браузерной пробы — + _PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому + clear_source_bans умеет фильтровать по ней (`only_reason`). + Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация — транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно @@ -820,7 +868,7 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: { "proxy_id": proxy_id, "source": source, - "reason": f"banned:{source}", + "reason": reason or f"banned:{source}", "base_hours": SOURCE_BAN_BASE_HOURS, "max_hours": SOURCE_BAN_MAX_HOURS, "max_fails": MAX_CONSECUTIVE_FAILS, @@ -866,7 +914,14 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: ) -def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, reason: str) -> int: +def clear_source_bans( + db: Session, + proxy_id: int, + *, + source: str | None = None, + reason: str, + only_reason: str | None = None, +) -> int: """Снять баны узла по источникам (#2600 п.2). Returns число снятых строк. ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через @@ -889,6 +944,13 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, SOURCE_BAN_BASE_HOURS. `reason` идёт только в лог (человекочитаемый повод — «manual enable», «ip rotated»). + + `only_reason` — ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые + написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое + свидетельство, площадка отдаёт его и забаненному IP, поэтому снимать им бан, + распознанный боевым сбором по капче/QRATOR-заглушке, нельзя. Оператор и ротация + IP этот фильтр НЕ ставят: там повод как раз объявить историю пары недействительной + целиком. None — снимать всё, как и раньше. """ rows = db.execute( text( @@ -896,10 +958,11 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, DELETE FROM scrape_proxy_source_bans WHERE proxy_id = CAST(:proxy_id AS bigint) AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text)) + AND (CAST(:only_reason AS text) IS NULL OR reason = CAST(:only_reason AS text)) RETURNING source """ ), - {"proxy_id": proxy_id, "source": source}, + {"proxy_id": proxy_id, "source": source, "only_reason": only_reason}, ).fetchall() db.commit() if rows: @@ -913,6 +976,75 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, return len(rows) +def mark_source_probe( + db: Session, + proxy_id: int, + *, + source: str, + ok: bool, + fail_kind: str | None = None, + detail: str = "", +) -> str: + """Записать вердикт браузерной пробы по ПАРЕ «узел × источник» (#2800). + + Пара — то, чего до сих пор не хватало: узел не «жив/мёртв» вообще, он годен или + не годен КОНКРЕТНОЙ площадке. Хранилище для этого уже есть и его уже читает + `acquire(source)` — `scrape_proxy_source_bans`; новой сущности не заводим. + + КОМУ ПРИНАДЛЕЖИТ ОТКАЗ (шкала та же, что у `classify_browser_probe`, но граница + другая — здесь судится ПАРА, а не узел): + - "sidecar" — общая зависимость лежит, к паре отношения не имеет → "ignored". + Иначе одна упавшая зависимость забанила бы разом все пары (#2686 в третий раз); + - "proxy" — через этот узел до площадки не доходит транспорт + (NS_ERROR_PROXY_*, camoufox не поднялся) → бан пары; + - "page" — дошли, но площадка отдала ЭТОМУ exit-IP не ресурс, а заглушку + (200 + «Ошибка — Циан» вместо robots.txt) → тоже бан пары. + Для УЗЛА этот исход по-прежнему «не виноват» (см. mark_browser_health), для + ПАРЫ — виноват ровно он: собирать через такой узел эту площадку нельзя. + + Успех снимает ТОЛЬКО строку, написанную пробой (`only_reason`). Бан, распознанный + боевым сбором, остаётся: robots.txt площадка отдаёт и забаненному IP, и разрешить + дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре. + + Защита последнего узла и эскалация срока — целиком из `mark_banned`, здесь ничего + своего: если после бана у `acquire(source)` не осталось бы кандидатов, бан не + пишется (голодание хуже работы через плохой узел). + + Returns: "ok" | "cleared" (сняли свой бан) | "banned" | "ignored". + """ + if ok: + cleared = clear_source_bans( + db, + proxy_id, + source=source, + reason=f"browser probe OK for source={source} ({detail})", + only_reason=_PROBE_BAN_REASON, + ) + return "cleared" if cleared else "ok" + + if fail_kind not in ("proxy", "page"): + logger.warning( + "proxy_pool: pair probe FAILED id=%d source=%s, но отказ НЕ принадлежит паре " + "(fail_kind=%s): %s — вердикт не пишем", + proxy_id, + source, + fail_kind, + detail, + ) + return "ignored" + + logger.warning( + "proxy_pool: pair probe FAILED id=%d source=%s (fail_kind=%s): %s — пишем бан " + "пары, узел остаётся первосортным для остальных площадок (#2800)", + proxy_id, + source, + fail_kind, + detail, + ) + mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON) + return "banned" + + def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int: """Освободить lease'ы старше older_than_minutes (упавший sweep не вызвал release). @@ -976,8 +1108,39 @@ 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. +def _probe_sources_for(affinity: str) -> list[str]: + """Источники, которым узел с такой affinity МОЖЕТ достаться (#2800). + + Ровно предикат основной выборки `acquire`: `provider_affinity IN (:source,'any')`. + Спрашивать площадки, которым узел всё равно не выдадут, — платить за диагностику, + которой никто не воспользуется. + + ponytail: fallback-заход acquire умеет отдать узел и чужому источнику (когда своих + свободных нет) — такая пара останется без вердикта и решится как раньше, по факту + прогона. Полный крест по ВСЕМ источникам для каждого узла стоил бы столько же + только на проде (там сейчас все узлы 'any'), а на пуле с выделенными affinity рос + бы зря. Если fallback станет частым — снять условие, цена известна: N_узлов × 4. + """ + from scraper_kit.browser_fetcher import PROBE_SOURCES + + return [s for s in PROBE_SOURCES if affinity in (s, "any")] + + +async def _run_pair_probes( + db: Session, proxy_id: int, url: str, kind: str, affinity: str +) -> tuple[str, dict[str, int]]: + """Крест «этот узел × каждая его площадка» + запись вердиктов (#2800). + + Возвращает (исход mark_browser_health для УЗЛА, счётчики по парам). + + Два уровня вердикта, и они не пересекаются: + - ПАРА (`mark_source_probe` → scrape_proxy_source_bans) — по каждой площадке + отдельно, это то, что читает `acquire(source)`; + - УЗЕЛ (`mark_browser_health` → browser_fail_streak/browser_unfit_since) — по + итогу ВСЕГО креста: хоть одна площадка ответила → браузерный тракт через узел + работает (ok); все отказали транспортом → отказ узла. Отказ ОДНОЙ площадки + узел глобально не пятнает — иначе на месте вылеченного схлопывания диагнозов + появилось бы новое. Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет healthcheck — ipify-часть уже отработала и её результат записан. Диагностика не @@ -985,18 +1148,60 @@ async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) -> """ 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 + counters = {"pair_checked": 0, "pair_banned": 0, "pair_cleared": 0} + fail_kinds: list[str] = [] + any_ok = False + last_detail = "" + + for source in _probe_sources_for(affinity): + try: + ok, fail_kind, detail = await probe_proxy_via_browser( + _settings.browser_http_endpoint, url, proxy_kind=kind, source=source + ) + if not ok and fail_kind == "proxy": + # Подтверждение НЕМЕДЛЕННО, а не через такт: запуск camoufox бывает + # флаки сам по себе, а бан пары стоит источнику 6 часов узла. Повтор + # идёт по уже поднятому браузеру с тем же прокси — секунды, и только + # на отказах. Порог «2 подряд» у УЗЛОВОГО вердикта живёт своей жизнью + # (BROWSER_UNFIT_THRESHOLD), здесь он был бы сутками ожидания. + ok, fail_kind, detail = await probe_proxy_via_browser( + _settings.browser_http_endpoint, url, proxy_kind=kind, source=source + ) + except Exception: + logger.warning( + "proxy_pool: pair probe crashed id=%d source=%s — вердикт не записан", + proxy_id, + source, + exc_info=True, + ) + continue + + counters["pair_checked"] += 1 + last_detail = detail + if ok: + any_ok = True + else: + fail_kinds.append(fail_kind or "other") + outcome = mark_source_probe( + db, proxy_id, source=source, ok=ok, fail_kind=fail_kind, detail=detail ) - 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) + if outcome == "banned": + counters["pair_banned"] += 1 + elif outcome == "cleared": + counters["pair_cleared"] += 1 + + if counters["pair_checked"] == 0: + return "ignored", counters # крест не состоялся — узел не судим + + if any_ok: + return mark_browser_health(db, proxy_id, True, detail=last_detail), counters + # Все площадки отказали. Узлу это принадлежит, только если КАЖДЫЙ отказ — + # транспортный: смесь с "page"/"sidecar" значит «дело не (только) в узле». + node_kind = "proxy" if all(k == "proxy" for k in fail_kinds) else fail_kinds[0] + return ( + mark_browser_health(db, proxy_id, False, fail_kind=node_kind, detail=last_detail), + counters, + ) def _mask(url: str) -> str: @@ -1032,18 +1237,20 @@ 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: при выключенном флаге браузер ходит мимо пула и проба - измеряла бы то, чем никто не пользуется. + БРАУЗЕРНАЯ ПРОБА (#2723, на пару — #2800): узлам, прошедшим ipify и не + проверявшимся браузером дольше BROWSER_PROBE_MINUTES, гоняется КРЕСТ проб ЧЕРЕЗ + САЙДКАР — по одной навигации на каждую площадку, которую этот узел может + обслуживать (тот же тракт, что у боевого сбора: camoufox стартует с этим прокси, + потом навигация на robots.txt РАБОЧЕГО хоста площадки). Вердикт пары идёт в + scrape_proxy_source_bans (его читает acquire(source)), вердикт узла — в отдельные + browser_*-поля; ни один из них не смешивается с consecutive_fails/enabled. Гейт — + settings.use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и + проба измеряла бы то, чем никто не пользуется. Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на один и тот же upstream-endpoint (ipify) не нужен. Returns counters {reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok, - browser_unfit, browser_refit}. + browser_unfit, browser_refit, pair_checked, pair_banned, pair_cleared}. """ reaped = reap_stale_leases(db) @@ -1051,7 +1258,7 @@ 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, provider_affinity, (browser_check_at IS NULL OR browser_check_at < now() - make_interval( mins => CAST(:browser_probe_minutes AS integer) @@ -1082,6 +1289,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: browser_ok = 0 browser_unfit = 0 browser_refit = 0 + pair_checked = 0 + pair_banned = 0 + pair_cleared = 0 for row in proxies: proxy_id = int(row["id"]) url = str(row["url"]) @@ -1111,8 +1321,13 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: # вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет # не тот узел», из-за которого #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"])) + outcome, pair_counters = await _run_pair_probes( + db, proxy_id, url, str(row["kind"]), str(row["provider_affinity"]) + ) browser_checked += 1 + pair_checked += pair_counters["pair_checked"] + pair_banned += pair_counters["pair_banned"] + pair_cleared += pair_counters["pair_cleared"] if outcome in ("ok", "refit"): browser_ok += 1 if outcome == "refit": @@ -1142,7 +1357,8 @@ 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 browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d", + "bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d " + "pair_checked=%d pair_banned=%d pair_cleared=%d", reaped, checked, ok_count, @@ -1153,6 +1369,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: browser_ok, browser_unfit, browser_refit, + pair_checked, + pair_banned, + pair_cleared, ) return { "reaped": reaped, @@ -1167,4 +1386,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: "browser_ok": browser_ok, "browser_unfit": browser_unfit, "browser_refit": browser_refit, + # Вердикты по ПАРАМ (#2800). Тоже отдельно от узловых: browser_ok=1 и + # pair_banned=2 одновременно — это не противоречие, а точный диагноз + # «браузер через узел работает, но две площадки его не пускают». + "pair_checked": pair_checked, + "pair_banned": pair_banned, + "pair_cleared": pair_cleared, } diff --git a/tradein-mvp/backend/tests/services/test_proxy_pool.py b/tradein-mvp/backend/tests/services/test_proxy_pool.py index a62f9886..bd360aed 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_pool.py +++ b/tradein-mvp/backend/tests/services/test_proxy_pool.py @@ -397,12 +397,18 @@ class FakeSession: ) if "DELETE FROM scrape_proxy_source_bans" in sql and "proxy_id = CAST" in sql: - # clear_source_bans: снять баны узла (все либо один source), #2600 п.2 + # clear_source_bans: снять баны узла (все либо один source), #2600 п.2. + # Фильтр по reason (#2800) гейтим по подстроке боевого SQL — как ban-фильтры + # в acquire-ветке: иначе мок «чинил» бы код, который фильтра не содержит, и + # тест на «успешная проба не гасит чужой бан» остался бы зелёным на сломанном. + filters_reason = "reason = CAST(:only_reason AS text)" in sql + only_reason = p.get("only_reason") if filters_reason else None cleared = [ b for b in self.bans if b["proxy_id"] == p["proxy_id"] and (p["source"] is None or b["source"] == p["source"]) + and (only_reason is None or b.get("reason") == only_reason) ] self.bans = [b for b in self.bans if b not in cleared] return _FakeResult([{"source": b["source"]} for b in cleared]) diff --git a/tradein-mvp/backend/tests/test_2723_browser_probe.py b/tradein-mvp/backend/tests/test_2723_browser_probe.py index 0bfb8dae..f44e0181 100644 --- a/tradein-mvp/backend/tests/test_2723_browser_probe.py +++ b/tradein-mvp/backend/tests/test_2723_browser_probe.py @@ -260,27 +260,35 @@ async def test_healthcheck_marks_unfit_when_http_green_browser_red( 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 + # За такт узел опрашивается по КАЖДОЙ обслуживаемой площадке (#2800), а каждый + # транспортный отказ ещё и подтверждается повтором: 4 источника × 2 попытки за + # прогон. Узловой вердикт по-прежнему один на прогон — browser_checked == 1 выше. + assert len(calls) == 2 * 4 * 2 async def test_healthcheck_browser_probe_respects_slow_tick( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет.""" + """Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет. + + Крест по площадкам (#2800) такт НЕ участил: он умножил цену ОДНОГО прогона на + число обслуживаемых источников (здесь 4), а прогонов с браузерной пробой + по-прежнему один на BROWSER_PROBE_MINUTES. + """ 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 + assert len(calls) == 4 await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] - assert len(calls) == 1, "браузерная проба обязана идти реже ipify — она стоит camoufox" + assert len(calls) == 4, "браузерная проба обязана идти реже 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 + assert len(calls) == 8 async def test_healthcheck_skips_browser_probe_when_http_dead( diff --git a/tradein-mvp/backend/tests/test_2800_per_source_probe.py b/tradein-mvp/backend/tests/test_2800_per_source_probe.py new file mode 100644 index 00000000..7eca4afd --- /dev/null +++ b/tradein-mvp/backend/tests/test_2800_per_source_probe.py @@ -0,0 +1,382 @@ +"""#2800 — браузерная проба спрашивает КАЖДУЮ площадку, вердикт пишется на ПАРУ. + +Продолжение #2723 на другой оси. Там проба ходила не тем транспортом; здесь — +верным транспортом, но всегда на один адрес (robots.txt Авито), поэтому её зелёный +ответ означал «узел годен для Авито», а читался как «узел годен вообще». + +Замер на проде 09.08.2026 (тот же тракт: сайдкар → camoufox с этим прокси → навигация): + + узел | affinity | avito | ekb.cian.ru | realty.ya.ru | bff-search-web.domclick.ru + -----+----------+-------+---------------------+--------------+--------------------------- + 1 | domclick | 200 | 200 «Ошибка — Циан» | 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY + 10 | any | 200 | 200 | 200 | 200 + +Что сторожится (каждый тест ниже падает на коде до фикса): + + 1. ГЛАВНОЕ: узел, зелёный по Авито и мёртвый по Домклику, ОТБРАКОВЫВАЕТСЯ для + Домклика и остаётся первосортным для Авито. До фикса `acquire('domclick')` + выдавал его как ни в чём не бывало. + 2. Адрес пробы — рабочий хост КАЖДОЙ площадки, а не один зашитый и не apex-домен + (`domclick.ru` через тот же узел отвечает 200 — проба по нему была бы зелёной). + 3. HTTP 200 с заглушкой вместо robots.txt — это отказ пары, а не успех. + 4. Успешная проба снимает ТОЛЬКО свою строку бана: robots.txt площадка отдаёт и + забаненному IP, и гасить им вердикт живого сбора нельзя (та же ошибка, что + «дешёвая ipify стирает браузерный вердикт» в #2723). + 5. Отказ ОДНОЙ площадки не пятнает узел глобально; отказ ВСЕХ — пятнает (узловой + вердикт #2723 сохранён). + 6. Лежащий сайдкар не пишет ни одного бана пары (#2686-класс). + 7. Цена такта названа числом и закреплена: узлов × обслуживаемых источников. +""" + +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 typing import Any + +import pytest +import scraper_kit.browser_fetcher as bf + +from app.services import proxy_pool +from app.services.proxy_pool import BROWSER_UNFIT_THRESHOLD, acquire, release +from tests.services.test_proxy_pool import FakeSession, _proxy + +# Живой замер с прода 09.08.2026 — узел h1 мёртв для Домклика и годен остальным. +_LIVE_MATRIX: dict[tuple[str, str], tuple[bool, str | None, str]] = { + ("http://u:p@h1:8080", "domclick"): ( + False, + "proxy", + '{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}', + ), +} + + +def _patch_probes( + monkeypatch: pytest.MonkeyPatch, + matrix: dict[tuple[str, str], tuple[bool, str | None, str]], + calls: list[tuple[str, str]] | None = None, + *, + default: tuple[bool, str | None, str] = (True, None, "html_len=16477"), +) -> None: + """ipify всегда зелёная; браузерная проба отвечает по матрице (прокси, источник).""" + + async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]: + return True, "1.2.3.4", 10, None + + async def _fake_browser( + endpoint: str, proxy_url: str, **kw: Any + ) -> tuple[bool, str | None, str]: + source = str(kw.get("source", "avito")) + if calls is not None: + calls.append((proxy_url, source)) + return matrix.get((proxy_url, source), default) + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http) + monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True) + monkeypatch.setattr(bf, "probe_proxy_via_browser", _fake_browser) + + +# ── 1. главное: вердикт разведён по источникам ─────────────────────────────── + + +async def test_node_dead_for_domclick_is_not_issued_to_domclick( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Узел зелёный по Авито и мёртвый по Домклику: Домклику НЕ выдаём, Авито — выдаём. + + Именно этот сценарий четверо суток давал `domclick_city_sweep` ноль лотов при + `browser_fail_streak=0` и свежей пробе. + """ + _patch_probes(monkeypatch, _LIVE_MATRIX) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + # Домклику достаётся только исправный узел… + first = acquire(db, "domclick") # type: ignore[arg-type] + assert first is not None and first.id == 2 + + # …а когда он занят, Домклик остаётся БЕЗ прокси, вместо того чтобы получить + # заведомо мёртвый узел 1 и сжечь на нём прогон. ЭТО и есть поломка, ради которой + # заведена задача: до фикса здесь выдавался узел 1 с browser_fail_streak=0. + assert acquire(db, "domclick") is None, ( # type: ignore[arg-type] + "Домклику выдан узел, у которого рабочий хост Домклика отвечает NS_ERROR_PROXY_BAD_GATEWAY" + ) + + # Для Авито тот же узел 1 — полноценный кандидат: бан у пары, не у узла. + lease = acquire(db, "avito") # type: ignore[arg-type] + assert lease is not None and lease.id == 1 + + ban = db._ban(1, "domclick") + assert ban is not None + assert ban["reason"] == "probe:browser", "строку должна опознавать сама проба" + assert db._ban(1, "avito") is None and db._ban(2, "domclick") is None + assert counters["pair_banned"] == 1 + + +async def test_one_dead_platform_does_not_stain_the_node_globally( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Узловой вердикт остаётся про узел: одна мёртвая площадка его не помечает.""" + _patch_probes(monkeypatch, _LIVE_MATRIX) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + row = db._by_id(1) + assert row["browser_unfit_since"] is None + assert row["browser_fail_streak"] == 0 + assert row["enabled"] is True and row["consecutive_fails"] == 0 + + +async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.MonkeyPatch) -> None: + """Обратная сторона: транспорт не доходит НИКУДА → это уже диагноз узлу (#2723 цел).""" + dead_everywhere = { + ("http://u:p@h1:8080", src): (False, "proxy", "503 browser unavailable") + for src in ("avito", "cian", "yandex", "domclick") + } + _patch_probes(monkeypatch, dead_everywhere) + db = FakeSession([_proxy(1), _proxy(2)]) + + for _ in range(BROWSER_UNFIT_THRESHOLD): + db._by_id(1)["browser_check_at"] = None # снять гейт такта, ускорить подтверждение + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert db._by_id(1)["browser_unfit_since"] is not None + assert db._by_id(2)["browser_unfit_since"] is None + + +# ── 2-3. адрес пробы и «200 ≠ ответ площадки» ──────────────────────────────── + + +@pytest.mark.parametrize( + ("source", "must_contain"), + [ + ("avito", "www.avito.ru"), + ("cian", "ekb.cian.ru"), + ("yandex", "realty.yandex.ru"), + # apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200, + # а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной. + ("domclick", "bff-search-web.domclick.ru"), + ], +) +async def test_probe_asks_the_working_host_of_each_source( + monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str +) -> None: + 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: ... + + 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["payload"] = json + return _Resp() + + 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", source=source + ) + + assert ok is True + assert must_contain in seen["payload"]["url"] + assert seen["payload"]["url"].endswith("/robots.txt") # нагрузки на площадку нет + # Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии. + assert seen["payload"]["source"] == "generic" + + +async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """374 КБ «Ошибка — Циан» с кодом 200 — это отказ пары, а не успех пробы.""" + stub = "Ошибка - Циан…" + + class _Resp: + status_code = 200 + text = "{}" + + @staticmethod + def json() -> dict[str, str]: + return {"html": stub} + + class _Client: + def __init__(self, **_kw: Any) -> None: ... + + 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: + return _Resp() + + 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", source="cian" + ) + + assert ok is False + # Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP. + assert fail_kind == "page" + assert "not robots.txt" in detail + + +async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None: + """«page» не принадлежит узлу (#2723), но принадлежит паре — собирать через неё нельзя.""" + _patch_probes( + monkeypatch, + {("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")}, + ) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert db._ban(1, "cian") is not None + assert db._by_id(1)["browser_unfit_since"] is None # узел не при чём + + +# ── 4. проба снимает только свою строку ────────────────────────────────────── + + +async def test_probe_clears_only_its_own_ban(monkeypatch: pytest.MonkeyPatch) -> None: + """Зелёный robots.txt снимает вердикт ПРОБЫ и не трогает бан, распознанный сбором. + + robots.txt площадка отдаёт и забаненному IP — разрешить дешёвому успеху гасить + дорогой вердикт значило бы повторить #2723 на паре. + """ + later = datetime.now(UTC) + timedelta(hours=6) + db = FakeSession( + [_proxy(1), _proxy(2)], + bans=[ + { + "proxy_id": 1, + "source": "avito", + "banned_until": later, + "ban_count": 1, + "reason": "banned:avito", # распознан боевым сбором (капча/QRATOR) + }, + { + "proxy_id": 1, + "source": "cian", + "banned_until": later, + "ban_count": 1, + "reason": "probe:browser", # прошлый вердикт самой пробы + }, + ], + ) + _patch_probes(monkeypatch, {}) # все площадки отвечают + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert db._ban(1, "avito") is not None, "чужой бан проба снимать не имеет права" + assert db._ban(1, "cian") is None, "свой вердикт проба обязана снять" + assert counters["pair_cleared"] == 1 + + +# ── 5-6. чужие отказы ──────────────────────────────────────────────────────── + + +async def test_sidecar_outage_bans_nothing(monkeypatch: pytest.MonkeyPatch) -> None: + """Лежащий сайдкар не должен забанить разом все пары (#2686-класс).""" + down = { + (f"http://u:p@h{pid}:8080", src): (False, "sidecar", "ConnectError") + for pid in (1, 2) + for src in ("avito", "cian", "yandex", "domclick") + } + _patch_probes(monkeypatch, down) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["pair_banned"] == 0 + assert db.bans == [] + assert db._by_id(1)["browser_unfit_since"] is None + + +# ── 7. цена такта ──────────────────────────────────────────────────────────── + + +async def test_probe_cost_is_nodes_times_servable_sources( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Крест ограничен источниками, которым узел вообще может достаться. + + Диагностика не должна превращаться в нагрузку: узел с выделенной affinity + спрашивает ОДНУ площадку, 'any' — все четыре. На проде это 4 узла × 4 источника + = 16 навигаций за такт (раз в BROWSER_PROBE_MINUTES), то есть 16 robots.txt на + площадку в сутки против ~1000 боевых /fetch. + """ + calls: list[tuple[str, str]] = [] + _patch_probes(monkeypatch, {}, calls) + db = FakeSession([_proxy(1, affinity="any"), _proxy(2, affinity="domclick")]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert sorted(s for url, s in calls if url.endswith("h1:8080")) == [ + "avito", + "cian", + "domclick", + "yandex", + ] + assert [s for url, s in calls if url.endswith("h2:8080")] == ["domclick"] + assert len(calls) == 5 + + +async def test_confirmed_failure_needs_a_second_look(monkeypatch: pytest.MonkeyPatch) -> None: + """Транспортный отказ пары подтверждается повтором — запуск camoufox бывает флаки. + + Повтор идёт по уже поднятому браузеру и только на отказах, поэтому цена такта из + теста выше не меняется, пока всё зелено. + """ + calls: list[tuple[str, str]] = [] + _patch_probes(monkeypatch, _LIVE_MATRIX, calls) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert calls.count(("http://u:p@h1:8080", "domclick")) == 2 + assert calls.count(("http://u:p@h1:8080", "avito")) == 1 + + +async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPatch) -> None: + """Один провал, второй заход зелёный → бан пары не пишется.""" + attempts: dict[str, int] = {} + + async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]: + return True, "1.2.3.4", 10, None + + async def _flaky(endpoint: str, proxy_url: str, **kw: Any) -> tuple[bool, str | None, str]: + source = str(kw.get("source", "avito")) + key = f"{proxy_url}|{source}" + attempts[key] = attempts.get(key, 0) + 1 + if source == "domclick" and proxy_url.endswith("h1:8080") and attempts[key] == 1: + return False, "proxy", "503 browser unavailable" + return True, None, "html_len=150" + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http) + monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True) + monkeypatch.setattr(bf, "probe_proxy_via_browser", _flaky) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["pair_banned"] == 0 + assert db._ban(1, "domclick") is None + lease = acquire(db, "domclick") # type: ignore[arg-type] + assert lease is not None + release(db, lease.id) # type: ignore[arg-type] 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 640f7492..034de30f 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 @@ -37,22 +37,50 @@ _RETRY_SLEEP_S: float = 1.0 _HTTP_TIMEOUT_S: float = 120.0 # навигация медленная → щедрый таймаут # ── проба узла ПО БРАУЗЕРНОМУ ТРАКТУ (#2723) ───────────────────────────────── -# Адрес пробы. Требования к нему ровно три, и robots.txt Авито им отвечает: +# Адрес пробы. Требования к нему ровно три, и robots.txt им отвечает: # 1) тот же тракт, что у работы — сайдкар, camoufox, ЭТОТ прокси, настоящая # навигация. Все 90 записанных обрывов сбора («browser unavailable (proxy may # be down)») рождались на launch'е camoufox с прокси — проба обязана его делать; -# 2) та же площадка, что реально отказывает (100% обрывов — avito): TLS-рукопожатие -# и маршрут до её edge, а не до нейтрального хоста; -# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл ~4КБ, который +# 2) та же площадка, что реально отказывает: TLS-рукопожатие и маршрут до ЕЁ edge, +# а не до нейтрального хоста; +# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл, который # автоматическим клиентам читать прямо предписано. НЕ выдача и НЕ карточка. -# Такт пробы редкий (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' — свой инстанс, боевые -# развёртки его не используют. +# +# АДРЕС НА КАЖДЫЙ ИСТОЧНИК, а не один зашитый (#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 + "domclick": "https://bff-search-web.domclick.ru/robots.txt", +} +_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" +# 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с # замерено на проде вместе с релончем, плюс запас на медленный узел. @@ -126,8 +154,10 @@ def classify_browser_probe(status: int | None, detail: str) -> str: browser_fail_streak. - "sidecar" — сайдкар недоступен/не сконфигурирован (connect error, таймаут, 503 «no proxy configured», прочие 5xx). Узел не виноват. - - "page" — тракт сработал, но ответ не похож на страницу (пустое тело). - Узел не виноват; повод посмотреть на площадку, не на пул. + - "page" — тракт сработал, но ответ не похож на запрошенный ресурс (пустое + тело либо 200 со страницей-заглушкой вместо robots.txt, #2800). + Браузерный тракт узла исправен — не годится ПАРА «узел × + площадка»: этому exit-IP площадка ресурс не отдала. """ if status is None: return "sidecar" # до ответа не дошло — сайдкар/сеть контейнера @@ -143,10 +173,11 @@ async def probe_proxy_via_browser( proxy_url: str, *, proxy_kind: str = "http", - url: str = _PROXY_PROBE_URL, + source: str = "avito", + url: str | None = None, timeout_s: float = _PROXY_PROBE_TIMEOUT_S, ) -> tuple[bool, str | None, str]: - """Проверить узел ТЕМ ЖЕ трактом, которым идёт работа: сайдкар → camoufox → прокси. + """Проверить ПАРУ «узел × площадка» тем же трактом, что и работа: сайдкар → camoufox → прокси. Standalone (не метод `BrowserFetcher`) и БЕЗ пула: аренда узла здесь не нужна и вредна — health-checker проверяет узлы, в том числе арендованные, и не должен @@ -156,13 +187,18 @@ async def probe_proxy_via_browser( делает 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, + "url": url or _PROBE_URLS.get(source, _PROXY_PROBE_URL), "source": _PROXY_PROBE_SOURCE, "proxy": proxy_url, "proxy_kind": proxy_kind, @@ -184,6 +220,15 @@ async def probe_proxy_via_browser( html = "" if not html: return False, classify_browser_probe(resp.status_code, detail), "empty html" + if _PROBE_CONTENT_MARKER not in html: + # 200 и непустое тело, но это не robots.txt — площадка отдала заглушку + # ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»). + # Тракт узла исправен, негодна пара — отсюда "page", а не "proxy". + return ( + False, + "page", + f"not robots.txt (html_len={len(html)}): {' '.join(html.split())[:120]}", + ) return True, None, f"html_len={len(html)}"