"""#3402: капча Циана приходит с HTTP 200 — это отказ площадки, а не «не разобрали».
Замер прода 06.09.2026: Циан отдаёт капчу (`
Captcha - база объявлений ЦИАН`,
44 КБ; `Вы не робот?`, 16 КБ) и страницу ошибки (`Ошибка - Циан`, 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
_URL = "https://ekb.cian.ru/sale/flat/327830237/"
# Фрагмент прод-страницы капчи: заголовок + то самое слово в теле.
_CAPTCHA_HTML = (
""
"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