diff --git a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py
index e91512c7..68f606d4 100644
--- a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py
+++ b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py
@@ -490,7 +490,7 @@ def _sidecar_ban_page_error(upstream_status: int | None = 401) -> SidecarBanPage
@pytest.mark.asyncio
async def test_fetch_detail_does_not_report_ban_again_on_sidecar_ban_page() -> None:
- """Sidecar-бан рапортует сам фетчер (`_report_platform_ban`, #3288) — здесь уже нет.
+ """Sidecar-бан рапортует сам фетчер (`report_platform_ban`, #3288) — здесь уже нет.
Повтор отсюда приходит ПОСЛЕ возможной ротации lease по fail-streak и банил бы
свежий узел. Ветка `parse_detail_html` ниже — другой случай: тот детект наш,
diff --git a/tradein-mvp/backend/tests/test_3283_avito_sidecar_ban_is_platform_ban.py b/tradein-mvp/backend/tests/test_3283_avito_sidecar_ban_is_platform_ban.py
index 13c9971f..47016293 100644
--- a/tradein-mvp/backend/tests/test_3283_avito_sidecar_ban_is_platform_ban.py
+++ b/tradein-mvp/backend/tests/test_3283_avito_sidecar_ban_is_platform_ban.py
@@ -99,7 +99,7 @@ async def test_fetch_detail_sidecar_ban_page_raises_platform_block_not_infra(
async def test_fetch_detail_sidecar_ban_page_does_not_report_ban_again(
sidecar_status: int,
) -> None:
- """Бан рапортует ФЕТЧЕР (`_report_platform_ban`), провайдер — уже нет (#3288).
+ """Бан рапортует ФЕТЧЕР (`report_platform_ban`), провайдер — уже нет (#3288).
До #3288 здесь стоял `assert_called_once()`, и это было верно, пока фетчер о
бане не знал. Теперь рапорт идёт из `_post_fetch` — РАНЬШЕ и по правильному
diff --git a/tradein-mvp/backend/tests/test_3288_avito_ban_per_source.py b/tradein-mvp/backend/tests/test_3288_avito_ban_per_source.py
index 316c1d94..34d194a3 100644
--- a/tradein-mvp/backend/tests/test_3288_avito_ban_per_source.py
+++ b/tradein-mvp/backend/tests/test_3288_avito_ban_per_source.py
@@ -370,7 +370,7 @@ async def test_empty_pool_during_post_ban_rotation_keeps_platform_diagnosis() ->
"""Ротация — best-effort: её `NoProxyAvailableError` не должна съесть бан.
Второго узла в пуле нет, поэтому `_acquire_lease()` внутри ротации падает.
- Фальсификация: убери `except NoProxyAvailableError` в `_report_platform_ban` —
+ Фальсификация: убери `except NoProxyAvailableError` в `report_platform_ban` —
наверх уедет она вместо `SidecarBanPageError`, провайдер завернёт её в
AvitoSidecarUnavailableError, и подтверждённый отказ площадки попадёт в
`scrape_runs.ban_kind` как 'infra' (ровно та подмена, что чинил #3283).
diff --git a/tradein-mvp/backend/tests/test_3402_cian_captcha_http200.py b/tradein-mvp/backend/tests/test_3402_cian_captcha_http200.py
new file mode 100644
index 00000000..86df109b
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_3402_cian_captcha_http200.py
@@ -0,0 +1,167 @@
+"""#3402: капча Циана приходит с HTTP 200 — это отказ площадки, а не «не разобрали».
+
+Замер прода 06.09.2026: Циан отдаёт капчу (`
Captcha - база объявлений ЦИАН`,
+44 КБ) и страницу ошибки (`Ошибка - Циан`, 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.
+
+Этот файл покрывает ВТОРОЙ слой (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 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 = (
+ ""
+ "Captcha - база объявлений ЦИАН"
+ ""
+ ""
+)
+_ERROR_PAGE_HTML = (
+ "Ошибка - Циан"
+ "Что-то пошло не так
"
+)
+# Здоровая карточка в текущем (`.concat`) формате _cianConfig. Слово `captcha` в теле
+# ЕСТЬ — в нормальной карточке Циана оно встречается 11 раз (антифрод-скрипты), и
+# именно поэтому признаком отказа оно быть не может.
+_CARD_HTML = (
+ "Купить 1-комн. квартиру — ЦИАН"
+ ""
+)
+# Страница без состояния и без маркеров отказа — дрейф разметки, прежнее поведение.
+_NO_STATE_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, _ERROR_PAGE_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_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
diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py
index 53408256..7e6a48ee 100644
--- a/tradein-mvp/browser/server.py
+++ b/tradein-mvp/browser/server.py
@@ -1963,6 +1963,40 @@ def _is_domclick_refusal(html: str) -> bool:
return any(marker in lower for marker in _DOMCLICK_REFUSAL_MARKERS)
+# ── Отказ Циана с кодом 200 (#3402, замер прода 06.09.2026) ────────────────────
+# Циан отдаёт капчу и страницу ошибки с HTTP 200, поэтому _REFUSAL_STATUSES их не
+# видит, а маркеров Авито (_CHALLENGE_MARKERS/_BAN_MARKERS) в них нет. Отказ уезжал
+# наверх как валидный HTML: парсер не находил defaultState, прогон
+# cian_detail_backfill держал ОДНУ аренду на весь батч и сжигал через капча-узел
+# 210-400 карточек подряд (прогоны 6200: 0/210; 6123/6091/6052/6032/6010/5981: 0/400,
+# при 161/162 через здоровый узел на прогоне 13).
+#
+# Опознаём по , а не по слову "captcha" в теле: оно встречается 11 раз и в
+# НОРМАЛЬНОЙ карточке (антифрод-скрипты), 17 — на странице капчи; как признак оно
+# неотличимо. Тире в заголовке нормализуется (— и – → -): вёрстка Циана печатает
+# его по-разному, а различать заголовки по виду дефиса — заведомо хрупко.
+_CIAN_REFUSAL_TITLES: tuple[str, ...] = (
+ "captcha - база объявлений циан",
+ "ошибка - циан",
+)
+
+_TITLE_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL)
+
+
+def _page_title(html: str) -> str:
+ """Текст — схлопнутые пробелы, нижний регистр, нормализованное тире."""
+ match = _TITLE_RE.search(html)
+ if match is None:
+ return ""
+ return " ".join(match.group(1).split()).lower().replace("—", "-").replace("–", "-")
+
+
+def _is_cian_refusal(html: str) -> bool:
+ """True, если HTML — капча или страница ошибки Циана (см. _CIAN_REFUSAL_TITLES)."""
+ title = _page_title(html)
+ return any(marker in title for marker in _CIAN_REFUSAL_TITLES)
+
+
# Маркеры исключения playwright «страница прямо сейчас перезагружается». Ловим по
# тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит
# уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для
@@ -2335,6 +2369,11 @@ async def _fetch_once(
raise BanPageDetectedError(
f"tradein-browser[{provider}]: статический отказ площадки url={url!r}"
)
+ if provider == "cian" and _is_cian_refusal(text):
+ raise BanPageDetectedError(
+ f"tradein-browser[{provider}]: капча/страница ошибки Циана "
+ f"(title={_page_title(text)!r}) url={url!r}"
+ )
logger.info(
"tradein-browser[%s]: %s → HTTP %s, тело %d Б url=%r",
provider,
@@ -2370,6 +2409,14 @@ async def _fetch_once(
raise BanPageDetectedError(
f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}"
)
+ # Капча/страница ошибки Циана (#3402): приходит с HTTP 200, поэтому ни
+ # _REFUSAL_STATUSES, ни маркеры Авито её не ловят — распознаём по
+ # и отдаём наверх тем же путём (403 + ban_page), что отказ DomClick.
+ if provider == "cian" and _is_cian_refusal(html):
+ raise BanPageDetectedError(
+ f"tradein-browser[{provider}]: капча/страница ошибки Циана "
+ f"(title={_page_title(html)!r}) url={url!r}"
+ )
if provider == "domclick":
# DomClick — своя ветка (#3196): нет отдельного маркера самого
# рукопожатия (см. комментарий у _DOMCLICK_SUCCESS_MARKER), поэтому
diff --git a/tradein-mvp/browser/test_server_cian_captcha.py b/tradein-mvp/browser/test_server_cian_captcha.py
new file mode 100644
index 00000000..7fc70efa
--- /dev/null
+++ b/tradein-mvp/browser/test_server_cian_captcha.py
@@ -0,0 +1,233 @@
+"""test_server_cian_captcha.py — капча Циана приходит с HTTP 200 (#3402).
+
+Замер прода 06.09.2026: Циан отдаёт капчу (`Captcha - база объявлений ЦИАН`,
+44 КБ) и страницу ошибки (`Ошибка - Циан`, 374 КБ) с кодом **200**, поэтому
+_REFUSAL_STATUSES {403,429} их не видит, а маркеры Авито (_CHALLENGE_MARKERS /
+_BAN_MARKERS) в них не встречаются. HTML уезжал клиенту как успех, парсер не находил
+defaultState, и cian_detail_backfill держал ОДНУ аренду на весь батч, сжигая через
+капча-узел 210-400 карточек подряд (прогоны 6200: 0/210; 6123/6091/6052/6032/6010/5981:
+0/400 — против 161/162 через здоровый узел на прогоне 13).
+
+Слово `captcha` как признак не годится: в НОРМАЛЬНОЙ карточке Циана оно встречается 11
+раз (антифрод-скрипты), на странице капчи — 17. Отсюда детект по .
+
+camoufox НЕ запускается: _browsers[provider] — поддельный browser/page (зеркалит
+test_server_http_status.py). wait_for_timeout на фейковой page — no-op.
+
+Запуск (из tradein-mvp/browser/)::
+
+ python -m pytest test_server_cian_captcha.py -q
+"""
+
+from __future__ import annotations
+
+import asyncio
+import importlib.util
+import json
+from pathlib import Path
+from typing import Any
+
+import pytest
+from aiohttp.test_utils import make_mocked_request
+
+# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
+_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
+_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
+assert _spec is not None and _spec.loader is not None
+server = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(server)
+
+
+@pytest.fixture(autouse=True)
+def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Чистое per-provider состояние на каждый тест (зеркалит соседние тесты)."""
+ monkeypatch.setattr(server, "_browsers", {})
+ monkeypatch.setattr(server, "_browser_cms", {})
+ monkeypatch.setattr(server, "_page_counters", {})
+ monkeypatch.setattr(server, "_locks", {})
+ monkeypatch.setattr(server, "_retry_tasks", {})
+ monkeypatch.setattr(server, "_last_goto_at", {})
+ monkeypatch.setattr(server, "_last_response_status", {})
+ monkeypatch.setattr(server, "_launched_proxy", {})
+ monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
+ monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
+
+
+# Обёртка капчи — фрагмент прод-страницы: заголовок + то самое слово в теле.
+_CAPTCHA_HTML = (
+ ""
+ ""
+ "Captcha - база объявлений ЦИАН"
+ ""
+ ""
+ ""
+)
+_ERROR_PAGE_HTML = (
+ "Ошибка - Циан"
+ "Что-то пошло не так
"
+)
+# Нормальная карточка: слово captcha в теле есть (антифрод), состояние — на месте.
+_CARD_HTML = (
+ "Купить 1-комн. квартиру — ЦИАН"
+ ""
+ ""
+ ""
+)
+
+
+class _Response:
+ """Поддельный playwright Response — интересует только .status."""
+
+ def __init__(self, status: int) -> None:
+ self.status = status
+
+
+class _Page:
+ """Поддельная page: goto отдаёт Response(200), content() — заданный HTML."""
+
+ def __init__(self, html: str, status: int = 200) -> None:
+ self._html = html
+ self._status = status
+ self.goto_urls: list[str] = []
+ self.closed = 0
+
+ async def route(self, pattern: str, handler: Any) -> None:
+ return None
+
+ async def goto(self, url: str, **kwargs: Any) -> _Response:
+ self.goto_urls.append(url)
+ return _Response(self._status)
+
+ async def wait_for_timeout(self, ms: int) -> None:
+ return None
+
+ async def content(self) -> str:
+ return self._html
+
+ async def close(self) -> None:
+ self.closed += 1
+
+
+class _Browser:
+ def __init__(self, page: _Page) -> None:
+ self._page = page
+
+ async def new_page(self) -> _Page:
+ return self._page
+
+
+def _install(monkeypatch: pytest.MonkeyPatch, page: _Page, provider: str = "cian") -> None:
+ server._browsers[provider] = _Browser(page)
+ monkeypatch.setattr(
+ server, "_RECYCLE_PAGES_BY_PROVIDER", dict.fromkeys(server.PROVIDERS, 10_000)
+ )
+ monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0)
+ monkeypatch.setattr(server, "_MIN_PAGE_INTERVAL_BY_PROVIDER", {})
+ monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0)
+
+
+def _json_body(response: Any) -> dict[str, Any]:
+ return json.loads(response.body.decode())
+
+
+async def _coro(value: Any) -> Any:
+ return value
+
+
+def _make_request(body: dict[str, Any]) -> Any:
+ request = make_mocked_request("POST", "/fetch")
+ request.json = lambda: _coro(body) # type: ignore[method-assign]
+ return request
+
+
+# ── Детектор: что считается отказом, а что нет ────────────────────────────────
+
+
+@pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ERROR_PAGE_HTML])
+def test_cian_refusal_pages_are_recognised(html: str) -> None:
+ assert server._is_cian_refusal(html) is True
+
+
+def test_normal_card_with_the_word_captcha_is_not_a_refusal() -> None:
+ """Слово `captcha` в теле нормальной карточки признаком отказа НЕ является."""
+ assert "captcha" in _CARD_HTML.lower()
+ assert server._is_cian_refusal(_CARD_HTML) is False
+
+
+def test_em_dash_in_title_is_normalised() -> None:
+ """Вёрстка печатает тире по-разному — детект не должен зависеть от его вида."""
+ assert server._is_cian_refusal("Ошибка — Циан") is True
+
+
+# ── /fetch: 403 + ban_page на капче, отданной с HTTP 200 ──────────────────────
+
+
+def test_fetch_once_raises_on_cian_captcha(monkeypatch: pytest.MonkeyPatch) -> None:
+ """HTTP 200 + капча → BanPageDetectedError, а не «валидный HTML» наверх."""
+ page = _Page(_CAPTCHA_HTML)
+ _install(monkeypatch, page)
+
+ with pytest.raises(server.BanPageDetectedError):
+ asyncio.run(server._fetch_once("cian", "https://ekb.cian.ru/sale/flat/1/"))
+
+
+def test_fetch_once_passes_normal_card_through(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Здоровая карточка (с тем же словом в теле) отдаётся как раньше."""
+ page = _Page(_CARD_HTML)
+ _install(monkeypatch, page)
+
+ html = asyncio.run(server._fetch_once("cian", "https://ekb.cian.ru/sale/flat/1/"))
+
+ assert html == _CARD_HTML
+
+
+def test_fetch_handler_returns_403_with_ban_page_on_captcha(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Тот же путь, что #3379/#3288 п.4: 403 + ban_page + ЧЕСТНЫЙ upstream-статус 200.
+
+ Статус передаём как есть: апстрим ответил 200, и врать про 403 площадки нельзя —
+ клиент опознаёт бан по признаку `ban_page`, а не по коду (образы сайдкара и
+ бэкенда деплоятся врозь).
+ """
+ monkeypatch.setattr(server, "IS_PROD", False)
+ page = _Page(_CAPTCHA_HTML)
+ _install(monkeypatch, page)
+
+ async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
+ return True
+
+ monkeypatch.setattr(server, "_ensure_browser", _ensure)
+
+ response = asyncio.run(
+ server.fetch_handler(
+ _make_request({"url": "https://ekb.cian.ru/sale/flat/1/", "source": "cian"})
+ )
+ )
+
+ body = _json_body(response)
+ assert response.status == 403
+ assert body["ban_page"] is True
+ assert body["status"] == 200
+ assert "BanPageDetectedError" in body["error"]
+
+
+def test_fetch_handler_does_not_ban_other_providers_on_the_same_html(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Детект per-provider: та же страница у Авито проходит прежним путём (нет ложных банов)."""
+ monkeypatch.setattr(server, "IS_PROD", False)
+ page = _Page(_CAPTCHA_HTML)
+ _install(monkeypatch, page, provider="avito")
+
+ async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
+ return True
+
+ monkeypatch.setattr(server, "_ensure_browser", _ensure)
+
+ response = asyncio.run(
+ server.fetch_handler(_make_request({"url": "https://www.avito.ru/x", "source": "avito"}))
+ )
+
+ assert response.status == 200
+ assert _json_body(response)["html"] == _CAPTCHA_HTML
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py
index ae4b653d..a2587ab2 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py
@@ -823,7 +823,7 @@ class BrowserFetcher:
шаг — так отказ ПЛОЩАДКИ (подтверждённая бан-страница) не копит глобальный
счётчик здоровья узла: он исправен, его отбил конкретный источник, и его
судьбу решает `mark_banned(source=...)` по паре «узел×источник»
- (см. `_report_platform_ban`). До #3288 узел с тремя бан-страницами Авито
+ (см. `report_platform_ban`). До #3288 узел с тремя бан-страницами Авито
уходил из выдачи ВСЕМ источникам — прод-замер 31.08: yandex/cian/domclick
получали ProxyPoolExhaustedError при banned_for_source=0 и трёх живых узлах;
- ok=False копит `_lease_fail_streak`; после `_LEASE_ROTATE_AFTER_FAILS`
@@ -883,8 +883,13 @@ class BrowserFetcher:
)
self._lease = self._acquire_lease()
- def _report_platform_ban(self, reason: str) -> None:
- """Исход /fetch, который сайдкар опознал как бан-страницу площадки (#3288).
+ def report_platform_ban(self, reason: str) -> None:
+ """Исход /fetch, который опознан как бан-страница площадки (#3288).
+
+ Публичный метод (#3402): тем же путём обязан идти отказ, распознанный не
+ сайдкаром, а провайдером — по телу ответа с HTTP 200 (капча Циана). Разница с
+ `report_ban` существенна: тот только пишет бан пары «узел×источник», а сменить
+ сожжённую аренду ВНУТРИ батча позволяет только fail-streak ниже.
Отличается от обычного провала РОВНО одним: узел не получает `mark_health(False)`.
Бан — приговор паре «узел×источник» (`mark_banned`, строка в
@@ -982,9 +987,7 @@ class BrowserFetcher:
# httpx.HTTPStatusError, и общий `except Exception` ниже забирал её себе,
# отправляя подтверждённый отказ ПЛОЩАДКИ в глобальный счётчик здоровья узла.
self.last_response_status = None
- self._report_platform_ban(
- f"sidecar ban page (upstream={exc.upstream_status}) for {url}"
- )
+ self.report_platform_ban(f"sidecar ban page (upstream={exc.upstream_status}) for {url}")
raise
except Exception:
# NoProxyAvailableError (пустой пул) сюда НЕ приходит: он поднимается в
@@ -1041,7 +1044,7 @@ class BrowserFetcher:
except SidecarBanPageError as exc:
# Ветка ДО общего except по той же причине, что в _post_fetch (#3288):
# бан-страница — отказ площадки, а не отказ узла.
- self._report_platform_ban(
+ self.report_platform_ban(
f"sidecar ban page on fetch-json (upstream={exc.upstream_status}) for {url}"
)
raise
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py
index a8797516..27912d28 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py
@@ -585,7 +585,7 @@ async def fetch_detail(
# DomClick (#3239/#3283, providers/domclick/detail.py).
#
# report_ban ЗДЕСЬ НЕТ намеренно (#3288): бан рапортует сам fetcher, в
- # _report_platform_ban, — раньше и по ПРАВИЛЬНОМУ lease. К моменту этого
+ # report_platform_ban, — раньше и по ПРАВИЛЬНОМУ lease. К моменту этого
# кадра fetcher мог уже сротироваться на свежий узел по fail-streak, и
# повторный рапорт отсюда банил бы того, кто к площадке не ходил.
raise AvitoBlockedError(
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 c67c54f0..df29ffb6 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
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any
from sqlalchemy import text
from sqlalchemy.orm import Session
+from scraper_kit.browser_fetcher import SidecarBanPageError
from scraper_kit.ceiling_height import plausible_ceiling_m
from scraper_kit.cian_exceptions import CianBlockedError
from scraper_kit.cian_state_parser import extract_all_states, extract_state
@@ -99,6 +100,33 @@ def _raise_if_blocked(offer_url: str, status_code: int) -> None:
raise CianBlockedError(f"Cian detail {offer_url} → HTTP 403 (WAF-блок узла)")
+# ── Капча/страница ошибки Циана с HTTP 200 (#3402) ──────────────────────────────
+# Дубль детектора сайдкара (browser/server.py::_is_cian_refusal) — намеренный:
+# образы backend и browser деплоятся раздельно и расходятся на часы, а в этот
+# промежуток отказ площадки приходит сюда ровно так же, как до правки — 200 + HTML
+# капчи. Опознаём по , а не по слову "captcha": оно есть и в нормальной
+# карточке (11 вхождений против 17 на капче), то есть как признак не различает.
+_REFUSAL_TITLES: tuple[str, ...] = (
+ "captcha - база объявлений циан",
+ "ошибка - циан",
+)
+
+_TITLE_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL)
+
+
+def _refusal_title(html: str) -> str | None:
+ """Заголовок страницы, если это капча/ошибка Циана; иначе None.
+
+ Тире нормализуется (— и – → -): вёрстка печатает его по-разному, а различать
+ заголовки по виду дефиса — заведомо хрупко.
+ """
+ match = _TITLE_RE.search(html)
+ if match is None:
+ return None
+ title = " ".join(match.group(1).split()).lower().replace("—", "-").replace("–", "-")
+ return title if any(marker in title for marker in _REFUSAL_TITLES) else None
+
+
async def fetch_detail(
offer_url: str,
*,
@@ -127,7 +155,10 @@ async def fetch_detail(
CianBlockedError: HTTP 403 на curl-путях — WAF Циана отбил узел, с которого мы
пришли (#2700). Оба вызывающих в orchestration/pipeline.py уже считают
исключение в `errors_count`, а на own-session-пути оно дополнительно снимает
- узел с выдачи Циану через `curl_proxy_url`.
+ узел с выдачи Циану через `curl_proxy_url`. Тем же исключением приезжает
+ капча/страница ошибки Циана (#3402): её сайдкар отдаёт как `ban_page` + 403,
+ а если образ сайдкара старее — она распознаётся здесь, по , уже после
+ HTTP 200 (`report_platform_ban` на живом lease + raise вместо тихого None).
NoProxyAvailableError: пул прокси пуст (#2616) — пробрасывается со ВСЕХ путей, а
не гасится в None: запрос не уходил, и следующий вызов упрётся в то же самое,
поэтому решение «оборвать батч» принимает вызывающий (#3197).
@@ -136,6 +167,18 @@ async def fetch_detail(
# Browser path: get fully JS-rendered HTML; same parse path follows.
try:
html = await browser_fetcher.fetch(offer_url)
+ except SidecarBanPageError as exc:
+ # #3402: сайдкар опознал отказ площадки по маркерам тела (капча/страница
+ # ошибки Циана, `ban_page` + 403). ПОРЯДОК ВЕТОК ВАЖЕН — SidecarBanPageError
+ # подкласс httpx.HTTPStatusError, и общий `except` ниже увёл бы
+ # подтверждённый отказ Циана в `return None`, то есть в «не смогли
+ # разобрать». Зеркалит avito/detail.py и domclick/detail.py (#3283/#3239).
+ #
+ # report_ban ЗДЕСЬ НЕТ намеренно (#3288): бан рапортует сам fetcher, в
+ # report_platform_ban, — раньше и по ПРАВИЛЬНОМУ lease.
+ raise CianBlockedError(
+ f"Cian detail: сайдкар опознал отказ площадки для {offer_url}: {exc}"
+ ) from exc
except Exception as exc:
# «Пул пуст» — не отказ страницы: запрос не уходил вовсе, и вызывающий обязан
# оборвать батч (#3197). Проглоченный здесь, он приезжал наверх как None, то
@@ -189,6 +232,28 @@ async def fetch_detail(
# NOTE: detail pages use 'defaultState', SERP uses 'initialState'
offer_state = extract_state(html, mfe="frontend-offer-card", key="defaultState")
if offer_state is None:
+ # #3402: страницы БЕЗ состояния бывают двух разных родов, и до этой правки
+ # обе печатались как «extraction failed» — то есть отказ площадки читался как
+ # дрейф нашей разметки. Капча/страница ошибки Циана приходит с HTTP 200,
+ # диагностировать её по статусу (ban_kind_from_status) нечем — только по телу.
+ title = _refusal_title(html)
+ if title is not None:
+ if browser_fetcher is not None:
+ # Детект НАШ, сайдкар его не видел (старый образ отдал 200) — рапорт
+ # обязателен здесь, иначе тот же узел доработает батч до конца. lease
+ # ещё жив: caller держит `async with BrowserFetcher(...)` снаружи.
+ # report_platform_ban (а не report_ban): бан пары «узел×cian» ПЛЮС
+ # fail-streak, по которому аренда меняется ВНУТРИ батча — ровно то же,
+ # что делает сайдкар-путь через SidecarBanPageError.
+ browser_fetcher.report_platform_ban(
+ f"cian detail: капча/страница ошибки Циана для {offer_url}"
+ )
+ logger.warning(
+ "Cian detail %s: капча/страница ошибки Циана (title=%r)", offer_url, title
+ )
+ raise CianBlockedError(
+ f"Cian detail: капча/страница ошибки Циана (title={title!r}) для {offer_url}"
+ )
logger.warning("Cian detail %s: defaultState extraction failed", offer_url)
return None
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py
index f1fe6f5c..66912922 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py
@@ -639,7 +639,7 @@ async def fetch_detail(
# вместо 'platform' и ротация IP не запустилась бы вовсе.
#
# report_ban ЗДЕСЬ НЕТ намеренно (#3288): его делает сам fetcher в
- # _report_platform_ban — раньше и по ПРАВИЛЬНОМУ lease (после ротации по
+ # report_platform_ban — раньше и по ПРАВИЛЬНОМУ lease (после ротации по
# fail-streak этот кадр забанил бы уже СЛЕДУЮЩИЙ узел). Ветка
# parse_detail_html ниже — другой случай: там детект НАШ, fetcher его не
# видит, и report_ban остаётся.