feat(tradein-browser): per-provider page-interval override (#1812 follow-up) #1826
2 changed files with 160 additions and 5 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
104
tradein-mvp/browser/test_server_pacing.py
Normal file
104
tradein-mvp/browser/test_server_pacing.py
Normal 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)}"
|
||||||
Loading…
Add table
Reference in a new issue