"""Провайдер-уровень: детект-правило «капча vs честная пустая выдача» (#2625). Issue #2625: капча Циана и пустые выдачи Яндекса засчитывались как успешный прогон (status='done', errors_count=0, lots_fetched=0) — не было сигнала для run-level банана (см. `scraper_kit.orchestration.pipeline.run_cian_city_sweep` / `run_yandex_city_sweep` / `run_cian_full_load` / `run_yandex_full_load`, которые читают эти счётчики). Это НЕ тесты оркестрации (те — test_scraper_kit_pipeline_parity2.py:: test_*_extraction_failed_marks_banned и соседи) — здесь проверяется именно низкоуровневое правило на самом scraper-инстансе: Cian: extract_state() вернул None → state_extraction_failures++ (капча/смена вёрстки). Валидный state с offers=[] → НЕ failure (честная пустая выдача). Yandex: gate-API payload/pager так и не извлеклись после retries (tarpit / JSON-ошибка / gate-error-payload / отсутствие response.search.offers) → gate_fetch_failures++. Валидный payload с entities=[] → НЕ failure. Без сети, без БД, без browser_fetcher — прямые вызовы sync/async-методов scraper'ов. """ from __future__ import annotations import types from unittest.mock import AsyncMock import pytest def _cian_config() -> types.SimpleNamespace: return types.SimpleNamespace(glitchtip_dsn=None) # ── Cian: Redux-state extraction (_extract_state_tracked) ───────────────────── def test_cian_parse_serp_html_captcha_marks_failure() -> None: """extract_state() → None (капча/смена вёрстки) → attempts=1, failures=1.""" from scraper_kit.providers.cian.serp import CianScraper scraper = CianScraper(_cian_config()) lots = scraper._parse_serp_html("captcha page, no window._cianConfig") assert lots == [] assert scraper.state_extraction_attempts == 1 assert scraper.state_extraction_failures == 1 def test_cian_parse_serp_html_honest_empty_not_a_failure(monkeypatch: pytest.MonkeyPatch) -> None: """Валидный state с offers=[] (честная пустая выдача) → attempts=1, failures=0.""" from scraper_kit.providers.cian import serp as cian_serp monkeypatch.setattr( cian_serp, "extract_state", lambda html, mfe, key: {"results": {"offers": [], "totalOffers": 0}}, ) scraper = cian_serp.CianScraper(_cian_config()) lots = scraper._parse_serp_html("valid empty SERP") assert lots == [] assert scraper.state_extraction_attempts == 1 assert scraper.state_extraction_failures == 0 def test_cian_mixed_captcha_then_honest_empty_only_first_counts_as_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: """Партиция «не все запросы прогона провалились» на уровне одного scraper'а: один captcha-fail + один честный 0 → attempts=2, failures=1 (не 2).""" from scraper_kit.providers.cian import serp as cian_serp scraper = cian_serp.CianScraper(_cian_config()) monkeypatch.setattr(cian_serp, "extract_state", lambda html, mfe, key: None) scraper._parse_serp_html("captcha") monkeypatch.setattr( cian_serp, "extract_state", lambda html, mfe, key: {"results": {"offers": [], "totalOffers": 0}}, ) scraper._parse_serp_html("honest empty") assert scraper.state_extraction_attempts == 2 assert scraper.state_extraction_failures == 1 def test_cian_extract_total_offers_shares_same_tracked_counters( monkeypatch: pytest.MonkeyPatch, ) -> None: """_extract_total_offers (probe path, full_load bisection) — тот же trackер, что _parse_serp_html (SERP-парсинг): оба должны учитываться в run-level детекте.""" from scraper_kit.providers.cian import serp as cian_serp monkeypatch.setattr(cian_serp, "extract_state", lambda html, mfe, key: None) scraper = cian_serp.CianScraper(_cian_config()) total = scraper._extract_total_offers("captcha on probe") assert total is None assert scraper.state_extraction_attempts == 1 assert scraper.state_extraction_failures == 1 # ── Yandex: gate-API structure extraction (_track_gate_result) ──────────────── class _FakeGateBrowser: """BrowserFetcher-заглушка: fetch() возвращает заранее заданные тела ответа (по одному на вызов, FIFO) — camoufox-обёртку
{...}
не эмулируем, _http_get достаёт JSON через первый `{` fallback (см. _extract_json_from_content).""" def __init__(self, bodies: list[str]) -> None: self._bodies = list(bodies) self.calls = 0 async def fetch(self, url: str) -> str: self.calls += 1 return self._bodies.pop(0) def _yandex_scraper_with_bodies(bodies: list[str]) -> object: from scraper_kit.providers.yandex.serp import YandexRealtyScraper scraper = YandexRealtyScraper(types.SimpleNamespace()) scraper._browser = _FakeGateBrowser(bodies) # type: ignore[assignment] return scraper class _RaisingBrowser: """BrowserFetcher-заглушка: fetch() ВСЕГДА поднимает исключение (transport failure — сеть/browser сбой, НЕ content-ответ) — deep-review fix 1 (#2600).""" async def fetch(self, url: str) -> str: raise RuntimeError("connection reset by peer") def _yandex_scraper_with_raising_browser() -> object: from scraper_kit.providers.yandex.serp import YandexRealtyScraper scraper = YandexRealtyScraper(types.SimpleNamespace()) scraper._browser = _RaisingBrowser() # type: ignore[assignment] return scraper @pytest.mark.asyncio async def test_yandex_fetch_page_json_gate_error_marks_failure() -> None: """gate-error payload (нет response.search.offers) → attempts=1, failures=1.""" scraper = _yandex_scraper_with_bodies(['{"error": "captcha"}']) payload = await scraper._fetch_page_json(None, 1, None, None) assert payload is None assert scraper.gate_fetch_attempts == 1 assert scraper.gate_fetch_failures == 1 @pytest.mark.asyncio async def test_yandex_fetch_page_json_honest_empty_not_a_failure() -> None: """Валидный payload с entities=[] (честная пустая выдача) → attempts=1, failures=0.""" body = ( '{"response": {"search": {"offers": ' '{"entities": [], "pager": {"page": 0, "totalItems": 0, "totalPages": 0}}}}}' ) scraper = _yandex_scraper_with_bodies([body]) payload = await scraper._fetch_page_json(None, 1, None, None) assert payload is not None assert scraper.gate_fetch_attempts == 1 assert scraper.gate_fetch_failures == 0 @pytest.mark.asyncio async def test_yandex_mixed_tarpit_then_honest_empty_only_first_counts_as_failure() -> None: """Один gate-error fail + один честный 0 → attempts=2, failures=1 (не 2).""" ok_body = ( '{"response": {"search": {"offers": ' '{"entities": [], "pager": {"page": 0, "totalItems": 0, "totalPages": 0}}}}}' ) scraper = _yandex_scraper_with_bodies(['{"error": "captcha"}', ok_body]) await scraper._fetch_page_json(None, 1, None, None) await scraper._fetch_page_json(None, 2, None, None) assert scraper.gate_fetch_attempts == 2 assert scraper.gate_fetch_failures == 1 # ── Code-review addendum (#2625): response-без-offers schema drift ──────────── # # {"response": {...}} без вложенного search.offers (schema drift / заглушка) # проходит _is_gate_error как «не ошибка» (есть "response", нет "error"), но # _extract_gate_data на нём вернёт None. Гэп был в двух местах: # 1. _fetch_page_json (probe/degraded/leaf — full_load путь) трекал ok=True # по одному лишь _is_gate_error, не проверяя реальное наличие offers. # 2. fetch_around page>=2 (city_sweep пагинация) — то же самое. # fetch_around_multi_room page=1 уже делал это правильно (эталон). @pytest.mark.asyncio async def test_yandex_fetch_page_json_schema_drift_marks_failure_not_success() -> None: """(a) response есть, но БЕЗ search.offers → _is_gate_error пропускает, _extract_gate_data проваливается → failure затрекан (не честный успех).""" body = '{"response": {"someOtherField": 1}}' scraper = _yandex_scraper_with_bodies([body]) payload = await scraper._fetch_page_json(None, 1, None, None) assert payload is not None # контракт возврата не меняется assert scraper.gate_fetch_attempts == 1 assert scraper.gate_fetch_failures == 1 @pytest.mark.asyncio async def test_yandex_fetch_around_page_ge2_schema_drift_marks_failure_anti_flap() -> None: """(b) page>=2 в fetch_around (пагинация внутри fetch_around_multi_room) — тот же drift-shape ПОСЛЕ успешной page=1 → failure затрекан, а не «честный конец выдачи». attempts=2, failures=1 — партиция НЕ триггерит orchestration banned-gate (тот требует failures==attempts, см. run_yandex_city_sweep).""" ok_body = ( '{"response": {"search": {"offers": ' '{"entities": [{"offerId": "1", "price": {"value": 5000000}}], ' '"pager": {"page": 0, "totalItems": 1, "totalPages": 2}}}}}' ) drift_body = '{"response": {"someOtherField": 1}}' scraper = _yandex_scraper_with_bodies([ok_body, drift_body]) scraper.request_delay_sec = 0.0 # skip real inter-request sleep in test lots_p1 = await scraper.fetch_around(56.84, 60.60, page=1) lots_p2 = await scraper.fetch_around(56.84, 60.60, page=2) assert len(lots_p1) == 1 assert lots_p2 == [] assert scraper.gate_fetch_attempts == 2 assert scraper.gate_fetch_failures == 1 # только page2, не весь прогон @pytest.mark.asyncio async def test_yandex_fetch_page_json_one_request_one_attempt_no_double_count() -> None: """(c) Инвариант «1 запрос = 1 attempt»: caller (как _probe) сам вызывает _extract_gate_data на уже полученном payload — это НЕ второй track-вызов, счётчик мутируется только внутри _fetch_page_json.""" from scraper_kit.providers.yandex.serp import _extract_gate_data body = '{"response": {"someOtherField": 1}}' scraper = _yandex_scraper_with_bodies([body]) payload = await scraper._fetch_page_json(None, 1, None, None) assert scraper.gate_fetch_attempts == 1 assert scraper.gate_fetch_failures == 1 # Caller-side re-check (то, что реально делает _probe) — не трогает счётчики. result = _extract_gate_data(payload) if payload is not None else None assert result is None assert scraper.gate_fetch_attempts == 1 # ── __aexit__: report_ban на 100% failure (#2600 п.1 + deep-review fix 1) ────── # # Здесь репортится РАНЬШЕ, чем pipeline.py's run-level banned-статус (#2625) — в # __aexit__ ДО release lease (см. CianScraper/YandexRealtyScraper __aexit__ # docstring-комментарии). Floor attempts>=_MIN_ATTEMPTS_FOR_BAN_REPORT (=3, deep- # review fix 1) — единичный admin-прогон (ровно 1 fetch_around) не должен банить # здоровый узел на 1/1=100%. class _FakeBrowserLease: """BrowserFetcher-заглушка с report_ban-recorder + async __aexit__ (для CianScraper/YandexRealtyScraper.__aexit__, который её awaits).""" def __init__(self) -> None: self.banned: list[str] = [] self.aexit_called = False def report_ban(self, reason: str) -> None: self.banned.append(reason) async def __aexit__(self, *args: object) -> None: self.aexit_called = True @pytest.mark.asyncio async def test_cian_aexit_reports_ban_on_all_attempts_failed() -> None: from scraper_kit.providers.cian.serp import CianScraper scraper = CianScraper(_cian_config()) for _ in range(3): # floor: attempts >= _MIN_ATTEMPTS_FOR_BAN_REPORT scraper._parse_serp_html("captcha page, no window._cianConfig") assert scraper.state_extraction_attempts == 3 assert scraper.state_extraction_failures == 3 fake_browser = _FakeBrowserLease() scraper._browser = fake_browser # type: ignore[assignment] await scraper.__aexit__(None, None, None) assert fake_browser.banned # report_ban вызван ДО release (__aexit__ дошёл до конца) assert fake_browser.aexit_called # release всё равно случился (browser.__aexit__) @pytest.mark.asyncio async def test_cian_aexit_no_ban_report_below_attempts_floor() -> None: """deep-review fix 1: attempts=1 (единичный admin-прогон, 100% failure) НЕ репортит бан — floor attempts>=_MIN_ATTEMPTS_FOR_BAN_REPORT его не пускает.""" from scraper_kit.providers.cian.serp import CianScraper scraper = CianScraper(_cian_config()) scraper._parse_serp_html("captcha page, no window._cianConfig") assert scraper.state_extraction_attempts == 1 assert scraper.state_extraction_failures == 1 fake_browser = _FakeBrowserLease() scraper._browser = fake_browser # type: ignore[assignment] await scraper.__aexit__(None, None, None) assert fake_browser.banned == [] @pytest.mark.asyncio async def test_cian_aexit_no_ban_report_on_honest_empty(monkeypatch: pytest.MonkeyPatch) -> None: """Честная пустая выдача (attempts>0, failures=0) — report_ban НЕ вызывается.""" from scraper_kit.providers.cian import serp as cian_serp monkeypatch.setattr( cian_serp, "extract_state", lambda html, mfe, key: {"results": {"offers": [], "totalOffers": 0}}, ) scraper = cian_serp.CianScraper(_cian_config()) scraper._parse_serp_html("valid empty SERP") assert scraper.state_extraction_failures == 0 fake_browser = _FakeBrowserLease() scraper._browser = fake_browser # type: ignore[assignment] await scraper.__aexit__(None, None, None) assert fake_browser.banned == [] @pytest.mark.asyncio async def test_cian_aexit_no_ban_report_when_no_attempts_made() -> None: """attempts=0 (scraper упал до первого fetch) — guard attempts>0 не даёт ложного 100%-failure на пустой выборке из нуля попыток.""" from scraper_kit.providers.cian.serp import CianScraper scraper = CianScraper(_cian_config()) assert scraper.state_extraction_attempts == 0 fake_browser = _FakeBrowserLease() scraper._browser = fake_browser # type: ignore[assignment] await scraper.__aexit__(None, None, None) assert fake_browser.banned == [] @pytest.mark.asyncio async def test_yandex_aexit_reports_ban_on_all_attempts_failed() -> None: scraper = _yandex_scraper_with_bodies(['{"error": "captcha"}'] * 3) for _ in range(3): # floor: attempts >= _MIN_ATTEMPTS_FOR_BAN_REPORT await scraper._fetch_page_json(None, 1, None, None) assert scraper.gate_fetch_attempts == 3 assert scraper.gate_fetch_failures == 3 fake_browser = _FakeBrowserLease() scraper._browser = fake_browser # type: ignore[assignment] await scraper.__aexit__(None, None, None) assert fake_browser.banned assert fake_browser.aexit_called @pytest.mark.asyncio async def test_yandex_aexit_no_ban_report_below_attempts_floor() -> None: """deep-review fix 1: attempts=1 (единичный admin-прогон, 100% failure) НЕ репортит бан — тот же floor, что cian.""" scraper = _yandex_scraper_with_bodies(['{"error": "captcha"}']) await scraper._fetch_page_json(None, 1, None, None) assert scraper.gate_fetch_attempts == 1 assert scraper.gate_fetch_failures == 1 fake_browser = _FakeBrowserLease() scraper._browser = fake_browser # type: ignore[assignment] await scraper.__aexit__(None, None, None) assert fake_browser.banned == [] @pytest.mark.asyncio async def test_yandex_aexit_no_ban_report_on_honest_empty() -> None: body = ( '{"response": {"search": {"offers": ' '{"entities": [], "pager": {"page": 0, "totalItems": 0, "totalPages": 0}}}}}' ) scraper = _yandex_scraper_with_bodies([body]) await scraper._fetch_page_json(None, 1, None, None) assert scraper.gate_fetch_failures == 0 fake_browser = _FakeBrowserLease() scraper._browser = fake_browser # type: ignore[assignment] await scraper.__aexit__(None, None, None) assert fake_browser.banned == [] # ── Yandex: transport vs content failure (deep-review fix 1, #2600 п.4) ──────── # # _http_get: fetch() raising (no response at all) -> transport_error=True. # Callers (_fetch_page_json/fetch_around/fetch_around_multi_room's page=1 probe) # must NOT feed transport_error=True into _track_gate_result — that counter feeds # report_ban (пул/бан), a transport failure is "наш прокси сдох"/сетевой сбой # (mark_health(ok=False) already covers it inside BrowserFetcher._post_fetch). @pytest.mark.asyncio async def test_http_get_transport_exception_sets_transport_error_flag() -> None: scraper = _yandex_scraper_with_raising_browser() resp = await scraper._http_get("https://realty.yandex.ru/gate/x", timeout=60) assert resp.status_code == 0 assert resp.transport_error is True @pytest.mark.asyncio async def test_http_get_no_json_found_is_not_transport_error() -> None: """Ответ пришёл (HTTP 200-эквивалент camoufox), но JSON не извлёкся — content ambiguous (тарпит-страница), НЕ transport_error.""" scraper = _yandex_scraper_with_bodies(["no json here"]) resp = await scraper._http_get("https://realty.yandex.ru/gate/x", timeout=60) assert resp.status_code == 0 assert resp.transport_error is False @pytest.mark.asyncio async def test_yandex_fetch_page_json_transport_error_does_not_track() -> None: """(a) fetch() raised (transport) — _fetch_page_json НЕ инкрементит gate_fetch_*.""" scraper = _yandex_scraper_with_raising_browser() payload = await scraper._fetch_page_json(None, 1, None, None) assert payload is None assert scraper.gate_fetch_attempts == 0 assert scraper.gate_fetch_failures == 0 @pytest.mark.asyncio async def test_yandex_fetch_around_all_transport_failures_not_tracked( monkeypatch: pytest.MonkeyPatch, ) -> None: """(a) fetch_around: ВСЕ retry-попытки — transport exception (сеть мертва целиком) -> retries-exhausted НЕ репортится как gate-failure (had_content_failure=False).""" from scraper_kit.providers.yandex import serp as yandex_serp monkeypatch.setattr(yandex_serp.asyncio, "sleep", AsyncMock()) # без реальных 2s×N scraper = _yandex_scraper_with_raising_browser() lots = await scraper.fetch_around(56.8, 60.6, page=1) assert lots == [] assert scraper.gate_fetch_attempts == 0 assert scraper.gate_fetch_failures == 0 @pytest.mark.asyncio async def test_yandex_fetch_around_mixed_transport_then_content_failure_is_tracked( monkeypatch: pytest.MonkeyPatch, ) -> None: """Микс: первая попытка — transport exception (не считается), retry рвёт JSON (content-сигнал, тарпит) -> had_content_failure=True -> retries-exhausted ВСЁ ЖЕ репортится (хотя бы одна попытка дала реальный content-сигнал).""" from scraper_kit.providers.yandex import serp as yandex_serp from scraper_kit.providers.yandex.serp import YandexRealtyScraper monkeypatch.setattr(yandex_serp.asyncio, "sleep", AsyncMock()) # без реальных 2s×N class _FlakyThenTarpitBrowser: def __init__(self) -> None: self.calls = 0 async def fetch(self, url: str) -> str: self.calls += 1 if self.calls == 1: raise RuntimeError("transient network blip") return "no json here (tarpit)" scraper = YandexRealtyScraper(types.SimpleNamespace()) scraper._browser = _FlakyThenTarpitBrowser() # type: ignore[assignment] lots = await scraper.fetch_around(56.8, 60.6, page=1) assert lots == [] assert scraper.gate_fetch_attempts == 1 assert scraper.gate_fetch_failures == 1 @pytest.mark.asyncio async def test_yandex_end_to_end_transport_errors_never_report_ban() -> None: """(a) end-to-end: 3 подряд transport-провала (сеть мертва) — gate_fetch_attempts остаётся 0, __aexit__ guard (attempts>=floor) не срабатывает -> report_ban НЕ вызывается. Отличимо от настоящего 3x content-бана (test_yandex_aexit_reports_ ban_on_all_attempts_failed выше, ГДЕ attempts=3 и banned непусто).""" scraper = _yandex_scraper_with_raising_browser() for _ in range(3): await scraper._fetch_page_json(None, 1, None, None) assert scraper.gate_fetch_attempts == 0 fake_browser = _FakeBrowserLease() scraper._browser = fake_browser # type: ignore[assignment] await scraper.__aexit__(None, None, None) assert fake_browser.banned == []