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
Два 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) нет.
276 lines
15 KiB
Python
276 lines
15 KiB
Python
"""#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)]
|