feat(tradein-browser): per-provider page-interval override (#1812 follow-up) #1826

Merged
lekss361 merged 1 commit from feat/browser-per-provider-pacing into main 2026-06-20 08:47:07 +00:00
2 changed files with 160 additions and 5 deletions

View file

@ -50,6 +50,14 @@ Per-provider модель (#1793):
коннектам предыдущей страницы дренироваться, прежде чем коннектам предыдущей страницы дренироваться, прежде чем
открыть новую меньше пиковый параллелизм на прокси. открыть новую меньше пиковый параллелизм на прокси.
0 = без пейсинга. 0 = без пейсинга.
BROWSER_MIN_PAGE_INTERVAL_S_{PROVIDER} per-provider override интервала пейсинга.
PROVIDER в upper-case: AVITO, CIAN, YANDEX, GENERIC.
Например: BROWSER_MIN_PAGE_INTERVAL_S_CIAN=18 задаёт 18с
только для cian, не затрагивая другие провайдеры. Если env
не задан или содержит невалидное значение фолбэк на
глобальный BROWSER_MIN_PAGE_INTERVAL_S. Backward-compat:
при незаданных per-provider env поведение идентично
предыдущему (чисто аддитивный override).
BROWSER_PROXY_AVITO http-прокси для avito (fallback AVITO_PROXY_URL). BROWSER_PROXY_AVITO http-прокси для avito (fallback AVITO_PROXY_URL).
BROWSER_PROXY_CIAN http-прокси для cian (fallback CIAN_PROXY_URL). BROWSER_PROXY_CIAN http-прокси для cian (fallback CIAN_PROXY_URL).
BROWSER_PROXY_YANDEX http-прокси для yandex (fallback YANDEX_PROXY_URL). BROWSER_PROXY_YANDEX http-прокси для yandex (fallback YANDEX_PROXY_URL).
@ -71,6 +79,7 @@ import asyncio
import base64 import base64
import logging import logging
import os import os
from collections.abc import Mapping
from urllib.parse import urlparse from urllib.parse import urlparse
from aiohttp import web from aiohttp import web
@ -132,6 +141,46 @@ _BLOCKED_TYPES: frozenset[str] = _parse_block_types(
BROWSER_MIN_PAGE_INTERVAL_S: float = float(os.environ.get("BROWSER_MIN_PAGE_INTERVAL_S", "2.0")) BROWSER_MIN_PAGE_INTERVAL_S: float = float(os.environ.get("BROWSER_MIN_PAGE_INTERVAL_S", "2.0"))
def _resolve_min_interval(
provider: str,
environ: Mapping[str, str] | None = None,
) -> float:
"""Возвращает эффективный интервал пейсинга для провайдера.
Читает per-provider override из ``BROWSER_MIN_PAGE_INTERVAL_S_{PROVIDER}``
(PROVIDER в upper-case). Невалидное или отсутствующее значение фолбэк на
глобальный ``BROWSER_MIN_PAGE_INTERVAL_S``. Не кидает исключений.
Args:
provider: имя провайдера ("avito", "cian", "yandex", "generic").
environ: env-словарь; None ``os.environ`` (тестируемость без патча модуля).
Returns:
Эффективный интервал в секундах (0).
"""
env = environ if environ is not None else os.environ
key = f"BROWSER_MIN_PAGE_INTERVAL_S_{provider.upper()}"
raw = env.get(key)
if raw is not None:
try:
return float(raw)
except ValueError:
logger.warning(
"tradein-browser: %s=%r невалидно (не float), используем глобал %.1f",
key,
raw,
BROWSER_MIN_PAGE_INTERVAL_S,
)
return BROWSER_MIN_PAGE_INTERVAL_S
# Per-provider эффективные интервалы пейсинга, вычисляются на module-load из os.environ.
# Ключи: все известные провайдеры из PROVIDERS. Логируются на старте (_on_startup).
_MIN_PAGE_INTERVAL_BY_PROVIDER: dict[str, float] = {
p: _resolve_min_interval(p) for p in ("avito", "cian", "yandex", "generic")
}
def _provider_for_url(url: str) -> str: def _provider_for_url(url: str) -> str:
"""Определяет поставщика по host URL. """Определяет поставщика по host URL.
@ -412,6 +461,7 @@ async def _on_startup(app: web.Application) -> None:
"параллель между %s, ≤1 на поставщика; браузеры стартуют по требованию", "параллель между %s, ≤1 на поставщика; браузеры стартуют по требованию",
list(PROVIDERS), list(PROVIDERS),
) )
logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER)
async def _on_cleanup(app: web.Application) -> None: async def _on_cleanup(app: web.Application) -> None:
@ -549,21 +599,22 @@ async def _apply_resource_block(page: object) -> None:
async def _pace_provider(provider: str) -> None: async def _pace_provider(provider: str) -> None:
"""Притормаживает перед page.goto, если с прошлого goto этого провайдера прошло """Притормаживает перед page.goto, если с прошлого goto этого провайдера прошло
меньше BROWSER_MIN_PAGE_INTERVAL_S даёт под-коннектам прошлой страницы меньше эффективного интервала даёт под-коннектам прошлой страницы дренироваться,
дренироваться, снижая пиковый параллелизм на прокси. снижая пиковый параллелизм на прокси.
Caller держит _locks[provider] timestamp per-provider читается/пишется без гонки. Caller держит _locks[provider] timestamp per-provider читается/пишется без гонки.
Записывает момент ПОСЛЕ возможного sleep (т.е. фактический момент следующего goto), Записывает момент ПОСЛЕ возможного sleep (т.е. фактический момент следующего goto),
чтобы интервал считался между реальными навигациями, а не между входами в функцию. чтобы интервал считался между реальными навигациями, а не между входами в функцию.
No-op при BROWSER_MIN_PAGE_INTERVAL_S <= 0. No-op при эффективном интервале провайдера <= 0.
""" """
if BROWSER_MIN_PAGE_INTERVAL_S <= 0: interval = _MIN_PAGE_INTERVAL_BY_PROVIDER.get(provider, BROWSER_MIN_PAGE_INTERVAL_S)
if interval <= 0:
return return
now = asyncio.get_event_loop().time() now = asyncio.get_event_loop().time()
last = _last_goto_at.get(provider) last = _last_goto_at.get(provider)
if last is not None: if last is not None:
elapsed = now - last elapsed = now - last
remaining = BROWSER_MIN_PAGE_INTERVAL_S - elapsed remaining = interval - elapsed
if remaining > 0: if remaining > 0:
logger.debug( logger.debug(
"tradein-browser[%s]: pacing — ждём %.2fс перед goto", provider, remaining "tradein-browser[%s]: pacing — ждём %.2fс перед goto", provider, remaining

View file

@ -0,0 +1,104 @@
"""test_server_pacing.py — юниты для per-provider pacing-интервалов (#1812 follow-up).
Проверяет:
1. per-provider env override переопределяет глобал для конкретного провайдера;
2. отсутствие per-provider env фолбэк на глобальный BROWSER_MIN_PAGE_INTERVAL_S;
3. невалидный per-provider env (не float) фолбэк на глобал, без исключения;
4. пустая строка как per-provider env фолбэк на глобал (ValueError при float("")).
camoufox/Playwright НЕ поднимается: _resolve_min_interval вызывается с фейковым environ.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_pacing.py -q
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
# ── _resolve_min_interval ────────────────────────────────────────────────────────
def test_per_provider_override_replaces_global() -> None:
"""BROWSER_MIN_PAGE_INTERVAL_S_CIAN=18 + глобал=12 → cian=18, avito=12."""
fake_env = {
"BROWSER_MIN_PAGE_INTERVAL_S": "12",
"BROWSER_MIN_PAGE_INTERVAL_S_CIAN": "18",
}
# Патчим глобал в модуле, чтобы фолбэк брался из него (не из os.environ при load).
original_global = server.BROWSER_MIN_PAGE_INTERVAL_S
server.BROWSER_MIN_PAGE_INTERVAL_S = 12.0
try:
assert server._resolve_min_interval("cian", fake_env) == 18.0
assert server._resolve_min_interval("avito", fake_env) == 12.0
finally:
server.BROWSER_MIN_PAGE_INTERVAL_S = original_global
def test_no_per_provider_env_falls_back_to_global() -> None:
"""Нет BROWSER_MIN_PAGE_INTERVAL_S_YANDEX → фолбэк на глобал."""
fake_env: dict[str, str] = {}
original_global = server.BROWSER_MIN_PAGE_INTERVAL_S
server.BROWSER_MIN_PAGE_INTERVAL_S = 5.0
try:
result = server._resolve_min_interval("yandex", fake_env)
assert result == 5.0
finally:
server.BROWSER_MIN_PAGE_INTERVAL_S = original_global
def test_invalid_per_provider_env_falls_back_to_global() -> None:
"""Невалидный env (не float) → фолбэк на глобал, без исключения."""
fake_env = {"BROWSER_MIN_PAGE_INTERVAL_S_AVITO": "abc"}
original_global = server.BROWSER_MIN_PAGE_INTERVAL_S
server.BROWSER_MIN_PAGE_INTERVAL_S = 7.0
try:
result = server._resolve_min_interval("avito", fake_env)
assert result == 7.0
finally:
server.BROWSER_MIN_PAGE_INTERVAL_S = original_global
def test_empty_string_per_provider_env_falls_back_to_global() -> None:
"""Пустая строка env → float("") → ValueError → фолбэк на глобал."""
fake_env = {"BROWSER_MIN_PAGE_INTERVAL_S_GENERIC": ""}
original_global = server.BROWSER_MIN_PAGE_INTERVAL_S
server.BROWSER_MIN_PAGE_INTERVAL_S = 3.0
try:
result = server._resolve_min_interval("generic", fake_env)
assert result == 3.0
finally:
server.BROWSER_MIN_PAGE_INTERVAL_S = original_global
def test_zero_per_provider_env_disables_pacing() -> None:
"""BROWSER_MIN_PAGE_INTERVAL_S_AVITO=0 → 0.0 (без пейсинга для avito)."""
fake_env = {
"BROWSER_MIN_PAGE_INTERVAL_S": "12",
"BROWSER_MIN_PAGE_INTERVAL_S_AVITO": "0",
}
original_global = server.BROWSER_MIN_PAGE_INTERVAL_S
server.BROWSER_MIN_PAGE_INTERVAL_S = 12.0
try:
assert server._resolve_min_interval("avito", fake_env) == 0.0
# Другие провайдеры без override получают глобал.
assert server._resolve_min_interval("cian", fake_env) == 12.0
finally:
server.BROWSER_MIN_PAGE_INTERVAL_S = original_global
def test_all_providers_covered() -> None:
"""Все 4 провайдера (avito/cian/yandex/generic) обрабатываются резолвером."""
for provider in ("avito", "cian", "yandex", "generic"):
result = server._resolve_min_interval(provider, {})
assert isinstance(result, float), f"{provider}: ожидали float, получили {type(result)}"