diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 5fffb9d0..706153eb 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -34,7 +34,9 @@ Per-provider модель (#1793): Переменные окружения: BROWSER_PORT — TCP-порт HTTP-сервера (default: 3000) - BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска (default: 15) + BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска, глобальный + дефолт для провайдеров без код-дефолта (default: 15). + per-provider код-дефолт см. BROWSER_RECYCLE_PAGES_{PROVIDER}. BROWSER_NAV_TIMEOUT_MS — таймаут page.goto в мс (default: 60000) BROWSER_WAIT_MS — ожидание гидрации listings после DOMContentLoaded, мс (default: 6000) BROWSER_CHALLENGE_WAIT_MS — бюджет ожидания QRATOR PoW-челленджа Авито (#3045), @@ -84,6 +86,24 @@ Per-provider модель (#1793): глобальный BROWSER_MIN_PAGE_INTERVAL_S. Backward-compat: при незаданных per-provider env поведение идентично предыдущему (чисто аддитивный override). + BROWSER_RECYCLE_PAGES_{PROVIDER} — per-provider override recycle-порога (#3205). + PROVIDER в upper-case: AVITO, CIAN, YANDEX, GENERIC, + DOMCLICK. Приоритет: per-provider env → глобальный + BROWSER_RECYCLE_PAGES → код-дефолт per-provider (сейчас + только domclick=1) → общий фолбэк 15. Живой замер + 2026-08-29 (#3205, прод): ДомКлик отдаёт РОВНО одну + карточку на процесс camoufox — три независимых прогона, + карточка #1 всегда 200 (~1МБ SSR-стейт), карточка #2 + всегда 401 (~26625 байт страницы отказа); A/B на восьми + карточках — свой браузер на карточку 4/4 успеха, один + браузер на четыре 1/4. Признак живёт на уровне ПРОЦЕССА + (camoufox генерирует отпечаток при запуске, а не при + создании context'а) — свежий context (browser.new_page()) + отказ со второй карточки НЕ снимает, значит + reset_context/reuse_context (#3118) эту проблему не решают: + нужен новый процесс. Цена перезапуска ~0.6с (первый + холодный запуск ~3.5с). avito/cian/yandex остаются на 15 — + свой узор бана, отдельного замера под recycle=1 нет. SCRAPER_PROXY_URL — http-прокси, ОБЩИЙ для всех поставщиков (avito/cian/ yandex/generic). #2616 шаг 2: per-provider BROWSER_PROXY_AVITO/CIAN/YANDEX и legacy @@ -127,7 +147,12 @@ ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "dev") IS_PROD: bool = ENVIRONMENT == "production" BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000")) -BROWSER_RECYCLE_PAGES: int = int(os.environ.get("BROWSER_RECYCLE_PAGES", "15")) +# Глобальной константы recycle больше нет (#3205): порог стал поставщик-зависимым и +# живёт в _RECYCLE_PAGES_BY_PROVIDER. Env BROWSER_RECYCLE_PAGES по-прежнему читается — +# внутри _resolve_recycle_pages, как один из уровней приоритета. Константу убрали +# намеренно: пока она существовала, ~24 теста патчили ЕЁ, чтобы запретить перезапуск +# браузера, и после перехода на словарь этот патч перестал на что-либо влиять — +# охранник остался в коде, но охранять перестал. BROWSER_NAV_TIMEOUT_MS: int = int(os.environ.get("BROWSER_NAV_TIMEOUT_MS", "60000")) # 6000 (не 2500): avito гидрирует listings client-side ПОСЛЕ domcontentloaded; # на 2.5с в HTML генерик-шелл без объявлений (0 listings), на 5-6с — полная @@ -165,7 +190,10 @@ FETCH_JSON_LOAD_WAIT_MS: int = int(os.environ.get("FETCH_JSON_LOAD_WAIT_MS", "15 # Известные поставщики. "generic" — фолбэк для всех прочих хостов (один общий # инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод. -PROVIDERS: tuple[str, ...] = ("avito", "cian", "yandex", "generic") +# "domclick" (#3205): собственный инстанс — площадка отдаёт РОВНО одну карточку на +# процесс camoufox (см. _RECYCLE_PAGES_BY_PROVIDER ниже), общий с generic счётчик +# страниц пачкал бы чужие запросы чужим порогом перезапуска. +PROVIDERS: tuple[str, ...] = ("avito", "cian", "yandex", "generic", "domclick") def _parse_bool(raw: str | None, default: bool) -> bool: @@ -218,7 +246,7 @@ def _resolve_min_interval( глобальный ``BROWSER_MIN_PAGE_INTERVAL_S``. Не кидает исключений. Args: - provider: имя провайдера ("avito", "cian", "yandex", "generic"). + provider: имя провайдера ("avito", "cian", "yandex", "generic", "domclick"). environ: env-словарь; None → ``os.environ`` (тестируемость без патча модуля). Returns: @@ -242,9 +270,7 @@ def _resolve_min_interval( # 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") -} +_MIN_PAGE_INTERVAL_BY_PROVIDER: dict[str, float] = {p: _resolve_min_interval(p) for p in PROVIDERS} # Код-дефолт block_images ПО ПРОВАЙДЕРУ. Сейчас пуст — все провайдеры блокируют # картинки, как и до #3185 (см. _BLOCK_IMAGES_DEFAULT_FALLBACK). Ручка из #3185 @@ -275,7 +301,7 @@ def _resolve_block_images( исключений — невалидный/отсутствующий env тихо проваливается на следующий уровень. Args: - provider: имя провайдера ("avito", "cian", "yandex", "generic"). + provider: имя провайдера ("avito", "cian", "yandex", "generic", "domclick"). environ: env-словарь; None → ``os.environ`` (тестируемость без патча модуля). Returns: @@ -292,15 +318,84 @@ def _resolve_block_images( # Per-provider эффективные флаги block_images, вычисляются на module-load из os.environ. # Читается в _launch_browser; логируется на старте (_on_startup). -_BLOCK_IMAGES_BY_PROVIDER: dict[str, bool] = { - p: _resolve_block_images(p) for p in ("avito", "cian", "yandex", "generic") -} +_BLOCK_IMAGES_BY_PROVIDER: dict[str, bool] = {p: _resolve_block_images(p) for p in PROVIDERS} + +# Код-дефолт recycle_pages ПО ПРОВАЙДЕРУ (#3205). ДомКлик отдаёт РОВНО одну карточку на +# процесс camoufox — вторая и далее получают 401-страницу отказа (~26625 байт) вместо +# ~1МБ карточки с SSR-стейтом. Признак живёт на уровне ПРОЦЕССА (camoufox генерирует +# отпечаток при запуске, не при создании context'а): в замере browser.new_page() на +# каждую карточку (свежий изолированный context) отказ со второй карточки всё равно +# случался — значит reuse_context/reset_context (#3118) эту задачу НЕ решает, нужен +# новый процесс, а не новый context. Живой замер 2026-08-29 (#3205): три независимых +# прогона через camoufox+прод-прокси — карточка #1 всегда 200, карточка #2 всегда 401; +# A/B на восьми карточках — свой браузер на каждую 4/4 успеха, один браузер на четыре +# 1/4. Цена перезапуска процесса ~0.6с (первый холодный запуск ~3.5с) — дёшево против +# цены отказа. avito/cian/yandex НЕ трогаем — узор бана у них другой, отдельного замера +# под recycle=1 нет, дефолт 15 остаётся. +_RECYCLE_PAGES_DEFAULT_BY_PROVIDER: dict[str, int] = {"domclick": 1} +_RECYCLE_PAGES_DEFAULT_FALLBACK: int = 15 + + +def _resolve_recycle_pages( + provider: str, + environ: Mapping[str, str] | None = None, +) -> int: + """Возвращает эффективный recycle_pages threshold для провайдера (#3205). + + Приоритет (выше — сильнее): per-provider env ``BROWSER_RECYCLE_PAGES_{PROVIDER}`` + → глобальный env ``BROWSER_RECYCLE_PAGES`` → код-дефолт per-provider + (``_RECYCLE_PAGES_DEFAULT_BY_PROVIDER``; сейчас только domclick=1) → общий фолбэк 15. + Невалидное (не int) значение на любом из env-уровней тихо проваливается на + следующий уровень приоритета (фолбэк, а не падение). Валидное, но <1 — приводим к 1 + (порог меньше единицы бессмысленен: перезапуск на КАЖДОЙ странице — это и есть 1). + + Args: + provider: имя провайдера ("avito", "cian", "yandex", "generic", "domclick"). + environ: env-словарь; None → ``os.environ`` (тестируемость без патча модуля). + + Returns: + Эффективный порог recycle (страниц до перезапуска), ≥1. + """ + env = environ if environ is not None else os.environ + + def _parse(raw: str | None, fallback: int, env_key: str) -> int: + if raw is None: + return fallback + try: + value = int(raw) + except ValueError: + logger.warning( + "tradein-browser: %s=%r невалидно (не int), используем %d", + env_key, + raw, + fallback, + ) + return fallback + if value < 1: + logger.warning("tradein-browser: %s=%d < 1, приводим к 1", env_key, value) + return 1 + return value + + provider_default = _RECYCLE_PAGES_DEFAULT_BY_PROVIDER.get( + provider, _RECYCLE_PAGES_DEFAULT_FALLBACK + ) + global_value = _parse( + env.get("BROWSER_RECYCLE_PAGES"), provider_default, "BROWSER_RECYCLE_PAGES" + ) + key = f"BROWSER_RECYCLE_PAGES_{provider.upper()}" + return _parse(env.get(key), global_value, key) + + +# Per-provider эффективные пороги recycle, вычисляются на module-load из os.environ. +# Читаются в _do_fetch/_do_fetch_json/_do_login при сравнении с _page_counters; +# логируются на старте (_on_startup). +_RECYCLE_PAGES_BY_PROVIDER: dict[str, int] = {p: _resolve_recycle_pages(p) for p in PROVIDERS} def _provider_for_url(url: str) -> str: """Определяет поставщика по host URL. - avito/cian/yandex → одноимённый провайдер; всё прочее → "generic". + avito/cian/yandex/domclick → одноимённый провайдер; всё прочее → "generic". """ host = (urlparse(url).hostname or "").lower() if "avito" in host: @@ -309,6 +404,8 @@ def _provider_for_url(url: str) -> str: return "cian" if "yandex" in host: return "yandex" + if "domclick" in host: + return "domclick" return "generic" @@ -464,7 +561,7 @@ async def _launch_browser(provider: str, proxy_override: str | None = None) -> N "tradein-browser[%s]: запуск AsyncCamoufox (proxy=%s, recycle_pages=%d)", provider, proxy is not None, - BROWSER_RECYCLE_PAGES, + _RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK), ) cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type] browser = await cm.__aenter__() @@ -660,6 +757,7 @@ async def _on_startup(app: web.Application) -> None: ) logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER) logger.info("tradein-browser: block-images %s", _BLOCK_IMAGES_BY_PROVIDER) + logger.info("tradein-browser: recycle-pages %s", _RECYCLE_PAGES_BY_PROVIDER) _warn_retired_env() @@ -724,9 +822,16 @@ def _resolve_provider(body: dict, url: str) -> str: берём из "provider" ИЛИ "source", но применяем ТОЛЬКО если оно в PROVIDERS; иначе host-detect; иначе generic. - # domclick (source='domklik') нет в PROVIDERS → fallthrough на host-detect - # domclick.ru → generic. Намеренно (все поставщики делят один SCRAPER_PROXY_URL - # с #2616 шаг 2, generic-путь эквивалентен). + # domclick (#3205): explicit body["source"]="domklik" (опечатка площадки в + # caller'е) нет в PROVIDERS дословно → fallthrough на host-detect, а + # _provider_for_url узнаёт domclick.ru по host и отдаёт "domclick" — ЭТО + # больше НЕ synonim для generic. До #3205 комментарий здесь утверждал, что + # generic-путь эквивалентен, потому что все поставщики делят один + # SCRAPER_PROXY_URL (#2616 шаг 2) — прокси действительно общий, но с #3205 у + # domclick СВОЙ порог перезапуска браузера (recycle_pages=1, см. + # _RECYCLE_PAGES_BY_PROVIDER): площадка отдаёт ровно одну карточку на процесс + # camoufox, и общий с generic счётчик страниц пачкал бы чужие запросы чужим + # порогом. """ explicit = body.get("provider") or body.get("source") if isinstance(explicit, str) and explicit in PROVIDERS: @@ -1362,11 +1467,12 @@ async def _fetch_once( _page_counters[provider], ) - if _page_counters[provider] >= BROWSER_RECYCLE_PAGES: + recycle_pages = _RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK) + if _page_counters[provider] >= recycle_pages: logger.info( "tradein-browser[%s]: recycle threshold (%d) достигнут, перезапуск браузера", provider, - BROWSER_RECYCLE_PAGES, + recycle_pages, ) await _relaunch_browser(provider) @@ -1525,11 +1631,12 @@ async def _fetch_json_once( _page_counters[provider], ) - if _page_counters[provider] >= BROWSER_RECYCLE_PAGES: + recycle_pages = _RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK) + if _page_counters[provider] >= recycle_pages: logger.info( "tradein-browser[%s]: recycle threshold (%d) достигнут, перезапуск браузера", provider, - BROWSER_RECYCLE_PAGES, + recycle_pages, ) await _relaunch_browser(provider) @@ -1836,11 +1943,12 @@ async def _login_once(provider: str, params: dict) -> list[dict]: await page.close() # type: ignore[attr-defined] _page_counters[provider] = _page_counters.get(provider, 0) + 1 - if _page_counters[provider] >= BROWSER_RECYCLE_PAGES: + recycle_pages = _RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK) + if _page_counters[provider] >= recycle_pages: logger.info( "tradein-browser[%s]: recycle threshold (%d) достигнут после login, перезапуск", provider, - BROWSER_RECYCLE_PAGES, + recycle_pages, ) await _relaunch_browser(provider) diff --git a/tradein-mvp/browser/test_server.py b/tradein-mvp/browser/test_server.py index cc6d44ac..6a1ae2db 100644 --- a/tradein-mvp/browser/test_server.py +++ b/tradein-mvp/browser/test_server.py @@ -71,12 +71,17 @@ def test_provider_for_url_yandex() -> None: def test_provider_for_url_generic_fallback() -> None: assert server._provider_for_url("https://example.com/x") == "generic" - assert server._provider_for_url("https://domclick.ru/x") == "generic" # Битый / пустой URL — без хоста → generic, не падаем. assert server._provider_for_url("not-a-url") == "generic" assert server._provider_for_url("") == "generic" +def test_provider_for_url_domclick() -> None: + """#3205: у ДомКлика собственный провайдер, а не generic (свой recycle-порог).""" + assert server._provider_for_url("https://domclick.ru/x") == "domclick" + assert server._provider_for_url("https://ekaterinburg.domclick.ru/card/1") == "domclick" + + # ── _provider_proxy ────────────────────────────────────────────────────────────── @@ -120,14 +125,14 @@ def test_resolve_provider_uses_source_field() -> None: assert server._resolve_provider({"source": "yandex"}, "https://www.avito.ru/x") == "yandex" -def test_resolve_provider_domclick_source_to_generic() -> None: - """domclick scraper шлёт source='domklik' (нет в PROVIDERS) + хост domclick.ru. +def test_resolve_provider_domclick_source_falls_back_to_host_detect() -> None: + """#3205: domclick scraper шлёт source='domklik' (опечатка, нет в PROVIDERS). - 'domklik' не валиден → host-detect domclick.ru → generic. Намеренно: 'domklik' - не входит в PROVIDERS (нет выделенного instance). + 'domklik' не валиден как explicit → host-detect domclick.ru → выделенный + провайдер "domclick" (собственный instance + recycle_pages=1), а НЕ generic. """ assert ( - server._resolve_provider({"source": "domklik"}, "https://domclick.ru/x") == "generic" + server._resolve_provider({"source": "domklik"}, "https://domclick.ru/x") == "domclick" ) diff --git a/tradein-mvp/browser/test_server_fetch_json.py b/tradein-mvp/browser/test_server_fetch_json.py index 3e841cf4..c564bcd3 100644 --- a/tradein-mvp/browser/test_server_fetch_json.py +++ b/tradein-mvp/browser/test_server_fetch_json.py @@ -111,7 +111,10 @@ def test_fetch_json_returns_evaluate_result(monkeypatch: pytest.MonkeyPatch) -> """fetch_json_handler возвращает {"status","body"} ровно как отдал page.evaluate.""" page = _FakePage({"status": 200, "body": '{"ok": true}'}) server._browsers["avito"] = _FakeBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True @@ -139,7 +142,10 @@ def test_fetch_json_navigates_to_origin_not_url(monkeypatch: pytest.MonkeyPatch) """Навигация идёт на origin (same-origin якорь), а fetch — уже на url.""" page = _FakePage({"status": 200, "body": "ok"}) server._browsers["avito"] = _FakeBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True @@ -159,7 +165,10 @@ def test_fetch_json_uses_explicit_origin(monkeypatch: pytest.MonkeyPatch) -> Non """Явный body["origin"] перебивает выведенный из url.""" page = _FakePage({"status": 200, "body": "ok"}) server._browsers["avito"] = _FakeBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True @@ -184,7 +193,10 @@ def test_fetch_json_passes_post_method_and_body(monkeypatch: pytest.MonkeyPatch) """method/headers/body прокидываются в page.evaluate без искажений.""" page = _FakePage({"status": 201, "body": "{}"}) server._browsers["avito"] = _FakeBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True @@ -217,7 +229,10 @@ def test_fetch_json_settle_uses_configured_ms(monkeypatch: pytest.MonkeyPatch) - """Settle перед in-page fetch = FETCH_JSON_SETTLE_MS (а не хардкод 500).""" page = _FakePage({"status": 200, "body": "ok"}) server._browsers["avito"] = _FakeBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) monkeypatch.setattr(server, "FETCH_JSON_SETTLE_MS", 1777) async def _ensure(provider: str, proxy_override: str | None = None) -> bool: @@ -237,7 +252,10 @@ def test_fetch_json_passes_inpage_retry_params(monkeypatch: pytest.MonkeyPatch) """В page.evaluate прокидываются retries/retryDelayMs из конфигурации (#1917).""" page = _FakePage({"status": 200, "body": "ok", "attempts": 1}) server._browsers["avito"] = _FakeBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) monkeypatch.setattr(server, "FETCH_JSON_INPAGE_RETRIES", 2) monkeypatch.setattr(server, "FETCH_JSON_RETRY_DELAY_MS", 654) @@ -301,7 +319,10 @@ def test_fetch_json_500_on_evaluate_error(monkeypatch: pytest.MonkeyPatch) -> No page = _FakePage({"status": 200, "body": "ok"}) page.evaluate = AsyncMock(side_effect=RuntimeError("eval boom")) server._browsers["avito"] = _FakeBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True @@ -340,7 +361,10 @@ def test_do_fetch_json_relaunch_on_browser_crash(monkeypatch: pytest.MonkeyPatch # Первый инстанс — крашащийся; после relaunch подставляем здоровый. server._browsers["avito"] = _FakeBrowser(crashing_page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) relaunched: list[str] = [] @@ -391,7 +415,10 @@ def test_fetch_json_retries_evaluate_on_the_same_page(monkeypatch: pytest.Monkey ) browser = _FakeBrowser(page) server._browsers["avito"] = browser - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) monkeypatch.setattr(server, "FETCH_JSON_LOAD_WAIT_MS", 4242) result = asyncio.run( @@ -420,7 +447,10 @@ def test_fetch_json_same_page_retry_happens_once(monkeypatch: pytest.MonkeyPatch page = _FakePage({"status": 0, "body": ""}) page.evaluate = AsyncMock(side_effect=RuntimeError(message)) server._browsers["avito"] = _FakeBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) with pytest.raises(RuntimeError, match="Execution context was destroyed"): asyncio.run( @@ -442,7 +472,10 @@ def test_fetch_json_no_same_page_retry_for_unrelated_error(monkeypatch: pytest.M page = _FakePage({"status": 0, "body": ""}) page.evaluate = AsyncMock(side_effect=RuntimeError("boom")) server._browsers["avito"] = _FakeBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) with pytest.raises(RuntimeError, match="boom"): asyncio.run( @@ -491,7 +524,10 @@ def test_do_fetch_json_recovers_from_destroyed_context( ) browser = _FakeBrowser(page) server._browsers["avito"] = browser - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) relaunched = _no_relaunch(monkeypatch) result = asyncio.run( @@ -523,7 +559,10 @@ def test_fetch_json_retry_survives_load_timeout(monkeypatch: pytest.MonkeyPatch) side_effect=TimeoutError("Timeout 15000ms exceeded") ) server._browsers["avito"] = _FakeBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) _no_relaunch(monkeypatch) result = asyncio.run( @@ -552,7 +591,10 @@ def test_do_fetch_json_gives_up_after_one_context_retry(monkeypatch: pytest.Monk ) browser = _FakeBrowser(page) server._browsers["avito"] = browser - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) _no_relaunch(monkeypatch) with pytest.raises(RuntimeError, match="Execution context was destroyed"): @@ -575,7 +617,10 @@ def test_do_fetch_json_does_not_retry_unrelated_error(monkeypatch: pytest.Monkey page = _FakePage({"status": 0, "body": ""}) page.evaluate = AsyncMock(side_effect=RuntimeError("net::ERR_PROXY_CONNECTION_FAILED")) server._browsers["avito"] = _FakeBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) _no_relaunch(monkeypatch) with pytest.raises(RuntimeError, match="ERR_PROXY_CONNECTION_FAILED"): @@ -598,7 +643,10 @@ def test_fetch_json_handler_500_carries_reason_in_body(monkeypatch: pytest.Monke page = _FakePage({"status": 0, "body": ""}) page.evaluate = AsyncMock(side_effect=RuntimeError(message)) server._browsers["avito"] = _FakeBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) _no_relaunch(monkeypatch) async def _ensure(provider: str, proxy_override: str | None = None) -> bool: diff --git a/tradein-mvp/browser/test_server_http_status.py b/tradein-mvp/browser/test_server_http_status.py index b179a178..36fb7428 100644 --- a/tradein-mvp/browser/test_server_http_status.py +++ b/tradein-mvp/browser/test_server_http_status.py @@ -117,7 +117,10 @@ class _Browser: def _install(monkeypatch: pytest.MonkeyPatch, page: _Page, provider: str = "avito") -> None: server._browsers[provider] = _Browser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0) monkeypatch.setattr(server, "_MIN_PAGE_INTERVAL_BY_PROVIDER", {}) monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0) diff --git a/tradein-mvp/browser/test_server_pacing.py b/tradein-mvp/browser/test_server_pacing.py index 1451608a..a96beb01 100644 --- a/tradein-mvp/browser/test_server_pacing.py +++ b/tradein-mvp/browser/test_server_pacing.py @@ -106,8 +106,8 @@ def test_zero_per_provider_env_disables_pacing() -> None: def test_all_providers_covered() -> None: - """Все 4 провайдера (avito/cian/yandex/generic) обрабатываются резолвером.""" - for provider in ("avito", "cian", "yandex", "generic"): + """Все провайдеры (avito/cian/yandex/generic/domclick) обрабатываются резолвером.""" + for provider in ("avito", "cian", "yandex", "generic", "domclick"): result = server._resolve_min_interval(provider, {}) assert isinstance(result, float), f"{provider}: ожидали float, получили {type(result)}" @@ -129,12 +129,12 @@ def _reset_pacing_state(monkeypatch: pytest.MonkeyPatch) -> None: def test_pacing_get_returns_all_providers() -> None: - """GET /pacing возвращает providers для всех 4 источников.""" + """GET /pacing возвращает providers для всех источников (включая domclick, #3205).""" response = asyncio.run(server.pacing_get_handler(make_mocked_request("GET", "/pacing"))) body = _json_body(response) assert response.status == 200 providers = {p["source"]: p for p in body["providers"]} - assert set(providers) == {"avito", "cian", "yandex", "generic"} + assert set(providers) == {"avito", "cian", "yandex", "generic", "domclick"} def test_pacing_get_reflects_live_value(monkeypatch: pytest.MonkeyPatch) -> None: @@ -203,9 +203,13 @@ def test_pacing_put_zero_disables_pacing() -> None: def test_pacing_put_bad_source_400() -> None: - """PUT /pacing с невалидным source → 400.""" + """PUT /pacing с невалидным source → 400. + + #3205: "domclick" теперь валидный provider (собственный instance) — источник + невалидности переведён на заведомо неизвестное имя. + """ response = asyncio.run( - server.pacing_put_handler(_put_request({"source": "domclick", "interval_s": 5.0})) + server.pacing_put_handler(_put_request({"source": "bogus", "interval_s": 5.0})) ) assert response.status == 400 body = _json_body(response) diff --git a/tradein-mvp/browser/test_server_pow_challenge.py b/tradein-mvp/browser/test_server_pow_challenge.py index 863b7a55..79bf1313 100644 --- a/tradein-mvp/browser/test_server_pow_challenge.py +++ b/tradein-mvp/browser/test_server_pow_challenge.py @@ -116,7 +116,10 @@ def _install( monkeypatch: pytest.MonkeyPatch, page: _ChallengePage, provider: str = "avito" ) -> None: server._browsers[provider] = _ChallengeBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) # ── детекторы: чистые функции ──────────────────────────────────────────────────── diff --git a/tradein-mvp/browser/test_server_recycle.py b/tradein-mvp/browser/test_server_recycle.py new file mode 100644 index 00000000..14e36d99 --- /dev/null +++ b/tradein-mvp/browser/test_server_recycle.py @@ -0,0 +1,74 @@ +"""test_server_recycle.py — юниты для per-provider recycle_pages threshold (#3205). + +Проверяет ``_resolve_recycle_pages``: + 1. per-provider env перебивает глобальный env; + 2. глобальный env перебивает код-дефолт per-provider; + 3. невалидное (не int) значение → фолбэк, без исключения; + 4. значение <1 → приводится к 1; + 5. domclick без env → код-дефолт 1 (живой замер #3205: одна карточка на процесс); + 6. avito/cian/yandex без env → общий фолбэк 15 (их узор бана не менялся). + +camoufox/Playwright НЕ поднимается: тестируется чистая функция резолвера. + +Запуск (из tradein-mvp/browser/):: + + python -m pytest test_server_recycle.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) + + +def test_per_provider_env_overrides_global() -> None: + """BROWSER_RECYCLE_PAGES_DOMCLICK=3 + глобал=10 → domclick=3.""" + fake_env = {"BROWSER_RECYCLE_PAGES": "10", "BROWSER_RECYCLE_PAGES_DOMCLICK": "3"} + assert server._resolve_recycle_pages("domclick", fake_env) == 3 + + +def test_global_env_overrides_code_default() -> None: + """Нет per-provider env, но задан глобальный → перебивает код-дефолт domclick=1.""" + fake_env = {"BROWSER_RECYCLE_PAGES": "7"} + assert server._resolve_recycle_pages("domclick", fake_env) == 7 + + +def test_invalid_per_provider_env_falls_back() -> None: + """Невалидный (не int) per-provider env → фолбэк на глобальный уровень.""" + fake_env = {"BROWSER_RECYCLE_PAGES": "9", "BROWSER_RECYCLE_PAGES_CIAN": "abc"} + assert server._resolve_recycle_pages("cian", fake_env) == 9 + + +def test_invalid_global_env_falls_back_to_code_default() -> None: + """Невалидный глобальный env → фолбэк на код-дефолт per-provider (без исключения).""" + fake_env = {"BROWSER_RECYCLE_PAGES": "not-a-number"} + assert server._resolve_recycle_pages("domclick", fake_env) == 1 + assert server._resolve_recycle_pages("cian", fake_env) == 15 + + +def test_value_below_one_clamped_to_one() -> None: + """Явный env <1 недопустим → приводится к 1, а не проваливается дальше по цепочке.""" + assert server._resolve_recycle_pages("avito", {"BROWSER_RECYCLE_PAGES_AVITO": "0"}) == 1 + assert server._resolve_recycle_pages("cian", {"BROWSER_RECYCLE_PAGES": "-5"}) == 1 + + +def test_domclick_without_env_defaults_to_one() -> None: + """#3205: без единого env — domclick=1 (одна карточка на процесс camoufox).""" + assert server._resolve_recycle_pages("domclick", {}) == 1 + + +def test_other_providers_without_env_default_to_fifteen() -> None: + """avito/cian/yandex без env — общий фолбэк 15, узор бана у них не менялся.""" + for provider in ("avito", "cian", "yandex"): + assert server._resolve_recycle_pages(provider, {}) == 15 + + +def test_generic_without_env_defaults_to_fifteen() -> None: + assert server._resolve_recycle_pages("generic", {}) == 15 diff --git a/tradein-mvp/browser/test_server_reuse_context.py b/tradein-mvp/browser/test_server_reuse_context.py index cd828b12..53c7f05a 100644 --- a/tradein-mvp/browser/test_server_reuse_context.py +++ b/tradein-mvp/browser/test_server_reuse_context.py @@ -47,7 +47,13 @@ def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(server, "_page_counters", {}) monkeypatch.setattr(server, "_locks", {}) monkeypatch.setattr(server, "_last_goto_at", {}) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + # #3205: recycle threshold теперь per-provider (_RECYCLE_PAGES_BY_PROVIDER), не + # единый BROWSER_RECYCLE_PAGES — патчим весь словарь высоким порогом, чтобы тесты + # reuse/reset context (несколько _fetch_once подряд, включая domclick=1 по коду) + # не триггерили настоящий _relaunch_browser/camoufox. + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", {p: 10_000 for p in server.PROVIDERS} + ) class _FakePage: diff --git a/tradein-mvp/browser/test_server_smoke.py b/tradein-mvp/browser/test_server_smoke.py index 13c8527d..4e358d3d 100644 --- a/tradein-mvp/browser/test_server_smoke.py +++ b/tradein-mvp/browser/test_server_smoke.py @@ -67,7 +67,13 @@ def test_health_reports_browsers_not_ready() -> None: assert response.status == 200 body = _json_body(response) assert body["status"] == "ok" - assert body["browsers"] == {"avito": False, "cian": False, "yandex": False, "generic": False} + assert body["browsers"] == { + "avito": False, + "cian": False, + "yandex": False, + "generic": False, + "domclick": False, + } def test_health_reports_partial_readiness() -> None: @@ -191,7 +197,10 @@ def test_fetch_opens_and_closes_own_page(monkeypatch: pytest.MonkeyPatch) -> Non """Один /fetch открывает ровно одну собственную страницу и закрывает её.""" tracker = _Tracker() server._browsers["avito"] = _FakeBrowser(tracker) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True @@ -209,11 +218,14 @@ def test_fetch_opens_and_closes_own_page(monkeypatch: pytest.MonkeyPatch) -> Non def test_fetch_recycles_after_threshold(monkeypatch: pytest.MonkeyPatch) -> None: - """Достижение BROWSER_RECYCLE_PAGES → _relaunch_browser + сброс счётчика.""" + """Достижение recycle-порога → _relaunch_browser + сброс счётчика. + + #3205: порог теперь per-provider (_RECYCLE_PAGES_BY_PROVIDER), патчим ключ avito. + """ tracker = _Tracker() server._browsers["avito"] = _FakeBrowser(tracker) server._page_counters["avito"] = 0 - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 1) + monkeypatch.setitem(server._RECYCLE_PAGES_BY_PROVIDER, "avito", 1) relaunched = {"n": 0} @@ -342,7 +354,10 @@ def test_fetch_once_navigates_origin_then_url_when_provided( """origin передан → ДВА goto по порядку: origin первым, затем url.""" page = _OriginTrackingPage() server._browsers["avito"] = _OriginTrackingBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) html = asyncio.run( server._fetch_once( @@ -359,7 +374,10 @@ def test_fetch_once_single_goto_when_origin_none(monkeypatch: pytest.MonkeyPatch """origin не передан (дефолт, avito/cian/yandex) → РОВНО один goto(url), без regressии.""" page = _OriginTrackingPage() server._browsers["avito"] = _OriginTrackingBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1")) @@ -380,7 +398,10 @@ def test_fetch_once_injects_cookies_before_goto_with_derived_domain( """cookies передан → context.add_cookies() ДО goto(url), domain = "." + hostname.""" page = _OriginTrackingPage() server._browsers["cian"] = _OriginTrackingBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) html = asyncio.run( server._fetch_once( @@ -415,7 +436,10 @@ def test_fetch_once_no_cookie_injection_when_cookies_none( """cookies=None (дефолт) → add_cookies НЕ вызывается, поведение не меняется.""" page = _OriginTrackingPage() server._browsers["avito"] = _OriginTrackingBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1")) @@ -432,7 +456,10 @@ def test_fetch_once_cookie_domain_derivation_is_generic_not_domclick_hardcoded( """ page = _OriginTrackingPage() server._browsers["avito"] = _OriginTrackingBrowser(page) - monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) asyncio.run( server._fetch_once(