diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py index b1001074..5420f902 100644 --- a/backend/app/api/v1/admin_scrape.py +++ b/backend/app/api/v1/admin_scrape.py @@ -1789,6 +1789,20 @@ def trigger_ekburg_permits( return {"task_id": result.id, "scope": scope, "queued_at": "now"} +# WAF cooldown guard message (#2443 — DOM.РФ hard-banned this VPS's IP 2026-05-24 +# после серии failed catalog SSR extras-сессий). Beat schedule для catalog-object +# и catalog-flat scrape'ов ОТКЛЮЧЕН по этой же причине (см. beat_schedule.py) — +# оба ad-hoc admin-эндпоинта ниже бьют по ТОМУ ЖЕ /сервисы/* BrowserSession +# path family, поэтому без явного оператор-override могут углубить бан (#2445 D1). +_WAF_COOLDOWN_GUARD_MSG = ( + "Ad-hoc catalog-scrape заблокирован guard'ом: DOM.РФ WAF hard-ban этого VPS IP " + "2026-05-24 (issue #2443), beat schedule для этого таска отключён по той же " + "причине. Повторный ad-hoc запуск может углубить бан. Если ты осознанно " + "принимаешь этот риск (WAF cooldown прошёл, targeted smoke-test и т.п.) — " + "передай i_understand_waf_risk=true в теле запроса." +) + + class TriggerKnCatalogObjectsRequest(BaseModel): region_code: int = Field(default=66, ge=1, le=99) max_objects: int | None = Field(default=None, ge=1, le=2000) @@ -1800,6 +1814,14 @@ class TriggerKnCatalogObjectsRequest(BaseModel): "что уже скраплено сегодня." ), ) + i_understand_waf_risk: bool = Field( + default=False, + description=( + "Обязателен (True) для запуска. Guard против случайного re-trigger'а " + "после DOM.РФ WAF hard-ban 2026-05-24 (#2443) — этот scraper бьёт по " + "тому же /сервисы/* BrowserSession path family, что вызвал бан." + ), + ) @router.post("/kn-catalog-objects") @@ -1816,7 +1838,14 @@ def trigger_kn_catalog_objects( - max_objects=None → дефолтный лимит таска (300). - max_objects=3 → smoke-тест. - force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд. + + WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true — + beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc + re-trigger без явного подтверждения оператора запрещён. """ + if not payload.i_understand_waf_risk: + raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG) + from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects kwargs: dict[str, Any] = { @@ -1847,6 +1876,14 @@ class TriggerKnCatalogFlatsRequest(BaseModel): "('Загрузить все'). По умолчанию пропускает то, что скраплено < 30 дней назад." ), ) + i_understand_waf_risk: bool = Field( + default=False, + description=( + "Обязателен (True) для запуска. Guard против случайного re-trigger'а " + "после DOM.РФ WAF hard-ban 2026-05-24 (#2443) — этот scraper ездит по " + "тому же /сервисы/* BrowserSession path family, что и catalog-objects." + ), + ) @router.post("/kn-catalog-flats") @@ -1863,7 +1900,14 @@ def trigger_kn_catalog_flats( - max_flats=None → дефолтный лимит таска (300). - max_flats=3 → smoke-тест. - force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash. + + WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true — + same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger + того же WAF-бана. """ + if not payload.i_understand_waf_risk: + raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG) + from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats kwargs: dict[str, Any] = { diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 420c1dbc..d7e73041 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -120,13 +120,18 @@ class Settings(BaseSettings): # endpoint'ов ≈ 17k запросов через один браузер. На старой concurrency=8 # WAF банил VPS-IP mid-sweep. Лечим двумя рычагами. # - # Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch() - # ТОЛЬКО для KN-sweep BrowserSession (другие скраперы — nspd/catalog — - # продолжают использовать модульный дефолт _BROWSER_CONCURRENCY=8 без - # изменений). 2 — эмпирически безопасный потолок против volume-бана. + # Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch(). + # Изначально вводился ТОЛЬКО для KN-sweep BrowserSession; с #2445 D2 + # (2026-07) domrf_catalog.py / domrf_catalog_object.py (catalog-flat / + # catalog-object scrapers) тоже переиспользуют эти же значения — они бьют + # по тому же /сервисы/* path family, что и вызвало WAF hard-ban 2026-05-24 + # (#2443). nspd и прочие скраперы вне этого path family продолжают + # использовать модульный дефолт _BROWSER_CONCURRENCY=8 без изменений. + # 2 — эмпирически безопасный потолок против volume-бана. # ENV: SCRAPE_KN_BROWSER_CONCURRENCY. scrape_kn_browser_concurrency: int = 2 - # Окно случайной паузы (мс) между запросами KN-sweep. Шире дефолта + # Окно случайной паузы (мс) между запросами KN-sweep (и, с #2445 D2, catalog- + # flat/catalog-object scrape'ов — см. комментарий выше). Шире дефолта # (600–1500), чтобы размазать запросы во времени и не триггерить rate-ban. # min < max обязателен (иначе random.uniform отдаст границу). При throttle # ширим до 1200–3000. ENV: SCRAPE_KN_REQUEST_JITTER_MIN_MS / _MAX_MS. diff --git a/backend/app/services/scrapers/domrf_catalog.py b/backend/app/services/scrapers/domrf_catalog.py index 5b153d09..0896d3fc 100644 --- a/backend/app/services/scrapers/domrf_catalog.py +++ b/backend/app/services/scrapers/domrf_catalog.py @@ -44,6 +44,7 @@ from urllib.parse import urljoin from sqlalchemy import text from sqlalchemy.orm import Session +from app.core.config import settings from app.services.scrapers.flat_plans import download_plan_image, upsert_flat_plans from app.services.scrapers.stealth import BASE_URL, BrowserSession, jitter_sleep @@ -926,6 +927,15 @@ async def scrape_catalog_batch( load_state=load_state, # auth=None — страницы каталога публичные, Basic auth не нужен auth=None, + # #2445 D2 anti-ban: тот же /сервисы/* path family, что вызвал DOM.РФ WAF + # hard-ban 2026-05-24 (#2443). Без явного override BrowserSession тихо + # наследует модульный дефолт stealth.py (concurrency=8, jitter 600-1500ms). + # Переиспользуем throttled настройки, принятые scrape_kn.py после #1945 + # (settings.scrape_kn_* — общий anti-ban рычаг, НЕ специфичный для KN-sweep, + # несмотря на имя). + concurrency=settings.scrape_kn_browser_concurrency, + jitter_min_ms=settings.scrape_kn_request_jitter_min_ms, + jitter_max_ms=settings.scrape_kn_request_jitter_max_ms, ) as session: # Warm-up: WAF DOM.РФ (2026-05-24) требует session cookies для SSR-страниц. # Зеркалит scrape_catalog_objects. Idempotent на весь batch. diff --git a/backend/app/services/scrapers/domrf_catalog_object.py b/backend/app/services/scrapers/domrf_catalog_object.py index 3a8b765e..ad7ef832 100644 --- a/backend/app/services/scrapers/domrf_catalog_object.py +++ b/backend/app/services/scrapers/domrf_catalog_object.py @@ -24,6 +24,7 @@ from typing import Any from sqlalchemy import text from sqlalchemy.orm import Session +from app.core.config import settings from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedError, jitter_sleep logger = logging.getLogger(__name__) @@ -440,6 +441,16 @@ async def scrape_catalog_objects( region_code=region_code, # Страницы каталога публичные — Basic auth не нужен auth=None, + # #2445 D2 anti-ban: этот scraper ходит по тому же /сервисы/* path family, + # что вызвал DOM.РФ WAF hard-ban 2026-05-24 (#2443). Без явного override + # BrowserSession тихо наследует модульный дефолт stealth.py (concurrency=8, + # jitter 600-1500ms) — тот самый throttle-less профиль, что и раньше + # триггерил volume-ban. Переиспользуем throttled настройки, принятые + # scrape_kn.py после #1945 (settings.scrape_kn_* — общий anti-ban рычаг, + # НЕ специфичный для KN-sweep, несмотря на имя). + concurrency=settings.scrape_kn_browser_concurrency, + jitter_min_ms=settings.scrape_kn_request_jitter_min_ms, + jitter_max_ms=settings.scrape_kn_request_jitter_max_ms, ) as session: # Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies. # DOM.РФ WAF (2026-05-24) блокирует SSR-страницы объектов без этих cookies. diff --git a/backend/app/services/scrapers/stealth.py b/backend/app/services/scrapers/stealth.py index db02ad0c..6abf2b13 100644 --- a/backend/app/services/scrapers/stealth.py +++ b/backend/app/services/scrapers/stealth.py @@ -33,9 +33,11 @@ USER_AGENT = ( # Максимум одновременных in-page fetch() на одну BrowserSession; # поднимали 3→6→8 под asyncio.gather fan-out, на наш.дом.рф ServicePipe WAF-tolerant. -# Это МОДУЛЬНЫЙ ДЕФОЛТ — используется catalog/nspd/прочими скраперами. KN-sweep с -# 2026-05 ловит volume-ban WAF (#1945) и переопределяет это через -# BrowserSession(concurrency=...) на settings.scrape_kn_browser_concurrency=2. +# Это МОДУЛЬНЫЙ ДЕФОЛТ — используется nspd/прочими скраперами вне /сервисы/* path +# family. KN-sweep с 2026-05 ловит volume-ban WAF (#1945) и переопределяет это через +# BrowserSession(concurrency=...) на settings.scrape_kn_browser_concurrency=2; после +# WAF hard-ban 2026-05-24 (#2443) domrf_catalog.py / domrf_catalog_object.py — тот же +# /сервисы/* path family — тоже переопределяют на throttled настройки (#2445 D2). _BROWSER_CONCURRENCY = 8 # Дефолтное окно inter-request jitter (мс). KN-sweep ширит его через diff --git a/backend/tests/api/v1/test_admin_scrape_kn_catalog_waf_guard.py b/backend/tests/api/v1/test_admin_scrape_kn_catalog_waf_guard.py new file mode 100644 index 00000000..db72ae82 --- /dev/null +++ b/backend/tests/api/v1/test_admin_scrape_kn_catalog_waf_guard.py @@ -0,0 +1,108 @@ +"""Тесты для WAF cooldown guard (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах. + +POST /api/v1/admin/scrape/kn-catalog-objects и /kn-catalog-flats бьют по тому же +/сервисы/* BrowserSession path family, что вызвал DOM.РФ WAF hard-ban 2026-05-24 +(#2443). Beat schedule для обоих тасков отключён по этой причине — эти эндпоинты +должны отказывать в запуске БЕЗ явного i_understand_waf_risk=true, чтобы случайный +smoke-тест/re-trigger не углубил бан. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +from app.main import app + +OBJECTS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-objects" +FLATS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-flats" + + +def _mock_apply_async_result(task_id: str = "fake-task-id") -> MagicMock: + result = MagicMock() + result.id = task_id + return result + + +def test_kn_catalog_objects_refuses_without_override() -> None: + """Без i_understand_waf_risk (default False) — 400, task НЕ dispatched.""" + with patch( + "app.workers.tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects.apply_async" + ) as mock_apply_async: + resp = TestClient(app).post(OBJECTS_ENDPOINT, json={"region_code": 66}) + + assert resp.status_code == 400 + detail = resp.json()["detail"] + assert "2443" in detail + assert "i_understand_waf_risk" in detail + mock_apply_async.assert_not_called() + + +def test_kn_catalog_objects_dispatches_with_override() -> None: + """С i_understand_waf_risk=true — таск dispatched как раньше.""" + with patch( + "app.workers.tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects.apply_async", + return_value=_mock_apply_async_result("obj-task-1"), + ) as mock_apply_async: + resp = TestClient(app).post( + OBJECTS_ENDPOINT, + json={"region_code": 66, "max_objects": 3, "i_understand_waf_risk": True}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["task_id"] == "obj-task-1" + assert body["max_objects"] == 3 + mock_apply_async.assert_called_once() + call_kwargs = mock_apply_async.call_args[1]["kwargs"] + assert call_kwargs["region_code"] == 66 + assert call_kwargs["max_objects"] == 3 + + +def test_kn_catalog_flats_refuses_without_override() -> None: + """Без i_understand_waf_risk (default False) — 400, task НЕ dispatched.""" + with patch( + "app.workers.tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats.apply_async" + ) as mock_apply_async: + resp = TestClient(app).post(FLATS_ENDPOINT, json={"region_code": 66}) + + assert resp.status_code == 400 + detail = resp.json()["detail"] + assert "2443" in detail + assert "i_understand_waf_risk" in detail + mock_apply_async.assert_not_called() + + +def test_kn_catalog_flats_dispatches_with_override() -> None: + """С i_understand_waf_risk=true — таск dispatched как раньше.""" + with patch( + "app.workers.tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats.apply_async", + return_value=_mock_apply_async_result("flat-task-1"), + ) as mock_apply_async: + resp = TestClient(app).post( + FLATS_ENDPOINT, + json={"region_code": 66, "max_flats": 3, "i_understand_waf_risk": True}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["task_id"] == "flat-task-1" + assert body["max_flats"] == 3 + mock_apply_async.assert_called_once() + call_kwargs = mock_apply_async.call_args[1]["kwargs"] + assert call_kwargs["region_code"] == 66 + assert call_kwargs["max_flats"] == 3 + + +def test_kn_catalog_objects_explicit_false_still_refuses() -> None: + """i_understand_waf_risk=false явно (не только default) — тоже 400.""" + with patch( + "app.workers.tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects.apply_async" + ) as mock_apply_async: + resp = TestClient(app).post( + OBJECTS_ENDPOINT, json={"region_code": 66, "i_understand_waf_risk": False} + ) + + assert resp.status_code == 400 + mock_apply_async.assert_not_called() diff --git a/backend/tests/services/scrapers/test_domrf_catalog_browsersession_throttle.py b/backend/tests/services/scrapers/test_domrf_catalog_browsersession_throttle.py new file mode 100644 index 00000000..fc3b3947 --- /dev/null +++ b/backend/tests/services/scrapers/test_domrf_catalog_browsersession_throttle.py @@ -0,0 +1,72 @@ +"""Тест для #2445 D2: scrape_catalog_batch() (per-flat catalog scraper, #2442) +должен конструировать BrowserSession с throttled concurrency/jitter +(settings.scrape_kn_*), а НЕ с модульным дефолтом stealth.py (concurrency=8, +jitter 600-1500ms). + +Этот scraper ходит по тому же /сервисы/* path family, что вызвал DOM.РФ WAF +hard-ban 2026-05-24 (#2443) — без явного override BrowserSession тихо унаследовал +бы дефолт, углубляя риск повторного бана. +""" + +from __future__ import annotations + +from datetime import date +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.core.config import settings + + +class _FakeBrowserSession: + """Async context manager stub — избегаем реального Playwright/network.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + self.init_kwargs = kwargs + self.warm_up = AsyncMock() + + async def __aenter__(self) -> _FakeBrowserSession: + return self + + async def __aexit__(self, *exc: object) -> None: + return None + + +@pytest.mark.asyncio +async def test_scrape_catalog_batch_passes_throttled_browsersession_kwargs() -> None: + from app.services.scrapers import domrf_catalog as mod + + captured: dict[str, object] = {} + + def _factory(*args: object, **kwargs: object) -> _FakeBrowserSession: + captured.update(kwargs) + return _FakeBrowserSession(*args, **kwargs) + + db = MagicMock() + flats = [{"ods_id": "abc123", "catalog_url_hash": "hash1", "obj_id": 1}] + fake_outcome = { + "success": True, + "fields_extracted": 3, + "updated": True, + "plan_found": False, + "plan_downloaded": False, + } + + with ( + patch.object(mod, "BrowserSession", side_effect=_factory) as mock_cls, + patch.object(mod, "scrape_one_flat", new=AsyncMock(return_value=fake_outcome)), + ): + stats = await mod.scrape_catalog_batch( + db=db, + flats=flats, + region_code=66, + snapshot_date=date(2026, 5, 17), + ) + + mock_cls.assert_called_once() + assert captured["concurrency"] == settings.scrape_kn_browser_concurrency + assert captured["jitter_min_ms"] == settings.scrape_kn_request_jitter_min_ms + assert captured["jitter_max_ms"] == settings.scrape_kn_request_jitter_max_ms + # Sanity: эти throttled значения — НЕ модульный дефолт stealth.py. + assert captured["concurrency"] != 8 + assert stats["success"] == 1 diff --git a/backend/tests/services/scrapers/test_domrf_catalog_object_browsersession_throttle.py b/backend/tests/services/scrapers/test_domrf_catalog_object_browsersession_throttle.py new file mode 100644 index 00000000..9b807932 --- /dev/null +++ b/backend/tests/services/scrapers/test_domrf_catalog_object_browsersession_throttle.py @@ -0,0 +1,63 @@ +"""Тест для #2445 D2: scrape_catalog_objects() должен конструировать BrowserSession +с throttled concurrency/jitter (settings.scrape_kn_*), а НЕ с модульным дефолтом +stealth.py (concurrency=8, jitter 600-1500ms). + +Этот scraper ходит по тому же /сервисы/* path family, что вызвал DOM.РФ WAF +hard-ban 2026-05-24 (#2443) — без явного override BrowserSession тихо унаследовал +бы дефолт, углубляя риск повторного бана. +""" + +from __future__ import annotations + +from datetime import date +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.core.config import settings + + +class _FakeBrowserSession: + """Async context manager stub — избегаем реального Playwright/network.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + self.init_kwargs = kwargs + self.warm_up = AsyncMock() + + async def __aenter__(self) -> _FakeBrowserSession: + return self + + async def __aexit__(self, *exc: object) -> None: + return None + + +@pytest.mark.asyncio +async def test_scrape_catalog_objects_passes_throttled_browsersession_kwargs() -> None: + from app.services.scrapers import domrf_catalog_object as mod + + captured: dict[str, object] = {} + + def _factory(*args: object, **kwargs: object) -> _FakeBrowserSession: + captured.update(kwargs) + return _FakeBrowserSession(*args, **kwargs) + + db = MagicMock() + + with ( + patch.object(mod, "BrowserSession", side_effect=_factory) as mock_cls, + patch.object(mod, "scrape_catalog_object", new=AsyncMock(return_value=True)), + ): + stats = await mod.scrape_catalog_objects( + db=db, + obj_ids=[65136], + snapshot_date=date(2026, 5, 17), + region_code=66, + ) + + mock_cls.assert_called_once() + assert captured["concurrency"] == settings.scrape_kn_browser_concurrency + assert captured["jitter_min_ms"] == settings.scrape_kn_request_jitter_min_ms + assert captured["jitter_max_ms"] == settings.scrape_kn_request_jitter_max_ms + # Sanity: эти throttled значения — НЕ модульный дефолт stealth.py. + assert captured["concurrency"] != 8 + assert stats["succeeded"] == 1 diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 1633da89..9b8febee 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -1926,6 +1926,10 @@ export interface paths { * - max_objects=None → дефолтный лимит таска (300). * - max_objects=3 → smoke-тест. * - force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд. + * + * WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true — + * beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc + * re-trigger без явного подтверждения оператора запрещён. */ post: operations["trigger_kn_catalog_objects_api_v1_admin_scrape_kn_catalog_objects_post"]; delete?: never; @@ -1955,6 +1959,10 @@ export interface paths { * - max_flats=None → дефолтный лимит таска (300). * - max_flats=3 → smoke-тест. * - force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash. + * + * WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true — + * same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger + * того же WAF-бана. */ post: operations["trigger_kn_catalog_flats_api_v1_admin_scrape_kn_catalog_flats_post"]; delete?: never; @@ -5529,6 +5537,12 @@ export interface components { * @default false */ force: boolean; + /** + * I Understand Waf Risk + * @description Обязателен (True) для запуска. Guard против случайного re-trigger'а после DOM.РФ WAF hard-ban 2026-05-24 (#2443) — этот scraper ездит по тому же /сервисы/* BrowserSession path family, что и catalog-objects. + * @default false + */ + i_understand_waf_risk: boolean; }; /** TriggerKnCatalogObjectsRequest */ TriggerKnCatalogObjectsRequest: { @@ -5545,6 +5559,12 @@ export interface components { * @default false */ force: boolean; + /** + * I Understand Waf Risk + * @description Обязателен (True) для запуска. Guard против случайного re-trigger'а после DOM.РФ WAF hard-ban 2026-05-24 (#2443) — этот scraper бьёт по тому же /сервисы/* BrowserSession path family, что вызвал бан. + * @default false + */ + i_understand_waf_risk: boolean; }; /** TriggerKnRequest */ TriggerKnRequest: {