Свип ДомКлика ходит в тёплом браузерном контексте, а не поднимает камуфокс на каждый фетч
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 15s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m7s

build_browser_fetcher получил keyword-only reuse_context (дефолт False — остальные
шесть вызывающих не меняются), и свип домклика зовёт фабрику с reuse_context=True.

Почему. Свип был единственным, кто остался на холодном пути: сайдкар на каждый
/fetch делал browser.new_page() и поднимал новый камуфокс. Прод-замер 17.09,
прогон 7389 (Москва): 600 страниц за 3ч05м, 1 корзина из 6, память сайдкара
426 МиБ → 1.79 ГиБ, в логе «запуск AsyncCamoufox» между почти каждым запросом.
Якорная вкладка (#3118) в таком режиме не переживает фетч, её поднимают заново
через goto(origin), и именно эти goto валятся таймаутом 60с, убивая корзину
целиком — так подряд легли '5+', 'st', '1'. Оба detail-backfill'а давно ходят
с reuse_context=True; там же записан замер: 26 холодных фетчей = 100% блок, те же
карточки в тёплом контексте = 5/5 примерно по 2с.

Упавшая корзина сбрасывает контекст. Тёплый контекст, сгоревший на одной корзине,
иначе переползает в следующую — ровно этот каскад и наблюдался. Сброс стоит в
generic except перед continue, где следующий fetch() его и доставит.

В ветке DomClickBlockedError сброса СОЗНАТЕЛЬНО нет: request_context_reset() лишь
взводит _context_reset_pending, а тот уезжает в сайдкар ключом reset_context
следующего fetch(), которого после break не будет. Остаётся известная дыра —
_contexts[provider] в сайдкаре живёт без TTL, так что сожжённый блоком контекст
достаётся следующему прогону; закрыть её можно только отдельной ручкой сброса
в сайдкаре, это отдельная задача.

Три двойника фетчера в тестах дополнены request_context_reset / reuse_context —
без этого они падали TypeError/AttributeError, то есть проверяли собственную
неполноту вместо поведения свипа.

Сьют: 6473 passed, 74 skipped.
This commit is contained in:
bot-backend 2026-09-17 21:01:35 +03:00
parent dbac5d4e1c
commit 40eadfd169
6 changed files with 185 additions and 2 deletions

View file

@ -186,6 +186,12 @@ class _FakeFetcher:
def report_ban(self, reason: str) -> None:
return None
def request_context_reset(self) -> None:
# #3118: свип зовёт сброс тёплого контекста на каждой упавшей корзине —
# двойник обязан повторять сигнатуру настоящего фетчера, иначе он проверяет
# не поведение свипа, а собственную неполноту.
return None
@pytest.fixture
def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None:

View file

@ -0,0 +1,132 @@
"""Свип ДомКлика ходит в тёплый переиспользуемый браузер-контекст сайдкара (#3118).
Замер (см. `backend/app/tasks/domclick_detail_backfill.py:394-401`): 26 подряд
холодных фетчей = 100% QRATOR-блок, те же карточки в тёплом контексте 5/5
примерно по 2с. Причина `browser.new_page()` создаёт НОВЫЙ изолированный
context сайдкара на каждый `/fetch`, из-за чего живой `qrator_jsid2` (куки,
которые сайт ротирует через Set-Cookie) никогда не доживает до следующего
запроса, а якорная вкладка не выживает между фетчами (`goto(origin)` валился
таймаутом 60с, убивая всю корзину).
Три проверки:
1. `build_browser_fetcher(..., reuse_context=True)` включает флаг на фетчере,
дефолт (без параметра) выключен (не ломаем прочие call-site'ы).
2. `DomClickScraper.fetch_city` строит фетчер именно с `reuse_context=True`.
3. На QRATOR-блоке `fetcher.request_context_reset()` вызывается ДО
`fetcher.report_ban()` иначе сожжённый блоком тёплый контекст травит
остаток прогона тем же `qrator_jsid2`.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import types
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _fetcher_config() -> types.SimpleNamespace:
return types.SimpleNamespace(
browser_http_endpoint="http://sidecar:8080",
use_proxy_pool_browser=False,
environment="test",
)
def test_build_browser_fetcher_reuse_context_default_is_off() -> None:
"""Без явного параметра поведение прежнее — прочие call-site'ы не меняются."""
from scraper_kit.providers._base import build_browser_fetcher
fetcher = build_browser_fetcher(_fetcher_config(), "domclick")
assert fetcher._reuse_context is False
def test_build_browser_fetcher_reuse_context_true_sets_flag() -> None:
from scraper_kit.providers._base import build_browser_fetcher
fetcher = build_browser_fetcher(_fetcher_config(), "domclick", reuse_context=True)
assert fetcher._reuse_context is True
class _FakeFetcher:
"""Двойник BrowserFetcher: фиксирует порядок reset/ban."""
def __init__(self) -> None:
self.calls: list[Any] = []
def request_context_reset(self) -> None:
self.calls.append("reset")
def report_ban(self, reason: str) -> None:
self.calls.append(("ban", reason))
class _FakeFetcherCM:
def __init__(self, fetcher: _FakeFetcher) -> None:
self._fetcher = fetcher
async def __aenter__(self) -> _FakeFetcher:
return self._fetcher
async def __aexit__(self, *_exc: Any) -> None:
return None
@pytest.mark.asyncio
async def test_domclick_sweep_builds_fetcher_with_reuse_context() -> None:
"""`fetch_city` зовёт фабрику фетчера с `reuse_context=True` (не дефолтом)."""
from scraper_kit.domclick_exceptions import DomClickBlockedError
from scraper_kit.providers.domclick.serp import DomClickScraper
fetcher = _FakeFetcher()
mock_build = MagicMock(return_value=_FakeFetcherCM(fetcher))
scraper = DomClickScraper(_fetcher_config())
# Обрываем на первом же бакете — детали сбора корзины здесь не проверяются.
with (
patch("scraper_kit.providers._base.build_browser_fetcher", mock_build),
patch.object(
scraper, "_sweep_bucket", AsyncMock(side_effect=DomClickBlockedError("qrator"))
),
):
await scraper.fetch_city(city_id=66, pages=1)
assert mock_build.call_args.kwargs.get("reuse_context") is True, (
f"свип построил фетчер без reuse_context=True: {mock_build.call_args}"
)
@pytest.mark.asyncio
async def test_domclick_sweep_resets_context_after_failed_bucket() -> None:
"""Упавшая корзина сбрасывает тёплый контекст, чтобы он не переполз в следующую.
Прод 17.09, прогон 7389: после падения 'rooms=3' три корзины подряд легли за две
минуты каждая на таймауте goto(origin) якорная вкладка не поднималась в том же
сгоревшем контексте. Без сброса reuse_context=True превращает одну неудачу в
цепочку.
"""
from scraper_kit.providers.domclick.serp import DomClickScraper
fetcher = _FakeFetcher()
mock_build = MagicMock(return_value=_FakeFetcherCM(fetcher))
scraper = DomClickScraper(_fetcher_config())
with (
patch("scraper_kit.providers._base.build_browser_fetcher", mock_build),
patch.object(scraper, "_sweep_bucket", AsyncMock(side_effect=RuntimeError("boom"))),
):
await scraper.fetch_city(city_id=66, pages=1)
assert fetcher.calls.count("reset") == 6, (
f"сброс контекста ожидался на каждой из 6 упавших корзин: {fetcher.calls!r}"
)
assert not any(c != "reset" for c in fetcher.calls), (
f"report_ban не должен вызываться на не-QRATOR ошибке: {fetcher.calls!r}"
)

View file

@ -43,7 +43,12 @@ def _scraper() -> DomClickScraper:
class _FakeFetcherCtx:
async def __aenter__(self) -> SimpleNamespace:
return SimpleNamespace(report_ban=lambda *_a, **_k: None)
# request_context_reset (#3118): вызывается ДО report_ban на QRATOR-блоке —
# двойник фетчера обязан его иметь, иначе AttributeError на первом же блоке.
return SimpleNamespace(
report_ban=lambda *_a, **_k: None,
request_context_reset=lambda *_a, **_k: None,
)
async def __aexit__(self, *_exc: Any) -> None:
return None

View file

@ -73,7 +73,11 @@ def _make_recorder() -> tuple[type, list[dict[str, Any]]]:
proxy_provider: object | None = None,
use_pool: bool = False,
environment: str = "dev",
reuse_context: bool = False,
) -> None:
# reuse_context (#3118) обязан быть в сигнатуре двойника: фабрика
# build_browser_fetcher передаёт его ВСЕГДА, и двойник без него падал бы
# TypeError на каждом вызывающем, а не проверял то, ради чего написан.
calls.append(
{
"source": source,
@ -81,6 +85,7 @@ def _make_recorder() -> tuple[type, list[dict[str, Any]]]:
"proxy_provider": proxy_provider,
"use_pool": use_pool,
"environment": environment,
"reuse_context": reuse_context,
}
)

View file

@ -192,6 +192,7 @@ def build_browser_fetcher(
*,
proxy_provider: ProxyProvider | None = None,
fetch_timeout_s: float | None = None,
reuse_context: bool = False,
) -> BrowserFetcher:
"""Собрать `BrowserFetcher` с `config: ScraperConfig` **mandatory**.
@ -221,6 +222,11 @@ def build_browser_fetcher(
(120s). Явный таймаут передаёт ровно один call-site `yandex/serp.py` (30s);
`yandex/newbuilding.py` идёт на дефолтных 120s.
`reuse_context=False` (дефолт) сохраняет прежнее поведение всех вызывающих:
холодный контекст на каждый /fetch. `reuse_context=True` (#3118, свип домклика)
держит один сайдкар-контекст на весь прогон вместо нового камуфокса на каждый
фетч.
`environment=getattr(config, "environment", "dev")` (#2616 шаг 1) — прокидывается в
`BrowserFetcher._pool_proxy`: пул пуст/сломан + прод отказ вместо мёртвого
env-прокси. `getattr` с дефолтом "dev" минимальные ScraperConfig-заглушки без поля
@ -234,6 +240,7 @@ def build_browser_fetcher(
proxy_provider=proxy_provider,
use_pool=config.use_proxy_pool_browser,
environment=environment,
reuse_context=reuse_context,
)
return BrowserFetcher(
source=source,
@ -242,6 +249,7 @@ def build_browser_fetcher(
proxy_provider=proxy_provider,
use_pool=config.use_proxy_pool_browser,
environment=environment,
reuse_context=reuse_context,
)

View file

@ -522,8 +522,17 @@ class DomClickScraper(BaseScraper):
# вживую 09.08: через него 500, через мобильный узел пула — 200 и
# snippetsCount=678 в бакете 'st'), поэтому свип брал 0 лотов 4 дня подряд.
# proxy_provider=None (тесты/dev) по-прежнему валиден — env-fallback.
# reuse_context=True (#3118): без него сайдкар поднимает новый камуфокс на
# КАЖДЫЙ /fetch (browser.new_page() создаёт свежий изолированный контекст) —
# разовая инъекция cookie выше никогда не видит живой qrator_jsid2, который
# сайт ротирует через Set-Cookie (TTL ~2.5ч). Замер: 26 подряд холодных
# фетчей = 100% блок, те же карточки в тёплом контексте — 5/5 примерно по 2с
# (см. backend/app/tasks/domclick_detail_backfill.py:394-401). Заодно
# холодный путь не давал якорной вкладке выжить между фетчами — goto(origin)
# валился таймаутом 60с, убивая всю корзину. Тёплый контекст держит один
# сайдкар-контекст на весь прогон вместо этого.
async with build_browser_fetcher(
self._config, "domclick", proxy_provider=self._proxy_provider
self._config, "domclick", proxy_provider=self._proxy_provider, reuse_context=True
) as fetcher:
# Циклический сдвиг: состав корзин прежний, меняется только точка входа.
# Отрицательный/большой индекс нормализуем — вызывающий передаёт остаток от
@ -582,6 +591,16 @@ class DomClickScraper(BaseScraper):
# прокинут выше) это уже не no-op: узел уходит в
# scrape_proxy_source_bans и следующий acquire("domclick") его не
# выдаст.
# NB: request_context_reset() здесь СОЗНАТЕЛЬНО не зовём. Он лишь
# взводит `_context_reset_pending`, а тот уезжает в сайдкар только
# ключом `reset_context` СЛЕДУЮЩЕГО fetch() (browser_fetcher.py:565);
# `__aexit__` его не сливает. Ниже сразу break — фетчей больше не
# будет, флаг умрёт вместе с объектом. Вызов был бы no-op'ом, который
# читается как защита.
# Дыра остаётся: `_contexts[provider]` в сайдкаре — dict без TTL, так
# что сожжённый блоком контекст достанется СЛЕДУЮЩЕМУ прогону. Закрыть
# можно только отдельной ручкой сброса в сайдкаре (сейчас там только
# /fetch, /fetch-json, /login, /health, /pacing) — отдельной задачей.
fetcher.report_ban(f"domklik QRATOR block during rooms={bucket!r}")
break
except Exception as exc:
@ -595,6 +614,14 @@ class DomClickScraper(BaseScraper):
# Exception, а не BaseException — CancelledError (SIGTERM-drain,
# watchdog asyncio.wait_for) обязан пройти насквозь.
self.fetch_errors += 1
# #3118: корзина упала — тёплый контекст (reuse_context=True) мог
# сгореть вместе с ней, и тогда он переползёт в следующую корзину.
# Прод 17.09, прогон 7389: после падения 'rooms=3' три корзины подряд
# ('5+', 'st', '1') легли за две минуты каждая на таймауте
# goto(origin) — якорная вкладка не поднималась в том же контексте.
# Сброс стоит РОВНО здесь, а не в ветке DomClickBlockedError: там
# сразу break и прогон заканчивается, сбрасывать уже нечего.
fetcher.request_context_reset()
logger.warning(
"domklik: bucket rooms=%r failed (%s) — skipping to next bucket",
bucket,