Merge pull request 'feat(tradein-browser): per-provider page-interval override (#1812 follow-up)' (#1826) from feat/browser-per-provider-pacing into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m2s
Deploy Trade-In / deploy (push) Successful in 1m28s
All checks were successful
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m2s
Deploy Trade-In / deploy (push) Successful in 1m28s
Reviewed-on: #1826
This commit is contained in:
commit
7d92411347
2 changed files with 160 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
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