Merge pull request 'fix(cian): капча видна в отчёте прогона (ban_kinds.platform по типу) и банит узел на curl-пути (#3402 follow-up)' (#3406) from fix/3402-followups-ban-kinds-curl into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m10s
Deploy Trade-In / build-backend (push) Successful in 1m41s
Deploy Trade-In / deploy (push) Successful in 1m50s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s

This commit is contained in:
bot-backend 2026-09-06 10:40:11 +00:00
commit 75b0931fad
4 changed files with 144 additions and 16 deletions

View file

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

View file

@ -45,6 +45,10 @@ from scraper_kit.providers.cian import detail as cian_detail
from app.tasks import cian_history_backfill 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/" _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 len(fetcher.fetched) == 2 # батч не оборван: следующее объявление взято
assert result.listings_succeeded == 1 # второе обогащено ПОСЛЕ смены аренды assert result.listings_succeeded == 1 # второе обогащено ПОСЛЕ смены аренды
assert result.listings_failed_fetch == 1 assert result.listings_failed_fetch == 1
async def test_captcha_is_counted_as_platform_refusal_not_as_parse_failure() -> None:
"""Капча попадает в `ban_kinds['platform']`, а не только в «не разобрали».
Красный на main: `_note_refusal` ключевался ИСКЛЮЧИТЕЛЬНО по HTTP-статусу
(`ban_kind_from_status`), а капча приходит с 200 (свой детект) либо без статуса
(сайдкар) `ban_kinds` пуст, `listings_blocked == 0`, наверх уезжал один
`listings_failed_fetch`. Отчёт прогона в этом виде не отличает волну капчи от
дрейфа нашей разметки то есть следующая волна снова была бы невидима.
"""
fetcher = _FakeFetcher()
with (
patch.object(cian_history_backfill, "BrowserFetcher", lambda **kw: fetcher),
patch.object(cian_history_backfill, "save_detail_enrichment", MagicMock()),
patch("asyncio.sleep", new_callable=AsyncMock),
):
result = await cian_history_backfill.backfill_cian_history(
_db_with_rows(1), do_listings=True, do_houses=False, do_valuations=False
)
assert dict(result.ban_kinds) == {"platform": 1}
assert result.listings_blocked == 1
assert result.ban_kind == "platform" # то, что уедет в scrape_runs.ban_kind
# ── curl-путь: капча банит узел, а не только прерывает разбор ─────────────────
async def test_captcha_on_curl_path_bans_the_node_for_cian() -> None:
"""HTTP 200 + капча на own-session curl → `mark_banned(node × cian)`.
Красный на main: детект капчи стоит в общем parse-пути, ЗА границей
`with curl_proxy_url(...)`, исключение поднималось уже после того, как хелпер
проставил узлу `mark_health(ok=True)`. Узел, которому Циан показывает капчу,
оставался в выдаче Циану (тот же дефект, что #2700 у 403).
Путь боевой: `pipeline.py` зовёт `fetch_detail(config=, proxy_provider=)` без
`browser_fetcher` это detail-фаза cian city sweep.
"""
spy = _SpyProvider()
with patch.object(
cian_detail,
"build_curl_cffi_session",
return_value=_session_returning(200, _CAPTCHA_HTML),
):
with pytest.raises(CianBlockedError):
await cian_detail.fetch_detail(_URL, config=_FakeConfig(), proxy_provider=spy)
assert spy.mark_banned_calls == [(1, "cian")]
assert (1, True) not in spy.mark_health_calls, "узел с капчей записан здоровым"
assert spy.mark_health_calls == [(1, False)]
assert spy.release_calls == [1] # lease не течёт
async def test_error_page_on_curl_path_does_not_ban_the_node() -> None:
"""«Ошибка - Циан» на curl-пути — по-прежнему не бан (цикл наблюдения, #3402)."""
spy = _SpyProvider()
with patch.object(
cian_detail,
"build_curl_cffi_session",
return_value=_session_returning(200, _ERROR_PAGE_HTML),
):
result = await cian_detail.fetch_detail(_URL, config=_FakeConfig(), proxy_provider=spy)
assert result is None
assert spy.mark_banned_calls == []
assert spy.mark_health_calls == [(1, True)]

View file

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

View file

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