From baed8f75a37671aac497aaa31ab258229f2104b5 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Thu, 17 Sep 2026 18:10:25 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B2=D0=B8=D0=BF=20=D0=94=D0=BE=D0=BC?= =?UTF-8?q?=D0=9A=D0=BB=D0=B8=D0=BA=D0=B0=20=D1=85=D0=BE=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=20=D0=B2=20=D1=82=D1=91=D0=BF=D0=BB=D0=BE=D0=BC=20=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=83=D0=B7=D0=B5=D1=80=D0=BD=D0=BE=D0=BC=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B5,=20=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=BE=D0=B4=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D0=BA=D0=B0=D0=BC=D1=83=D1=84=D0=BE=D0=BA=D1=81=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=B0=D0=B6=D0=B4=D1=8B=D0=B9=20=D1=84=D0=B5?= =?UTF-8?q?=D1=82=D1=87=20(#3595)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_2670_streak_and_partial_coverage.py | 6 + .../tests/test_3118_domclick_reuse_context.py | 132 ++++++++++++++++++ .../tests/test_domclick_incremental_save.py | 7 +- .../backend/tests/test_kit_serp_proxy_pool.py | 5 + .../src/scraper_kit/providers/_base.py | 8 ++ .../scraper_kit/providers/domclick/serp.py | 29 +++- 6 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3118_domclick_reuse_context.py diff --git a/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py b/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py index 9c5cfae6..02984991 100644 --- a/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py +++ b/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py @@ -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: diff --git a/tradein-mvp/backend/tests/test_3118_domclick_reuse_context.py b/tradein-mvp/backend/tests/test_3118_domclick_reuse_context.py new file mode 100644 index 00000000..7769f9e3 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3118_domclick_reuse_context.py @@ -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}" + ) diff --git a/tradein-mvp/backend/tests/test_domclick_incremental_save.py b/tradein-mvp/backend/tests/test_domclick_incremental_save.py index ec969d4a..b8e9fd87 100644 --- a/tradein-mvp/backend/tests/test_domclick_incremental_save.py +++ b/tradein-mvp/backend/tests/test_domclick_incremental_save.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py b/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py index 91442669..594a71f2 100644 --- a/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py +++ b/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py @@ -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, } ) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py index b21b8aa4..453cb087 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py @@ -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, ) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py index 9700761a..2f0d2807 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py @@ -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,