Свип ДомКлика ходит в тёплом браузерном контексте, а не поднимает камуфокс на каждый фетч #3595
6 changed files with 185 additions and 2 deletions
|
|
@ -186,6 +186,12 @@ class _FakeFetcher:
|
||||||
def report_ban(self, reason: str) -> None:
|
def report_ban(self, reason: str) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def request_context_reset(self) -> None:
|
||||||
|
# #3118: свип зовёт сброс тёплого контекста на каждой упавшей корзине —
|
||||||
|
# двойник обязан повторять сигнатуру настоящего фетчера, иначе он проверяет
|
||||||
|
# не поведение свипа, а собственную неполноту.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None:
|
def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
|
|
||||||
132
tradein-mvp/backend/tests/test_3118_domclick_reuse_context.py
Normal file
132
tradein-mvp/backend/tests/test_3118_domclick_reuse_context.py
Normal 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}"
|
||||||
|
)
|
||||||
|
|
@ -43,7 +43,12 @@ def _scraper() -> DomClickScraper:
|
||||||
|
|
||||||
class _FakeFetcherCtx:
|
class _FakeFetcherCtx:
|
||||||
async def __aenter__(self) -> SimpleNamespace:
|
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:
|
async def __aexit__(self, *_exc: Any) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,11 @@ def _make_recorder() -> tuple[type, list[dict[str, Any]]]:
|
||||||
proxy_provider: object | None = None,
|
proxy_provider: object | None = None,
|
||||||
use_pool: bool = False,
|
use_pool: bool = False,
|
||||||
environment: str = "dev",
|
environment: str = "dev",
|
||||||
|
reuse_context: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# reuse_context (#3118) обязан быть в сигнатуре двойника: фабрика
|
||||||
|
# build_browser_fetcher передаёт его ВСЕГДА, и двойник без него падал бы
|
||||||
|
# TypeError на каждом вызывающем, а не проверял то, ради чего написан.
|
||||||
calls.append(
|
calls.append(
|
||||||
{
|
{
|
||||||
"source": source,
|
"source": source,
|
||||||
|
|
@ -81,6 +85,7 @@ def _make_recorder() -> tuple[type, list[dict[str, Any]]]:
|
||||||
"proxy_provider": proxy_provider,
|
"proxy_provider": proxy_provider,
|
||||||
"use_pool": use_pool,
|
"use_pool": use_pool,
|
||||||
"environment": environment,
|
"environment": environment,
|
||||||
|
"reuse_context": reuse_context,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,7 @@ def build_browser_fetcher(
|
||||||
*,
|
*,
|
||||||
proxy_provider: ProxyProvider | None = None,
|
proxy_provider: ProxyProvider | None = None,
|
||||||
fetch_timeout_s: float | None = None,
|
fetch_timeout_s: float | None = None,
|
||||||
|
reuse_context: bool = False,
|
||||||
) -> BrowserFetcher:
|
) -> BrowserFetcher:
|
||||||
"""Собрать `BrowserFetcher` с `config: ScraperConfig` **mandatory**.
|
"""Собрать `BrowserFetcher` с `config: ScraperConfig` **mandatory**.
|
||||||
|
|
||||||
|
|
@ -221,6 +222,11 @@ def build_browser_fetcher(
|
||||||
(120s). Явный таймаут передаёт ровно один call-site — `yandex/serp.py` (30s);
|
(120s). Явный таймаут передаёт ровно один call-site — `yandex/serp.py` (30s);
|
||||||
`yandex/newbuilding.py` идёт на дефолтных 120s.
|
`yandex/newbuilding.py` идёт на дефолтных 120s.
|
||||||
|
|
||||||
|
`reuse_context=False` (дефолт) — сохраняет прежнее поведение всех вызывающих:
|
||||||
|
холодный контекст на каждый /fetch. `reuse_context=True` (#3118, свип домклика)
|
||||||
|
держит один сайдкар-контекст на весь прогон вместо нового камуфокса на каждый
|
||||||
|
фетч.
|
||||||
|
|
||||||
`environment=getattr(config, "environment", "dev")` (#2616 шаг 1) — прокидывается в
|
`environment=getattr(config, "environment", "dev")` (#2616 шаг 1) — прокидывается в
|
||||||
`BrowserFetcher._pool_proxy`: пул пуст/сломан + прод → отказ вместо мёртвого
|
`BrowserFetcher._pool_proxy`: пул пуст/сломан + прод → отказ вместо мёртвого
|
||||||
env-прокси. `getattr` с дефолтом "dev" — минимальные ScraperConfig-заглушки без поля
|
env-прокси. `getattr` с дефолтом "dev" — минимальные ScraperConfig-заглушки без поля
|
||||||
|
|
@ -234,6 +240,7 @@ def build_browser_fetcher(
|
||||||
proxy_provider=proxy_provider,
|
proxy_provider=proxy_provider,
|
||||||
use_pool=config.use_proxy_pool_browser,
|
use_pool=config.use_proxy_pool_browser,
|
||||||
environment=environment,
|
environment=environment,
|
||||||
|
reuse_context=reuse_context,
|
||||||
)
|
)
|
||||||
return BrowserFetcher(
|
return BrowserFetcher(
|
||||||
source=source,
|
source=source,
|
||||||
|
|
@ -242,6 +249,7 @@ def build_browser_fetcher(
|
||||||
proxy_provider=proxy_provider,
|
proxy_provider=proxy_provider,
|
||||||
use_pool=config.use_proxy_pool_browser,
|
use_pool=config.use_proxy_pool_browser,
|
||||||
environment=environment,
|
environment=environment,
|
||||||
|
reuse_context=reuse_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -522,8 +522,17 @@ class DomClickScraper(BaseScraper):
|
||||||
# вживую 09.08: через него 500, через мобильный узел пула — 200 и
|
# вживую 09.08: через него 500, через мобильный узел пула — 200 и
|
||||||
# snippetsCount=678 в бакете 'st'), поэтому свип брал 0 лотов 4 дня подряд.
|
# snippetsCount=678 в бакете 'st'), поэтому свип брал 0 лотов 4 дня подряд.
|
||||||
# proxy_provider=None (тесты/dev) по-прежнему валиден — env-fallback.
|
# 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(
|
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:
|
) as fetcher:
|
||||||
# Циклический сдвиг: состав корзин прежний, меняется только точка входа.
|
# Циклический сдвиг: состав корзин прежний, меняется только точка входа.
|
||||||
# Отрицательный/большой индекс нормализуем — вызывающий передаёт остаток от
|
# Отрицательный/большой индекс нормализуем — вызывающий передаёт остаток от
|
||||||
|
|
@ -582,6 +591,16 @@ class DomClickScraper(BaseScraper):
|
||||||
# прокинут выше) это уже не no-op: узел уходит в
|
# прокинут выше) это уже не no-op: узел уходит в
|
||||||
# scrape_proxy_source_bans и следующий acquire("domclick") его не
|
# 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}")
|
fetcher.report_ban(f"domklik QRATOR block during rooms={bucket!r}")
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
@ -595,6 +614,14 @@ class DomClickScraper(BaseScraper):
|
||||||
# Exception, а не BaseException — CancelledError (SIGTERM-drain,
|
# Exception, а не BaseException — CancelledError (SIGTERM-drain,
|
||||||
# watchdog asyncio.wait_for) обязан пройти насквозь.
|
# watchdog asyncio.wait_for) обязан пройти насквозь.
|
||||||
self.fetch_errors += 1
|
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(
|
logger.warning(
|
||||||
"domklik: bucket rooms=%r failed (%s) — skipping to next bucket",
|
"domklik: bucket rooms=%r failed (%s) — skipping to next bucket",
|
||||||
bucket,
|
bucket,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue