gendesign/tradein-mvp/browser/test_server_cian_captcha.py
bot-backend 957e9e8c01
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m27s
fix(#3402): капча = «Captcha…» и «Вы не робот?» (бан+ротация); «Ошибка - Циан» — только лог на цикл наблюдения
Ревью (⚠️ minor) на #3403: «Ошибка - Циан» может быть транзиентной 5xx-страницей,
отданной с кодом 200, а не отказом конкретному узлу. Цена ошибки несимметрична —
mark_banned эскалирует TTL до часов, поэтому 20-минутный сбой площадки выбил бы из
выдачи весь пул. Один список маркеров этого различить не мог: и капча, и страница
ошибки шли одним путём в BanPageDetectedError.

Проба прода 06.09.2026 09:25 UTC (одна карточка по узлам через сайдкар):
* узел 14, час назад отдававший «Captcha - база объявлений ЦИАН», вернул НАСТОЯЩУЮ
  карточку — капча снимается за 1-2 часа, то есть TTL бана по назначению;
* узел 1 отдал ТРЕТИЙ вариант отказа — `<title>Вы не робот?`, 16 КБ (час назад —
  «Ошибка - Циан», 374 КБ). Прежние маркеры его не знали вовсе: отказ уезжал наверх
  как валидный HTML ровно так же, как до #3402.

Маркеры разделены на два класса, одинаково в обоих слоях (образы backend и browser
деплоятся раздельно и расходятся на часы):
* КАПЧА — «captcha - база объявлений циан» + «вы не робот?»: безусловный отказ
  площадки, прежний путь (сайдкар → BanPageDetectedError → 403 + ban_page; kit →
  report_platform_ban + CianBlockedError). За ней нет контента, и узел, которому её
  показали, будет получать её дальше;
* «ошибка - циан» — ТОЛЬКО ЛОГ: сайдкар отдаёт HTML клиенту как есть и пишет WARNING
  «страница ошибки Циана (title=…, upstream=…) — не бан, только лог (#3402)», kit при
  провале extract_state пишет WARNING и возвращает прежний None. Ни бана, ни рапорта,
  ни исключения — решение принимаем по частоте в логах за цикл наблюдения, а не по
  догадке о природе страницы.

Нормализация заголовка прежняя (регистр/пробелы/тире). `_is_cian_refusal` →
`_is_cian_captcha` + `_log_cian_error_page`; `_refusal_title` → `_page_title` и два
кортежа маркеров рядом.

Фальсификация: «вы не робот?» убран из маркеров обоих слоёв → kit 1 failed
(«DID NOT RAISE CianBlockedError»), сайдкар 3 failed («DID NOT RAISE
BanPageDetectedError», `_is_cian_captcha` → assert False is True). Маркер возвращён,
обе сьюты зелёные: backend 5599 passed / 35 skipped, browser 246 passed.
2026-09-06 14:55:45 +05:00

307 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""test_server_cian_captcha.py — капча Циана приходит с HTTP 200 (#3402).
Замер прода 06.09.2026: Циан отдаёт капчу (`<title>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 - база объявлений ЦИАН</title>"
"</head><body><div id='captcha'></div>"
"<script>window.__captcha__ = {sitekey: 'x'};</script>"
"</body></html>"
)
# Второй вариант капчи — тот же отказ, другая вёрстка (проба 06.09.2026, узел 1, 16 КБ).
_ROBOT_HTML = (
"<!DOCTYPE html><html lang='ru'><head><title>Вы не робот?</title></head>"
"<body><div id='captcha-container'></div></body></html>"
)
_ERROR_PAGE_HTML = (
"<!DOCTYPE html><html lang='ru'><head><title>Ошибка - Циан</title></head>"
"<body><h1>Что-то пошло не так</h1></body></html>"
)
# Нормальная карточка: слово captcha в теле есть (антифрод), состояние — на месте.
_CARD_HTML = (
"<html><head><title>Купить 1-комн. квартиру — ЦИАН</title></head><body>"
"<script>window.captchaConfig = {}; /* captcha captcha captcha */</script>"
"<script>window._cianConfig['frontend-offer-card'] = [];</script>"
"</body></html>"
)
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("<title>Ошибка — Циан</title>", "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