gendesign/backend/tests/services/scrapers/test_stealth_throttle_proxy.py
Light1YT 4571938b56 fix(scrapers): throttle + optional proxy levers for KN flats loader (#1945)
DOM.РФ WAF банит на full sweep (~17k req @ concurrency=8): сессия,
тронувшая gated /сервисы/api/object/* extras, получает 403 → cookies
сессии флагуются → последующие запросы (incl. flats) тоже 403.

Эмпирически (prod worker, throttle@concurrency=2, без прокси):
  • flats-only, 100 объектов / 104 req — 0 WAF-бан;
  • mixed flats+extras — extras 403 сразу, затем травит сессию (flats→403).
Значит throttle снижает rate, но НЕ устраняет session-poisoning от extras;
прокси (mobile-proxy, как в tradein) — следующий рычаг, off-by-default.

- config: scrape_kn_browser_concurrency=2, scrape_kn_request_jitter_{min,max}_ms,
  scrape_kn_proxy_url=None (прокси опционален, дефолт — прямое подключение).
- stealth.BrowserSession: per-instance concurrency / jitter / proxy_url;
  parse_proxy_url(http://user:pass@host:port → Playwright proxy-dict).
  Модульный _BROWSER_CONCURRENCY=8 не тронут — catalog/nspd без изменений.
- domrf_kn.run_region_sweep + workers/tasks/scrape_kn: прокидывают новые
  settings ТОЛЬКО в KN-sweep BrowserSession.
- tests: parse_proxy_url, threading concurrency/jitter/proxy, defaults unchanged.
2026-06-28 17:10:22 +05:00

114 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Unit-тесты для KN-loader anti-ban рычагов в scrapers/stealth (#1945).
DOM.РФ WAF банит VPS-IP по volume/rate на full sweep (~17k запросов при
concurrency=8). Лечим: (1) per-instance throttle (concurrency + шире jitter)
для KN-sweep, НЕ трогая модульный дефолт остальных скраперов; (2) опциональный
прокси (default off → поведение без изменений).
Тесты проверяют:
• parse_proxy_url корректно разбирает http://user:pass@host:port;
• BrowserSession прокидывает concurrency/jitter/proxy в self-поля;
• дефолтное (None) поведение НЕ меняется (concurrency=8, jitter 6001500, без прокси).
"""
from __future__ import annotations
import pytest
from app.services.scrapers.stealth import (
_BROWSER_CONCURRENCY,
_DEFAULT_JITTER_MAX_MS,
_DEFAULT_JITTER_MIN_MS,
BrowserSession,
parse_proxy_url,
)
class TestParseProxyUrl:
"""parse_proxy_url → Playwright proxy-dict {server, username?, password?}."""
def test_none_returns_none(self) -> None:
assert parse_proxy_url(None) is None
def test_empty_string_returns_none(self) -> None:
assert parse_proxy_url("") is None
def test_full_creds_url(self) -> None:
got = parse_proxy_url("http://user:pass@host.example:8080")
assert got == {
"server": "http://host.example:8080",
"username": "user",
"password": "pass",
}
def test_no_creds(self) -> None:
got = parse_proxy_url("http://host.example:3128")
assert got == {"server": "http://host.example:3128"}
def test_creds_not_leaked_into_server(self) -> None:
# Playwright требует creds ОТДЕЛЬНЫМИ ключами, не в server-URL.
# Используем уникальные токены, которых нет в host/scheme.
got = parse_proxy_url("http://secretuser:secretpass@1.2.3.4:9999")
assert got is not None
assert got["server"] == "http://1.2.3.4:9999"
assert "secretuser" not in got["server"]
assert "secretpass" not in got["server"]
assert "@" not in got["server"]
def test_scheme_preserved(self) -> None:
got = parse_proxy_url("https://u:p@host:443")
assert got is not None
assert got["server"] == "https://host:443"
def test_default_scheme_when_missing(self) -> None:
# urlsplit без схемы кладёт всё в path → host не резолвится → ValueError.
with pytest.raises(ValueError):
parse_proxy_url("host.example:8080")
def test_host_required(self) -> None:
with pytest.raises(ValueError):
parse_proxy_url("http://:8080")
class TestBrowserSessionThrottleThreading:
"""concurrency/jitter/proxy прокидываются в инстанс-поля BrowserSession."""
def test_concurrency_threaded(self) -> None:
sess = BrowserSession(concurrency=2)
assert sess._concurrency == 2
assert sess._sem._value == 2
def test_jitter_threaded(self) -> None:
sess = BrowserSession(jitter_min_ms=1200, jitter_max_ms=3000)
assert sess._jitter_min_ms == 1200
assert sess._jitter_max_ms == 3000
def test_proxy_threaded_and_parsed(self) -> None:
sess = BrowserSession(proxy_url="http://u:p@proxy.local:8000")
assert sess._proxy == {
"server": "http://proxy.local:8000",
"username": "u",
"password": "p",
}
class TestBrowserSessionDefaultsUnchanged:
"""None у каждого рычага = старое поведение (другие скраперы НЕ затронуты)."""
def test_default_concurrency_is_module_default(self) -> None:
sess = BrowserSession()
assert sess._concurrency == _BROWSER_CONCURRENCY == 8
assert sess._sem._value == 8
def test_default_jitter_is_module_default(self) -> None:
sess = BrowserSession()
assert sess._jitter_min_ms == _DEFAULT_JITTER_MIN_MS == 600
assert sess._jitter_max_ms == _DEFAULT_JITTER_MAX_MS == 1500
def test_default_proxy_is_none(self) -> None:
sess = BrowserSession()
assert sess._proxy is None
def test_explicit_none_proxy_is_none(self) -> None:
sess = BrowserSession(proxy_url=None)
assert sess._proxy is None