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
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:
parent
c0c666cccc
commit
68d0575080
8 changed files with 323 additions and 8 deletions
|
|
@ -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] = {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ширит его через
|
||||
|
|
|
|||
108
backend/tests/api/v1/test_admin_scrape_kn_catalog_waf_guard.py
Normal file
108
backend/tests/api/v1/test_admin_scrape_kn_catalog_waf_guard.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue