From 224f91e8fa212df20c61db376308eacb4e0af3c3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 6 Sep 2026 15:30:12 +0500 Subject: [PATCH] =?UTF-8?q?fix(cian):=20=D0=BA=D0=B0=D0=BF=D1=87=D0=B0=20?= =?UTF-8?q?=D0=B2=D0=B8=D0=B4=D0=BD=D0=B0=20=D0=B2=20=D0=BE=D1=82=D1=87?= =?UTF-8?q?=D1=91=D1=82=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B8=20=D0=B1=D0=B0=D0=BD=D0=B8=D1=82=20=D1=83=D0=B7=D0=B5?= =?UTF-8?q?=D0=BB=20=D0=BD=D0=B0=20curl-=D0=BF=D1=83=D1=82=D0=B8=20(#3402)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два follow-up из ревью #3403. 1. Капча-волна была невидима в счётчиках. `_note_refusal` ключевался только по HTTP-статусу, а капча приходит с 200 (свой детект по ) или без статуса (сайдкар) → рос один `listings_failed_fetch`, `ban_kinds` оставался пустым, и волна отказа площадки читалась как дрейф нашей разметки. Теперь диагноз берётся сперва по ТИПУ исключения (`ban_kind_of_exception`), статус — фолбэк. Инвариант #3196 сохранён: 'unknown' по типу И None по статусу по-прежнему ничего не пишут. `ban_kind_of_exception` расширен с `AvitoBlockedError` до `ProxyBanError` — это ровно тот mixin, по которому generic-прокси-слой уже снимает узел с выдачи источнику. Для Авито поведение не меняется (AvitoBlockedError его наследует), Cian/DomClick перестают приезжать как 'unknown'. 2. curl-путь детектил капчу, но не банил: общий parse-путь лежит ЗА границей `with curl_proxy_url(...)`, и `CianBlockedError` поднимался уже после `mark_health(ok=True)` — узел, которому Циан показывает капчу, оставался в выдаче Циану (дефект #2700, только на HTTP 200). Проверка перенесена ВНУТРЬ блока, `finally` хелпера сам делает `mark_banned(source='cian')`. Тесты по значению (оба красные на main): батч с капчей → ban_kinds == {platform: 1}; curl-путь + HTML капчи → mark_banned == [(1, 'cian')], mark_health(ok=True) нет. --- .../app/tasks/cian_history_backfill.py | 42 +++++++--- .../tests/test_3402_cian_captcha_http200.py | 76 +++++++++++++++++++ .../src/scraper_kit/orchestration/pipeline.py | 14 +++- .../src/scraper_kit/providers/cian/detail.py | 28 ++++++- 4 files changed, 144 insertions(+), 16 deletions(-) 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 (свой детект по <title>) или вообще без + статуса (сайдкар не дошёл до навигации), поэтому по статусу она диагностировалась + как «не разобрали»: рос только `listings_failed_fetch`, `ban_kinds` оставался + пустым — и следующая капча-волна снова выглядела бы дрейфом нашей разметки; + * HTTP-статус ответа (403/429/5xx) — прежний путь для всего остального. + + Всё, что не установлено ни тем, ни другим ('unknown' по типу И None по статусу), НЕ + инкрементит ни `listings_blocked`, ни `ban_kinds`: недиагностируемые случаи, записанные + как 'unknown', в scrape_runs.mark_banned (scheduler.py) превращали наши собственные + сбои в фиктивный бан площадки (#2764). """ - kind = ban_kind_from_status(status) + kind = ban_kind_of_exception(exc) if exc is not None else BAN_KIND_UNKNOWN + if kind == BAN_KIND_UNKNOWN: + # Тип ничего не доказал — спрашиваем статус (прежнее поведение). + kind = ban_kind_from_status(status) if kind is None: return None result.listings_blocked += 1 @@ -248,7 +264,11 @@ async def backfill_cian_history( result.listings_succeeded, ) break - kind = _note_refusal(result, bf.last_response_status) + # exc, а не только статус: капча Циана — подтверждённый отказ + # площадки (CianBlockedError), но приходит с HTTP 200/без статуса + # (#3402 follow-up). Без типа волна капчи писалась в счётчик + # «не разобрали» и прогон отдавал пустой ban_kinds. + kind = _note_refusal(result, bf.last_response_status, exc) logger.warning( "cian_detail fetch failed for listing_id=%s url=%s: %s " "(http=%s ban_kind=%s)", 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, а если образ сайдкара старее — она распознаётся здесь, по <title>, уже после HTTP 200 - (`report_platform_ban` на живом lease + raise вместо тихого None). Страница + (`report_platform_ban` на живом lease + raise вместо тихого None). На + curl-путях та же капча поднимается ВНУТРИ `curl_proxy_url` — иначе бан узла + не случался бы вовсе (#3402 follow-up). Страница «Ошибка - Циан» баном НЕ считается — WARNING и прежний None, см. `_ERROR_PAGE_TITLES`. NoProxyAvailableError: пул прокси пуст (#2616) — пробрасывается со ВСЕХ путей, а @@ -236,6 +250,16 @@ async def fetch_detail( logger.warning("Cian detail fetch %s → HTTP %d", offer_url, resp.status_code) return None html = resp.text + # Капча приходит с HTTP 200, то есть мимо `_raise_if_blocked`. Спрашиваем + # ЗДЕСЬ, пока lease жив: общий parse-путь ниже поднимет то же исключение + # уже после выхода из `with curl_proxy_url`, где узлу проставлен + # mark_health(ok=True) и бана пары «узел × cian» не будет (#3402). + title = _page_title(html) + if _is_captcha_title(title): + logger.warning("Cian detail %s: капча Циана (title=%r)", offer_url, title) + raise CianBlockedError( + f"Cian detail: капча Циана (title={title!r}) для {offer_url}" + ) finally: await own_session.close() @@ -251,7 +275,7 @@ async def fetch_detail( # (ban_kind_from_status) нечем — только по телу. Три рода: капча (бан+ротация), # страница ошибки (только лог, природа не доказана), дрейф разметки (как было). title = _page_title(html) - if any(marker in title for marker in _CAPTCHA_TITLES): + if _is_captcha_title(title): if browser_fetcher is not None: # Детект НАШ, сайдкар его не видел (старый образ отдал 200) — рапорт # обязателен здесь, иначе тот же узел доработает батч до конца. lease