"""#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", "path_suffix"), [ ("avito", "www.avito.ru", "/robots.txt"), ("cian", "ekb.cian.ru", "/robots.txt"), ("yandex", "realty.yandex.ru", "/robots.txt"), # apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200, # а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной. # # #2855: хоста мало — нужен ЗАЩИЩЁННЫЙ ПУТЬ. QRATOR закрывает /api/offers/*, # robots.txt того же bff-хоста отдаётся свободно (это знал уже #2800 — см. п.4 # в шапке: «robots.txt площадка отдаёт и забаненному IP»), поэтому проба по # нему зелёная ровно тогда, когда свип получает блок. ("domclick", "bff-search-web.domclick.ru", "/api/offers/count/v1"), ], ) async def test_probe_asks_the_working_host_of_each_source( monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str, path_suffix: str ) -> None: seen: dict[str, Any] = {} # Тело ответа зависит от площадки: у robots.txt-источников признак «ресурс отдан» — # 'User-agent', у Домклика — ключ 'snippetsCount' живого count-ответа. _body = ( '{"result":{"snippetsCount":678}}' if source == "domclick" else "
User-agent: *
" ) class _Resp: status_code = 200 text = "{}" @staticmethod def json() -> dict[str, str]: return {"html": _body} 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"] # #2855: путь тоже сторожим — у robots.txt-источников он лёгкий, у Домклика это # боевой count-эндпоинт (одно число в ответе, без пагинации и выдачи), то есть # нагрузка на площадку остаётся минимальной, а защита — той же, что у работы. assert path_suffix in seen["payload"]["url"] # Инстанс сайдкара остаётся '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" # #2855: сообщение называет КОНКРЕТНЫЙ маркер, потому что он теперь зависит от # площадки ('User-agent' у robots.txt-источников, 'snippetsCount' у Домклика). # Прежний текст «not robots.txt» стал бы враньём там, где robots.txt и не просили. assert "no marker 'User-agent'" 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] avito = db._ban(1, "avito") assert avito is not None, "чужой бан проба снимать не имеет права" assert (avito["reason"], avito["ban_count"]) == ("banned:avito", 1), "и не переписывать" assert db._ban(1, "cian") is None, "свой вердикт проба обязана снять" assert counters["pair_cleared"] == 1 # ── 4b. …и не присваивает чужую (дефект #2803, реализовался на проде) ───────── async def test_probe_does_not_steal_a_live_ban(monkeypatch: pytest.MonkeyPatch) -> None: """Упавшая проба НЕ переписывает активный бан, поставленный боевым сбором. Прод 09.08.2026, пара (1, cian): строка `banned:cian, ban_count=1, до 00:21` после упавшей пробы стала `probe:browser, ban_count=2, до 07:43`. Фильтр «снимаю только своё» при этом цел, но защищать перестаёт: присвоенная строка уже «своя», и следующая успешная проба сняла бы ею бан, который площадка поставила по-настоящему. Плюс сама метка перестаёт быть свидетельством («нас отбили» неотличимо от «мы не смогли», #2764), а ban_count складывает события разного рода в одну эскалацию — отдых пары вырос с 6 ч до 12 ч. """ until = datetime.now(UTC) + timedelta(hours=6) db = FakeSession( [_proxy(1), _proxy(2)], bans=[ { "proxy_id": 1, "source": "cian", "banned_until": until, "ban_count": 1, "reason": "banned:cian", # боевой сбор: Циан отдал заглушку } ], ) _patch_probes( monkeypatch, {("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")}, ) counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] ban = db._ban(1, "cian") assert ban is not None assert ban["reason"] == "banned:cian", "проба присвоила себе бан боевого сбора" assert ban["ban_count"] == 1, "два события разного рода посчитаны одной эскалацией" assert ban["banned_until"] == until, "чужой срок проба не пересчитывает (может и укоротить)" assert counters["pair_banned"] == 0, "счётчик не должен объявлять баном то, чего не записал" async def test_live_ban_takes_over_the_probe_row(monkeypatch: pytest.MonkeyPatch) -> None: """Зеркало намеренно НЕ симметрично: боевой сбор строку пробы перехватывает. Его вердикт сильнее — площадка отбила нас именно сейчас, — пара остаётся забаненной, а метка становится точнее. Если запретить и ему, строка останется за пробой, и её же зелёный robots.txt снесёт настоящий бан площадки: тот же дефект, только зеркально. """ db = FakeSession( [_proxy(1), _proxy(2)], bans=[ { "proxy_id": 1, "source": "cian", "banned_until": datetime.now(UTC) + timedelta(hours=6), "ban_count": 1, "reason": "probe:browser", } ], ) proxy_pool.mark_banned(db, 1, source="cian") # type: ignore[arg-type] assert db._ban(1, "cian")["reason"] == "banned:cian" # …и с этой минуты зелёная проба его не снимет — ради чего перехват и нужен. _patch_probes(monkeypatch, {}) await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] assert db._ban(1, "cian") is not None # ── 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] # ── 8. #2855: проба обязана делить с работой ЗАЩИЩЁННЫЙ ПУТЬ, а не только хост ── @pytest.mark.parametrize( ("api_body", "expect_ok"), [ # Блок: площадка отдаёт robots.txt свободно и закрывает /api/offers/* QRATOR'ом. # На старом коде проба спрашивала robots.txt → 'User-agent' на месте → ok=True, # то есть «пара здорова» ровно там, где свип получает блок. Красный на main. ("Ошибкаqrator captcha", False), # Контроль: живой ответ count-эндпоинта → ok=True. Держит двусторонность # ВНУТРИ ветки: реализация «маркер не найден никогда» прошла бы первый случай # и провалила бы этот, то есть тест не может позеленеть от глухого отказа. # На origin/main этот случай тоже красный, но по другой причине — там проба # спрашивает robots.txt, `ok` выходит True, и падает проверка АДРЕСА. ('{"result":{"snippetsCount":678}}', True), ], ) async def test_domclick_probe_sees_the_block_that_robots_txt_hides( monkeypatch: pytest.MonkeyPatch, api_body: str, expect_ok: bool ) -> None: """Площадка: robots.txt отдаёт всем, /api/offers/* закрывает. Проба обязана увидеть блок. Замер прода 13.08.2026: 04:30 healthcheck `pair_banned=0` → 05:02 свип `QRATOR block during rooms='1'` и узел 11 в бан. За сутки 64 проверки пар и 5 банов на все четыре площадки при ежедневном блоке Домклика. """ seen: dict[str, Any] = {} class _Resp: status_code = 200 text = "{}" def __init__(self, body: str) -> None: self._body = body def json(self) -> dict[str, str]: return {"html": self._body} 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 asked = str(json["url"]) # Площадка ведёт себя как в проде: лёгкий путь открыт, боевой — закрыт. if asked.endswith("/robots.txt"): return _Resp("
User-agent: *
") return _Resp(api_body) 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="domclick" ) assert ok is expect_ok if not expect_ok: # Тракт узла исправен (200 пришёл) — негодна ПАРА, значит "page", не "proxy". assert fail_kind == "page" # Спрашивали именно боевой путь, а не robots.txt того же хоста. assert "/api/offers/" in seen["payload"]["url"]