From 0c3cb512c9e9bf80e3f1b56ce9913863294e2b39 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 15:47:40 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/scrapers):=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D1=88=D0=B8?= =?UTF-8?q?=D0=B5=D1=81=D1=8F=20=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=D1=8B=20?= =?UTF-8?q?=D0=BF=D1=83=D0=BB=D0=B0=20=D0=BF=D1=80=D0=BE=D0=BA=D1=81=D0=B8?= =?UTF-8?q?=20(#2830)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три места продолжали брать egress из статичного SCRAPER_PROXY_URL, который не знает про scrape_proxy_source_bans. Тот же класс, что #2796 (Домклик, ноль лотов четверо суток) и #2798/#2821 (Циан, 403 пятнадцать суток при «узел здоров» в пуле). 1. cian_price_history (ручка POST /admin/scrape/cian-price-history) — узел из пула + вердикт обратно (mark_banned на 403, mark_health, release). Ловушка, из-за которой одного `proxy_provider=` мало: USE_PROXY_POOL_CURL задан только контейнеру scraper, а ручка живёт в backend, где флага нет — curl_proxy_url молча ушёл бы на env. Отсюда _PoolCurlConfig; иначе правка была бы зелёной и без эффекта. Пул пуст в проде → отказ до HTTP и разрыв батча, а не 50 попыток по 5 секунд. 2. GET /admin/scraper/health показывал settings.scraper_proxy_url как «прокси источника», пока трафик после #2825/#2831 выбирается пулом по запросу. Теперь тот же резолвер, что у боевых путей; пул исчерпан для источника → пусто, а не статичный узел (зелёная строка на месте отказа хуже пустой). Вердикт пулу отсюда НЕ уходит: ipify-проба про доступность ipify, а не про репутацию узла у площадки (#2805). 3. resolve_cian_zhk_url_via_search — вторая нога обогащения ЖК: #2767 перевёл на пул только fetch_newbuilding, резолв ЖК-url остался на config.cian_proxy_url. Плюс 403 там гасился в `return None` — узел получал mark_health(ok=True) и оставался в выдаче Циану (механика #2700). Сейчас ветка спящая: resolved_zhk_url=0 во всех 59 прогонах. Заодно удалён мёртвый resolve_cian_zhk_url: путь /zhk// 404-ит с #972, вызывающих не было ни одного, egress тоже был мимо пула — чинить незачем, удалить честнее. Тесты красные на старом коде поведенчески (пул не получил вердикт / панель показала static-env-node вместо пулового узла), не на отсутствии нового имени. Refs #2830 --- tradein-mvp/backend/app/api/v1/admin.py | 52 ++- .../app/services/cian_price_history.py | 58 ++- .../app/tasks/newbuilding_enrich_backfill.py | 7 +- .../tests/test_2830_pool_bypass_tails.py | 340 ++++++++++++++++++ .../backend/tests/test_scraper_admin_apis.py | 5 +- .../scraper_kit/providers/cian/newbuilding.py | 199 +++++----- 6 files changed, 520 insertions(+), 141 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index c5df222a..9eab49fe 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -74,6 +74,7 @@ from app.services import proxy_rotation as proxy_rotation_svc from app.services import scrape_runs as runs_mod from app.services.estimator import LISTINGS_FRESH_DAYS from app.services.geocoder import geocode, known_city_hint +from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url from app.services.proxy_pool import clear_source_bans from app.services.scheduler import has_running_run from app.services.scraper_adapters import ( @@ -2370,18 +2371,33 @@ class ScraperHealthResponse(BaseModel): _ROTATABLE_SOURCES = ("avito", "cian", "yandex") -def _provider_proxy_url(source: str) -> str | None: - """Effective proxy URL для source (учитывает property-fallback в settings). +def _provider_proxy_url(db: Session, source: str) -> str | None: + """Узел, который РЕАЛЬНО получит трафик этого источника (#2830). - #2616 шаг 2: avito/cian/yandex все три сходятся на settings.scraper_proxy_url - (per-provider AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — мёртвая - mobileproxy-подписка, #2613). + Раньше здесь стоял `settings.scraper_proxy_url` — одна и та же статичная + переменная для всех трёх источников. После #2825/#2831 egress выбирается из + `scrape_proxies` по запросу и с учётом `scrape_proxy_source_bans`, то есть + страница показывала один узел, а трафик шёл через другой — слепое пятно ровно + того класса, который спрятал инцидент 2026-08-10 (месяц сбора через узел, + забаненный и Avito, и Cian), только теперь на диагностической странице. + + Тот же резолвер, что у боевых ad-hoc путей (`cian_session.verify_session`, + `*_detail_backfill`) — не «похожая логика», иначе страница снова начнёт + расходиться с трафиком. + + Вердикт пулу отсюда НЕ уходит и уходить не должен (#2805): резолвер read-only, + lease не берёт, а ipify-проба ниже проверяет доступность ipify через узел, а не + его репутацию у Авито/Циана — присваивать узлу отказ по чужой пробе значит + выдавать ему чужой бан. """ - return { - "avito": settings.scraper_proxy_url, - "cian": settings.cian_proxy_url, - "yandex": settings.yandex_proxy_url, - }.get(source) + try: + return resolve_proxy_url(db, source) + except ProxyPoolExhaustedError: + # Пул не пуст, но для source не осталось ни одного здорового небаненного узла. + # resolve_proxy_url уже написал error с разбивкой; здесь отдаём None — пусть + # страница покажет «—», а не статичный env-узел (зелёная строка на месте + # отказа хуже пустой). + return None def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]: @@ -2497,19 +2513,23 @@ async def _probe_current_ip(proxy_url: str | None) -> str | None: @router.get("/scraper/health", response_model=ScraperHealthResponse) -async def scraper_health() -> ScraperHealthResponse: +async def scraper_health( + db: Annotated[Session, Depends(get_db)], +) -> ScraperHealthResponse: """Сводный health для единой scrapers-страницы: fetch_mode + browser + провайдеры. - fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser). - browser: GET tradein-browser /health (reachable + per-browser ready-флаги). - - providers: для avito/cian/yandex — proxy host/port, rotate_supported - (#2616 шаг 2: всегда False — changeip mobileproxy-ротация снята, мёртвый - аккаунт #2613; живая ASocks-ротация — POST /admin/proxies/{id}/rotate, #2611, - не per-provider-source), best-effort current_ip (пробинг через прокси на ipify). + - providers: для avito/cian/yandex — узел, который пул отдаст ЭТОМУ источнику + сейчас (#2830, см. `_provider_proxy_url`; пусто = ни одного небаненного + здорового узла), rotate_supported (#2616 шаг 2: всегда False — changeip + mobileproxy-ротация снята, мёртвый аккаунт #2613; живая ASocks-ротация — + POST /admin/proxies/{id}/rotate, #2611, не per-provider-source), best-effort + current_ip (пробинг через этот же узел на ipify). Все пробинги параллельны (asyncio.gather) и time-boxed — суммарно ≤10с. """ - proxy_urls = {s: _provider_proxy_url(s) for s in _ROTATABLE_SOURCES} + proxy_urls = {s: _provider_proxy_url(db, s) for s in _ROTATABLE_SOURCES} browser, *ips = await asyncio.gather( _probe_browser_health(), diff --git a/tradein-mvp/backend/app/services/cian_price_history.py b/tradein-mvp/backend/app/services/cian_price_history.py index fdf1518f..0e23310e 100644 --- a/tradein-mvp/backend/app/services/cian_price_history.py +++ b/tradein-mvp/backend/app/services/cian_price_history.py @@ -19,19 +19,40 @@ from dataclasses import dataclass, field # golden-parity была доказана против legacy cian_detail-модуля до его удаления, # #2397 Part E2; extract_state/ScrapedLot parity-тесты убраны вместе с остальным # legacy scrapers-каталогом, #2397 финальный шаг E — kit единственный живой путь). -# RealScraperConfig — тот же read-only адаптер над settings, что и остальные -# kit-инжекции (#2131) — сохраняет proxy-поведение (config.cian_proxy_url) -# идентичным прежнему прямому импорту settings. from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment +from scraper_kit.proxy_errors import NoProxyAvailableError from sqlalchemy import text from sqlalchemy.orm import Session -from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig +from app.services.scraper_adapters import ( + RealMatcherAdapter, + RealProxyProvider, + RealScraperConfig, +) from app.services.scraper_settings import get_scraper_delay logger = logging.getLogger(__name__) +class _PoolCurlConfig(RealScraperConfig): + """RealScraperConfig с принудительно включённым pool-режимом curl (#2830). + + `USE_PROXY_POOL_CURL` задан только контейнеру `scraper` (docker-compose.prod.yml + services.scraper.environment), а этот бэкфилл запускается ручкой + `POST /admin/scrape/cian-price-history` в контейнере `backend`, где переменной нет + → `settings.use_proxy_pool_curl` = False. С ней `providers/_proxy.py::curl_proxy_url` + ИГНОРИРУЕТ переданный `proxy_provider` и уходит на статичный `SCRAPER_PROXY_URL`: + один `proxy_provider=` был бы правкой без эффекта (зелёный тест, нулевой прод). + + Флаг — рубильник раскатки pool-режима для планировщика, а не решение «этому пути + пул не нужен»: инцидент 2026-08-10 (#2830) — ровно про то, что нужен именно ему. + """ + + @property + def use_proxy_pool_curl(self) -> bool: + return True + + @dataclass class CianPriceHistoryResult: checked: int = 0 @@ -60,6 +81,11 @@ async def backfill_cian_price_history( result = CianPriceHistoryResult() t0 = time.time() delay = get_scraper_delay("cian") # default 5.0s + # Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает + # `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт + # (mark_banned на CianBlockedError / mark_health / release). + scraper_config = _PoolCurlConfig() + proxy_provider = RealProxyProvider() if listing_id is not None: rows = ( @@ -107,9 +133,27 @@ async def backfill_cian_price_history( url: str = row["source_url"] try: - # config= обязателен — kit fetch_detail без него не читает cian_proxy_url - # (direct connection), а без прокси datacenter-IP блокируется Cian (#806). - enrichment = await fetch_detail(url, config=RealScraperConfig()) + # config= обязателен — без него kit fetch_detail идёт напрямую, а без прокси + # datacenter-IP блокируется Cian (#806). proxy_provider= — узел из пула + # (#2830): раньше здесь был статичный SCRAPER_PROXY_URL, не знающий про + # `scrape_proxy_source_bans`, и 403 от отбитого узла никому не сообщался. + enrichment = await fetch_detail( + url, config=scraper_config, proxy_provider=proxy_provider + ) + except NoProxyAvailableError as exc: + # Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги + # упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с + # логом, который читается как «Циан нас блокирует». + logger.error( + "cian_price_history: нет доступного прокси в пуле (%s) — батч прерван " + "на listing_id=%s (обработано %d из %d)", + exc, + lid, + i, + len(rows), + ) + result.errors += 1 + break except Exception as exc: logger.warning( "cian_price_history: fetch failed listing_id=%s url=%s: %s", diff --git a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py index 632ec801..bf71333d 100644 --- a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py +++ b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py @@ -414,6 +414,9 @@ async def backfill_newbuilding_enrichment( # уходили в тот же адрес (страница блокировки вместо карточки). Провайдер здесь ≠ # «включить пул»: реально пул задействуется, только если включён # config.use_proxy_pool_browser (build_browser_fetcher внутри fetch_newbuilding). + # #2830: тот же провайдер уходит и в resolve-ногу (curl_cffi, флаг + # use_proxy_pool_curl) — #2767 починил только fetch, а резолв ЖК-url остался на + # статичном cian_proxy_url, то есть на второй ноге той же цепочки. proxy_provider = RealProxyProvider() result = NewbuildingEnrichBackfillResult() @@ -491,7 +494,9 @@ async def backfill_newbuilding_enrichment( continue try: - resolved = await resolve_cian_zhk_url_via_search(nb_id, config=scraper_config) + resolved = await resolve_cian_zhk_url_via_search( + nb_id, config=scraper_config, proxy_provider=proxy_provider + ) except Exception as exc: # defensive — resolver already catches internally logger.warning( "zhk-url resolve raised house_id=%s nb_id=%s: %s", house_id, nb_id, exc diff --git a/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py new file mode 100644 index 00000000..a6fb3820 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py @@ -0,0 +1,340 @@ +"""#2830: хвосты инцидента 2026-08-10 — пути, которые ещё ходили мимо пула прокси. + +Корень (issue #2830, разбор владельца): `SCRAPER_PROXY_URL` — один статичный узел, +общий для всех источников и НЕ знающий про `scrape_proxy_source_bans`. Три места +продолжали брать egress именно оттуда: + + 1. `cian_price_history` (ручка POST /admin/scrape/cian-price-history); + 2. `_provider_proxy_url` / GET /admin/scraper/health — ops-панель показывала статичный + узел, пока трафик уже выбирался пулом (#2825/#2831); + 3. `resolve_cian_zhk_url_via_search` — вторая нога обогащения ЖК: #2767 перевёл на пул + только `fetch_newbuilding`, резолв ЖК-url остался на `config.cian_proxy_url`. + +Тесты проверяют ПОВЕДЕНИЕ пула (какой узел взят и какой вердикт вернулся), а не наличие +kwarg'а в вызове. Красные на старом коде: + + * (1) `fetch_detail` вызывался без `proxy_provider` → lease не брался, 403 никому не + сообщался: `mark_banned_calls == []`. Плюс ловушка «правка без эффекта»: + `USE_PROXY_POOL_CURL` задан только контейнеру `scraper`, а ручка живёт в `backend`, + где флага нет — один `proxy_provider=` пул бы не включил (см. `_PoolCurlConfig`). + * (2) `_provider_proxy_url(source)` возвращал `settings.scraper_proxy_url` и не имел + параметра `db` — вызов из теста падал бы на сигнатуре, а исход «пул исчерпан» + выражения не имел вообще. + * (3) `resolve_cian_zhk_url_via_search` на 403 возвращал None: узел получал + `mark_health(ok=True)` и оставался в выдаче Циану (механика #2700/#2821). +""" + +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.providers.cian import detail as cian_detail +from scraper_kit.providers.cian import newbuilding as cian_newbuilding +from scraper_kit.proxy_errors import NoProxyAvailableError + +# Узел 9 (asocks-mobile-1) — тот, что 2026-08-10 отдавал Циану 200, пока статичный +# SCRAPER_PROXY_URL смотрел на забаненный узел 1 (asocks-residential-1). +_LEASE = ProxyLease(id=9, url="http://user:pass@pool-node-9:10313", kind="http", rotate_url=None) + + +class _SpyProvider: + """ProxyProvider-заглушка: запоминает вердикты, которые путь вернул пулу.""" + + def __init__(self, lease: ProxyLease | None = _LEASE) -> None: + self._lease = lease + self.acquire_calls: list[str] = [] + 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 | None: + self.acquire_calls.append(provider) + return self._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)) + + +class _SpyPool: + """Заглушка МОДУЛЯ `app.services.proxy_pool` — подставляется ПОД `RealProxyProvider`. + + Так тест краснеет на поведении (пул не получил вердикт), а не на отсутствии нового + имени в модуле: старый код проходит ровно тот же путь, просто ничего пулу не говорит. + """ + + def __init__(self, lease: Any = _LEASE) -> None: + self._lease = lease + self.acquire_calls: list[str] = [] + self.mark_health_calls: list[tuple[int, bool]] = [] + self.mark_banned_calls: list[tuple[int, str]] = [] + self.release_calls: list[int] = [] + + def acquire(self, db: Any, provider: str, **kw: Any) -> Any: + self.acquire_calls.append(provider) + return self._lease + + def release(self, db: Any, lease_id: int) -> None: + self.release_calls.append(lease_id) + + def mark_health(self, db: Any, lease_id: int, ok: bool, **kw: Any) -> None: + self.mark_health_calls.append((lease_id, ok)) + + def mark_banned(self, db: Any, lease_id: int, *, 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 + + +# ── 1. cian_price_history: узел из пула + вердикт обратно ──────────────────── + + +def _price_history_db(n_listings: int) -> MagicMock: + """Фейковая сессия: SELECT листингов, дальше COUNT'ы (до fetch дело не дойдёт).""" + rows = [ + {"id": 100 + i, "source_url": f"https://ekb.cian.ru/sale/flat/{100 + i}/"} + for i in range(n_listings) + ] + listings_result = MagicMock() + listings_result.mappings.return_value.all.return_value = rows + db = MagicMock() + db.execute.return_value = listings_result + return db + + +async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) -> Any: + from app.services import scraper_adapters + from app.services.cian_price_history import backfill_cian_price_history + + with ( + patch.object( + cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code) + ), + patch("app.services.cian_price_history.get_scraper_delay", return_value=0.0), + patch.object(scraper_adapters, "_proxy_pool", pool), + patch.object(scraper_adapters, "_SessionLocal", MagicMock()), + ): + return await backfill_cian_price_history(_price_history_db(n), batch_size=n) + + +@pytest.mark.asyncio +async def test_price_history_takes_pool_node_despite_flag_off() -> None: + """Узел берётся из пула даже при выключенном USE_PROXY_POOL_CURL (контейнер backend). + + Красный на старом коде дважды: не было ни `proxy_provider=`, ни принудительного + pool-режима — `curl_proxy_url` уходил на статичный env-узел и `acquire` не звал. + """ + from app.core.config import settings + + assert ( + settings.use_proxy_pool_curl is False + ), "тест обязан идти тем же путём, что прод-контейнер backend: без USE_PROXY_POOL_CURL" + pool = _SpyPool() + await _run_price_history(pool, status_code=200) + assert pool.acquire_calls == ["cian"] + assert pool.release_calls == [9] # lease не течёт + + +@pytest.mark.asyncio +async def test_price_history_403_bans_the_node_for_cian() -> None: + """403 от Циана снимает узел с выдачи ИМЕННО Циану. Красный: было `mark_banned` = [].""" + pool = _SpyPool() + result = await _run_price_history(pool, status_code=403) + assert pool.mark_banned_calls == [(9, "cian")] + assert pool.mark_health_calls == [(9, False)] + assert result.errors == 1 # прогон честен: отказ посчитан + + +@pytest.mark.asyncio +async def test_price_history_404_does_not_ban_the_node() -> None: + """Снятое объявление — не бан площадки: чужой вердикт узлу не присваиваем (#2805).""" + pool = _SpyPool() + await _run_price_history(pool, status_code=404) + assert pool.mark_banned_calls == [] + assert pool.mark_health_calls == [(9, True)] + + +@pytest.mark.asyncio +async def test_price_history_aborts_batch_when_pool_empty() -> None: + """Пул пуст в проде → отказ ДО HTTP и разрыв батча, а не 3 попытки подряд.""" + from app.core.config import settings + + pool = _SpyPool(lease=None) + # Прод-признак читает `curl_proxy_url` через config.environment (#2616 шаг 1): + # только в проде пустой пул = отказ, в dev это легитимный env/direct-путь. + with patch.object(settings, "environment", "production"): + result = await _run_price_history(pool, status_code=200, n=3) + assert pool.acquire_calls == ["cian"], "после отказа пула батч продолжаться не должен" + assert result.errors == 1 + assert result.checked == 3 # выбрали 3, но оборвались на первом + + +def test_no_proxy_available_is_not_a_platform_ban() -> None: + """Наш отказ инфраструктуры не должен читаться как бан площадки (#2616).""" + from scraper_kit.proxy_errors import ProxyBanError + + assert not issubclass(NoProxyAvailableError, ProxyBanError) + + +# ── 2. /admin/scraper/health: показывает узел, который реально получит трафик ── + + +def _health_db(*, candidate: dict[str, Any] | None, diag: dict[str, int]) -> MagicMock: + """Сессия для `proxy_egress`: SELECT кандидата, при пустом — SELECT-разбивка причин.""" + + def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: + row = diag if "count(*)" in str(stmt).lower() else candidate + res = MagicMock() + res.mappings.return_value.fetchone.return_value = row + return res + + db = MagicMock() + db.execute.side_effect = _execute + return db + + +def _get_health(db: MagicMock) -> dict[str, Any]: + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from app.api.v1 import admin as admin_module + from app.core.db import get_db + + def _fake_db() -> Any: + yield db + + app = FastAPI() + app.include_router(admin_module.router, prefix="/api/v1/admin") + app.dependency_overrides[get_db] = _fake_db + + async def _no_browser() -> Any: + return admin_module.BrowserHealth(reachable=False, browsers={}) + + async def _no_ip(proxy_url: str | None) -> str | None: + return None + + with ( + patch.object(admin_module, "_probe_browser_health", _no_browser), + patch.object(admin_module, "_probe_current_ip", _no_ip), + # Статичный env-узел ОТЛИЧАЕТСЯ от пулового — иначе тест не различит источники. + patch.object( + admin_module.settings.__class__, + "scraper_proxy_url", + property(lambda _self: "http://u:p@static-env-node:10423"), + ), + ): + r = TestClient(app).get("/api/v1/admin/scraper/health") + assert r.status_code == 200 + return {p["source"]: p for p in r.json()["providers"]} + + +def test_health_shows_pool_node_not_static_env() -> None: + """Панель показывает узел, который получит трафик, а не SCRAPER_PROXY_URL. + + Красный на старом коде поведенчески: возвращался `static-env-node` — ровно то + расхождение «панель показывает один узел, трафик идёт через другой», из-за + которого инцидент 2026-08-10 месяц оставался невидимым. + """ + db = _health_db( + candidate={"id": 9, "url": _LEASE.url, "label": "asocks-mobile-1"}, + diag={"pool_total": 4, "banned_for_source": 0, "unhealthy_or_disabled": 0}, + ) + by_source = _get_health(db) + assert {p["proxy_host"] for p in by_source.values()} == {"pool-node-9"} + assert by_source["cian"]["proxy_port"] == 10313 + + +def test_health_shows_nothing_when_pool_exhausted_for_source() -> None: + """Все узлы забанены для источника → «—», а не статичный env-узел. + + Зелёная строка на месте отказа хуже пустой: она читается как «прокси есть». + """ + db = _health_db( + candidate=None, + diag={"pool_total": 4, "banned_for_source": 4, "unhealthy_or_disabled": 0}, + ) + by_source = _get_health(db) + assert by_source["cian"]["proxy_host"] is None + assert by_source["cian"]["proxy_port"] is None + + +# ── 3. resolve ЖК-url: вторая нога обогащения (#2767 чинил только первую) ───── + + +@dataclass +class _FakeConfig: + use_proxy_pool_curl: bool = True + cian_proxy_url: str | None = "http://static-env-node:10423" + environment: str = "production" + + +async def _resolve(status_code: int, spy: _SpyProvider, html: str = "") -> Any: + with patch.object( + cian_newbuilding, + "build_curl_cffi_session", + return_value=_session_returning(status_code, html), + ): + return await cian_newbuilding.resolve_cian_zhk_url_via_search( + 48853, config=_FakeConfig(), proxy_provider=spy + ) + + +@pytest.mark.asyncio +async def test_zhk_resolve_uses_pool_node() -> None: + """Резолв ЖК-url берёт узел из пула, а не статичный cian_proxy_url.""" + spy = _SpyProvider() + html = '

ЖК

' + assert await _resolve(200, spy, html) == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru" + assert spy.acquire_calls == ["cian"] + assert spy.mark_health_calls == [(9, True)] + assert spy.release_calls == [9] + + +@pytest.mark.asyncio +async def test_zhk_resolve_403_reaches_the_pool() -> None: + """Красный на старом коде: 403 гасился в `return None`, узел оставался в выдаче.""" + spy = _SpyProvider() + with pytest.raises(CianBlockedError): + await _resolve(403, spy) + assert spy.mark_banned_calls == [(9, "cian")] + assert spy.mark_health_calls == [(9, False)] + assert spy.release_calls == [9] + + +@pytest.mark.asyncio +async def test_zhk_resolve_404_is_a_soft_miss_not_a_ban() -> None: + """404 cat.php — не бан: здоровый узел за отсутствующий ЖК не наказываем.""" + spy = _SpyProvider() + assert await _resolve(404, spy) is None + assert spy.mark_banned_calls == [] + assert spy.mark_health_calls == [(9, True)] + + +@pytest.mark.asyncio +async def test_zhk_resolve_empty_serp_is_a_soft_miss_not_a_ban() -> None: + """200 без слага (дрейф вёрстки / пустая выдача) — тоже не повод банить узел.""" + spy = _SpyProvider() + assert await _resolve(200, spy, "ничего") is None + assert spy.mark_banned_calls == [] + assert spy.mark_health_calls == [(9, True)] diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py index ee2fface..813d78a2 100644 --- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py +++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py @@ -156,7 +156,8 @@ def test_health_assembles_browser_and_providers(client: TestClient) -> None: with ( patch.object(admin_module, "_probe_browser_health", fake_browser_health), patch.object(admin_module, "_probe_current_ip", fake_current_ip), - patch.object(admin_module, "_provider_proxy_url", lambda s: proxy_urls[s]), + # #2830: узел резолвится из пула по (db, source), а не из статичного env + patch.object(admin_module, "_provider_proxy_url", lambda _db, s: proxy_urls[s]), patch.object(admin_module.settings, "scraper_fetch_mode", "browser"), ): r = client.get("/api/v1/admin/scraper/health") @@ -207,7 +208,7 @@ def test_health_browser_unreachable(client: TestClient) -> None: with ( patch.object(admin_module.httpx, "AsyncClient", _BoomClient), patch.object(admin_module, "_probe_current_ip", fake_current_ip), - patch.object(admin_module, "_provider_proxy_url", lambda s: None), + patch.object(admin_module, "_provider_proxy_url", lambda _db, _s: None), ): r = client.get("/api/v1/admin/scraper/health") diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py index 58bede93..74a239b0 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py @@ -25,8 +25,10 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any from scraper_kit.browser_fetcher import BrowserFetcher +from scraper_kit.cian_exceptions import CianBlockedError from scraper_kit.cian_state_parser import extract_all_states, extract_state from scraper_kit.providers._base import build_browser_fetcher, build_curl_cffi_session +from scraper_kit.providers._proxy import curl_proxy_url if TYPE_CHECKING: from curl_cffi.requests import AsyncSession # type: ignore[import-untyped] @@ -801,70 +803,11 @@ def save_newbuilding_enrichment( ) -async def resolve_cian_zhk_url( - cian_internal_house_id: int, - *, - config: ScraperConfig | None = None, - session: AsyncSession | None = None, -) -> str | None: - """Resolve canonical ZHK slug URL from a Cian internal house ID (LEGACY — BROKEN). - - .. deprecated:: - The redirect path this relies on — ``https://cian.ru/zhk//`` → canonical - slug — NO LONGER EXISTS. Cian now serves HTTP **404** for ``/zhk//`` - (verified 8/8 on prod, #972), so this returns None for every real id. Use - :func:`resolve_cian_zhk_url_via_search` instead: it fetches the cat.php - newbuilding-SERP and extracts the canonical ``zhk-*.cian.ru`` slug from its - markup (the WORKING path). Kept only for backward-compat / reference; do not - wire new callers to it. - - Args: - cian_internal_house_id: Cian's numeric ЖК identifier. - session: optional shared curl_cffi AsyncSession (caller owns lifecycle). - - Returns: - Canonical ZHK URL string, or None if request failed / redirect not followed. - - Note: - This function makes a real HTTP request — do NOT call it without rate limiting. - Caller must enforce per-request sleep matching scraper_settings 'cian' delay. - """ - close_session = False - if session is None: - # Mobile proxy wiring (#806 follow-up): resolve ЖК URL через мобильный прокси. - _proxy_url = config.cian_proxy_url if config is not None else None - session = build_curl_cffi_session( - proxy_url=_proxy_url, - timeout=15.0, - headers={ - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8", - }, - ) - close_session = True - - fallback_url = f"https://cian.ru/zhk/{cian_internal_house_id}/" - try: - resp = await session.get(fallback_url, allow_redirects=True) - final_url = str(resp.url) - if final_url and final_url != fallback_url: - logger.debug("resolve_cian_zhk_url id=%s → %s", cian_internal_house_id, final_url) - return final_url - # Redirect not followed or same URL — return fallback as canonical - if resp.status_code == 200: - return fallback_url - logger.warning( - "resolve_cian_zhk_url id=%s: HTTP %d, no usable URL", - cian_internal_house_id, - resp.status_code, - ) - return None - except Exception as exc: - logger.warning("resolve_cian_zhk_url id=%s failed: %s", cian_internal_house_id, exc) - return None - finally: - if close_session: - await session.close() +# Legacy `resolve_cian_zhk_url` (nb_id → https://cian.ru/zhk// → редирект на слаг) +# удалён в #2830: путь редиректа не существует с #972 (Циан отдаёт 404, проверено 8/8 на +# проде), вызывающих у функции не было ни одного, а собственную curl-сессию она строила +# на статичном `config.cian_proxy_url` — то есть мимо пула. Чинить egress мёртвого пути +# незачем; удалить честнее. Рабочий резолвер — `resolve_cian_zhk_url_via_search` ниже. def _extract_zhk_url_from_serp(html: str) -> str | None: @@ -897,59 +840,19 @@ def _extract_zhk_url_from_serp(html: str) -> str | None: return match.group(0) if match else None -async def resolve_cian_zhk_url_via_search( - nb_id: int, - *, - config: ScraperConfig | None = None, - session: AsyncSession | None = None, -) -> str | None: - """Resolve the canonical ЖК-slug URL for a Cian newbuilding id (the WORKING path). +async def _fetch_zhk_slug(session: AsyncSession, nb_id: int) -> str | None: + """Один GET cat.php-SERP → канонический zhk-слаг. None на любом мягком отказе. - Fetches the cat.php newbuilding-SERP for a single ``newobject[0]=`` and - extracts the canonical ``https://zhk-.cian.ru`` URL from its markup. This - replaces the legacy :func:`resolve_cian_zhk_url`, whose ``/zhk//`` redirect path - now 404s (verified on prod, #972). The returned slug URL is exactly what - :func:`fetch_newbuilding` parses, so the enrichment chain is - ``nb_id → cat.php SERP → zhk-slug-url → fetch_newbuilding → enrich``. - - Verified live (HTTP 200, slug extracted): - nb 48853 → https://zhk-parkovyy-kvartal-ekb-i.cian.ru (ЖК «Парковый квартал») - nb 102791 → https://zhk-izumrudnyy-bor-ekb-i.cian.ru - nb 24991 → https://zhk-baltym-park-ekb-i.cian.ru - - Args: - nb_id: Cian newbuilding id (``house_sources.ext_id`` for cian houses). - session: optional shared curl_cffi AsyncSession (caller owns lifecycle). When - None, an own session is created using the same mobile-proxy wiring as the - rest of the Cian scrapers (``config.cian_proxy_url`` when set, else direct). - - Returns: - The canonical ЖК-slug URL string, or None on non-200 / empty SERP / no match / - request failure (each logs a warning). - - Note: - Makes ONE real HTTP request and does NOT sleep — the CALLER enforces the - anti-bot delay (matching scraper_settings 'cian'). At scale this needs the - mobile proxy; low-volume direct fetches work for the bounded proof. + Raises: + CianBlockedError: HTTP 403 — WAF Циана отбил узел, с которого мы пришли + (#2700). Мягкие отказы (404, пустой SERP, дрейф вёрстки) остаются None: + наказывать за них здоровый узел нельзя. """ - close_session = False - if session is None: - # Mobile proxy wiring (#806): Cian блокирует datacenter-IP. proxy=None → прямое - # подключение (dev / proxy-down fallback — single fetches survive direct). - _proxy_url = config.cian_proxy_url if config is not None else None - session = build_curl_cffi_session( - proxy_url=_proxy_url, - timeout=30.0, - headers={ - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8", - }, - ) - close_session = True - serp_url = _CATPH_NEWBUILDING_SERP.format(nb_id=nb_id) try: resp = await session.get(serp_url, allow_redirects=True) + if resp.status_code == 403: + raise CianBlockedError(f"Cian cat.php nb_id={nb_id} → HTTP 403 (WAF-блок узла)") if resp.status_code != 200: logger.warning( "resolve_cian_zhk_url_via_search nb_id=%s: cat.php → HTTP %d", @@ -967,9 +870,75 @@ async def resolve_cian_zhk_url_via_search( return None logger.info("resolve_cian_zhk_url_via_search nb_id=%s → %s", nb_id, zhk_url) return zhk_url + except CianBlockedError: + # НЕ гасим: снаружи это `with curl_proxy_url(...)`, и только оттуда бан доходит + # до пула (mark_banned на пару «узел × cian»). Проглоченный 403 здесь означал бы + # mark_health(ok=True) на отбитый узел — механика #2700/#2821. + raise except Exception as exc: logger.warning("resolve_cian_zhk_url_via_search nb_id=%s failed: %s", nb_id, exc) return None - finally: - if close_session: - await session.close() + + +async def resolve_cian_zhk_url_via_search( + nb_id: int, + *, + config: ScraperConfig | None = None, + session: AsyncSession | None = None, + proxy_provider: ProxyProvider | None = None, +) -> str | None: + """Resolve the canonical ЖК-slug URL for a Cian newbuilding id (the WORKING path). + + Fetches the cat.php newbuilding-SERP for a single ``newobject[0]=`` and + extracts the canonical ``https://zhk-.cian.ru`` URL from its markup (the + legacy ``/zhk//`` redirect resolver 404s since #972 and is gone, #2830). The + returned slug URL is exactly what :func:`fetch_newbuilding` parses, so the + enrichment chain is ``nb_id → cat.php SERP → zhk-slug-url → fetch_newbuilding``. + + Verified live (HTTP 200, slug extracted): + nb 48853 → https://zhk-parkovyy-kvartal-ekb-i.cian.ru (ЖК «Парковый квартал») + nb 102791 → https://zhk-izumrudnyy-bor-ekb-i.cian.ru + nb 24991 → https://zhk-baltym-park-ekb-i.cian.ru + + Args: + nb_id: Cian newbuilding id (``house_sources.ext_id`` for cian houses). + session: optional shared curl_cffi AsyncSession (caller owns lifecycle; прокси + уже применён вызывающим — пул тут не трогаем). + proxy_provider: пул прокси (#2830). Без него собственная сессия строилась на + статичном ``config.cian_proxy_url`` — мимо ``scrape_proxy_source_bans``, + то есть вторая нога обогащения ЖК ходила ровно тем путём, который #2767 + починил у первой (``fetch_newbuilding``). + + Returns: + The canonical ЖК-slug URL string, or None on non-200 / empty SERP / no match / + request failure (each logs a warning). + + Raises: + CianBlockedError: HTTP 403 на own-session-пути — узел снимается с выдачи Циану + через ``curl_proxy_url``. Вызывающий (``newbuilding_enrich_backfill``) уже + считает исключение в ``failed_resolve``. + + Note: + Makes ONE real HTTP request and does NOT sleep — the CALLER enforces the + anti-bot delay (matching scraper_settings 'cian'). + """ + if session is not None: + return await _fetch_zhk_slug(session, nb_id) + + # Own-session path. Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env + # cian_proxy_url; пусто → прямое подключение (dev/no-op). + # curl_proxy_url: mark_banned на CianBlockedError + mark_health + release на выходе. + _env = config.cian_proxy_url if config is not None else None + with curl_proxy_url(config, proxy_provider, "cian", env_fallback_url=_env) as _proxy_url: + own_session = build_curl_cffi_session( + proxy_url=_proxy_url, + timeout=30.0, + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8", + }, + ) + try: + return await _fetch_zhk_slug(own_session, nb_id) + finally: + await own_session.close()