fix(tradein/cian): 403 снимает узел с выдачи, а не гаснет в return None (#2700) (#2821)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 3m10s
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 2m4s

This commit is contained in:
bot-backend 2026-08-10 11:29:19 +00:00
parent 0ed0140c9e
commit 20ec6a5d33
6 changed files with 408 additions and 1 deletions

View file

@ -1904,9 +1904,16 @@ async def scrape_cian_detail(
Without it debug-only (no DB write).
"""
_assert_allowed_url(offer_url)
from scraper_kit.cian_exceptions import CianBlockedError
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
enrichment = await fetch_detail(offer_url, config=RealScraperConfig())
try:
enrichment = await fetch_detail(offer_url, config=RealScraperConfig())
except CianBlockedError as exc:
# #2700: 403 теперь исключение (узел снимается с выдачи Циану). Ad-hoc ручке
# нужен внятный ответ, а не 500: «страницу не разобрали» и «нас не пустили с
# этого узла» — разные новости для того, кто дёргает ручку руками.
raise HTTPException(502, f"Cian заблокировал наш узел: {exc}") from exc
if enrichment is None:
raise HTTPException(404, f"Could not parse Cian detail page: {offer_url}")

View file

@ -225,6 +225,61 @@ def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None:
)
# #2700: сколько попыток фазы должно быть, чтобы «отказали все» что-то значило.
# 3 — не круглое число, а порог, на котором сам сбор уже сдаётся: столько подряд
# неудачных detail'ов достаточно оркестратору, чтобы ротировать прокси и оборвать фазу
# (_cian_detail_abort в orchestration/pipeline.py). Замер на проде 2026-08-10 за 90
# суток: порог отсекает 2 прогона с ЕДИНСТВЕННОЙ попыткой (одиночный отказ — шум, не
# диагноз) и оставляет 50 прогонов, где отказали 3-50 попыток подряд.
_PHASE_MIN_ATTEMPTS = 3
def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None:
"""Фаза прогона, у которой отказала КАЖДАЯ попытка (#2700). Текст причины или None.
Прогон состоит из фаз, а статус у него один. `_sweep_run_did_nothing` (#2625) ловит
случай, когда не сделано НИЧЕГО; этот когда целое направление работы отказало на
сто процентов, а соседнее сработало, и суммарный ненулевой сбор прячет отказ.
Живой повод (#2700): `cian_city_sweep` 15 суток подряд писал `detail_attempted=50,
detail_failed=50, errors_count=0, status=done` каждая detail-страница отдавала
HTTP 403. Ноль обогащённых при 1 680 собранных лотах внешне неотличим от здорового
прогона: результатный счётчик (lots_fetched) ненулевой, а до `errors_count` отказ
подзадачи не доходил вовсе (403 гасился внутри провайдера в `return None`).
Признак собственная бухгалтерия фазы: `<phase>_failed == <phase>_attempted` при
`attempted >= _PHASE_MIN_ATTEMPTS`. Пары ищутся В САМИХ counters (любой ключ
`X_attempted` со спутником `X_failed`), а не по зашитому списку фаз: список это
ровно то место, куда забывают дописать новую фазу, и тогда сторож молчит, выглядя
настроенным. На проде за 90 суток таких пар четыре: detail/houses/address/imv.
Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) поэтому
'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'.
Замер на проде 2026-08-10 за 90 суток: правило переводит в 'failed' 52 прогона из
3 292 'done' (1.6%) 33 cian_city_sweep* (detail 26, houses 15 часть прогонов
попадает по обеим фазам) и 11 avito_city_sweep* (detail; про эти никто не знал).
Остальные 3 240 остаются 'done'.
"""
for key in sorted(counters):
if not key.endswith("_attempted"):
continue
phase = key[: -len("_attempted")]
attempted = _pick_int(counters, key)
failed = _pick_int(counters, f"{phase}_failed")
if attempted is None or failed is None:
continue
if attempted >= _PHASE_MIN_ATTEMPTS and failed == attempted:
return (
f"phase-honest-status: фаза '{phase}' отказала полностью — "
f"{failed} из {attempted} попыток неудачны, обогащено 0. Остальные фазы "
f"прогона могли отработать, поэтому ненулевой сбор это НЕ опровергает. "
f"Причина НЕ установлена: блок площадки, наш прокси или разбор — статус "
f"'failed' без диагноза (#2700)"
)
return None
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
@ -497,12 +552,21 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка:
страж, который надо не забыть позвать, это тот же дефект оборванной проводки,
из-за которого задача и появилась.
#2700: там же — отказ называть успехом прогон, у которого отказала КАЖДАЯ попытка
целой фазы (см. _phase_totally_failed). Отличие от #2625: тот случай про «не сделано
ничего», этот про «одно направление работы мертво, а суммарный сбор это прячет».
"""
did_nothing = _sweep_run_did_nothing(counters)
if did_nothing is not None:
logger.error("%s run_id=%d", did_nothing, run_id)
mark_failed(db, run_id, did_nothing, counters)
return
phase_dead = _phase_totally_failed(counters)
if phase_dead is not None:
logger.error("%s run_id=%d", phase_dead, run_id)
mark_failed(db, run_id, phase_dead, counters)
return
total_seen, new_count = _column_counts(counters)
row = db.execute(
text(

View file

@ -0,0 +1,208 @@
"""#2700: 403 Циана перестаёт умирать внутри провайдера — узел снимается, прогон честен.
Живая различающая проба на проде 2026-08-10 (один и тот же detail-URL, один и тот же
код, менялся ТОЛЬКО прокси-узел):
узел 1 (asocks-residential-1, 46.8.110.92) HTTP 403, 21 564 б, `cian_waf_block`
узел 9 (asocks-mobile-1, 5.227.16.0) HTTP 200, 617 352 б, state ok
узел 10 (asocks-mobile-2, 95.104.183.29) HTTP 200, 617 355 б, state ok
узел 11 (asocks-mobile-3, 95.55.49.98) HTTP 200, 617 407 б, state ok
То есть отбита была ПАРА «узел × Циан», а не площадка (и не наши заголовки: те же 200
пришли без единой куки detail-страница авторизации не требует). Пятнадцать суток
подряд это выглядело как «Циан нас забанил» ровно потому, что 403 гасился в
`return None`: пул получал `mark_health(ok=True)` на отбитый узел и продолжал выдавать
его Циану, а прогон писал `detail_failed=50` при `errors_count=0` и статусе `done`.
Тесты красные на старом коде:
* `fetch_detail` возвращал None и НЕ звал `mark_banned` узел оставался в выдаче;
* `mark_done` писал `status='done'` прогону, у которого отказали все 50 попыток.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
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.contracts import ProxyLease
from scraper_kit.orchestration import runs as kit_runs
from scraper_kit.providers.cian import detail as cian_detail
from scraper_kit.proxy_errors import ProxyBanError
from app.services import scrape_runs as app_runs
_MODULES = {"kit": kit_runs, "app": app_runs}
_LEASE = ProxyLease(id=1, url="http://user:pass@node-1:10423", kind="http", rotate_url=None)
@dataclass
class _FakeConfig:
use_proxy_pool_curl: bool = True
cian_proxy_url: str | None = None
environment: str = "production"
class _SpyProvider:
"""ProxyProvider-заглушка (тот же контракт, что в test_proxy_pool_curl_paths)."""
def __init__(self) -> None:
self.mark_health_calls: list[tuple[int, bool]] = []
self.mark_banned_calls: list[tuple[int, str]] = []
self.release_calls: list[int] = []
def acquire(self, provider: str) -> ProxyLease:
return _LEASE
def release(self, lease: ProxyLease) -> None:
self.release_calls.append(lease.id)
def mark_health(
self, lease: ProxyLease, ok: bool, *, exit_ip: Any = None, latency_ms: Any = None
) -> None:
self.mark_health_calls.append((lease.id, ok))
def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
self.mark_banned_calls.append((lease.id, source))
def _session_returning(status_code: int, text: str = "") -> MagicMock:
session = MagicMock()
session.get = AsyncMock(return_value=MagicMock(status_code=status_code, text=text))
session.close = AsyncMock()
return session
async def _fetch(status_code: int, spy: _SpyProvider) -> Any:
with patch.object(
cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code)
):
return await cian_detail.fetch_detail(
"https://ekb.cian.ru/sale/flat/332775238/",
config=_FakeConfig(),
proxy_provider=spy,
)
# ── 1. 403 доходит до пула ────────────────────────────────────────────────────
async def test_403_bans_the_node_for_cian_only() -> None:
"""Красный на старом коде: было `return None`, бана узла не происходило.
Проверяется ПОВЕДЕНИЕ пула (`mark_banned` на паре «узел × cian»), а не наличие
нового имени в коде.
"""
spy = _SpyProvider()
with pytest.raises(CianBlockedError):
await _fetch(403, spy)
assert spy.mark_banned_calls == [(1, "cian")]
assert spy.mark_health_calls == [(1, False)]
assert spy.release_calls == [1] # lease не течёт даже на бане
def test_blocked_error_is_recognised_by_generic_proxy_layer() -> None:
"""Generic curl-слой узнаёт бан по `ProxyBanError`, не зная про Циан."""
assert issubclass(CianBlockedError, ProxyBanError)
# ── 2. Не-бан остаётся не-баном ───────────────────────────────────────────────
async def test_404_does_not_ban_the_node() -> None:
"""Снятое объявление — не бан: наказывать за него здоровый узел нельзя."""
spy = _SpyProvider()
assert await _fetch(404, spy) is None
assert spy.mark_banned_calls == []
assert spy.mark_health_calls == [(1, True)]
# ── 3. Прогон с полностью отказавшей фазой перестаёт быть 'done' ──────────────
def _capture_status(mod: Any, counters: dict[str, int]) -> list[str]:
"""Статусы всех UPDATE'ов, которые сделал mark_done на фейковой сессии.
Читаем СТАТУС В SQL (как в test_2625_run_that_did_nothing), а не имя вызванной
функции: тест обязан краснеть на поведении финализатора.
"""
statuses: list[str] = []
def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
sql = str(stmt)
for status in ("done", "failed", "banned"):
if f"status = '{status}'" in sql:
statuses.append(status)
return MagicMock()
db = MagicMock()
db.execute.side_effect = _execute
with patch.object(mod, "sentry_sdk", MagicMock()):
mod.mark_done(db, 3258, dict(counters))
return statuses
# Реальные counters с прода, не выдуманные.
# Прогон 3258 (cian_city_sweep, 06.08): 50 из 50 detail'ов отказали, статус 'done'.
PROD_3258_ALL_DETAIL_FAILED = {
"anchors_done": 5,
"anchors_total": 5,
"errors_count": 0,
"lots_fetched": 1680,
"lots_inserted": 59,
"lots_updated": 626,
"detail_attempted": 50,
"detail_failed": 50,
"detail_enriched": 0,
"houses_attempted": 40,
"houses_failed": 40,
"houses_enriched": 0,
}
# Прогон 3597 (cian_city_sweep, 10.08, уже после того как 403 ушёл): 10 из 11 удачны.
PROD_3597_MOSTLY_OK = {
**PROD_3258_ALL_DETAIL_FAILED,
"errors_count": 1,
"detail_attempted": 11,
"detail_failed": 1,
"detail_enriched": 10,
"houses_attempted": 0,
"houses_failed": 0,
}
# Одиночная попытка, и та неудачна — шум, а не диагноз: прогон остаётся 'done'.
SINGLE_ATTEMPT_FAILED = {
**PROD_3597_MOSTLY_OK,
"detail_attempted": 1,
"detail_failed": 1,
"detail_enriched": 0,
}
@pytest.mark.parametrize("name", list(_MODULES))
def test_phase_failed_100_percent_is_not_done(name: str) -> None:
"""Прод-прогон 3258: detail 50/50 отказ → 'failed'. Красный на старом коде."""
assert _capture_status(_MODULES[name], PROD_3258_ALL_DETAIL_FAILED) == ["failed"]
@pytest.mark.parametrize("name", list(_MODULES))
def test_partial_phase_failure_stays_done(name: str) -> None:
"""Прод-прогон 3597: 1 отказ из 11 → остаётся 'done' (частичный отказ — не отказ)."""
assert _capture_status(_MODULES[name], PROD_3597_MOSTLY_OK) == ["done"]
@pytest.mark.parametrize("name", list(_MODULES))
def test_single_failed_attempt_stays_done(name: str) -> None:
"""Порог _PHASE_MIN_ATTEMPTS: одна неудачная попытка прогон не роняет."""
assert _capture_status(_MODULES[name], SINGLE_ATTEMPT_FAILED) == ["done"]
@pytest.mark.parametrize("name", list(_MODULES))
def test_zero_attempts_stays_done(name: str) -> None:
"""Фаза не запускалась (0 попыток) — 0 == 0 не должно читаться как отказ."""
counters = {**PROD_3597_MOSTLY_OK, "detail_attempted": 0, "detail_failed": 0}
assert _capture_status(_MODULES[name], counters) == ["done"]

View file

@ -0,0 +1,34 @@
"""Cian-specific exceptions для anti-bot detection."""
from scraper_kit.proxy_errors import ProxyBanError
class CianBlockedError(ProxyBanError):
"""HTTP 403 от Циана — узел, с которого мы пришли, отбит WAF'ом площадки.
Живая различающая проба 2026-08-10 (#2700), один и тот же detail-URL, один и тот
же код, менялся ТОЛЬКО прокси-узел:
узел 1 (asocks-residential, 46.8.110.92) HTTP 403, 21 564 байт,
маркер `cian_waf_block`
узел 9 (asocks-mobile-1, 5.227.16.0) HTTP 200, 617 352 байт, state ok
узел 10 (asocks-mobile-2, 95.104.183.29) HTTP 200, 617 355 байт, state ok
узел 11 (asocks-mobile-3, 95.55.49.98) HTTP 200, 617 407 байт, state ok
То есть 403 свойство ПАРЫ «узел × Циан», а не площадки вообще и не нашего
запроса: detail-страница Циана авторизации не требует и отдаётся без единой куки
(проба выше шла без них). Поэтому исключение наследует `ProxyBanError` тот же
приём, что у `AvitoBlockedError`/`DomClickBlockedError`: generic curl-слой
(`providers/_proxy.py::curl_proxy_url`) увидит `isinstance(exc, ProxyBanError)` и
снимет узел с выдачи ИМЕННО Циану (per-source бан, #2600 п.2), не трогая остальные
источники.
Почему это заводится отдельным исключением, а не остаётся `return None`: пока 403
гасился внутри `fetch_detail`, наружу не выходило НИЧЕГО пул получал
`mark_health(ok=True)` на отбитый узел и продолжал выдавать его Циану, а прогон
писал `detail_failed=50` при `errors_count=0` и статусе `done` (#2700: 15 суток
подряд, 50 из 50 отказов ежедневно).
404 сюда НЕ относится: удалённое объявление не бан, узел за него наказывать
нельзя. Остальные не-200 остаются прежним мягким отказом (`None` + WARNING).
"""

View file

@ -220,6 +220,61 @@ def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None:
)
# #2700: сколько попыток фазы должно быть, чтобы «отказали все» что-то значило.
# 3 — не круглое число, а порог, на котором сам сбор уже сдаётся: столько подряд
# неудачных detail'ов достаточно оркестратору, чтобы ротировать прокси и оборвать фазу
# (_cian_detail_abort в orchestration/pipeline.py). Замер на проде 2026-08-10 за 90
# суток: порог отсекает 2 прогона с ЕДИНСТВЕННОЙ попыткой (одиночный отказ — шум, не
# диагноз) и оставляет 50 прогонов, где отказали 3-50 попыток подряд.
_PHASE_MIN_ATTEMPTS = 3
def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None:
"""Фаза прогона, у которой отказала КАЖДАЯ попытка (#2700). Текст причины или None.
Прогон состоит из фаз, а статус у него один. `_sweep_run_did_nothing` (#2625) ловит
случай, когда не сделано НИЧЕГО; этот когда целое направление работы отказало на
сто процентов, а соседнее сработало, и суммарный ненулевой сбор прячет отказ.
Живой повод (#2700): `cian_city_sweep` 15 суток подряд писал `detail_attempted=50,
detail_failed=50, errors_count=0, status=done` каждая detail-страница отдавала
HTTP 403. Ноль обогащённых при 1 680 собранных лотах внешне неотличим от здорового
прогона: результатный счётчик (lots_fetched) ненулевой, а до `errors_count` отказ
подзадачи не доходил вовсе (403 гасился внутри провайдера в `return None`).
Признак собственная бухгалтерия фазы: `<phase>_failed == <phase>_attempted` при
`attempted >= _PHASE_MIN_ATTEMPTS`. Пары ищутся В САМИХ counters (любой ключ
`X_attempted` со спутником `X_failed`), а не по зашитому списку фаз: список это
ровно то место, куда забывают дописать новую фазу, и тогда сторож молчит, выглядя
настроенным. На проде за 90 суток таких пар четыре: detail/houses/address/imv.
Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) поэтому
'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'.
Замер на проде 2026-08-10 за 90 суток: правило переводит в 'failed' 52 прогона из
3 292 'done' (1.6%) 33 cian_city_sweep* (detail 26, houses 15 часть прогонов
попадает по обеим фазам) и 11 avito_city_sweep* (detail; про эти никто не знал).
Остальные 3 240 остаются 'done'.
"""
for key in sorted(counters):
if not key.endswith("_attempted"):
continue
phase = key[: -len("_attempted")]
attempted = _pick_int(counters, key)
failed = _pick_int(counters, f"{phase}_failed")
if attempted is None or failed is None:
continue
if attempted >= _PHASE_MIN_ATTEMPTS and failed == attempted:
return (
f"phase-honest-status: фаза '{phase}' отказала полностью — "
f"{failed} из {attempted} попыток неудачны, обогащено 0. Остальные фазы "
f"прогона могли отработать, поэтому ненулевой сбор это НЕ опровергает. "
f"Причина НЕ установлена: блок площадки, наш прокси или разбор — статус "
f"'failed' без диагноза (#2700)"
)
return None
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
@ -562,12 +617,21 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка:
страж, который надо не забыть позвать, это тот же дефект оборванной проводки,
из-за которого задача и появилась.
#2700: там же — отказ называть успехом прогон, у которого отказала КАЖДАЯ попытка
целой фазы (см. _phase_totally_failed). Отличие от #2625: тот случай про «не сделано
ничего», этот про «одно направление работы мертво, а суммарный сбор это прячет».
"""
did_nothing = _sweep_run_did_nothing(counters)
if did_nothing is not None:
logger.error("%s run_id=%d", did_nothing, run_id)
mark_failed(db, run_id, did_nothing, counters)
return
phase_dead = _phase_totally_failed(counters)
if phase_dead is not None:
logger.error("%s run_id=%d", phase_dead, run_id)
mark_failed(db, run_id, phase_dead, counters)
return
total_seen, new_count = _column_counts(counters)
row = db.execute(
text(

View file

@ -23,6 +23,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
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
from scraper_kit.offer_price_history import clamp_diff_percent
from scraper_kit.providers._base import build_curl_cffi_session
@ -76,6 +77,23 @@ class DetailEnrichment:
raw_sister_states: dict[str, Any] = field(default_factory=dict)
def _raise_if_blocked(offer_url: str, status_code: int) -> None:
"""HTTP 403 → `CianBlockedError`; остальные статусы — молча возврат (#2700).
Ровно один статус, и это не список маркеров: 403 отдаёт WAF-страница Циана
(`cian_waf_block`, 21 КБ), и живая проба 2026-08-10 показала, что через здоровые
узлы тот же URL отдаётся 200-й. 404 (объявление снято) баном не считается
иначе мёртвый лот снимал бы с выдачи здоровый узел.
Маркеры страницы намеренно НЕ проверяются: список маркеров объясняет уже
случившийся отказ и молчит про неизвестный (урок #2767 от 09.08 — страница
блокировки восемь суток проходила как «маркеров нет»). Статус ответа такого
свойства не имеет.
"""
if status_code == 403:
raise CianBlockedError(f"Cian detail {offer_url} → HTTP 403 (WAF-блок узла)")
async def fetch_detail(
offer_url: str,
*,
@ -99,6 +117,12 @@ async def fetch_detail(
Caller is responsible for the context-manager lifecycle of the fetcher.
Returns: DetailEnrichment, or None если fetch / parse failed.
Raises:
CianBlockedError: HTTP 403 на curl-путях WAF Циана отбил узел, с которого мы
пришли (#2700). Оба вызывающих в orchestration/pipeline.py уже считают
исключение в `errors_count`, а на own-session-пути оно дополнительно снимает
узел с выдачи Циану через `curl_proxy_url`.
"""
if browser_fetcher is not None:
# Browser path: get fully JS-rendered HTML; same parse path follows.
@ -111,6 +135,7 @@ async def fetch_detail(
# Shared curl_cffi-сессия (прокси уже применён caller'ом) — пул не трогаем.
resp = await session.get(offer_url, allow_redirects=True)
if resp.status_code != 200:
_raise_if_blocked(offer_url, resp.status_code)
logger.warning("Cian detail fetch %s → HTTP %d", offer_url, resp.status_code)
return None
html = resp.text
@ -132,6 +157,11 @@ async def fetch_detail(
try:
resp = await own_session.get(offer_url, allow_redirects=True)
if resp.status_code != 200:
# ВНУТРИ curl_proxy_url: поднятый отсюда ProxyBanError доходит до
# пула (mark_banned на пару «узел × cian», #2600 п.2). Раньше здесь
# был `return None` — узел получал mark_health(ok=True) и оставался
# в выдаче Циану (#2700, 15 суток по 50 отказов в сутки).
_raise_if_blocked(offer_url, resp.status_code)
logger.warning("Cian detail fetch %s → HTTP %d", offer_url, resp.status_code)
return None
html = resp.text