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
Ревью (⚠️ 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.
307 lines
13 KiB
Python
307 lines
13 KiB
Python
"""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
|