diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index dfc68eac..c1bae4cd 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -40,7 +40,6 @@ from pydantic import BaseModel, Field, field_validator # убрал legacy app.services.scheduler.scheduler_loop fallback) — тот же orchestrator, # что и debug-роуты этого файла. from scraper_kit.base import save_listings -from scraper_kit.browser_fetcher import BrowserFetcher from scraper_kit.orchestration.pipeline import ( DEFAULT_REGION_CODE, run_avito_city_sweep, @@ -49,6 +48,7 @@ from scraper_kit.orchestration.pipeline import ( run_yandex_city_sweep, run_yandex_full_load, ) +from scraper_kit.providers._base import build_browser_fetcher from scraper_kit.providers.avito.detail import fetch_detail, save_detail_enrichment from scraper_kit.providers.avito.houses import fetch_house_catalog, save_house_catalog_enrichment from scraper_kit.providers.avito.imv import ( @@ -508,8 +508,12 @@ async def cian_auto_login( ) try: - async with BrowserFetcher( - source="cian", endpoint=settings.browser_http_endpoint + # #3197 (хвост): через фабрику, а не BrowserFetcher(source=, endpoint=) напрямую — + # прямая конструкция не проставляла proxy_provider/use_pool/environment, и сайдкар + # логинился со своего env-узла SCRAPER_PROXY_URL мимо пула (на проде это выключенный + # узел 9: 407 → camoufox InvalidIP), а прод-отказ «пул пуст» (#2616) тут был мёртв. + async with build_browser_fetcher( + RealScraperConfig(), "cian", proxy_provider=_kit_proxy_provider() ) as fetcher: raw_cookies = await fetcher.login( url=settings.cian_login_url, @@ -699,7 +703,12 @@ async def debug_domclick_detail_fetch( # построен и verified live именно 2026-07-04 (815КБ __SSR_STATE__ через свежий IP). # Заменяет прежний source="cian" (docstring domclick/detail.py — устаревшая # рекомендация от 2026-06-27, до появления выделенного пула). - async with BrowserFetcher(source="domclick", endpoint=settings.browser_http_endpoint) as bf: + # #3197 (хвост): фабрика вместо прямой конструкции — она и есть то место, где + # proxy_provider/use_pool/environment попадают в тело POST /fetch. Без них узел + # выбирал сайдкар из своего env, а не пул с provider_affinity='domclick'. + async with build_browser_fetcher( + RealScraperConfig(), "domclick", proxy_provider=_kit_proxy_provider() + ) as bf: try: enrichment = await domclick_fetch_detail( body.card_url, browser_fetcher=bf, cookies=cookies diff --git a/tradein-mvp/backend/app/services/cian_price_history.py b/tradein-mvp/backend/app/services/cian_price_history.py index 0e23310e..21146957 100644 --- a/tradein-mvp/backend/app/services/cian_price_history.py +++ b/tradein-mvp/backend/app/services/cian_price_history.py @@ -34,25 +34,6 @@ 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 @@ -84,7 +65,13 @@ async def backfill_cian_price_history( # Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает # `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт # (mark_banned на CianBlockedError / mark_health / release). - scraper_config = _PoolCurlConfig() + # + # Флаг читается из окружения как у всех (#3386 хвост): до #3387 у контейнера + # `backend` не было `USE_PROXY_POOL_CURL`, и здесь стоял подкласс с зашитым + # `use_proxy_pool_curl = True` — иначе `curl_proxy_url` игнорировал бы + # `proxy_provider`. Теперь переменная задана и сервису `backend` (compose), а + # зашитая константа делала рубильник неотключаемым ровно на этом пути. + scraper_config = RealScraperConfig() proxy_provider = RealProxyProvider() if listing_id is not None: diff --git a/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py index 8384a28a..3c559fc8 100644 --- a/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py +++ b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py @@ -14,9 +14,12 @@ kwarg'а в вызове. Красные на старом коде: * (1) `fetch_detail` вызывался без `proxy_provider` → lease не брался, 403 никому не - сообщался: `mark_banned_calls == []`. Плюс ловушка «правка без эффекта»: - `USE_PROXY_POOL_CURL` задан только контейнеру `scraper`, а ручка живёт в `backend`, - где флага нет — один `proxy_provider=` пул бы не включил (см. `_PoolCurlConfig`). + сообщался: `mark_banned_calls == []`. Ловушка «правка без эффекта» была в том, что + `USE_PROXY_POOL_CURL` задавался только контейнеру `scraper`, а ручка живёт в + `backend`, где флага не было — один `proxy_provider=` пул бы не включил. Отсюда взялся + подкласс конфига с зашитым `use_proxy_pool_curl = True`; #3387 задал переменную и + сервису `backend`, костыль снят (#3386 хвост), флаг снова управляет путём в обе + стороны — см. `test_price_history_honours_flag_off`. * (2) `_provider_proxy_url(source)` возвращал `settings.scraper_proxy_url` и не имел параметра `db` — вызов из теста падал бы на сигнатуре, а исход «пул исчерпан» выражения не имел вообще. @@ -123,7 +126,10 @@ def _price_history_db(n_listings: int) -> MagicMock: return db -async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) -> Any: +async def _run_price_history( + pool: _SpyPool, *, status_code: int, n: int = 1, use_pool: bool = True +) -> Any: + from app.core.config import settings from app.services import scraper_adapters from app.services.cian_price_history import backfill_cian_price_history @@ -132,6 +138,7 @@ async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) -> 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(settings, "use_proxy_pool_curl", use_pool), patch.object(scraper_adapters, "_proxy_pool", pool), patch.object(scraper_adapters, "_SessionLocal", MagicMock()), ): @@ -139,23 +146,33 @@ async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) -> @pytest.mark.asyncio -async def test_price_history_takes_pool_node_despite_flag_off() -> None: - """Узел берётся из пула даже при выключенном USE_PROXY_POOL_CURL (контейнер backend). +async def test_price_history_takes_pool_node() -> None: + """Узел берётся из пула, вердикт возвращается, lease не течёт. - Красный на старом коде дважды: не было ни `proxy_provider=`, ни принудительного - pool-режима — `curl_proxy_url` уходил на статичный env-узел и `acquire` не звал. + Красный на коде до #2830: не было `proxy_provider=` — `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_honours_flag_off() -> None: + """USE_PROXY_POOL_CURL=false → честный env-путь, а не пул через зашитую константу. + + #3386 (хвост): пока в сервисе жил подкласс `RealScraperConfig` с + `use_proxy_pool_curl = True`, рубильник на этом пути был неотключаем — тест красный + на main (`acquire_calls == ["cian"]`). После #3387 переменная задана контейнеру + `backend` в compose, костыль лишний, и флаг снова управляет обеими сторонами. + """ + pool = _SpyPool() + result = await _run_price_history(pool, status_code=200, use_pool=False) + assert pool.acquire_calls == [], "при выключенном флаге пул не трогаем" + assert result.checked == 1, "запрос всё равно идёт — просто env-прокси, как раньше" + + @pytest.mark.asyncio async def test_price_history_403_bans_the_node_for_cian() -> None: """403 от Циана снимает узел с выдачи ИМЕННО Циану. Красный: было `mark_banned` = [].""" diff --git a/tradein-mvp/backend/tests/test_3197_admin_debug_browser_pool_wiring.py b/tradein-mvp/backend/tests/test_3197_admin_debug_browser_pool_wiring.py new file mode 100644 index 00000000..09896500 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3197_admin_debug_browser_pool_wiring.py @@ -0,0 +1,150 @@ +"""#3197 (хвост) — две служебные ручки admin.py строили BrowserFetcher мимо пула. + +`POST /admin/scrape/cian/auto-login` и `POST /admin/scrape/domclick/debug/detail-fetch` +конструировали `BrowserFetcher(source=..., endpoint=settings.browser_http_endpoint)` +напрямую — без `proxy_provider`/`use_pool`/`environment`, единственных трёх аргументов, +которые кладут "proxy" в тело POST /fetch сайдкара. Без них сайдкар берёт свой env-узел +(`SCRAPER_PROXY_URL`), на проде это выключенный узел 9 (407 → camoufox `InvalidIP`), а +прод-отказ «пул пуст → не ходить на env/direct» (#2616) на этих путях был мёртв: он +смотрит на `environment`, который до конструктора не доезжал. Соседние точки того же +эпика уже переведены: yandex newbuilding (#3389), cian history (#3197), domclick/avito. + +Тест меряет ЗНАЧЕНИЯ kwargs, а не то, из какого модуля взят класс: подделка ставится и на +`scraper_kit.providers._base.BrowserFetcher` (путь через фабрику), и на +`app.api.v1.admin.BrowserFetcher` (прямая конструкция, как было до правки, `create=True` — +после правки такого имени в модуле нет). Поэтому на откате красное читается как «в +конструктор не передан провайдер», а не как «мок не сработал». + +Сеть/БД/камуфокс замоканы; в сеть тест не ходит. +""" + +from __future__ import annotations + +import os +from contextlib import ExitStack +from types import SimpleNamespace +from typing import Any, ClassVar +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest + +from app.api.v1 import admin +from app.services import cian_session as cian_session_svc + +_TARGETS = ("scraper_kit.providers._base.BrowserFetcher", "app.api.v1.admin.BrowserFetcher") + + +class _CapturingFetcher: + """Собирает kwargs каждой конструкции; login() отдаёт валидный набор куки.""" + + captured: ClassVar[list[dict[str, Any]]] = [] + + def __init__(self, **kwargs: Any) -> None: + _CapturingFetcher.captured.append(kwargs) + + async def __aenter__(self) -> _CapturingFetcher: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def login(self, **_kwargs: Any) -> dict[str, str]: + return {name: "v" for name in cian_session_svc.CIAN_REQUIRED_COOKIES} + + +@pytest.fixture +def _pool_on(monkeypatch: pytest.MonkeyPatch) -> None: + """Curl-флаг включён всегда: это он открывает `_kit_proxy_provider()` (гейт #2163). + + Провайдер обязан доезжать до конструктора при ЛЮБОМ значении browser-флага — + `use_pool=False` фетчер его просто игнорирует, но call-site у dev и прода один. + """ + from app.core.config import settings + + monkeypatch.setattr(settings, "use_proxy_pool_curl", True) + _CapturingFetcher.captured = [] + + +def _assert_wiring(source: str, *, use_pool: bool, environment: str) -> None: + assert len(_CapturingFetcher.captured) == 1, "ручка обязана построить ровно один фетчер" + kwargs = _CapturingFetcher.captured[0] + assert kwargs["source"] == source + # .get(), а не [] — красное должно читаться как «значение не то», а не KeyError. + assert kwargs.get("proxy_provider") is not None, "без провайдера пул не подключится" + assert kwargs.get("use_pool") is use_pool, "флаг пула должен доезжать из конфига" + # #2616 шаг 1: без environment отказ «пул пуст» на этом пути мёртв. + assert kwargs.get("environment") == environment + # endpoint не должен потеряться при переезде на фабрику (#2322: без него TypeError). + from app.core.config import settings + + assert kwargs.get("endpoint") == settings.browser_http_endpoint + + +@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")]) +@pytest.mark.usefixtures("_pool_on") +async def test_cian_auto_login_wires_proxy_pool( + monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str +) -> None: + """Логин-браузер ходит через узел пула, а не через env-прокси сайдкара.""" + from app.core.config import settings + + monkeypatch.setattr(settings, "use_proxy_pool_browser", use_pool) + monkeypatch.setattr(settings, "environment", environment) + monkeypatch.setattr(settings, "cookie_encryption_key", "k" * 32) + monkeypatch.setattr(settings, "cian_login_email", "a@b.c") + monkeypatch.setattr(settings, "cian_login_password", "pw") + + with ExitStack() as stack: + for target in _TARGETS: + stack.enter_context(patch(target, _CapturingFetcher, create=True)) + stack.enter_context( + patch.object( + cian_session_svc, "verify_session", AsyncMock(return_value={"user": {"userId": 7}}) + ) + ) + stack.enter_context(patch.object(cian_session_svc, "save_session", MagicMock())) + result = await admin.cian_auto_login(db=MagicMock(), body=None) + + assert result["ok"] is True and result["userId"] == 7 + _assert_wiring("cian", use_pool=use_pool, environment=environment) + + +@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")]) +@pytest.mark.usefixtures("_pool_on") +async def test_domclick_debug_detail_wires_proxy_pool( + monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str +) -> None: + """Debug-карточка DomClick берёт узел с provider_affinity='domclick', а не env.""" + from scraper_kit.providers.domclick import detail as domclick_detail + + from app.core.config import settings + + monkeypatch.setattr(settings, "use_proxy_pool_browser", use_pool) + monkeypatch.setattr(settings, "environment", environment) + + enrichment = SimpleNamespace( + item_id="1", + repair_state=None, + living_area_m2=None, + year_built=None, + price_changes=[], + raw_extra={}, + ) + body = admin.DomClickDebugDetailFetchRequest( + card_url="https://ekaterinburg.domclick.ru/card/sale__flat__1" + ) + with ExitStack() as stack: + for target in _TARGETS: + stack.enter_context(patch(target, _CapturingFetcher, create=True)) + stack.enter_context( + patch.object(domclick_detail, "fetch_detail", AsyncMock(return_value=enrichment)) + ) + stack.enter_context( + patch("app.services.domclick_session.load_session", MagicMock(return_value=None)) + ) + result = await admin.debug_domclick_detail_fetch(body=body, db=MagicMock()) + + assert result.ok is True + _assert_wiring("domclick", use_pool=use_pool, environment=environment) diff --git a/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py b/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py index 884a7c8a..e354d718 100644 --- a/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py +++ b/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py @@ -6,7 +6,7 @@ VERIFY_BAN_SENTINEL / VERIFY_SOURCE_UNAVAILABLE_SENTINEL / VERIFY_MARKUP_CHANGED (403) и недоступность источника (5xx) выглядели как "куки протухли" — человек в момент инцидента перезаливал заведомо валидные куки вместо починки egress/прокси. -Покрытие 3 эндпоинтов (db/verify_session/BrowserFetcher мокаются, NO live network/DB), +Покрытие 3 эндпоинтов (db/verify_session/build_browser_fetcher мокаются, NO live network/DB), зеркалит паттерн test_domclick_admin_apis.py (dependency_overrides[get_db] + TestClient): - POST /api/v1/admin/scrape/cian/upload-cookies - POST /api/v1/admin/scrape/cian/auto-login @@ -178,7 +178,9 @@ def test_auto_login_ban_returns_503(client: TestClient) -> None: patch("app.api.v1.admin.settings.cian_login_email", "user@example.com"), patch("app.api.v1.admin.settings.cian_login_password", "secret"), patch( - "app.api.v1.admin.BrowserFetcher", + # #3197 (хвост): ручка строит фетчер фабрикой, а не BrowserFetcher напрямую — + # только так до сайдкара доезжают proxy_provider/use_pool/environment. + "app.api.v1.admin.build_browser_fetcher", return_value=_mock_browser_fetcher(_RAW_COOKIES), ), patch( @@ -197,7 +199,9 @@ def test_auto_login_success_saves_and_returns_200(client: TestClient) -> None: patch("app.api.v1.admin.settings.cian_login_email", "user@example.com"), patch("app.api.v1.admin.settings.cian_login_password", "secret"), patch( - "app.api.v1.admin.BrowserFetcher", + # #3197 (хвост): ручка строит фетчер фабрикой, а не BrowserFetcher напрямую — + # только так до сайдкара доезжают proxy_provider/use_pool/environment. + "app.api.v1.admin.build_browser_fetcher", return_value=_mock_browser_fetcher(_RAW_COOKIES), ), patch( diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py index ffdbce5c..444e215f 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py @@ -209,11 +209,11 @@ def build_browser_fetcher( `proxy_provider=None` (дефолт) — валидно, но у providers больше не встречается: после #3382 (cian newbuilding) и #3389 (yandex newbuilding) провайдер передают ВСЕ call-site'ы providers (avito/cian/domclick/yandex, serp и newbuilding). - Без пула остались только прямые конструкции `BrowserFetcher(...)` мимо этой - фабрики — служебные ручки `app/api/v1/admin.py` (511 cian-login, 702 - domclick-detail-debug); прямые конструкции в `orchestration/pipeline.py` и в - backfill-задачах пул получают. `use_pool` при `proxy_provider is None` эффективно - игнорируется `BrowserFetcher` (env-fallback, см. `browser_fetcher.py::_pool_proxy`). + Служебные ручки `app/api/v1/admin.py` (cian-login, domclick-detail-debug) были + последними прямыми конструкциями мимо фабрики — переведены сюда же (#3197 хвост); + прямые конструкции в `orchestration/pipeline.py` и в backfill-задачах пул получают + своими kwargs. `use_pool` при `proxy_provider is None` эффективно игнорируется + `BrowserFetcher` (env-fallback, см. `browser_fetcher.py::_pool_proxy`). `fetch_timeout_s=None` (дефолт) → используется дефолт `BrowserFetcher` (120s). Явный таймаут передаёт ровно один call-site — `yandex/serp.py` (30s);