"""test_server_cian_captcha.py — капча Циана приходит с HTTP 200 (#3402). Замер прода 06.09.2026: Циан отдаёт капчу (`Captcha - база объявлений ЦИАН`, 44 КБ; `<title>Вы не робот?`, 16 КБ) и страницу ошибки (`<title>Ошибка - Циан`, 374 КБ) с кодом **200**, поэтому _REFUSAL_STATUSES {403,429} их не видит, а маркеры Авито (_CHALLENGE_MARKERS / _BAN_MARKERS) в них не встречаются. HTML уезжал клиенту как успех, парсер не находил defaultState, и cian_detail_backfill держал ОДНУ аренду на весь батч, сжигая через капча-узел 210-400 карточек подряд (прогоны 6200: 0/210; 6123/6091/6052/6032/6010/5981: 0/400 — против 161/162 через здоровый узел на прогоне 13). Слово `captcha` как признак не годится: в НОРМАЛЬНОЙ карточке Циана оно встречается 11 раз (антифрод-скрипты), на странице капчи — 17. Отсюда детект по <title>. ДВА КЛАССА, а не один (проба прода 06.09.2026 09:25 UTC по узлам через сайдкар): * КАПЧА («Captcha - база объявлений ЦИАН», «Вы не робот?») — отказ площадки: бан пары «узел×cian» + ротация аренды. Снимается за 1-2 часа (узел 14 через час отдавал уже настоящую карточку), то есть TTL бана по назначению; * «Ошибка - Циан» — ТОЛЬКО ЛОГ. Природа не доказана: может быть транзиентной 5xx-заглушкой под кодом 200, а не отказом узлу (узел 1 за час сменил её на «Вы не робот?»). Цена ошибки несимметрична — mark_banned эскалирует TTL до часов, и 20-минутный сбой Циана выбил бы из выдачи весь пул. Решение — по частоте в логах. camoufox НЕ запускается: _browsers[provider] — поддельный browser/page (зеркалит test_server_http_status.py). wait_for_timeout на фейковой page — no-op. Запуск (из tradein-mvp/browser/):: python -m pytest test_server_cian_captcha.py -q """ from __future__ import annotations import asyncio import importlib.util import json import logging from pathlib import Path from typing import Any import pytest from aiohttp.test_utils import make_mocked_request # server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути. _SERVER_PATH = Path(__file__).resolve().parent / "server.py" _spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH) assert _spec is not None and _spec.loader is not None server = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(server) @pytest.fixture(autouse=True) def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: """Чистое per-provider состояние на каждый тест (зеркалит соседние тесты).""" monkeypatch.setattr(server, "_browsers", {}) monkeypatch.setattr(server, "_browser_cms", {}) monkeypatch.setattr(server, "_page_counters", {}) monkeypatch.setattr(server, "_locks", {}) monkeypatch.setattr(server, "_retry_tasks", {}) monkeypatch.setattr(server, "_last_goto_at", {}) monkeypatch.setattr(server, "_last_response_status", {}) monkeypatch.setattr(server, "_launched_proxy", {}) monkeypatch.setattr(server, "_locks_guard", asyncio.Lock()) monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False) # Обёртка капчи — фрагмент прод-страницы: заголовок + то самое слово в теле. _CAPTCHA_HTML = ( "<!DOCTYPE html><html lang='ru'><head>" "<meta charset='utf-8'>" "<title>Captcha - база объявлений ЦИАН" "
" "" "" ) # Второй вариант капчи — тот же отказ, другая вёрстка (проба 06.09.2026, узел 1, 16 КБ). _ROBOT_HTML = ( "Вы не робот?" "
" ) _ERROR_PAGE_HTML = ( "Ошибка - Циан" "

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

" ) # Нормальная карточка: слово captcha в теле есть (антифрод), состояние — на месте. _CARD_HTML = ( "Купить 1-комн. квартиру — ЦИАН" "" "" "" ) class _Response: """Поддельный playwright Response — интересует только .status.""" def __init__(self, status: int) -> None: self.status = status class _Page: """Поддельная page: goto отдаёт Response(200), content() — заданный HTML.""" def __init__(self, html: str, status: int = 200) -> None: self._html = html self._status = status self.goto_urls: list[str] = [] self.closed = 0 async def route(self, pattern: str, handler: Any) -> None: return None async def goto(self, url: str, **kwargs: Any) -> _Response: self.goto_urls.append(url) return _Response(self._status) async def wait_for_timeout(self, ms: int) -> None: return None async def content(self) -> str: return self._html async def close(self) -> None: self.closed += 1 class _Browser: def __init__(self, page: _Page) -> None: self._page = page async def new_page(self) -> _Page: return self._page def _install(monkeypatch: pytest.MonkeyPatch, page: _Page, provider: str = "cian") -> None: server._browsers[provider] = _Browser(page) monkeypatch.setattr( server, "_RECYCLE_PAGES_BY_PROVIDER", dict.fromkeys(server.PROVIDERS, 10_000) ) monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0) monkeypatch.setattr(server, "_MIN_PAGE_INTERVAL_BY_PROVIDER", {}) monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0) def _json_body(response: Any) -> dict[str, Any]: return json.loads(response.body.decode()) async def _coro(value: Any) -> Any: return value def _make_request(body: dict[str, Any]) -> Any: request = make_mocked_request("POST", "/fetch") request.json = lambda: _coro(body) # type: ignore[method-assign] return request # ── Детектор: что капча, что страница ошибки, что ни то ни другое ───────────── @pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ROBOT_HTML]) def test_cian_captcha_pages_are_recognised(html: str) -> None: """Оба варианта капчи — отказ площадки (второй, «Вы не робот?», добавлен 06.09).""" assert server._is_cian_captcha(html) is True def test_error_page_is_not_a_captcha() -> None: """«Ошибка - Циан» баном НЕ считается — иначе 20-минутный сбой Циана выбивает пул.""" assert server._is_cian_captcha(_ERROR_PAGE_HTML) is False def test_normal_card_with_the_word_captcha_is_not_a_refusal() -> None: """Слово `captcha` в теле нормальной карточки признаком отказа НЕ является.""" assert "captcha" in _CARD_HTML.lower() assert server._is_cian_captcha(_CARD_HTML) is False def test_em_dash_in_title_is_normalised(caplog: pytest.LogCaptureFixture) -> None: """Вёрстка печатает тире по-разному — детект не должен зависеть от его вида.""" with caplog.at_level(logging.WARNING): server._log_cian_error_page("Ошибка — Циан", "https://x", 200) assert "страница ошибки Циана" in caplog.text # ── /fetch: 403 + ban_page на капче, отданной с HTTP 200 ────────────────────── @pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ROBOT_HTML]) def test_fetch_once_raises_on_cian_captcha(monkeypatch: pytest.MonkeyPatch, html: str) -> None: """HTTP 200 + капча → BanPageDetectedError, а не «валидный HTML» наверх.""" page = _Page(html) _install(monkeypatch, page) with pytest.raises(server.BanPageDetectedError): asyncio.run(server._fetch_once("cian", "https://ekb.cian.ru/sale/flat/1/")) def test_fetch_once_logs_error_page_without_banning( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """«Ошибка - Циан» → HTML уезжает наверх как есть + WARNING; исключения НЕТ. Пока идёт цикл наблюдения (#3402): страница может быть транзиентным сбоем площадки, а `mark_banned` эскалирует TTL до часов — бан по догадке дороже пропущенного отказа. """ page = _Page(_ERROR_PAGE_HTML) _install(monkeypatch, page) with caplog.at_level(logging.WARNING): html = asyncio.run(server._fetch_once("cian", "https://ekb.cian.ru/sale/flat/1/")) assert html == _ERROR_PAGE_HTML assert "страница ошибки Циана" in caplog.text assert "не бан, только лог" in caplog.text def test_fetch_once_passes_normal_card_through(monkeypatch: pytest.MonkeyPatch) -> None: """Здоровая карточка (с тем же словом в теле) отдаётся как раньше.""" page = _Page(_CARD_HTML) _install(monkeypatch, page) html = asyncio.run(server._fetch_once("cian", "https://ekb.cian.ru/sale/flat/1/")) assert html == _CARD_HTML @pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ROBOT_HTML]) def test_fetch_handler_returns_403_with_ban_page_on_captcha( monkeypatch: pytest.MonkeyPatch, html: str, ) -> None: """Тот же путь, что #3379/#3288 п.4: 403 + ban_page + ЧЕСТНЫЙ upstream-статус 200. Статус передаём как есть: апстрим ответил 200, и врать про 403 площадки нельзя — клиент опознаёт бан по признаку `ban_page`, а не по коду (образы сайдкара и бэкенда деплоятся врозь). """ monkeypatch.setattr(server, "IS_PROD", False) page = _Page(html) _install(monkeypatch, page) async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True monkeypatch.setattr(server, "_ensure_browser", _ensure) response = asyncio.run( server.fetch_handler( _make_request({"url": "https://ekb.cian.ru/sale/flat/1/", "source": "cian"}) ) ) body = _json_body(response) assert response.status == 403 assert body["ban_page"] is True assert body["status"] == 200 assert "BanPageDetectedError" in body["error"] def test_fetch_handler_returns_200_without_ban_page_on_error_page( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """«Ошибка - Циан» доезжает клиенту как обычный ответ: 200, без `ban_page`.""" monkeypatch.setattr(server, "IS_PROD", False) page = _Page(_ERROR_PAGE_HTML) _install(monkeypatch, page) async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True monkeypatch.setattr(server, "_ensure_browser", _ensure) with caplog.at_level(logging.WARNING): response = asyncio.run( server.fetch_handler( _make_request({"url": "https://ekb.cian.ru/sale/flat/1/", "source": "cian"}) ) ) body = _json_body(response) assert response.status == 200 assert "ban_page" not in body assert body["html"] == _ERROR_PAGE_HTML assert "не бан, только лог" in caplog.text def test_fetch_handler_does_not_ban_other_providers_on_the_same_html( monkeypatch: pytest.MonkeyPatch, ) -> None: """Детект per-provider: та же страница у Авито проходит прежним путём (нет ложных банов).""" monkeypatch.setattr(server, "IS_PROD", False) page = _Page(_CAPTCHA_HTML) _install(monkeypatch, page, provider="avito") async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True monkeypatch.setattr(server, "_ensure_browser", _ensure) response = asyncio.run( server.fetch_handler(_make_request({"url": "https://www.avito.ru/x", "source": "avito"})) ) assert response.status == 200 assert _json_body(response)["html"] == _CAPTCHA_HTML