fix(cian): капча видна в отчёте прогона (ban_kinds.platform по типу) и банит узел на curl-пути (#3402 follow-up) #3406
4 changed files with 144 additions and 16 deletions
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue