diff --git a/tradein-mvp/backend/app/tasks/cian_history_backfill.py b/tradein-mvp/backend/app/tasks/cian_history_backfill.py
index 41c8f3f0..9b1299ec 100644
--- a/tradein-mvp/backend/app/tasks/cian_history_backfill.py
+++ b/tradein-mvp/backend/app/tasks/cian_history_backfill.py
@@ -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 (свой детект по
) или вообще без
+ статуса (сайдкар не дошёл до навигации), поэтому по статусу она диагностировалась
+ как «не разобрали»: рос только `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)",
diff --git a/tradein-mvp/backend/tests/test_3402_cian_captcha_http200.py b/tradein-mvp/backend/tests/test_3402_cian_captcha_http200.py
index 24a71a1b..78cd03b9 100644
--- a/tradein-mvp/backend/tests/test_3402_cian_captcha_http200.py
+++ b/tradein-mvp/backend/tests/test_3402_cian_captcha_http200.py
@@ -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)]
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py
index 59bafba9..dcee421d 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py
@@ -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
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py
index 37363bf5..bd34a25d 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py
@@ -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, а если образ
сайдкара старее — она распознаётся здесь, по , уже после 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