fix(tradein/domclick): одна карточка на процесс браузера — перезапускать camoufox после каждой страницы (#3205) #3206

Merged
lekss361 merged 1 commit from fix/3205-domclick-recycle into main 2026-08-29 10:16:56 +00:00
9 changed files with 340 additions and 62 deletions

View file

@ -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)

View file

@ -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"
)

View file

@ -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:

View file

@ -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)

View file

@ -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)

View file

@ -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),
)
# ── детекторы: чистые функции ────────────────────────────────────────────────────

View file

@ -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

View file

@ -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:

View file

@ -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(