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 = '