gendesign/tradein-mvp/backend/tests/test_3402_cian_captcha_http200.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

200 lines
11 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.

"""#3402: капча Циана приходит с HTTP 200 — это отказ площадки, а не «не разобрали».
Замер прода 06.09.2026: Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`,
44 КБ; `<title>Вы не робот?`, 16 КБ) и страницу ошибки (`<title>Ошибка - Циан`, 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 = (
"<!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>"
)
# Здоровая карточка в текущем (`.concat`) формате _cianConfig. Слово `captcha` в теле
# ЕСТЬ — в нормальной карточке Циана оно встречается 11 раз (антифрод-скрипты), и
# именно поэтому признаком отказа оно быть не может.
_CARD_HTML = (
"<html><head><title>Купить 1-комн. квартиру — ЦИАН</title></head><body>"
"<script>window.captchaSettings = {}; /* captcha */</script><script>"
"window._cianConfig['frontend-offer-card'] = "
"(window._cianConfig['frontend-offer-card'] || []).concat("
'[{"key":"defaultState","value":{"offerData":{"offer":{"cianId":327830237}}},'
'"priority":0}]);'
"</script></body></html>"
)
# Страница без состояния и без маркеров отказа — дрейф разметки, прежнее поведение.
_NO_STATE_HTML = "<html><head><title>Купить квартиру — ЦИАН</title></head><body></body></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