gendesign/tradein-mvp/backend/tests/test_3402_cian_captcha_http200.py
bot-backend 224f91e8fa
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m11s
fix(cian): капча видна в отчёте прогона и банит узел на curl-пути (#3402)
Два follow-up из ревью #3403.

1. Капча-волна была невидима в счётчиках. `_note_refusal` ключевался только по
   HTTP-статусу, а капча приходит с 200 (свой детект по <title>) или без статуса
   (сайдкар) → рос один `listings_failed_fetch`, `ban_kinds` оставался пустым, и
   волна отказа площадки читалась как дрейф нашей разметки. Теперь диагноз берётся
   сперва по ТИПУ исключения (`ban_kind_of_exception`), статус — фолбэк. Инвариант
   #3196 сохранён: 'unknown' по типу И None по статусу по-прежнему ничего не пишут.

   `ban_kind_of_exception` расширен с `AvitoBlockedError` до `ProxyBanError` — это
   ровно тот mixin, по которому generic-прокси-слой уже снимает узел с выдачи
   источнику. Для Авито поведение не меняется (AvitoBlockedError его наследует),
   Cian/DomClick перестают приезжать как 'unknown'.

2. curl-путь детектил капчу, но не банил: общий parse-путь лежит ЗА границей
   `with curl_proxy_url(...)`, и `CianBlockedError` поднимался уже после
   `mark_health(ok=True)` — узел, которому Циан показывает капчу, оставался в
   выдаче Циану (дефект #2700, только на HTTP 200). Проверка перенесена ВНУТРЬ
   блока, `finally` хелпера сам делает `mark_banned(source='cian')`.

Тесты по значению (оба красные на main): батч с капчей → ban_kinds == {platform: 1};
curl-путь + HTML капчи → mark_banned == [(1, 'cian')], mark_health(ok=True) нет.
2026-09-06 15:30:12 +05:00

276 lines
15 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
# Заглушки пула/конфига — те же, что у #2700 (403 на том же curl-пути): дублировать
# контракт ProxyProvider второй раз незачем.
from tests.test_2700_cian_detail_403_node import _FakeConfig, _session_returning, _SpyProvider
_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
async def test_captcha_is_counted_as_platform_refusal_not_as_parse_failure() -> None:
"""Капча попадает в `ban_kinds['platform']`, а не только в «не разобрали».
Красный на main: `_note_refusal` ключевался ИСКЛЮЧИТЕЛЬНО по HTTP-статусу
(`ban_kind_from_status`), а капча приходит с 200 (свой детект) либо без статуса
(сайдкар) → `ban_kinds` пуст, `listings_blocked == 0`, наверх уезжал один
`listings_failed_fetch`. Отчёт прогона в этом виде не отличает волну капчи от
дрейфа нашей разметки — то есть следующая волна снова была бы невидима.
"""
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(1), do_listings=True, do_houses=False, do_valuations=False
)
assert dict(result.ban_kinds) == {"platform": 1}
assert result.listings_blocked == 1
assert result.ban_kind == "platform" # то, что уедет в scrape_runs.ban_kind
# ── curl-путь: капча банит узел, а не только прерывает разбор ─────────────────
async def test_captcha_on_curl_path_bans_the_node_for_cian() -> None:
"""HTTP 200 + капча на own-session curl → `mark_banned(node × cian)`.
Красный на main: детект капчи стоит в общем parse-пути, ЗА границей
`with curl_proxy_url(...)`, — исключение поднималось уже после того, как хелпер
проставил узлу `mark_health(ok=True)`. Узел, которому Циан показывает капчу,
оставался в выдаче Циану (тот же дефект, что #2700 у 403).
Путь боевой: `pipeline.py` зовёт `fetch_detail(config=, proxy_provider=)` без
`browser_fetcher` — это detail-фаза cian city sweep.
"""
spy = _SpyProvider()
with patch.object(
cian_detail,
"build_curl_cffi_session",
return_value=_session_returning(200, _CAPTCHA_HTML),
):
with pytest.raises(CianBlockedError):
await cian_detail.fetch_detail(_URL, config=_FakeConfig(), proxy_provider=spy)
assert spy.mark_banned_calls == [(1, "cian")]
assert (1, True) not in spy.mark_health_calls, "узел с капчей записан здоровым"
assert spy.mark_health_calls == [(1, False)]
assert spy.release_calls == [1] # lease не течёт
async def test_error_page_on_curl_path_does_not_ban_the_node() -> None:
"""«Ошибка - Циан» на curl-пути — по-прежнему не бан (цикл наблюдения, #3402)."""
spy = _SpyProvider()
with patch.object(
cian_detail,
"build_curl_cffi_session",
return_value=_session_returning(200, _ERROR_PAGE_HTML),
):
result = await cian_detail.fetch_detail(_URL, config=_FakeConfig(), proxy_provider=spy)
assert result is None
assert spy.mark_banned_calls == []
assert spy.mark_health_calls == [(1, True)]