From 42b1ca6d7fb6ed08c53d3d4aca709867d33bb341 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 11:46:02 +0300 Subject: [PATCH 1/2] =?UTF-8?q?refactor(tradein/admin):=20debug=20sweep=20?= =?UTF-8?q?endpoints=20=D0=BD=D0=B0=20kit=20orchestration=20(#2397=20slice?= =?UTF-8?q?=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мигрирует 5 admin debug-роутов с legacy app.services.scrape_pipeline на scraper_kit.orchestration.pipeline (эпик #2277 decommission, umbrella #2397): - POST /scrape/avito-city-sweep -> run_avito_city_sweep - POST /scrape/cian-city-sweep -> run_cian_city_sweep - POST /scrape/cian-full-load -> run_cian_full_load - POST /scrape/yandex-city-sweep -> run_yandex_city_sweep - POST /scrape/yandex-full-load -> run_yandex_full_load DI-паттерн зеркалит app.scheduler_main._run_kit_scheduler / scraper_kit.orchestration.scheduler._job_* (уже используется в этом же файле для Group A #2305 debug-роутов): config=RealScraperConfig(), matcher=RealMatcherAdapter(), proxy_provider=_kit_proxy_provider() для всех пяти + enrichment=RealEnrichmentJobs() там, где kit-сигнатура его требует (avito_city_sweep, yandex_city_sweep, yandex_full_load — cian-варианты enrichment не принимают). shutdown_requested не прокидывается — у admin BackgroundTasks нет SIGTERM-drain семантики, дефолт kit (lambda: False) эквивалентен прежнему поведению. Разблокирует финальное удаление зависимости admin.py от scrape_pipeline.py. Production-scheduler (app/services/scheduler.py, USE_KIT_SCHEDULER=False ship-dark) остаётся на legacy — это отдельный, более крупный шаг миграции SERP-оркестрации. --- tradein-mvp/backend/app/api/v1/admin.py | 50 +++++++++++++++++++++---- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index b26e3bf1..450dd0a7 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -28,9 +28,27 @@ from pydantic import BaseModel, Field, field_validator # (endpoint не прокинут через config, в отличие от SERP-классов и BaseScraper.__aenter__ # соседних провайдеров) — вне scope этой задачи (см. "только consume, не трогать # scraper_kit provider-логику"), фиксируем как follow-up. +# +# scraper_kit-миграция (#2397 slice A, эпик #2277): 5 debug city-sweep/full-load +# роутов (avito-city-sweep, cian-city-sweep, cian-full-load, yandex-city-sweep, +# yandex-full-load) переключены с app.services.scrape_pipeline (legacy) на +# scraper_kit.orchestration.pipeline — тот же DI-паттерн, что и app.scheduler_main +# ._run_kit_scheduler / scraper_kit.orchestration.scheduler._job_*: config/matcher/ +# enrichment/proxy_provider инжектируются явно вместо module-level импортов app.* +# внутри scrape_pipeline.py. shutdown_requested НЕ прокидывается (нет SIGTERM-drain +# семантики у admin BackgroundTasks — эквивалент дефолту lambda: False, поведение не +# меняется). Боевой scheduler.py (production sweep-cron) остаётся на legacy — это +# отдельный, ещё больший шаг (см. vault Event_Legacy_Scrapers_Partial_Deletion_Jul04). from scraper_kit.base import save_listings from scraper_kit.browser_fetcher import BrowserFetcher -from scraper_kit.orchestration.pipeline import DEFAULT_REGION_CODE +from scraper_kit.orchestration.pipeline import ( + DEFAULT_REGION_CODE, + run_avito_city_sweep, + run_cian_city_sweep, + run_cian_full_load, + run_yandex_city_sweep, + run_yandex_full_load, +) 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 ( @@ -54,14 +72,12 @@ from app.services import cian_session as cian_session_svc from app.services import scrape_runs as runs_mod from app.services.geocoder import geocode from app.services.scheduler import has_running_run -from app.services.scrape_pipeline import ( - run_avito_city_sweep, - run_cian_city_sweep, - run_cian_full_load, - run_yandex_city_sweep, - run_yandex_full_load, +from app.services.scraper_adapters import ( + RealEnrichmentJobs, + RealMatcherAdapter, + RealProxyProvider, + RealScraperConfig, ) -from app.services.scraper_adapters import RealMatcherAdapter, RealProxyProvider, RealScraperConfig from app.services.scraper_settings import get_scraper_delay, invalidate_cache from app.services.scrapers.yandex_newbuilding import YandexNewbuildingScraper from app.tasks.avito_detail_backfill import run_avito_detail_backfill @@ -840,6 +856,10 @@ async def start_avito_city_sweep( await run_avito_city_sweep( sweep_db, run_id=run_id, + config=RealScraperConfig(), + matcher=RealMatcherAdapter(), + enrichment=RealEnrichmentJobs(), + proxy_provider=_kit_proxy_provider(), radius_m=payload.radius_m, pages_per_anchor=payload.pages_per_anchor, enrich_houses=payload.enrich_houses, @@ -930,6 +950,9 @@ async def start_cian_city_sweep( await run_cian_city_sweep( sweep_db, run_id=run_id, + config=RealScraperConfig(), + matcher=RealMatcherAdapter(), + proxy_provider=_kit_proxy_provider(), radius_m=payload.radius_m, pages_per_anchor=payload.pages_per_anchor, request_delay_sec=payload.request_delay_sec, @@ -1059,6 +1082,9 @@ async def start_cian_full_load( await run_cian_full_load( task_db, run_id=run_id, + config=RealScraperConfig(), + matcher=RealMatcherAdapter(), + proxy_provider=_kit_proxy_provider(), price_cap_per_bucket=payload.price_cap_per_bucket, request_delay_sec=payload.request_delay_sec, concurrency=payload.concurrency, @@ -1160,6 +1186,10 @@ async def start_yandex_full_load( await run_yandex_full_load( task_db, run_id=run_id, + config=RealScraperConfig(), + matcher=RealMatcherAdapter(), + enrichment=RealEnrichmentJobs(), + proxy_provider=_kit_proxy_provider(), price_cap_per_bucket=payload.price_cap_per_bucket, request_delay_sec=payload.request_delay_sec, concurrency=payload.concurrency, @@ -1224,6 +1254,10 @@ async def start_yandex_city_sweep( await run_yandex_city_sweep( sweep_db, run_id=run_id, + config=RealScraperConfig(), + matcher=RealMatcherAdapter(), + enrichment=RealEnrichmentJobs(), + proxy_provider=_kit_proxy_provider(), radius_m=payload.radius_m, pages_per_anchor=payload.pages_per_anchor, request_delay_sec=payload.request_delay_sec, From d0b011d388a98791d3caf6b2107530e30eda68e1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 12:11:56 +0300 Subject: [PATCH 2/2] =?UTF-8?q?test(tradein/admin):=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=D1=86=D0=B5=D0=BB=D0=B8=D1=82=D1=8C=20patch-?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=B3=D0=B5=D1=82=D1=8B=20=D0=BD=D0=B0=20kit?= =?UTF-8?q?=20+=20DI-guard=20asserts=20(#2397=20slice=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После миграции admin.py на scraper_kit.orchestration.pipeline (предыдущий коммит) 4 endpoint-теста продолжали патчить устаревший app.services.scrape_pipeline.run_* — имя, на которое admin.py больше не ссылается. Мок не перехватывал вызов → FastAPI BackgroundTask реально запускал kit-sweep (сетевой I/O), что локально (без :5432) тихо падало на подключении к БД и глоталось except'ом, а на CI грозило зависанием/таймаутом. Перенацелены patch-таргеты на app.api.v1.admin.run_* (namespace, куда забинжен kit-импорт): - test_city_sweep.py::test_start_endpoint_ok -> run_avito_city_sweep - test_cian_city_sweep.py::test_cian_start_endpoint_ok -> run_cian_city_sweep - test_yandex_city_sweep.py::test_yandex_start_endpoint_ok -> run_yandex_city_sweep - test_sweep_imv_phase.py::test_sweep_endpoint_passes_enrich_imv -> run_avito_city_sweep Добавлены DI-guard assertions (закрывают coverage-gap: раньше проверялся только HTTP 200, не факт вызова kit с правильным DI) — mock_sweep.call_args.kwargs проверяется на RealScraperConfig/RealMatcherAdapter/RealEnrichmentJobs (там, где kit-сигнатура их требует) + наличие proxy_provider. --- .../backend/tests/test_cian_city_sweep.py | 13 ++++++++-- tradein-mvp/backend/tests/test_city_sweep.py | 17 +++++++++++-- .../backend/tests/test_sweep_imv_phase.py | 24 ++++++++++++++++--- .../backend/tests/test_yandex_city_sweep.py | 17 +++++++++++-- 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/tradein-mvp/backend/tests/test_cian_city_sweep.py b/tradein-mvp/backend/tests/test_cian_city_sweep.py index 2c839d4c..9b84de47 100644 --- a/tradein-mvp/backend/tests/test_cian_city_sweep.py +++ b/tradein-mvp/backend/tests/test_cian_city_sweep.py @@ -717,12 +717,14 @@ def test_cian_start_endpoint_validates_delay_too_low(app_with_admin) -> None: def test_cian_start_endpoint_ok(app_with_admin) -> None: - """Valid request → 200, run_id в ответе.""" + """Valid request → 200, run_id; kit sweep вызван с DI (config/matcher, БЕЗ enrichment).""" from unittest.mock import AsyncMock, patch + from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig + with ( patch("app.services.scrape_runs.create_run", return_value=42), - patch("app.services.scrape_pipeline.run_cian_city_sweep", new_callable=AsyncMock), + patch("app.api.v1.admin.run_cian_city_sweep", new_callable=AsyncMock) as mock_sweep, ): r = app_with_admin.post( "/api/v1/admin/scrape/cian-city-sweep", @@ -735,6 +737,13 @@ def test_cian_start_endpoint_ok(app_with_admin) -> None: assert body["pages_per_anchor"] == 2 assert body["detail_top_n"] == 5 + mock_sweep.assert_called_once() + kw = mock_sweep.call_args.kwargs + assert isinstance(kw["config"], RealScraperConfig) + assert isinstance(kw["matcher"], RealMatcherAdapter) + assert "proxy_provider" in kw + assert "enrichment" not in kw + def test_cian_cancel_endpoint(app_with_admin) -> None: """Cancel endpoint возвращает run_id + cancelled=True.""" diff --git a/tradein-mvp/backend/tests/test_city_sweep.py b/tradein-mvp/backend/tests/test_city_sweep.py index 2dbfae1e..31684af7 100644 --- a/tradein-mvp/backend/tests/test_city_sweep.py +++ b/tradein-mvp/backend/tests/test_city_sweep.py @@ -313,11 +313,17 @@ def test_start_endpoint_validates_request_delay_too_low(app_with_admin) -> None: def test_start_endpoint_ok(app_with_admin) -> None: - """Valid request → 200, run_id в ответе.""" + """Valid request → 200, run_id в ответе; kit sweep вызван с DI (config/matcher/enrichment).""" + from app.services.scraper_adapters import ( + RealEnrichmentJobs, + RealMatcherAdapter, + RealScraperConfig, + ) + with ( patch("app.api.v1.admin.has_running_run", return_value=False), patch("app.services.scrape_runs.create_run", return_value=7), - patch("app.services.scrape_pipeline.run_avito_city_sweep", new_callable=AsyncMock), + patch("app.api.v1.admin.run_avito_city_sweep", new_callable=AsyncMock) as mock_sweep, ): r = app_with_admin.post( "/api/v1/admin/scrape/avito-city-sweep", @@ -329,6 +335,13 @@ def test_start_endpoint_ok(app_with_admin) -> None: assert body["status"] == "running" assert body["pages_per_anchor"] == 2 + mock_sweep.assert_called_once() + kw = mock_sweep.call_args.kwargs + assert isinstance(kw["config"], RealScraperConfig) + assert isinstance(kw["matcher"], RealMatcherAdapter) + assert isinstance(kw["enrichment"], RealEnrichmentJobs) + assert "proxy_provider" in kw + def test_cancel_endpoint_exists(app_with_admin) -> None: """Cancel endpoint returns run_id + cancelled flag.""" diff --git a/tradein-mvp/backend/tests/test_sweep_imv_phase.py b/tradein-mvp/backend/tests/test_sweep_imv_phase.py index 7b501727..7a577320 100644 --- a/tradein-mvp/backend/tests/test_sweep_imv_phase.py +++ b/tradein-mvp/backend/tests/test_sweep_imv_phase.py @@ -453,14 +453,24 @@ def test_city_sweep_start_request_enrich_imv_false() -> None: def test_sweep_endpoint_passes_enrich_imv(app_with_admin) -> None: - """POST /scrape/avito-city-sweep с enrich_imv=False → sweep вызывается без IMV.""" + """POST /scrape/avito-city-sweep с enrich_imv=False → sweep вызывается без IMV. + + kit sweep вызван с DI (config/matcher/enrichment) — mock должен перехватывать + имя, забинженное в app.api.v1.admin (kit-импорт), не устаревший scrape_pipeline. + """ + from app.services.scraper_adapters import ( + RealEnrichmentJobs, + RealMatcherAdapter, + RealScraperConfig, + ) + with ( patch("app.api.v1.admin.has_running_run", return_value=False), patch("app.services.scrape_runs.create_run", return_value=42), patch( - "app.services.scrape_pipeline.run_avito_city_sweep", + "app.api.v1.admin.run_avito_city_sweep", new_callable=AsyncMock, - ), + ) as mock_sweep, ): r = app_with_admin.post( "/api/v1/admin/scrape/avito-city-sweep", @@ -470,6 +480,14 @@ def test_sweep_endpoint_passes_enrich_imv(app_with_admin) -> None: body = r.json() assert body["run_id"] == 42 + mock_sweep.assert_called_once() + kw = mock_sweep.call_args.kwargs + assert isinstance(kw["config"], RealScraperConfig) + assert isinstance(kw["matcher"], RealMatcherAdapter) + assert isinstance(kw["enrichment"], RealEnrichmentJobs) + assert "proxy_provider" in kw + assert kw["enrich_imv"] is False + @pytest.fixture def app_with_admin(): diff --git a/tradein-mvp/backend/tests/test_yandex_city_sweep.py b/tradein-mvp/backend/tests/test_yandex_city_sweep.py index 3eeaa61a..32b31898 100644 --- a/tradein-mvp/backend/tests/test_yandex_city_sweep.py +++ b/tradein-mvp/backend/tests/test_yandex_city_sweep.py @@ -623,12 +623,18 @@ def test_yandex_start_endpoint_validates_delay_too_low(app_with_admin) -> None: def test_yandex_start_endpoint_ok(app_with_admin) -> None: - """Valid request → 200, run_id в ответе.""" + """Valid request → 200, run_id в ответе; kit sweep вызван с DI (config/matcher/enrichment).""" from unittest.mock import patch + from app.services.scraper_adapters import ( + RealEnrichmentJobs, + RealMatcherAdapter, + RealScraperConfig, + ) + with ( patch("app.services.scrape_runs.create_run", return_value=55), - patch("app.services.scrape_pipeline.run_yandex_city_sweep", new_callable=AsyncMock), + patch("app.api.v1.admin.run_yandex_city_sweep", new_callable=AsyncMock) as mock_sweep, ): r = app_with_admin.post( "/api/v1/admin/scrape/yandex-city-sweep", @@ -641,6 +647,13 @@ def test_yandex_start_endpoint_ok(app_with_admin) -> None: assert body["pages_per_anchor"] == 2 assert body["detail_top_n"] == 0 + mock_sweep.assert_called_once() + kw = mock_sweep.call_args.kwargs + assert isinstance(kw["config"], RealScraperConfig) + assert isinstance(kw["matcher"], RealMatcherAdapter) + assert isinstance(kw["enrichment"], RealEnrichmentJobs) + assert "proxy_provider" in kw + def test_yandex_cancel_endpoint(app_with_admin) -> None: """Cancel endpoint возвращает run_id + cancelled=True."""