"""#3402: капча Циана приходит с HTTP 200 — это отказ площадки, а не «не разобрали». Замер прода 06.09.2026: Циан отдаёт капчу (`Captcha - база объявлений ЦИАН`, 44 КБ; `<title>Вы не робот?`, 16 КБ) и страницу ошибки (`<title>Ошибка - Циан`, 374 КБ) с кодом **200**. Детектор сайдкара их не знал (`_REFUSAL_STATUSES` = {403,429}, маркеры сняты с Авито/Домклика), HTML уезжал клиенту как успех, `extract_state` возвращал None и провайдер печатал «defaultState extraction failed» — то есть отказ ПЛОЩАДКИ читался как дрейф НАШЕЙ разметки. Аренда не менялась: `fetch()` уже отрапортовал `mark_health(ok=True)` (HTTP-уровень успешен), fail-streak обнулялся, и один капча-узел сжигал весь батч — прогоны 6200: 0/210; 6123/6091/6052/6032/6010/5981: 0/400 при 161/162 через здоровый узел на прогоне 13. ДВА КЛАССА, а не один (проба прода 06.09.2026 09:25 UTC — одна карточка по узлам): * КАПЧА («Captcha - база объявлений ЦИАН», «Вы не робот?») — безусловный отказ площадки: `report_platform_ban` + `CianBlockedError`. Снимается за 1-2 часа (узел 14, час назад отдававший капчу, вернул настоящую карточку) — TTL бана по назначению; * «Ошибка - Циан» — ТОЛЬКО ЛОГ, прежний `None`. Природа не доказана: страница может быть транзиентной 5xx-заглушкой под кодом 200, а не отказом конкретному узлу (узел 1 за час сменил её на «Вы не робот?»). `mark_banned` эскалирует TTL до часов — по этой догадке 20-минутный сбой Циана выбил бы из выдачи весь пул. Решение — по частоте в логах за цикл наблюдения. Этот файл покрывает ВТОРОЙ слой (kit) — он нужен потому, что образы backend и browser деплоятся раздельно и расходятся на часы: в это окно сайдкар ещё отдаёт 200 с капчей. Первый слой (сайдкар: 403 + ban_page) — `browser/test_server_cian_captcha.py`. Красное на main: * `fetch_detail` на капче возвращала None и НЕ звала `report_platform_ban`; * батч из двух объявлений давал 0 успехов — второй листинг шёл через тот же узел. """ from __future__ import annotations import logging import os from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from scraper_kit.cian_exceptions import CianBlockedError from scraper_kit.providers.cian import detail as cian_detail from app.tasks import cian_history_backfill # Заглушки пула/конфига — те же, что у #2700 (403 на том же curl-пути): дублировать # контракт ProxyProvider второй раз незачем. from tests.test_2700_cian_detail_403_node import _FakeConfig, _session_returning, _SpyProvider _URL = "https://ekb.cian.ru/sale/flat/327830237/" # Фрагмент прод-страницы капчи: заголовок + то самое слово в теле. _CAPTCHA_HTML = ( "<!DOCTYPE html><html lang='ru'><head><meta charset='utf-8'>" "<title>Captcha - база объявлений ЦИАН" "
" "" ) # Второй вариант капчи — тот же отказ, другая вёрстка (проба 06.09.2026, узел 1, 16 КБ). _ROBOT_HTML = ( "Вы не робот?" "
" ) _ERROR_PAGE_HTML = ( "Ошибка - Циан" "

Что-то пошло не так

" ) # Здоровая карточка в текущем (`.concat`) формате _cianConfig. Слово `captcha` в теле # ЕСТЬ — в нормальной карточке Циана оно встречается 11 раз (антифрод-скрипты), и # именно поэтому признаком отказа оно быть не может. _CARD_HTML = ( "Купить 1-комн. квартиру — ЦИАН" "" ) # Страница без состояния и без маркеров отказа — дрейф разметки, прежнее поведение. _NO_STATE_HTML = "Купить квартиру — ЦИАН" class _FakeFetcher: """BrowserFetcher-заглушка: капча, пока не сменится «аренда». `report_platform_ban` зеркалит боевую механику ровно в том, что нас интересует: рапорт по текущему lease + смена аренды (в бою — `report_ban` + fail-streak → `_LEASE_ROTATE_AFTER_FAILS`, browser_fetcher.py). Здесь смена мгновенная — тест про то, ЗОВЁТСЯ ли рапорт и продолжается ли батч на новом узле, а не про порог. """ def __init__(self, *, refusal_html: str = _CAPTCHA_HTML, **kwargs: Any) -> None: self.node = 1 self.refusal_html = refusal_html self.last_response_status: int | None = 200 self.ban_reports: list[str] = [] self.fetched: list[str] = [] async def __aenter__(self) -> _FakeFetcher: return self async def __aexit__(self, *_: object) -> None: return None async def fetch(self, url: str, **kwargs: Any) -> str: self.fetched.append(url) return self.refusal_html if self.node == 1 else _CARD_HTML def report_platform_ban(self, reason: str) -> None: self.ban_reports.append(reason) self.node += 1 # ── Слой kit: капча → CianBlockedError + рапорт бана ────────────────────────── @pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ROBOT_HTML]) async def test_captcha_page_is_a_platform_refusal(html: str) -> None: """HTTP 200 + капча (оба варианта вёрстки) → CianBlockedError, а не тихий None.""" fetcher = _FakeFetcher(refusal_html=html) with pytest.raises(CianBlockedError) as exc_info: await cian_detail.fetch_detail(_URL, browser_fetcher=fetcher) assert "капча Циана" in str(exc_info.value) # Рапорт бана — по ЖИВОМУ lease, внутри `async with BrowserFetcher(...)` caller'а. assert len(fetcher.ban_reports) == 1 assert "cian detail" in fetcher.ban_reports[0] async def test_error_page_is_logged_but_not_banned(caplog: pytest.LogCaptureFixture) -> None: """«Ошибка - Циан» → прежний None + WARNING; `report_platform_ban` НЕ зовётся. Цикл наблюдения (#3402): страница может быть транзиентным сбоем площадки, а бан эскалирует TTL до часов — за 20-минутный сбой Циана пул вылетел бы из выдачи. """ fetcher = _FakeFetcher(refusal_html=_ERROR_PAGE_HTML) with caplog.at_level(logging.WARNING): result = await cian_detail.fetch_detail(_URL, browser_fetcher=fetcher) assert result is None assert fetcher.ban_reports == [] assert "страница ошибки Циана" in caplog.text assert "не бан, только лог" in caplog.text async def test_page_without_state_and_without_markers_still_returns_none() -> None: """Дрейф разметки (нет состояния, нет маркеров) — прежнее поведение, не бан.""" fetcher = _FakeFetcher(refusal_html=_NO_STATE_HTML) result = await cian_detail.fetch_detail(_URL, browser_fetcher=fetcher) assert result is None assert fetcher.ban_reports == [] async def test_healthy_card_with_the_word_captcha_parses() -> None: """Слово `captcha` в теле здоровой карточки баном НЕ считается.""" fetcher = _FakeFetcher() fetcher.node = 2 # «здоровый узел» → отдаёт карточку result = await cian_detail.fetch_detail(_URL, browser_fetcher=fetcher) assert result is not None assert result.cian_id == 327830237 assert fetcher.ban_reports == [] # ── Батч: аренда меняется ВНУТРИ прогона, а не на следующем ─────────────────── def _db_with_rows(n: int) -> MagicMock: db = MagicMock() db.execute.return_value.mappings.return_value.all.return_value = [ {"id": i, "source_url": f"https://ekb.cian.ru/sale/flat/{i}/"} for i in range(1, n + 1) ] return db async def test_batch_rotates_after_captcha_and_enriches_the_next_listing() -> None: """Капча на первом объявлении → рапорт бана → второе обогащено на новом узле. На main тест красный по значению: `fetch_detail` возвращал None, рапорта не было, «узел» не менялся — оба объявления шли через капчу, listings_succeeded == 0. """ fetcher = _FakeFetcher() with ( patch.object(cian_history_backfill, "BrowserFetcher", lambda **kw: fetcher), patch.object(cian_history_backfill, "save_detail_enrichment", MagicMock()), patch("asyncio.sleep", new_callable=AsyncMock), ): result = await cian_history_backfill.backfill_cian_history( _db_with_rows(2), do_listings=True, do_houses=False, do_valuations=False ) assert fetcher.ban_reports, "капча не отрапортована пулу — узел доработает батч" assert len(fetcher.fetched) == 2 # батч не оборван: следующее объявление взято assert result.listings_succeeded == 1 # второе обогащено ПОСЛЕ смены аренды assert result.listings_failed_fetch == 1 async def test_captcha_is_counted_as_platform_refusal_not_as_parse_failure() -> None: """Капча попадает в `ban_kinds['platform']`, а не только в «не разобрали». Красный на main: `_note_refusal` ключевался ИСКЛЮЧИТЕЛЬНО по HTTP-статусу (`ban_kind_from_status`), а капча приходит с 200 (свой детект) либо без статуса (сайдкар) → `ban_kinds` пуст, `listings_blocked == 0`, наверх уезжал один `listings_failed_fetch`. Отчёт прогона в этом виде не отличает волну капчи от дрейфа нашей разметки — то есть следующая волна снова была бы невидима. """ fetcher = _FakeFetcher() with ( patch.object(cian_history_backfill, "BrowserFetcher", lambda **kw: fetcher), patch.object(cian_history_backfill, "save_detail_enrichment", MagicMock()), patch("asyncio.sleep", new_callable=AsyncMock), ): result = await cian_history_backfill.backfill_cian_history( _db_with_rows(1), do_listings=True, do_houses=False, do_valuations=False ) assert dict(result.ban_kinds) == {"platform": 1} assert result.listings_blocked == 1 assert result.ban_kind == "platform" # то, что уедет в scrape_runs.ban_kind # ── curl-путь: капча банит узел, а не только прерывает разбор ───────────────── async def test_captcha_on_curl_path_bans_the_node_for_cian() -> None: """HTTP 200 + капча на own-session curl → `mark_banned(node × cian)`. Красный на main: детект капчи стоит в общем parse-пути, ЗА границей `with curl_proxy_url(...)`, — исключение поднималось уже после того, как хелпер проставил узлу `mark_health(ok=True)`. Узел, которому Циан показывает капчу, оставался в выдаче Циану (тот же дефект, что #2700 у 403). Путь боевой: `pipeline.py` зовёт `fetch_detail(config=, proxy_provider=)` без `browser_fetcher` — это detail-фаза cian city sweep. """ spy = _SpyProvider() with patch.object( cian_detail, "build_curl_cffi_session", return_value=_session_returning(200, _CAPTCHA_HTML), ): with pytest.raises(CianBlockedError): await cian_detail.fetch_detail(_URL, config=_FakeConfig(), proxy_provider=spy) assert spy.mark_banned_calls == [(1, "cian")] assert (1, True) not in spy.mark_health_calls, "узел с капчей записан здоровым" assert spy.mark_health_calls == [(1, False)] assert spy.release_calls == [1] # lease не течёт async def test_error_page_on_curl_path_does_not_ban_the_node() -> None: """«Ошибка - Циан» на curl-пути — по-прежнему не бан (цикл наблюдения, #3402).""" spy = _SpyProvider() with patch.object( cian_detail, "build_curl_cffi_session", return_value=_session_returning(200, _ERROR_PAGE_HTML), ): result = await cian_detail.fetch_detail(_URL, config=_FakeConfig(), proxy_provider=spy) assert result is None assert spy.mark_banned_calls == [] assert spy.mark_health_calls == [(1, True)]