From ff979d4afdaf4aa99a3abe61f594ab8f4045028a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 6 Sep 2026 14:10:57 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(tradein/cian):=20=D0=BA=D0=B0=D0=BF?= =?UTF-8?q?=D1=87=D0=B0=20=D0=A6=D0=B8=D0=B0=D0=BD=D0=B0=20=D1=81=20HTTP?= =?UTF-8?q?=20200=20=E2=80=94=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D0=BA=D0=B8=20=D0=B2=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BE=D0=B8=D1=85=20=D1=81=D0=BB=D0=BE=D1=8F=D1=85=20(#3?= =?UTF-8?q?402)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Циан отдаёт капчу (`Captcha - база объявлений ЦИАН`, 44 КБ) и страницу ошибки (`<title>Ошибка - Циан`, 374 КБ) с кодом 200. Детектор сайдкара их не знал (_REFUSAL_STATUSES {403,429} + маркеры Авито/Домклика), HTML уезжал клиенту как успех, extract_state возвращал None и провайдер печатал «defaultState extraction failed» — отказ ПЛОЩАДКИ читался как дрейф НАШЕЙ разметки. Аренда при этом не менялась: fetch() уже отрапортовал mark_health(ok=True), fail-streak обнулялся, и один капча-узел сжигал батч целиком (6200: 0/210; 6123/6091/6052/6032/6010/5981: 0/400 — против 161/162 через здоровый узел на прогоне 13). Два слоя, потому что образы backend и browser деплоятся раздельно и расходятся на часы: * сайдкар (browser/server.py) — детект по <title> на обоих путях (navigate и подзапрос) → BanPageDetectedError → прежний путь #3288/#3379: 403 + ban_page + ЧЕСТНЫЙ upstream-статус 200; * kit (providers/cian/detail.py) — при провале extract_state те же маркеры → CianBlockedError вместо тихого None, плюс report_platform_ban по живому lease. Там же ветка SidecarBanPageError: отказ, опознанный сайдкаром, больше не гасится общим `except` в «не смогли разобрать». Слово `captcha` признаком быть не может: в нормальной карточке оно встречается 11 раз, на капче 17. Детект по <title> с нормализацией тире. `_report_platform_ban` → `report_platform_ban` (публичный): тем же путём обязан идти отказ, распознанный не сайдкаром, а провайдером. report_ban один только пишет бан пары «узел×источник» — сменить сожжённую аренду ВНУТРИ батча позволяет только fail-streak (_LEASE_ROTATE_AFTER_FAILS). --- .../tests/scrapers/test_domclick_detail.py | 2 +- ..._3283_avito_sidecar_ban_is_platform_ban.py | 2 +- .../tests/test_3288_avito_ban_per_source.py | 2 +- .../tests/test_3402_cian_captcha_http200.py | 167 +++++++++++++ tradein-mvp/browser/server.py | 47 ++++ .../browser/test_server_cian_captcha.py | 233 ++++++++++++++++++ .../src/scraper_kit/browser_fetcher.py | 17 +- .../src/scraper_kit/providers/avito/detail.py | 2 +- .../src/scraper_kit/providers/cian/detail.py | 67 ++++- .../scraper_kit/providers/domclick/detail.py | 2 +- 10 files changed, 528 insertions(+), 13 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3402_cian_captcha_http200.py create mode 100644 tradein-mvp/browser/test_server_cian_captcha.py 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: Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`, +44 КБ) и страницу ошибки (`<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. + +Этот файл покрывает ВТОРОЙ слой (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 = ( + "<!DOCTYPE html><html lang='ru'><head><meta charset='utf-8'>" + "<title>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"<title[^>]*>(.*?)", 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, ни маркеры Авито её не ловят — распознаём по <title> + # и отдаём наверх тем же путём (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: Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`, +44 КБ) и страницу ошибки (`<title>Ошибка - Циан`, 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. Отсюда детект по <title>. + +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 = ( + "<!DOCTYPE html><html lang='ru'><head>" + "<meta charset='utf-8'>" + "<title>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"<title[^>]*>(.*?)", 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 остаётся. -- 2.45.3 From 957e9e8c014aab3bb9930bb73a7270bd99267cde Mon Sep 17 00:00:00 2001 From: bot-backend <bot-backend@gendsgn.local> Date: Sun, 6 Sep 2026 14:55:45 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(#3402):=20=D0=BA=D0=B0=D0=BF=D1=87?= =?UTF-8?q?=D0=B0=20=3D=20=C2=ABCaptcha=E2=80=A6=C2=BB=20=D0=B8=20=C2=AB?= =?UTF-8?q?=D0=92=D1=8B=20=D0=BD=D0=B5=20=D1=80=D0=BE=D0=B1=D0=BE=D1=82=3F?= =?UTF-8?q?=C2=BB=20(=D0=B1=D0=B0=D0=BD+=D1=80=D0=BE=D1=82=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F);=20=C2=AB=D0=9E=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=20-?= =?UTF-8?q?=20=D0=A6=D0=B8=D0=B0=D0=BD=C2=BB=20=E2=80=94=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BB=D0=BE=D0=B3=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=86=D0=B8=D0=BA=D0=BB=20=D0=BD=D0=B0=D0=B1=D0=BB=D1=8E=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью (⚠️ minor) на #3403: «Ошибка - Циан» может быть транзиентной 5xx-страницей, отданной с кодом 200, а не отказом конкретному узлу. Цена ошибки несимметрична — mark_banned эскалирует TTL до часов, поэтому 20-минутный сбой площадки выбил бы из выдачи весь пул. Один список маркеров этого различить не мог: и капча, и страница ошибки шли одним путём в BanPageDetectedError. Проба прода 06.09.2026 09:25 UTC (одна карточка по узлам через сайдкар): * узел 14, час назад отдававший «Captcha - база объявлений ЦИАН», вернул НАСТОЯЩУЮ карточку — капча снимается за 1-2 часа, то есть TTL бана по назначению; * узел 1 отдал ТРЕТИЙ вариант отказа — `<title>Вы не робот?`, 16 КБ (час назад — «Ошибка - Циан», 374 КБ). Прежние маркеры его не знали вовсе: отказ уезжал наверх как валидный HTML ровно так же, как до #3402. Маркеры разделены на два класса, одинаково в обоих слоях (образы backend и browser деплоятся раздельно и расходятся на часы): * КАПЧА — «captcha - база объявлений циан» + «вы не робот?»: безусловный отказ площадки, прежний путь (сайдкар → BanPageDetectedError → 403 + ban_page; kit → report_platform_ban + CianBlockedError). За ней нет контента, и узел, которому её показали, будет получать её дальше; * «ошибка - циан» — ТОЛЬКО ЛОГ: сайдкар отдаёт HTML клиенту как есть и пишет WARNING «страница ошибки Циана (title=…, upstream=…) — не бан, только лог (#3402)», kit при провале extract_state пишет WARNING и возвращает прежний None. Ни бана, ни рапорта, ни исключения — решение принимаем по частоте в логах за цикл наблюдения, а не по догадке о природе страницы. Нормализация заголовка прежняя (регистр/пробелы/тире). `_is_cian_refusal` → `_is_cian_captcha` + `_log_cian_error_page`; `_refusal_title` → `_page_title` и два кортежа маркеров рядом. Фальсификация: «вы не робот?» убран из маркеров обоих слоёв → kit 1 failed («DID NOT RAISE CianBlockedError»), сайдкар 3 failed («DID NOT RAISE BanPageDetectedError», `_is_cian_captcha` → assert False is True). Маркер возвращён, обе сьюты зелёные: backend 5599 passed / 35 skipped, browser 246 passed. --- .../tests/test_3402_cian_captcha_http200.py | 57 ++++++++-- tradein-mvp/browser/server.py | 70 +++++++++--- .../browser/test_server_cian_captcha.py | 106 +++++++++++++++--- .../src/scraper_kit/providers/cian/detail.py | 69 +++++++----- 4 files changed, 230 insertions(+), 72 deletions(-) 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 86df109b..24a71a1b 100644 --- a/tradein-mvp/backend/tests/test_3402_cian_captcha_http200.py +++ b/tradein-mvp/backend/tests/test_3402_cian_captcha_http200.py @@ -1,26 +1,37 @@ """#3402: капча Циана приходит с HTTP 200 — это отказ площадки, а не «не разобрали». Замер прода 06.09.2026: Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`, -44 КБ) и страницу ошибки (`<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. +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`; + * `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 @@ -43,6 +54,11 @@ _CAPTCHA_HTML = ( "<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>Вы не робот?" + "
" +) _ERROR_PAGE_HTML = ( "Ошибка - Циан" "

Что-то пошло не так

" @@ -97,20 +113,37 @@ class _FakeFetcher: # ── Слой kit: капча → CianBlockedError + рапорт бана ────────────────────────── -@pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ERROR_PAGE_HTML]) +@pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ROBOT_HTML]) async def test_captcha_page_is_a_platform_refusal(html: str) -> None: - """HTTP 200 + капча/страница ошибки → CianBlockedError, а не тихий 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) + 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) diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 7e6a48ee..71de6501 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -1975,11 +1975,26 @@ def _is_domclick_refusal(html: str) -> bool: # НОРМАЛЬНОЙ карточке (антифрод-скрипты), 17 — на странице капчи; как признак оно # неотличимо. Тире в заголовке нормализуется (— и – → -): вёрстка Циана печатает # его по-разному, а различать заголовки по виду дефиса — заведомо хрупко. -_CIAN_REFUSAL_TITLES: tuple[str, ...] = ( +# +# КАПЧА — безусловный отказ площадки: страница требует пройти проверку, контента за +# ней нет, а узел, которому её показали, будет показывать её и дальше. Наверх идёт +# BanPageDetectedError → бан пары «узел×cian» + ротация аренды. Проба прода +# 06.09.2026 09:25 UTC по узлам: капча снимается за 1-2 часа (узел 14 через час +# отдавал уже настоящую карточку), то есть TTL бана по назначению. +_CIAN_CAPTCHA_TITLES: tuple[str, ...] = ( "captcha - база объявлений циан", - "ошибка - циан", + "вы не робот?", ) +# «Ошибка - Циан» — НЕ бан (#3402, цикл наблюдения). Природа страницы не доказана: +# она может быть транзиентной 5xx-заглушкой, отданной с кодом 200, а не отказом +# конкретному узлу (та же проба 06.09.2026: узел 1 за час сменил «Ошибка - Циан», +# 374 КБ, на «Вы не робот?», 16 КБ). Цена ошибочного бана несимметрична: mark_banned +# эскалирует TTL до часов, и 20-минутный сбой Циана выбил бы из выдачи весь пул. +# Поэтому здесь только WARNING — решение принимаем по частоте в логах, а не по +# догадке о причине. +_CIAN_ERROR_TITLES: tuple[str, ...] = ("ошибка - циан",) + _TITLE_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) @@ -1991,10 +2006,24 @@ def _page_title(html: str) -> str: return " ".join(match.group(1).split()).lower().replace("—", "-").replace("–", "-") -def _is_cian_refusal(html: str) -> bool: - """True, если HTML — капча или страница ошибки Циана (см. _CIAN_REFUSAL_TITLES).""" +def _is_cian_captcha(html: str) -> bool: + """True, если HTML — капча Циана (см. _CIAN_CAPTCHA_TITLES) — отказ площадки.""" title = _page_title(html) - return any(marker in title for marker in _CIAN_REFUSAL_TITLES) + return any(marker in title for marker in _CIAN_CAPTCHA_TITLES) + + +def _log_cian_error_page(html: str, url: str, status: int | None) -> None: + """WARNING на «Ошибка - Циан» (см. _CIAN_ERROR_TITLES); ни бана, ни исключения.""" + title = _page_title(html) + if not any(marker in title for marker in _CIAN_ERROR_TITLES): + return + logger.warning( + "tradein-browser[cian]: страница ошибки Циана (title=%r, upstream=%s) — " + "не бан, только лог (#3402) url=%r", + title, + status, + url, + ) # Маркеры исключения playwright «страница прямо сейчас перезагружается». Ловим по @@ -2369,11 +2398,13 @@ 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}" - ) + if provider == "cian": + if _is_cian_captcha(text): + raise BanPageDetectedError( + f"tradein-browser[{provider}]: капча Циана " + f"(title={_page_title(text)!r}) url={url!r}" + ) + _log_cian_error_page(text, url, status) logger.info( "tradein-browser[%s]: %s → HTTP %s, тело %d Б url=%r", provider, @@ -2409,14 +2440,17 @@ 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}" - ) + # Капча Циана (#3402): приходит с HTTP 200, поэтому ни _REFUSAL_STATUSES, ни + # маркеры Авито её не ловят — распознаём по <title> и отдаём наверх тем же + # путём (403 + ban_page), что отказ DomClick. «Ошибка - Циан» — НЕ бан, только + # лог: см. _CIAN_ERROR_TITLES. + if provider == "cian": + if _is_cian_captcha(html): + raise BanPageDetectedError( + f"tradein-browser[{provider}]: капча Циана " + f"(title={_page_title(html)!r}) url={url!r}" + ) + _log_cian_error_page(html, url, _last_response_status.get(provider)) 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 index 7fc70efa..a96043bd 100644 --- a/tradein-mvp/browser/test_server_cian_captcha.py +++ b/tradein-mvp/browser/test_server_cian_captcha.py @@ -1,16 +1,25 @@ """test_server_cian_captcha.py — капча Циана приходит с HTTP 200 (#3402). Замер прода 06.09.2026: Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`, -44 КБ) и страницу ошибки (`<title>Ошибка - Циан`, 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). +44 КБ; `<title>Вы не робот?`, 16 КБ) и страницу ошибки (`<title>Ошибка - Циан`, 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. Отсюда детект по <title>. +ДВА КЛАССА, а не один (проба прода 06.09.2026 09:25 UTC по узлам через сайдкар): + * КАПЧА («Captcha - база объявлений ЦИАН», «Вы не робот?») — отказ площадки: бан пары + «узел×cian» + ротация аренды. Снимается за 1-2 часа (узел 14 через час отдавал уже + настоящую карточку), то есть TTL бана по назначению; + * «Ошибка - Циан» — ТОЛЬКО ЛОГ. Природа не доказана: может быть транзиентной + 5xx-заглушкой под кодом 200, а не отказом узлу (узел 1 за час сменил её на «Вы не + робот?»). Цена ошибки несимметрична — mark_banned эскалирует TTL до часов, и + 20-минутный сбой Циана выбил бы из выдачи весь пул. Решение — по частоте в логах. + camoufox НЕ запускается: _browsers[provider] — поддельный browser/page (зеркалит test_server_http_status.py). wait_for_timeout на фейковой page — no-op. @@ -24,6 +33,7 @@ from __future__ import annotations import asyncio import importlib.util import json +import logging from pathlib import Path from typing import Any @@ -62,6 +72,11 @@ _CAPTCHA_HTML = ( "<script>window.__captcha__ = {sitekey: 'x'};</script>" "</body></html>" ) +# Второй вариант капчи — тот же отказ, другая вёрстка (проба 06.09.2026, узел 1, 16 КБ). +_ROBOT_HTML = ( + "<!DOCTYPE html><html lang='ru'><head><title>Вы не робот?" + "
" +) _ERROR_PAGE_HTML = ( "Ошибка - Циан" "

Что-то пошло не так

" @@ -140,37 +155,66 @@ def _make_request(body: dict[str, Any]) -> Any: 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 +@pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ROBOT_HTML]) +def test_cian_captcha_pages_are_recognised(html: str) -> None: + """Оба варианта капчи — отказ площадки (второй, «Вы не робот?», добавлен 06.09).""" + assert server._is_cian_captcha(html) is True + + +def test_error_page_is_not_a_captcha() -> None: + """«Ошибка - Циан» баном НЕ считается — иначе 20-минутный сбой Циана выбивает пул.""" + assert server._is_cian_captcha(_ERROR_PAGE_HTML) is False 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 + assert server._is_cian_captcha(_CARD_HTML) is False -def test_em_dash_in_title_is_normalised() -> None: +def test_em_dash_in_title_is_normalised(caplog: pytest.LogCaptureFixture) -> None: """Вёрстка печатает тире по-разному — детект не должен зависеть от его вида.""" - assert server._is_cian_refusal("Ошибка — Циан") is True + with caplog.at_level(logging.WARNING): + server._log_cian_error_page("Ошибка — Циан", "https://x", 200) + + assert "страница ошибки Циана" in caplog.text # ── /fetch: 403 + ban_page на капче, отданной с HTTP 200 ────────────────────── -def test_fetch_once_raises_on_cian_captcha(monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ROBOT_HTML]) +def test_fetch_once_raises_on_cian_captcha(monkeypatch: pytest.MonkeyPatch, html: str) -> None: """HTTP 200 + капча → BanPageDetectedError, а не «валидный HTML» наверх.""" - page = _Page(_CAPTCHA_HTML) + page = _Page(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_logs_error_page_without_banning( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """«Ошибка - Циан» → HTML уезжает наверх как есть + WARNING; исключения НЕТ. + + Пока идёт цикл наблюдения (#3402): страница может быть транзиентным сбоем площадки, + а `mark_banned` эскалирует TTL до часов — бан по догадке дороже пропущенного отказа. + """ + page = _Page(_ERROR_PAGE_HTML) + _install(monkeypatch, page) + + with caplog.at_level(logging.WARNING): + html = asyncio.run(server._fetch_once("cian", "https://ekb.cian.ru/sale/flat/1/")) + + assert html == _ERROR_PAGE_HTML + assert "страница ошибки Циана" in caplog.text + assert "не бан, только лог" in caplog.text + + def test_fetch_once_passes_normal_card_through(monkeypatch: pytest.MonkeyPatch) -> None: """Здоровая карточка (с тем же словом в теле) отдаётся как раньше.""" page = _Page(_CARD_HTML) @@ -181,8 +225,10 @@ def test_fetch_once_passes_normal_card_through(monkeypatch: pytest.MonkeyPatch) assert html == _CARD_HTML +@pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ROBOT_HTML]) def test_fetch_handler_returns_403_with_ban_page_on_captcha( monkeypatch: pytest.MonkeyPatch, + html: str, ) -> None: """Тот же путь, что #3379/#3288 п.4: 403 + ban_page + ЧЕСТНЫЙ upstream-статус 200. @@ -191,7 +237,7 @@ def test_fetch_handler_returns_403_with_ban_page_on_captcha( бэкенда деплоятся врозь). """ monkeypatch.setattr(server, "IS_PROD", False) - page = _Page(_CAPTCHA_HTML) + page = _Page(html) _install(monkeypatch, page) async def _ensure(provider: str, proxy_override: str | None = None) -> bool: @@ -212,6 +258,34 @@ def test_fetch_handler_returns_403_with_ban_page_on_captcha( assert "BanPageDetectedError" in body["error"] +def test_fetch_handler_returns_200_without_ban_page_on_error_page( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """«Ошибка - Циан» доезжает клиенту как обычный ответ: 200, без `ban_page`.""" + monkeypatch.setattr(server, "IS_PROD", False) + page = _Page(_ERROR_PAGE_HTML) + _install(monkeypatch, page) + + async def _ensure(provider: str, proxy_override: str | None = None) -> bool: + return True + + monkeypatch.setattr(server, "_ensure_browser", _ensure) + + with caplog.at_level(logging.WARNING): + 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 == 200 + assert "ban_page" not in body + assert body["html"] == _ERROR_PAGE_HTML + assert "не бан, только лог" in caplog.text + + def test_fetch_handler_does_not_ban_other_providers_on_the_same_html( monkeypatch: pytest.MonkeyPatch, ) -> None: 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 df29ffb6..37363bf5 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 @@ -101,30 +101,41 @@ def _raise_if_blocked(offer_url: str, status_code: int) -> None: # ── Капча/страница ошибки Циана с HTTP 200 (#3402) ────────────────────────────── -# Дубль детектора сайдкара (browser/server.py::_is_cian_refusal) — намеренный: +# Дубль детектора сайдкара (browser/server.py::_is_cian_captcha) — намеренный: # образы backend и browser деплоятся раздельно и расходятся на часы, а в этот # промежуток отказ площадки приходит сюда ровно так же, как до правки — 200 + HTML # капчи. Опознаём по , а не по слову "captcha": оно есть и в нормальной # карточке (11 вхождений против 17 на капче), то есть как признак не различает. -_REFUSAL_TITLES: tuple[str, ...] = ( +# +# КАПЧА — безусловный отказ площадки (бан пары «узел×cian» + ротация аренды): за ней +# нет контента, и узел, которому её показали, будет получать её дальше. Проба прода +# 06.09.2026 09:25 UTC по узлам: капча снимается за 1-2 часа, то есть TTL бана по +# назначению. +_CAPTCHA_TITLES: tuple[str, ...] = ( "captcha - база объявлений циан", - "ошибка - циан", + "вы не робот?", ) +# «Ошибка - Циан» — НЕ бан (цикл наблюдения): природа страницы не доказана, она может +# быть транзиентной 5xx-заглушкой Циана под кодом 200, а не отказом конкретному узлу. +# Цена ошибки несимметрична — mark_banned эскалирует TTL до часов, и 20-минутный сбой +# площадки выбил бы из выдачи весь пул. Здесь только WARNING и прежний None; решение +# принимаем по частоте в логах. +_ERROR_PAGE_TITLES: tuple[str, ...] = ("ошибка - циан",) + _TITLE_RE = re.compile(r"<title[^>]*>(.*?)", re.IGNORECASE | re.DOTALL) -def _refusal_title(html: str) -> str | None: - """Заголовок страницы, если это капча/ошибка Циана; иначе None. +def _page_title(html: str) -> str: + """Текст : схлопнутые пробелы, нижний регистр, нормализованное тире. Тире нормализуется (— и – → -): вёрстка печатает его по-разному, а различать заголовки по виду дефиса — заведомо хрупко. """ 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 + return "" + return " ".join(match.group(1).split()).lower().replace("—", "-").replace("–", "-") async def fetch_detail( @@ -156,9 +167,11 @@ async def fetch_detail( пришли (#2700). Оба вызывающих в orchestration/pipeline.py уже считают исключение в `errors_count`, а на own-session-пути оно дополнительно снимает узел с выдачи Циану через `curl_proxy_url`. Тем же исключением приезжает - капча/страница ошибки Циана (#3402): её сайдкар отдаёт как `ban_page` + 403, - а если образ сайдкара старее — она распознаётся здесь, по <title>, уже после - HTTP 200 (`report_platform_ban` на живом lease + raise вместо тихого None). + КАПЧА Циана (#3402): её сайдкар отдаёт как `ban_page` + 403, а если образ + сайдкара старее — она распознаётся здесь, по <title>, уже после HTTP 200 + (`report_platform_ban` на живом lease + raise вместо тихого None). Страница + «Ошибка - Циан» баном НЕ считается — WARNING и прежний None, см. + `_ERROR_PAGE_TITLES`. NoProxyAvailableError: пул прокси пуст (#2616) — пробрасывается со ВСЕХ путей, а не гасится в None: запрос не уходил, и следующий вызов упрётся в то же самое, поэтому решение «оборвать батч» принимает вызывающий (#3197). @@ -168,8 +181,8 @@ async def fetch_detail( try: html = await browser_fetcher.fetch(offer_url) except SidecarBanPageError as exc: - # #3402: сайдкар опознал отказ площадки по маркерам тела (капча/страница - # ошибки Циана, `ban_page` + 403). ПОРЯДОК ВЕТОК ВАЖЕН — SidecarBanPageError + # #3402: сайдкар опознал отказ площадки по маркерам тела (капча Циана, + # `ban_page` + 403). ПОРЯДОК ВЕТОК ВАЖЕН — SidecarBanPageError # подкласс httpx.HTTPStatusError, и общий `except` ниже увёл бы # подтверждённый отказ Циана в `return None`, то есть в «не смогли # разобрать». Зеркалит avito/detail.py и domclick/detail.py (#3283/#3239). @@ -232,12 +245,13 @@ 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: + # #3402: страницы БЕЗ состояния бывают трёх разных родов, и до этой правки все + # печатались как «extraction failed» — то есть отказ площадки читался как дрейф + # нашей разметки. Капча приходит с HTTP 200, диагностировать её по статусу + # (ban_kind_from_status) нечем — только по телу. Три рода: капча (бан+ротация), + # страница ошибки (только лог, природа не доказана), дрейф разметки (как было). + title = _page_title(html) + if any(marker in title for marker in _CAPTCHA_TITLES): if browser_fetcher is not None: # Детект НАШ, сайдкар его не видел (старый образ отдал 200) — рапорт # обязателен здесь, иначе тот же узел доработает батч до конца. lease @@ -245,15 +259,18 @@ async def fetch_detail( # report_platform_ban (а не report_ban): бан пары «узел×cian» ПЛЮС # fail-streak, по которому аренда меняется ВНУТРИ батча — ровно то же, # что делает сайдкар-путь через SidecarBanPageError. - browser_fetcher.report_platform_ban( - f"cian detail: капча/страница ошибки Циана для {offer_url}" - ) + 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}") + if any(marker in title for marker in _ERROR_PAGE_TITLES): + # Ни рапорта, ни исключения — см. _ERROR_PAGE_TITLES: пока это наблюдение, + # а не диагноз. Возврат прежний (None) — вызывающий считает «не разобрали». logger.warning( - "Cian detail %s: капча/страница ошибки Циана (title=%r)", offer_url, title - ) - raise CianBlockedError( - f"Cian detail: капча/страница ошибки Циана (title={title!r}) для {offer_url}" + "Cian detail %s: страница ошибки Циана (title=%r) — не бан, только лог (#3402)", + offer_url, + title, ) + return None logger.warning("Cian detail %s: defaultState extraction failed", offer_url) return None -- 2.45.3