"""test_server_cian_captcha.py — капча Циана приходит с HTTP 200 (#3402).
Замер прода 06.09.2026: Циан отдаёт капчу (`
Captcha - база объявлений ЦИАН`,
44 КБ; `Вы не робот?`, 16 КБ) и страницу ошибки (`Ошибка - Циан`, 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. Отсюда детект по .
ДВА КЛАССА, а не один (проба прода 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 = (
""
""
"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