From 3f30c1c9248895b19d98d997dae029892729a6e7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 20 Jun 2026 10:44:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein-browser):=20per-provider=20page-in?= =?UTF-8?q?terval=20override=20(BROWSER=5FMIN=5FPAGE=5FINTERVAL=5FS=5F{PRO?= =?UTF-8?q?VIDER})=20=E2=80=94=20#1812=20follow-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/browser/server.py | 61 +++++++++++-- tradein-mvp/browser/test_server_pacing.py | 104 ++++++++++++++++++++++ 2 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 tradein-mvp/browser/test_server_pacing.py diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 73409e7d..bd015177 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -50,6 +50,14 @@ Per-provider модель (#1793): коннектам предыдущей страницы дренироваться, прежде чем открыть новую → меньше пиковый параллелизм на прокси. 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_CIAN — http-прокси для cian (fallback CIAN_PROXY_URL). BROWSER_PROXY_YANDEX — http-прокси для yandex (fallback YANDEX_PROXY_URL). @@ -71,6 +79,7 @@ import asyncio import base64 import logging import os +from collections.abc import Mapping from urllib.parse import urlparse 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")) +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: """Определяет поставщика по host URL. @@ -412,6 +461,7 @@ async def _on_startup(app: web.Application) -> None: "параллель между %s, ≤1 на поставщика; браузеры стартуют по требованию", list(PROVIDERS), ) + logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER) 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: """Притормаживает перед page.goto, если с прошлого goto этого провайдера прошло - меньше BROWSER_MIN_PAGE_INTERVAL_S — даёт под-коннектам прошлой страницы - дренироваться, снижая пиковый параллелизм на прокси. + меньше эффективного интервала — даёт под-коннектам прошлой страницы дренироваться, + снижая пиковый параллелизм на прокси. Caller держит _locks[provider] → timestamp per-provider читается/пишется без гонки. Записывает момент ПОСЛЕ возможного 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 now = asyncio.get_event_loop().time() last = _last_goto_at.get(provider) if last is not None: elapsed = now - last - remaining = BROWSER_MIN_PAGE_INTERVAL_S - elapsed + remaining = interval - elapsed if remaining > 0: logger.debug( "tradein-browser[%s]: pacing — ждём %.2fс перед goto", provider, remaining diff --git a/tradein-mvp/browser/test_server_pacing.py b/tradein-mvp/browser/test_server_pacing.py new file mode 100644 index 00000000..a7b694cd --- /dev/null +++ b/tradein-mvp/browser/test_server_pacing.py @@ -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)}" -- 2.45.3