fix(cian): капча видна в отчёте прогона и банит узел на curl-пути (#3402)
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) нет.
This commit is contained in:
bot-backend 2026-09-06 15:30:12 +05:00
parent 3d154f4ec0
commit 224f91e8fa
4 changed files with 144 additions and 16 deletions

View file

@ -38,6 +38,8 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
from scraper_kit.orchestration.pipeline import ban_kind_of_exception
from scraper_kit.orchestration.runs import BAN_KIND_UNKNOWN
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
from scraper_kit.providers.cian.valuation import estimate_via_cian_valuation
from scraper_kit.proxy_errors import caused_by_no_proxy
@ -96,18 +98,32 @@ class CianBackfillResult:
return _dominant_ban_kind(self.ban_kinds)
def _note_refusal(result: CianBackfillResult, status: int | None) -> str | None:
"""Записать отказ detail-фетча, если его природа диагностируема по HTTP-статусу (#3196).
def _note_refusal(
result: CianBackfillResult, status: int | None, exc: BaseException | None = None
) -> str | None:
"""Записать отказ detail-фетча, если его природа установлена — типом или статусом.
Инвариант: непустой `ban_kinds` мы видели ответ с 403/429/5xx. `ban_kind_from_status`
отвечает диагнозом только на такие статусы; на всё прочее (в т.ч. статуса нет сайдкар
не дошёл до навигации, или это HTTP 200 с промахом нашего парсера дрейф разметки, а
не отказ площадки) возвращает None, и тогда эта функция НЕ инкрементит ни
`listings_blocked`, ни `ban_kinds` только возвращает None вызывающему. Раньше
недиагностируемые случаи писались как 'unknown' и это в scrape_runs.mark_banned
(scheduler.py) превращало наши собственные сбои в фиктивный бан площадки.
Инвариант (#3196) прежний: непустой `ban_kinds` ⟺ отказ был ПОКАЗАН, а не назначен.
Установить его можно двумя способами, и статуса одного мало:
* ТИП исключения (#3402 follow-up) — `CianBlockedError` (капча Циана, отказ
сайдкара `SidecarBanPageError`, WAF-403) наследует `ProxyBanError`, то есть по
построению означает «площадка себя показала»: `ban_kind_of_exception` даёт
'platform'. Капча приезжает с HTTP 200 (свой детект по <title>) или вообще без
статуса (сайдкар не дошёл до навигации), поэтому по статусу она диагностировалась
как «не разобрали»: рос только `listings_failed_fetch`, `ban_kinds` оставался
пустым и следующая капча-волна снова выглядела бы дрейфом нашей разметки;
* HTTP-статус ответа (403/429/5xx) прежний путь для всего остального.
Всё, что не установлено ни тем, ни другим ('unknown' по типу И None по статусу), НЕ
инкрементит ни `listings_blocked`, ни `ban_kinds`: недиагностируемые случаи, записанные
как 'unknown', в scrape_runs.mark_banned (scheduler.py) превращали наши собственные
сбои в фиктивный бан площадки (#2764).
"""
kind = ban_kind_from_status(status)
kind = ban_kind_of_exception(exc) if exc is not None else BAN_KIND_UNKNOWN
if kind == BAN_KIND_UNKNOWN:
# Тип ничего не доказал — спрашиваем статус (прежнее поведение).
kind = ban_kind_from_status(status)
if kind is None:
return None
result.listings_blocked += 1
@ -248,7 +264,11 @@ async def backfill_cian_history(
result.listings_succeeded,
)
break
kind = _note_refusal(result, bf.last_response_status)
# exc, а не только статус: капча Циана — подтверждённый отказ
# площадки (CianBlockedError), но приходит с HTTP 200/без статуса
# (#3402 follow-up). Без типа волна капчи писалась в счётчик
# «не разобрали» и прогон отдавал пустой ban_kinds.
kind = _note_refusal(result, bf.last_response_status, exc)
logger.warning(
"cian_detail fetch failed for listing_id=%s url=%s: %s "
"(http=%s ban_kind=%s)",

View file

@ -45,6 +45,10 @@ 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/"
# Фрагмент прод-страницы капчи: заголовок + то самое слово в теле.
@ -198,3 +202,75 @@ async def test_batch_rotates_after_captcha_and_enriches_the_next_listing() -> No
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)]

View file

@ -78,7 +78,7 @@ from scraper_kit.providers.yandex.serp import (
ROOM_PATH,
YandexRealtyScraper,
)
from scraper_kit.proxy_errors import NoProxyAvailableError, caused_by_no_proxy
from scraper_kit.proxy_errors import NoProxyAvailableError, ProxyBanError, caused_by_no_proxy
if TYPE_CHECKING:
from sqlalchemy.orm import Session
@ -200,7 +200,8 @@ def ban_kind_of_exception(exc: BaseException) -> str:
AvitoSidecarUnavailableError поднимается ровно там, где отказ породила наша
инфраструктура (браузерный сайдкар не отдал страницу) 'infra'.
AvitoBlockedError (firewall/403/captcha/content-block) и честный 429 площадки
ProxyBanError (mixin блок-исключений площадок: AvitoBlockedError, CianBlockedError,
DomClickBlockedError firewall/403/капча/content-block) и честный 429 площадки
единственные типы, которыми площадка себя ПОКАЗАЛА 'platform'.
Всё остальное 'unknown' (#2764). Раньше здесь стоял `else PLATFORM`, то есть
@ -217,7 +218,14 @@ def ban_kind_of_exception(exc: BaseException) -> str:
# см. proxy_errors.py: «это НАША инфраструктура (нет живого прокси), не
# внешний блок». Тот же диагноз, что и у отказа сайдкара.
return BAN_KIND_INFRA
if isinstance(exc, AvitoBlockedError | AvitoRateLimitedError):
if isinstance(exc, ProxyBanError | AvitoRateLimitedError):
# ProxyBanError вместо AvitoBlockedError (#3402 follow-up): это ровно тот
# mixin, по которому generic-прокси-слой снимает узел с выдачи источнику
# (providers/_proxy.py: `banned = isinstance(exc, ProxyBanError)`). Тип уже
# означает «площадка себя показала» — AvitoBlockedError его и наследует, так
# что для Авито ничего не меняется, а CianBlockedError/DomClickBlockedError
# перестают приезжать сюда как 'unknown'. Капча Циана (#3402) приходит с
# HTTP 200, и по статусу её диагностировать нечем — только по типу.
return BAN_KIND_PLATFORM
return BAN_KIND_UNKNOWN

View file

@ -138,6 +138,18 @@ def _page_title(html: str) -> str:
return " ".join(match.group(1).split()).lower().replace("", "-").replace("", "-")
def _is_captcha_title(title: str) -> bool:
"""Заголовок — капча Циана? Один предикат на оба места детекта.
Детект живёт в двух точках намеренно (#3402 follow-up): общий parse-путь ниже —
ЗА границей `with curl_proxy_url(...)`, и поднятый там `CianBlockedError` до пула
уже не доходит (узел успел получить `mark_health(ok=True)` и остаётся в выдаче
Циану ровно дефект #2700, только на HTTP 200). Поэтому curl-путь спрашивает про
капчу ВНУТРИ блока, а не после него.
"""
return any(marker in title for marker in _CAPTCHA_TITLES)
async def fetch_detail(
offer_url: str,
*,
@ -169,7 +181,9 @@ async def fetch_detail(
узел с выдачи Циану через `curl_proxy_url`. Тем же исключением приезжает
КАПЧА Циана (#3402): её сайдкар отдаёт как `ban_page` + 403, а если образ
сайдкара старее она распознаётся здесь, по <title>, уже после HTTP 200
(`report_platform_ban` на живом lease + raise вместо тихого None). Страница
(`report_platform_ban` на живом lease + raise вместо тихого None). На
curl-путях та же капча поднимается ВНУТРИ `curl_proxy_url` иначе бан узла
не случался бы вовсе (#3402 follow-up). Страница
«Ошибка - Циан» баном НЕ считается WARNING и прежний None, см.
`_ERROR_PAGE_TITLES`.
NoProxyAvailableError: пул прокси пуст (#2616) — пробрасывается со ВСЕХ путей, а
@ -236,6 +250,16 @@ async def fetch_detail(
logger.warning("Cian detail fetch %s → HTTP %d", offer_url, resp.status_code)
return None
html = resp.text
# Капча приходит с HTTP 200, то есть мимо `_raise_if_blocked`. Спрашиваем
# ЗДЕСЬ, пока lease жив: общий parse-путь ниже поднимет то же исключение
# уже после выхода из `with curl_proxy_url`, где узлу проставлен
# mark_health(ok=True) и бана пары «узел × cian» не будет (#3402).
title = _page_title(html)
if _is_captcha_title(title):
logger.warning("Cian detail %s: капча Циана (title=%r)", offer_url, title)
raise CianBlockedError(
f"Cian detail: капча Циана (title={title!r}) для {offer_url}"
)
finally:
await own_session.close()
@ -251,7 +275,7 @@ async def fetch_detail(
# (ban_kind_from_status) нечем — только по телу. Три рода: капча (бан+ротация),
# страница ошибки (только лог, природа не доказана), дрейф разметки (как было).
title = _page_title(html)
if any(marker in title for marker in _CAPTCHA_TITLES):
if _is_captcha_title(title):
if browser_fetcher is not None:
# Детект НАШ, сайдкар его не видел (старый образ отдал 200) — рапорт
# обязателен здесь, иначе тот же узел доработает батч до конца. lease