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.
This commit is contained in:
Light1YT 2026-06-28 17:10:22 +05:00
parent d651dac99a
commit 4571938b56
5 changed files with 226 additions and 6 deletions

View file

@ -114,6 +114,33 @@ class Settings(BaseSettings):
# Empty string = endpoint disabled.
scrape_admin_token: str = ""
# ── #1945 KN-loader anti-ban (throttle + optional proxy) ──────────────────
# DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ
# captcha — подтверждено): per-region sweep гонит ~1548 объектов × 11
# endpoint'ов ≈ 17k запросов через один браузер. На старой concurrency=8
# WAF банил VPS-IP mid-sweep. Лечим двумя рычагами.
#
# Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch()
# ТОЛЬКО для KN-sweep BrowserSession (другие скраперы — nspd/catalog —
# продолжают использовать модульный дефолт _BROWSER_CONCURRENCY=8 без
# изменений). 2 — эмпирически безопасный потолок против volume-бана.
# ENV: SCRAPE_KN_BROWSER_CONCURRENCY.
scrape_kn_browser_concurrency: int = 2
# Окно случайной паузы (мс) между запросами KN-sweep. Шире дефолта
# (6001500), чтобы размазать запросы во времени и не триггерить rate-ban.
# min < max обязателен (иначе random.uniform отдаст границу). При throttle
# ширим до 12003000. ENV: SCRAPE_KN_REQUEST_JITTER_MIN_MS / _MAX_MS.
scrape_kn_request_jitter_min_ms: int = 1200
scrape_kn_request_jitter_max_ms: int = 3000
# Рычаг 2 — прокси (ОПЦИОНАЛЬНО, default None → прямое подключение, поведение
# без изменений). Когда задан — KN-sweep BrowserSession запускает Chromium
# через этот прокси (формат http://user:pass@host:port; парсится в
# Playwright proxy={server,username,password}). Сильнейший рычаг против
# IP-бана: переиспользует тот же mobile-proxy паттерн, что tradein-стек
# (SCRAPER_PROXY_URL). Заводится ТОЛЬКО через окружение прод-контейнера.
# ENV: SCRAPE_KN_PROXY_URL.
scrape_kn_proxy_url: str | None = None
# NSPD-scraper (Playwright) УДАЛЁН 2026-05-11. Сменён на bulk geo-fetcher
# через rosreestr2coord — запускается вручную через /admin/scrape/geo.
# Settings оставлены deprecated на случай отката (можно удалить позже).

View file

@ -1142,7 +1142,7 @@ async def fetch_obj_checks(sess: BrowserSession, obj_id: int) -> tuple[Any, str]
# ── _fetch_*_safe wrappers for asyncio.gather in Phase B/C ───────────────────
# Каждый wrapper возвращает (kind, full_url, result_or_exception).
# Exceptions НЕ raise — помещаются в возвращаемый tuple.
# BrowserSession._sem (Semaphore(_BROWSER_CONCURRENCY)=8) bounds concurrency per-request.
# BrowserSession._sem (size = per-instance concurrency, #1945 KN=2) bounds concurrency.
async def _fetch_flats_safe(
@ -1660,9 +1660,18 @@ async def run_region_sweep(
photos_dir: Path | str | None = None,
place_override: str | None = None,
resume_from_run_id: int | None = None,
browser_concurrency: int | None = None,
request_jitter_min_ms: int | None = None,
request_jitter_max_ms: int | None = None,
proxy_url: str | None = None,
) -> dict[str, Any]:
"""Full sweep for one region: bootstrap → fetch objects → per-object processing.
#1945 anti-ban: ``browser_concurrency`` / ``request_jitter_*_ms`` / ``proxy_url``
прокидываются в BrowserSession sweep'а, чтобы троттлить/проксировать ТОЛЬКО KN-
прогон (другие скраперы используют модульные дефолты stealth.py без изменений).
None у каждого = дефолт stealth.py (concurrency=8, jitter 6001500, без прокси).
Resume-aware: если `resume_from_run_id` задан, читаем objects_snapshot и
progress_obj_index из исходного run и продолжаем с этого индекса. Новый
run_id создаётся (с resumed_from_run_id ссылкой), исходный остаётся
@ -1802,6 +1811,10 @@ async def run_region_sweep(
headed=headed,
load_state=load_state,
save_state=save_state,
concurrency=browser_concurrency,
jitter_min_ms=request_jitter_min_ms,
jitter_max_ms=request_jitter_max_ms,
proxy_url=proxy_url,
) as sess:
# Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies
# (___dmpkit___, domain_sid). Required since 2026-05-24 — without these
@ -1881,7 +1894,8 @@ async def run_region_sweep(
# ── Phase B/C — per-object processing (resumable, parallel per-object) ─
# Все endpoint'ы одного obj_id запускаются параллельно через asyncio.gather.
# BrowserSession._sem (Semaphore(_BROWSER_CONCURRENCY)=8) ограничивает запросы.
# BrowserSession._sem (size = browser_concurrency, #1945 троттлит KN до 2)
# ограничивает запросы.
# DB upserts выполняются последовательно после gather — один db Session
# не thread-safe для параллельной записи.
pdir = Path(photos_dir) if photos_dir else PHOTOS_DIR_DEFAULT

View file

@ -17,7 +17,7 @@ import json
import logging
import random
from typing import Any
from urllib.parse import urlencode
from urllib.parse import urlencode, urlsplit
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
@ -33,8 +33,43 @@ 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.
_BROWSER_CONCURRENCY = 8
# Дефолтное окно inter-request jitter (мс). KN-sweep ширит его через
# BrowserSession(jitter_min_ms=, jitter_max_ms=) против rate-бана (#1945).
_DEFAULT_JITTER_MIN_MS = 600
_DEFAULT_JITTER_MAX_MS = 1500
def parse_proxy_url(proxy_url: str | None) -> dict[str, str] | None:
"""Распарсить ``http://user:pass@host:port`` в Playwright proxy-dict.
Playwright ждёт ``{"server": "http://host:port", "username": ..., "password": ...}``
(creds отдельные ключи, НЕ в server-URL). Возвращает None для пустого/None
входа вызывающий код запускает Chromium без прокси (поведение по умолчанию).
Тот же URL-формат, что использует tradein-стек (SCRAPER_PROXY_URL).
"""
if not proxy_url:
return None
parts = urlsplit(proxy_url)
if not parts.hostname:
raise ValueError(f"proxy URL без host: {proxy_url!r}")
scheme = parts.scheme or "http"
server = f"{scheme}://{parts.hostname}"
if parts.port:
server += f":{parts.port}"
out: dict[str, str] = {"server": server}
if parts.username:
out["username"] = parts.username
if parts.password:
out["password"] = parts.password
return out
# Маппинг region_code → URL-сегмент города для реалистичного Referer.
# Не обязан быть исчерпывающим — fallback на /новостройки/строящиеся/.
REGION_LANDING_PATH = {
@ -94,6 +129,10 @@ class BrowserSession:
auth: str | None = "Basic MTpxd2U=",
load_state: str | None = None,
save_state: str | None = None,
concurrency: int | None = None,
jitter_min_ms: int | None = None,
jitter_max_ms: int | None = None,
proxy_url: str | None = None,
) -> None:
self.region_code = region_code
self.headed = headed
@ -105,11 +144,20 @@ class BrowserSession:
# so it can be committed and reused on a server.
self.load_state = load_state
self.save_state = save_state
# Per-instance concurrency (#1945): KN-sweep throttles to 2 against the
# volume-ban; catalog/nspd callers pass nothing → keep the module default
# _BROWSER_CONCURRENCY=8 (их поведение НЕ меняется).
self._concurrency = concurrency if concurrency is not None else _BROWSER_CONCURRENCY
# Per-instance inter-request jitter window (#1945). None → module default.
self._jitter_min_ms = jitter_min_ms if jitter_min_ms is not None else _DEFAULT_JITTER_MIN_MS
self._jitter_max_ms = jitter_max_ms if jitter_max_ms is not None else _DEFAULT_JITTER_MAX_MS
# Optional rotating proxy (#1945). None → Chromium launches direct (default).
self._proxy = parse_proxy_url(proxy_url)
self._pw: Any = None
self._browser: Browser | None = None
self._context: BrowserContext | None = None
self._page: Page | None = None
self._sem = asyncio.Semaphore(_BROWSER_CONCURRENCY)
self._sem = asyncio.Semaphore(self._concurrency)
self._request_count = 0
self._warmed_up = False
@ -127,12 +175,19 @@ class BrowserSession:
landing_url = make_referer(self.region_code)
logger.info("bootstrap: opening %s", landing_url)
self._pw = await async_playwright().start()
self._browser = await self._pw.chromium.launch(headless=not self.headed)
launch_kwargs: dict[str, Any] = {"headless": not self.headed}
ctx_kwargs: dict[str, Any] = {
"user_agent": USER_AGENT,
"locale": "ru-RU",
"viewport": {"width": 1920, "height": 1080},
}
if self._proxy is not None:
# Прокси задаётся и на launch, и на context (Playwright требует на
# browser-level для Chromium; context-level дублирует для надёжности).
launch_kwargs["proxy"] = self._proxy
ctx_kwargs["proxy"] = self._proxy
logger.info("bootstrap: routing Chromium through proxy %s", self._proxy["server"])
self._browser = await self._pw.chromium.launch(**launch_kwargs)
if self.load_state:
ctx_kwargs["storage_state"] = self.load_state
logger.info("bootstrap: loading saved storage_state from %s", self.load_state)
@ -193,7 +248,7 @@ class BrowserSession:
last_err: Exception | None = None
for attempt in range(5):
async with self._sem:
await jitter_sleep()
await jitter_sleep(self._jitter_min_ms, self._jitter_max_ms)
try:
self._request_count += 1
result = await self._page.evaluate(_FETCH_JS, {"url": url, "auth": self.auth})

View file

@ -210,6 +210,11 @@ def scrape_kn_region(
fetch_flats=fetch_flats,
extras=extras,
download_photos_binary=download_photos,
# #1945 anti-ban: throttle + optional proxy для KN-sweep.
browser_concurrency=settings.scrape_kn_browser_concurrency,
request_jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
request_jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
proxy_url=settings.scrape_kn_proxy_url,
)
)
@ -289,5 +294,10 @@ def resume_kn_run(self: Any, run_id: int) -> dict[str, Any]:
developers=developers,
load_state=state_path,
resume_from_run_id=run_id,
# #1945 anti-ban: throttle + optional proxy для KN-sweep.
browser_concurrency=settings.scrape_kn_browser_concurrency,
request_jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
request_jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
proxy_url=settings.scrape_kn_proxy_url,
)
)

View file

@ -0,0 +1,114 @@
"""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