fix(scrapers): WAF re-trigger guard + throttled BrowserSession для DOM.РФ catalog (#2445-D1/D2)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Failing after 3m1s
CI / backend-tests (pull_request) Successful in 15m39s

D1: admin-эндпоинты /kn-catalog-objects + /kn-catalog-flats теперь требуют
явный i_understand_waf_risk=true перед dispatch (иначе 400 со ссылкой на #2443),
т.к. их beat отключён после WAF-бана 2026-05-24 и случайный re-trigger углубит бан.

D2: обе catalog-BrowserSession (domrf_catalog_object.py + domrf_catalog.py flat-level)
переиспользуют throttled settings.scrape_kn_* (concurrency=2, jitter 1200-3000ms,
как scrape_kn после #1945) вместо молчаливого наследования дефолта stealth.py
(concurrency=8, 600-1500ms). Обновлены устаревшие комментарии config.py/stealth.py.

Refs #2445
This commit is contained in:
bot-backend 2026-07-06 00:44:19 +05:00
parent c0c666cccc
commit 68d0575080
8 changed files with 323 additions and 8 deletions

View file

@ -1789,6 +1789,20 @@ def trigger_ekburg_permits(
return {"task_id": result.id, "scope": scope, "queued_at": "now"} 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): class TriggerKnCatalogObjectsRequest(BaseModel):
region_code: int = Field(default=66, ge=1, le=99) region_code: int = Field(default=66, ge=1, le=99)
max_objects: int | None = Field(default=None, ge=1, le=2000) 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") @router.post("/kn-catalog-objects")
@ -1816,7 +1838,14 @@ def trigger_kn_catalog_objects(
- max_objects=None дефолтный лимит таска (300). - max_objects=None дефолтный лимит таска (300).
- max_objects=3 smoke-тест. - max_objects=3 smoke-тест.
- force=True "Загрузить все": игнорирует skip-today, грузит всё подряд. - 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 from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
@ -1847,6 +1876,14 @@ class TriggerKnCatalogFlatsRequest(BaseModel):
"('Загрузить все'). По умолчанию пропускает то, что скраплено < 30 дней назад." "('Загрузить все'). По умолчанию пропускает то, что скраплено < 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") @router.post("/kn-catalog-flats")
@ -1863,7 +1900,14 @@ def trigger_kn_catalog_flats(
- max_flats=None дефолтный лимит таска (300). - max_flats=None дефолтный лимит таска (300).
- max_flats=3 smoke-тест. - max_flats=3 smoke-тест.
- force=True 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash. - 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 from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {

View file

@ -120,13 +120,18 @@ class Settings(BaseSettings):
# endpoint'ов ≈ 17k запросов через один браузер. На старой concurrency=8 # endpoint'ов ≈ 17k запросов через один браузер. На старой concurrency=8
# WAF банил VPS-IP mid-sweep. Лечим двумя рычагами. # WAF банил VPS-IP mid-sweep. Лечим двумя рычагами.
# #
# Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch() # Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch().
# ТОЛЬКО для KN-sweep BrowserSession (другие скраперы — nspd/catalog — # Изначально вводился ТОЛЬКО для KN-sweep BrowserSession; с #2445 D2
# продолжают использовать модульный дефолт _BROWSER_CONCURRENCY=8 без # (2026-07) domrf_catalog.py / domrf_catalog_object.py (catalog-flat /
# изменений). 2 — эмпирически безопасный потолок против volume-бана. # 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. # ENV: SCRAPE_KN_BROWSER_CONCURRENCY.
scrape_kn_browser_concurrency: int = 2 scrape_kn_browser_concurrency: int = 2
# Окно случайной паузы (мс) между запросами KN-sweep. Шире дефолта # Окно случайной паузы (мс) между запросами KN-sweep (и, с #2445 D2, catalog-
# flat/catalog-object scrape'ов — см. комментарий выше). Шире дефолта
# (6001500), чтобы размазать запросы во времени и не триггерить rate-ban. # (6001500), чтобы размазать запросы во времени и не триггерить rate-ban.
# min < max обязателен (иначе random.uniform отдаст границу). При throttle # min < max обязателен (иначе random.uniform отдаст границу). При throttle
# ширим до 12003000. ENV: SCRAPE_KN_REQUEST_JITTER_MIN_MS / _MAX_MS. # ширим до 12003000. ENV: SCRAPE_KN_REQUEST_JITTER_MIN_MS / _MAX_MS.

View file

@ -44,6 +44,7 @@ from urllib.parse import urljoin
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session 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.flat_plans import download_plan_image, upsert_flat_plans
from app.services.scrapers.stealth import BASE_URL, BrowserSession, jitter_sleep from app.services.scrapers.stealth import BASE_URL, BrowserSession, jitter_sleep
@ -926,6 +927,15 @@ async def scrape_catalog_batch(
load_state=load_state, load_state=load_state,
# auth=None — страницы каталога публичные, Basic auth не нужен # auth=None — страницы каталога публичные, Basic auth не нужен
auth=None, 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: ) as session:
# Warm-up: WAF DOM.РФ (2026-05-24) требует session cookies для SSR-страниц. # Warm-up: WAF DOM.РФ (2026-05-24) требует session cookies для SSR-страниц.
# Зеркалит scrape_catalog_objects. Idempotent на весь batch. # Зеркалит scrape_catalog_objects. Idempotent на весь batch.

View file

@ -24,6 +24,7 @@ from typing import Any
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings
from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedError, jitter_sleep from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedError, jitter_sleep
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -440,6 +441,16 @@ async def scrape_catalog_objects(
region_code=region_code, region_code=region_code,
# Страницы каталога публичные — Basic auth не нужен # Страницы каталога публичные — Basic auth не нужен
auth=None, 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: ) as session:
# Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies. # Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies.
# DOM.РФ WAF (2026-05-24) блокирует SSR-страницы объектов без этих cookies. # DOM.РФ WAF (2026-05-24) блокирует SSR-страницы объектов без этих cookies.

View file

@ -33,9 +33,11 @@ USER_AGENT = (
# Максимум одновременных in-page fetch() на одну BrowserSession; # Максимум одновременных in-page fetch() на одну BrowserSession;
# поднимали 3→6→8 под asyncio.gather fan-out, на наш.дом.рф ServicePipe WAF-tolerant. # поднимали 3→6→8 под asyncio.gather fan-out, на наш.дом.рф ServicePipe WAF-tolerant.
# Это МОДУЛЬНЫЙ ДЕФОЛТ — используется catalog/nspd/прочими скраперами. KN-sweep с # Это МОДУЛЬНЫЙ ДЕФОЛТ — используется nspd/прочими скраперами вне /сервисы/* path
# 2026-05 ловит volume-ban WAF (#1945) и переопределяет это через # family. KN-sweep с 2026-05 ловит volume-ban WAF (#1945) и переопределяет это через
# BrowserSession(concurrency=...) на settings.scrape_kn_browser_concurrency=2. # 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 _BROWSER_CONCURRENCY = 8
# Дефолтное окно inter-request jitter (мс). KN-sweep ширит его через # Дефолтное окно inter-request jitter (мс). KN-sweep ширит его через

View file

@ -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()

View file

@ -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

View file

@ -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