diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 856a7213..07a08672 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -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}") diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index c7c887d5..d1471ebf 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -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`). + + Признак — собственная бухгалтерия фазы: `_failed == _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( diff --git a/tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py b/tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py new file mode 100644 index 00000000..d6637486 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py @@ -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"] diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/cian_exceptions.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/cian_exceptions.py new file mode 100644 index 00000000..e1e38ab5 --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/cian_exceptions.py @@ -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). + """ diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index 108a7891..645d7420 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -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`). + + Признак — собственная бухгалтерия фазы: `_failed == _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( 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 85dfbd4f..196845d3 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 @@ -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