All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m28s
CI Trade-In / backend-tests (pull_request) Successful in 4m53s
BFF-ручка Домклика не страница, а JSON-эндпоинт SPA. Сайдкар умел ровно одно — page.goto(url), — и навигацией браузера на API-хост мы делали то, чего настоящий клиент не делает никогда. Перехват сети на живой выдаче 30.08 это и показал: офферы приезжают в SSR-документе, а к BFF ходят XHR'ы за гео, районами и метро. Через мобильные узлы пула такая навигация упиралась в ChallengeTimeout: прогоны 5330 и 5351 — 9 из 9 и 6 из 6 запросов зависли на челлендже, 0 лотов. Карточки через те же узлы в те же минуты шли. Замер трёх режимов на одном узле, один и тот же ресурс: BFF navigate ChallengeTimeout, 55с BFF subresource HTTP 200, 52 байта, 6с BFF page_fetch ошибка Подзапрос проверен вширь: все шесть комнатных корзин HTTP 200, суммарно 6359 офферов против 6367, снятых напрямую с резидентного IP (расхождение — дрейф фонда за пару часов); список офферов отдаётся на всех четырёх узлах пула, по 20 штук, 120 КБ. Сайдкар получил fetch_mode: navigate (дефолт, прежнее поведение), subresource (context.request.get из прогретого контекста) и page_fetch (fetch из страницы). Третий оставлен, потому что теоретически он ближе всего к настоящему XHR, но в замере отказал — выбор сделан измерением, а не рассуждением. Прогрев origin обязателен: рукопожатие QRATOR попадает в куки контекста именно при заходе на страницу, поэтому свип теперь передаёт origin и referer, которых раньше не передавал вовсе. _decode_body распаковывает gzip по магическим байтам — карта офферов приезжает Content-Type: application/gzip, и без этого вызывающий получил бы бинарь в поле "html". Сама карта, к слову, подзапросом всё равно не берётся (279 байт — загрузчик QRATOR), но BFF полнее: 100% фонда против 62% у карты. fetch_mode кладётся в payload только когда он не дефолтный — сайдкар прежней версии не должен получать незнакомый ключ (тот же приём, что с referer в #3247). Тесты: сайдкар 212 passed (новый test_server_fetch_mode.py — 6 проверок), backend 316 passed (новый test_3264_sweep_subresource_mode.py — 5). Три тестовых дублёра _do_fetch/_post_fetch знали старую сигнатуру — обновлены.
309 lines
14 KiB
Python
309 lines
14 KiB
Python
"""Tests для тёплого переиспользуемого browser-context в DomClick detail-бэкфилле (#3118).
|
||
|
||
Проблема: sidecar's browser.new_page() создавал НОВЫЙ изолированный context на КАЖДЫЙ
|
||
/fetch, поэтому кук-инъекция (см. domclick_session), сделанная ОДИН РАЗ в начале
|
||
прогона, не видела Set-Cookie от площадки — живой qrator_jsid2 (~2.5ч TTL) протухал
|
||
уже после первого запроса. Живой замер: 26 подряд sidecar-фетчей = 100% блоков, те же
|
||
карточки в тёплом browser-контексте = 5/5 успешно.
|
||
|
||
Фикс: BrowserFetcher(reuse_context=True) держит per-provider context живым между
|
||
вызовами; куки вливаются один раз при его создании. Сброс сожжённого context'а
|
||
оркестратор просит через bf.request_context_reset() — так как fetch_detail()
|
||
(scraper_kit, намеренно не в scope того фикса) не прокидывает reset_context.
|
||
|
||
#3212 сузил условие сброса: НЕ на каждый блок, а один раз за прогон. Пропуск QRATOR
|
||
(qrator_jsid2 + qrator_jsr) живёт в контексте, и сброс его выбрасывает; повторная
|
||
валидация с того же IP сразу после принятой получает 403. Сброс на каждый блок поэтому
|
||
гарантировал следующий блок — приёмка #3118 и дала «1 успех из 10».
|
||
|
||
Зеркалит конвенции tests/tasks/test_domclick_detail_backfill.py (module-level
|
||
patch-target строки, _mock_db/_mock_session_svc helpers).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from datetime import UTC, datetime, timedelta
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
_wp_mock = MagicMock()
|
||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||
|
||
import pytest # noqa: E402
|
||
from scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402
|
||
|
||
from app.tasks.domclick_detail_backfill import run_domclick_detail_backfill # noqa: E402
|
||
|
||
_FETCH = "app.tasks.domclick_detail_backfill.fetch_detail"
|
||
_SAVE = "app.tasks.domclick_detail_backfill.save_detail_enrichment"
|
||
_RUNS = "app.tasks.domclick_detail_backfill.runs_mod"
|
||
_SLEEP = "app.tasks.domclick_detail_backfill.asyncio.sleep"
|
||
_SETTINGS = "app.tasks.domclick_detail_backfill.settings"
|
||
_BROWSER_FETCHER = "app.tasks.domclick_detail_backfill.BrowserFetcher"
|
||
_SESSION_SVC = "app.tasks.domclick_detail_backfill.domclick_session_svc"
|
||
|
||
|
||
def _make_snapshot(n: int) -> list[dict]:
|
||
return [
|
||
{
|
||
"id": i + 1,
|
||
"source_url": f"https://ekaterinburg.domclick.ru/card/sale__flat__{i + 1}",
|
||
}
|
||
for i in range(n)
|
||
]
|
||
|
||
|
||
def _mock_db(snapshot: list[dict]) -> MagicMock:
|
||
db = MagicMock()
|
||
sel = MagicMock()
|
||
sel.mappings.return_value.all.return_value = snapshot
|
||
db.execute.return_value = sel
|
||
return db
|
||
|
||
|
||
def _mock_session_svc(cookies: dict[str, str] | None) -> MagicMock:
|
||
svc = MagicMock()
|
||
svc.load_session.return_value = cookies
|
||
svc.COOKIE_EXPIRY_WARN_DAYS = 5
|
||
svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30)
|
||
return svc
|
||
|
||
|
||
def _mock_browser_fetcher_cls() -> tuple[MagicMock, AsyncMock]:
|
||
"""Возвращает (класс-мок, instance-мок) — instance с реальным request_context_reset."""
|
||
instance = AsyncMock()
|
||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||
instance.__aexit__ = AsyncMock(return_value=False)
|
||
# request_context_reset — синхронный метод (не awaited caller'ом), явный MagicMock
|
||
# вместо auto-AsyncMock-атрибута, чтобы вызов не оставлял неawaited coroutine.
|
||
instance.request_context_reset = MagicMock()
|
||
return MagicMock(return_value=instance), instance
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_backfill_constructs_browser_fetcher_with_reuse_context() -> None:
|
||
"""BrowserFetcher поднимается с reuse_context=True (#3118) -- тёплый context sidecar'а."""
|
||
snapshot = _make_snapshot(1)
|
||
db = _mock_db(snapshot)
|
||
runs = MagicMock()
|
||
mock_fetch = AsyncMock(return_value=MagicMock())
|
||
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
|
||
mock_svc = _mock_session_svc({"CAS_ID": "123", "qrator_jsid2": "live"})
|
||
mock_bf_cls, _instance = _mock_browser_fetcher_cls()
|
||
with (
|
||
patch(_SETTINGS, fake_settings),
|
||
patch(_SESSION_SVC, mock_svc),
|
||
patch(_RUNS, runs),
|
||
patch(_BROWSER_FETCHER, mock_bf_cls),
|
||
patch(_FETCH, mock_fetch),
|
||
patch(_SAVE, return_value=True),
|
||
patch(_SLEEP, new_callable=AsyncMock),
|
||
):
|
||
await run_domclick_detail_backfill(
|
||
db, run_id=100, params={"batch_size": 10, "budget_sec": 3600}
|
||
)
|
||
|
||
# #3197: конструктор с 08.2026 несёт ещё proxy_provider/use_pool/environment
|
||
# (проводка прокси-пула) -- этот тест проверяет только reuse_context, для полной
|
||
# проводки см. tests/test_3197_domclick_proxy_pool_wiring.py.
|
||
mock_bf_cls.assert_called_once()
|
||
_, call_kwargs = mock_bf_cls.call_args
|
||
assert call_kwargs["source"] == "domclick"
|
||
assert call_kwargs["endpoint"] == fake_settings.browser_http_endpoint
|
||
assert call_kwargs["reuse_context"] is True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_context_reset_requested_at_most_once_per_run() -> None:
|
||
"""#3212: сброс контекста запрашивается РОВНО ОДИН раз за прогон, а не на каждый блок.
|
||
|
||
До #3212 сброс шёл на КАЖДЫЙ блок, и это само себя поддерживало: в контексте лежит
|
||
пропуск QRATOR, сброс его выбрасывает, повторная валидация с того же IP сразу после
|
||
принятой получает 403 — то есть следующий фетч блокировался гарантированно. Одна
|
||
осечка превращалась в необратимый каскад (приёмка #3118: 1 успех из 10).
|
||
Здесь 3 подряд блока -> сброс запрошен ровно 1 раз.
|
||
"""
|
||
snapshot = _make_snapshot(10)
|
||
db = _mock_db(snapshot)
|
||
runs = MagicMock()
|
||
blocked_exc = DomClickBlockedError("browser fetch failed")
|
||
mock_fetch = AsyncMock(side_effect=blocked_exc)
|
||
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
|
||
mock_svc = _mock_session_svc({"CAS_ID": "123"})
|
||
mock_bf_cls, instance = _mock_browser_fetcher_cls()
|
||
with (
|
||
patch(_SETTINGS, fake_settings),
|
||
patch(_SESSION_SVC, mock_svc),
|
||
patch(_RUNS, runs),
|
||
patch(_BROWSER_FETCHER, mock_bf_cls),
|
||
patch(_FETCH, mock_fetch),
|
||
patch(_SLEEP, new_callable=AsyncMock),
|
||
):
|
||
result = await run_domclick_detail_backfill(
|
||
db,
|
||
run_id=101,
|
||
params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 3},
|
||
)
|
||
|
||
assert result.blocked == 3
|
||
assert instance.request_context_reset.call_count == 1, (
|
||
"ожидалась ровно одна попытка начать с чистого листа за весь прогон"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_successful_fetch_does_not_request_context_reset() -> None:
|
||
"""Успешный прогон без блоков -> request_context_reset() ни разу не вызывается."""
|
||
snapshot = _make_snapshot(3)
|
||
db = _mock_db(snapshot)
|
||
runs = MagicMock()
|
||
mock_fetch = AsyncMock(return_value=MagicMock())
|
||
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
|
||
mock_svc = _mock_session_svc({"CAS_ID": "123"})
|
||
mock_bf_cls, instance = _mock_browser_fetcher_cls()
|
||
with (
|
||
patch(_SETTINGS, fake_settings),
|
||
patch(_SESSION_SVC, mock_svc),
|
||
patch(_RUNS, runs),
|
||
patch(_BROWSER_FETCHER, mock_bf_cls),
|
||
patch(_FETCH, mock_fetch),
|
||
patch(_SAVE, return_value=True),
|
||
patch(_SLEEP, new_callable=AsyncMock),
|
||
):
|
||
result = await run_domclick_detail_backfill(
|
||
db, run_id=102, params={"batch_size": 10, "budget_sec": 3600}
|
||
)
|
||
|
||
assert result.blocked == 0
|
||
instance.request_context_reset.assert_not_called()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_block_then_recovery_requests_reset_only_on_the_block() -> None:
|
||
"""Блок -> успех -> успех: сброс запрошен РОВНО один раз (на самом блоке).
|
||
|
||
Один сброс на блок, не на каждый последующий запрос (см. docstring задачи) --
|
||
после восстановления (успешный fetch) счётчик подряд-блоков сбрасывается и
|
||
request_context_reset() больше не должен вызываться.
|
||
"""
|
||
snapshot = _make_snapshot(3)
|
||
db = _mock_db(snapshot)
|
||
runs = MagicMock()
|
||
blocked_exc = DomClickBlockedError("browser fetch failed")
|
||
mock_fetch = AsyncMock(side_effect=[blocked_exc, MagicMock(), MagicMock()])
|
||
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
|
||
mock_svc = _mock_session_svc({"CAS_ID": "123"})
|
||
mock_bf_cls, instance = _mock_browser_fetcher_cls()
|
||
with (
|
||
patch(_SETTINGS, fake_settings),
|
||
patch(_SESSION_SVC, mock_svc),
|
||
patch(_RUNS, runs),
|
||
patch(_BROWSER_FETCHER, mock_bf_cls),
|
||
patch(_FETCH, mock_fetch),
|
||
patch(_SAVE, return_value=True),
|
||
patch(_SLEEP, new_callable=AsyncMock),
|
||
):
|
||
result = await run_domclick_detail_backfill(
|
||
db,
|
||
run_id=103,
|
||
params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 3},
|
||
)
|
||
|
||
assert result.blocked == 1
|
||
assert result.enriched == 2
|
||
instance.request_context_reset.assert_called_once()
|
||
|
||
|
||
# ── BrowserFetcher: сама логика отложенного флага (#3118) ──────────────────────
|
||
# Тесты выше мокают BrowserFetcher целиком и проверяют ОРКЕСТРАТОР («на блок зовём
|
||
# request_context_reset ровно один раз»). Ниже — реальный BrowserFetcher с моком
|
||
# _post_fetch: проверяем потребление флага, ради которого метод и заведён.
|
||
|
||
|
||
def _fetcher(*, reuse_context: bool):
|
||
from scraper_kit.browser_fetcher import BrowserFetcher
|
||
|
||
bf = BrowserFetcher(
|
||
source="domclick", endpoint="http://browser:3000", reuse_context=reuse_context
|
||
)
|
||
bf._client = MagicMock() # обходим assert «используй как async context manager»
|
||
return bf
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_3118_pending_reset_consumed_exactly_once():
|
||
"""request_context_reset() влияет РОВНО на один следующий fetch, дальше — обычные."""
|
||
bf = _fetcher(reuse_context=True)
|
||
seen: list[bool] = []
|
||
|
||
async def _fake_post(
|
||
url, origin=None, cookies=None, reset_context=False, referer=None, fetch_mode=None
|
||
):
|
||
seen.append(reset_context)
|
||
return "<html></html>"
|
||
|
||
with patch.object(bf, "_post_fetch", side_effect=_fake_post):
|
||
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__1")
|
||
bf.request_context_reset()
|
||
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__2")
|
||
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__3")
|
||
|
||
assert seen == [False, True, False], seen
|
||
assert bf._context_reset_pending is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_3118_pending_reset_survives_transport_retry():
|
||
"""Transport-ошибка → retry идёт с тем же reset (сброс на сайдкаре идемпотентен)."""
|
||
import httpx
|
||
|
||
bf = _fetcher(reuse_context=True)
|
||
seen: list[bool] = []
|
||
|
||
async def _fake_post(
|
||
url, origin=None, cookies=None, reset_context=False, referer=None, fetch_mode=None
|
||
):
|
||
seen.append(reset_context)
|
||
if len(seen) == 1:
|
||
raise httpx.ConnectError("boom")
|
||
return "<html></html>"
|
||
|
||
with (
|
||
patch.object(bf, "_post_fetch", side_effect=_fake_post),
|
||
patch("scraper_kit.browser_fetcher.asyncio.sleep", new=AsyncMock()),
|
||
):
|
||
bf.request_context_reset()
|
||
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__4")
|
||
|
||
assert seen == [True, True], seen
|
||
assert bf._context_reset_pending is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_3118_payload_unchanged_when_reuse_context_off():
|
||
"""reuse_context=False (дефолт) → в payload /fetch новых ключей нет вовсе."""
|
||
bf = _fetcher(reuse_context=False)
|
||
captured: dict = {}
|
||
|
||
class _Resp:
|
||
@staticmethod
|
||
def json():
|
||
return {"html": "<html></html>"}
|
||
|
||
async def _fake_client_post(url, json=None):
|
||
captured.update(json or {})
|
||
return _Resp()
|
||
|
||
bf._client.post = _fake_client_post
|
||
with (
|
||
patch("scraper_kit.browser_fetcher._raise_for_sidecar_status", new=MagicMock()),
|
||
patch.object(bf, "_report_fetch_result", new=MagicMock()),
|
||
):
|
||
bf.request_context_reset() # no-op при выключенном флаге
|
||
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__5")
|
||
|
||
assert "reuse_context" not in captured
|
||
assert "reset_context" not in captured
|