All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 4m53s
serp.py не передавал куки сессии вообще — слова cookie в файле не было. Свип приходил к QRATOR с чистым браузером и был обязан решать proof-of-work с нуля на каждый запрос. Прод, прогон 5330 (30.08 03:49-03:55): 9 запросов к bff-search-web.domclick.ru, все 9 зависли на челлендже (перезагрузка 1/2, 2/2, отказ), status=failed, 0 лотов. Прокси при этом ротировался (узел 9 → 10) — узел тут ни при чём. Добор с тем же сайдкаром и тем же пулом в ту же ночь взял 37 карточек из 37 без единого блока. Разница ровно в куках. Чинить это стало возможно только сейчас: свип ходит не на ekaterinburg.domclick.ru, а на отдельный хост bff-search-web.domclick.ru, и до правки _cookie_domain (PR #3262) куки легли бы на .bff-search-web.domclick.ru, не совпав с сессией площадки. Теперь оба хоста схлопываются в общий .domclick.ru. Снимок приходит параметром снаружи, а не читается внутри kit: kit не импортирует app.* (strangler-инвариант #2133). Поэтому джоба domclick_city_sweep переопределена продуктовым Handler'ом — build_registry это прямо допускает («последнее слово за продуктом»), а БД читает только app-сторона. Отсутствие сессии не авария: load_session вернул None → свип идёт как раньше, без инъекции, факт логируется один раз. Цена решения — override повторяет вызов kit-джобы целиком и может тихо с ней разойтись. Добавлен тест, который зовёт оба джоба одинаково и сравнивает наборы kwargs, допуская расхождение ровно в cookies. Проверен мутацией: с искусственно добавленным в kit-версию аргументом краснеет, без него зелёный. Тесты: 245 passed, 1 skipped (domclick + parity).
297 lines
13 KiB
Python
297 lines
13 KiB
Python
"""#3264: свип DomClick (`domclick_city_sweep`) ходил на bff-search-web.domclick.ru БЕЗ
|
||
кук сессии и был обязан решать QRATOR PoW с нуля на каждый запрос — прод 30.08 (прогон
|
||
5330): 9/9 запросов зависли на challenge, 0 листингов. `domclick_detail_backfill` (добор
|
||
карточек) уже решал эту задачу инъекцией снимка `domclick_session.load_session` — этот
|
||
файл проверяет, что тот же снимок теперь доезжает и до свипа:
|
||
|
||
1. DomClickScraper(cookies=...) кладёт снимок в self._cookies и пробрасывает его в
|
||
ОБА fetcher.fetch() (count-probe и paginate-leaf).
|
||
2. run_domclick_city_sweep(cookies=...) пробрасывает их в DomClickScraper(...).
|
||
3. product_handlers._job_domclick_city_sweep (app-side override kit-native, см. его
|
||
докстринг) грузит куки через domclick_session.load_session(db) и не падает, когда
|
||
сессии в БД нет/протухла (load_session вернул None) — свип в этом случае работает
|
||
как раньше, без инъекции.
|
||
|
||
Не проверяется (вне периметра): реальный обход QRATOR, browser_fetcher.fetch()
|
||
внутренности (уже покрыты test_domclick_detail.py), ротация прокси/корзин (#2854/#2670).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
import pytest
|
||
from scraper_kit.providers.domclick.serp import DomClickScraper
|
||
|
||
PFX = "scraper_kit.orchestration.pipeline"
|
||
|
||
|
||
def _config() -> SimpleNamespace:
|
||
return SimpleNamespace(
|
||
scraper_fetch_mode="curl_cffi",
|
||
browser_http_endpoint="http://browser.test/fetch",
|
||
scraper_proxy_url=None,
|
||
avito_proxy_max_rotations=0,
|
||
avito_serp_ok_not_banned=True,
|
||
avito_proxy_rotate_settle_s=0.0,
|
||
proxy_rotate_attempts=1,
|
||
proxy_rotate_attempt_timeout_s=1.0,
|
||
cian_proxy_max_rotations=0,
|
||
yandex_proxy_max_rotations=0,
|
||
scraper_skip_seen_today=False,
|
||
cian_full_load_per_fetch_timeout_s=0.0,
|
||
)
|
||
|
||
|
||
# ── 1. DomClickScraper: cookies → self._cookies → оба fetcher.fetch() ────────────────
|
||
|
||
|
||
def test_domclick_scraper_stores_cookies() -> None:
|
||
snapshot = {"sbjs": "x", "dvc": "y"}
|
||
scraper = DomClickScraper(_config(), cookies=snapshot)
|
||
assert scraper._cookies == snapshot
|
||
|
||
|
||
def test_domclick_scraper_defaults_cookies_to_none() -> None:
|
||
scraper = DomClickScraper(_config())
|
||
assert scraper._cookies is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_count_passes_cookies_to_fetch() -> None:
|
||
snapshot = {"sbjs": "x"}
|
||
scraper = DomClickScraper(_config(), cookies=snapshot)
|
||
fetcher = MagicMock()
|
||
fetcher.fetch = AsyncMock(return_value='{"result": {"snippetsCount": 3}}')
|
||
n = await scraper._count(fetcher, "st", None, None)
|
||
assert n == 3
|
||
assert fetcher.fetch.await_args.kwargs["cookies"] == snapshot
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_count_passes_none_cookies_without_error() -> None:
|
||
"""Cookies отсутствуют/протухли — не авария, fetch() зовётся с cookies=None."""
|
||
scraper = DomClickScraper(_config(), cookies=None)
|
||
fetcher = MagicMock()
|
||
fetcher.fetch = AsyncMock(return_value='{"result": {"snippetsCount": 0}}')
|
||
await scraper._count(fetcher, "st", None, None)
|
||
assert fetcher.fetch.await_args.kwargs["cookies"] is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_paginate_passes_cookies_to_fetch() -> None:
|
||
snapshot = {"sbjs": "x"}
|
||
scraper = DomClickScraper(_config(), cookies=snapshot)
|
||
fetcher = MagicMock()
|
||
fetcher.fetch = AsyncMock(return_value='{"result": {"items": []}}')
|
||
out: list[object] = []
|
||
await scraper._paginate(fetcher, "st", None, None, set(), out, pages=1)
|
||
assert fetcher.fetch.await_args.kwargs["cookies"] == snapshot
|
||
|
||
|
||
# ── 2. run_domclick_city_sweep: cookies → DomClickScraper(...) ───────────────────────
|
||
|
||
def _stub_runs() -> MagicMock:
|
||
"""runs-двойник: is_cancelled=False (иначе sweep выходит ДО создания scraper'а)."""
|
||
m = MagicMock()
|
||
m.is_cancelled = MagicMock(return_value=False)
|
||
return m
|
||
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_run_domclick_city_sweep_passes_cookies_to_scraper() -> None:
|
||
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
|
||
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
|
||
|
||
snapshot = {"sbjs": "x"}
|
||
scraper = MagicMock()
|
||
scraper.__aenter__ = AsyncMock(return_value=scraper)
|
||
scraper.__aexit__ = AsyncMock(return_value=None)
|
||
scraper.fetch_city = AsyncMock(return_value=[])
|
||
scraper.blocked = False
|
||
scraper.geo_filtered = 0
|
||
scraper.fetch_errors = 0
|
||
scraper.buckets_completed = len(ROOM_BUCKETS)
|
||
scraper.buckets_total = len(ROOM_BUCKETS)
|
||
ctor = MagicMock(return_value=scraper)
|
||
|
||
db = MagicMock()
|
||
with (
|
||
patch(f"{PFX}.DomClickScraper", ctor),
|
||
patch(f"{PFX}.save_listings", MagicMock(return_value=(0, 0))),
|
||
patch(f"{PFX}.runs", _stub_runs()),
|
||
):
|
||
await run_domclick_city_sweep(
|
||
db,
|
||
config=_config(),
|
||
matcher=MagicMock(),
|
||
run_id=1,
|
||
city_id=4,
|
||
pages=1,
|
||
request_delay_sec=0.0,
|
||
cookies=snapshot,
|
||
)
|
||
|
||
assert ctor.call_args.kwargs["cookies"] == snapshot
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_run_domclick_city_sweep_none_cookies_does_not_break() -> None:
|
||
"""cookies=None (дефолт) — прежнее поведение, свип не падает."""
|
||
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
|
||
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
|
||
|
||
scraper = MagicMock()
|
||
scraper.__aenter__ = AsyncMock(return_value=scraper)
|
||
scraper.__aexit__ = AsyncMock(return_value=None)
|
||
scraper.fetch_city = AsyncMock(return_value=[])
|
||
scraper.blocked = False
|
||
scraper.geo_filtered = 0
|
||
scraper.fetch_errors = 0
|
||
scraper.buckets_completed = len(ROOM_BUCKETS)
|
||
scraper.buckets_total = len(ROOM_BUCKETS)
|
||
ctor = MagicMock(return_value=scraper)
|
||
|
||
db = MagicMock()
|
||
with (
|
||
patch(f"{PFX}.DomClickScraper", ctor),
|
||
patch(f"{PFX}.save_listings", MagicMock(return_value=(0, 0))),
|
||
patch(f"{PFX}.runs", _stub_runs()),
|
||
):
|
||
counters = await run_domclick_city_sweep(
|
||
db,
|
||
config=_config(),
|
||
matcher=MagicMock(),
|
||
run_id=1,
|
||
city_id=4,
|
||
pages=1,
|
||
request_delay_sec=0.0,
|
||
)
|
||
|
||
assert ctor.call_args.kwargs["cookies"] is None
|
||
assert counters.errors_count == 0
|
||
|
||
|
||
# ── 3. product_handlers._job_domclick_city_sweep: БД → cookies → sweep ───────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_job_domclick_city_sweep_loads_and_passes_cookies() -> None:
|
||
from app.services.product_handlers import _job_domclick_city_sweep
|
||
|
||
snapshot = {"sbjs": "x"}
|
||
db = MagicMock()
|
||
ctx = MagicMock(proxy_provider=None, shutdown_requested=lambda: False)
|
||
|
||
with (
|
||
patch(
|
||
"app.services.domclick_session.load_session",
|
||
MagicMock(return_value=snapshot),
|
||
),
|
||
patch(
|
||
"scraper_kit.orchestration.pipeline.run_domclick_city_sweep",
|
||
AsyncMock(),
|
||
) as run_mock,
|
||
patch("app.services.product_handlers.kit_pick_resume", MagicMock(return_value=None)),
|
||
):
|
||
await _job_domclick_city_sweep(db, 1, {}, ctx)
|
||
|
||
assert run_mock.await_args.kwargs["cookies"] == snapshot
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_job_domclick_city_sweep_none_session_is_not_fatal() -> None:
|
||
"""load_session вернул None (нет/протухла) — свип идёт БЕЗ инъекции, не падает."""
|
||
from app.services.product_handlers import _job_domclick_city_sweep
|
||
|
||
db = MagicMock()
|
||
ctx = MagicMock(proxy_provider=None, shutdown_requested=lambda: False)
|
||
|
||
with (
|
||
patch("app.services.domclick_session.load_session", MagicMock(return_value=None)),
|
||
patch(
|
||
"scraper_kit.orchestration.pipeline.run_domclick_city_sweep",
|
||
AsyncMock(),
|
||
) as run_mock,
|
||
patch("app.services.product_handlers.kit_pick_resume", MagicMock(return_value=None)),
|
||
):
|
||
await _job_domclick_city_sweep(db, 1, {}, ctx)
|
||
|
||
assert run_mock.await_args.kwargs["cookies"] is None
|
||
|
||
|
||
def test_domclick_city_sweep_registered_as_product_override() -> None:
|
||
"""build_product_handlers() переопределяет kit-native domclick_city_sweep (#3264)."""
|
||
from app.services.product_handlers import _job_domclick_city_sweep, build_product_handlers
|
||
|
||
handlers = build_product_handlers(ctx=None) # type: ignore[arg-type]
|
||
assert handlers["domclick_city_sweep"].job is _job_domclick_city_sweep
|
||
|
||
|
||
# ── 4. Дрейф override'а от kit-native (#3264) ────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_product_override_matches_kit_native_call_except_cookies() -> None:
|
||
"""Продуктовый override зовёт sweep ТЕМИ ЖЕ аргументами, что kit-native, плюс cookies.
|
||
|
||
Override (app.services.product_handlers) не оборачивает kit-джобу, а повторяет её
|
||
вызов целиком — иначе пришлось бы тащить чтение БД внутрь kit, что запрещено
|
||
strangler-инвариантом #2133. Цена такого решения — дрейф: параметр, добавленный в
|
||
kit-native `_job_domclick_city_sweep`, в override не появится, и свип по расписанию
|
||
молча поедет со старым набором. Этот тест — единственное, что такой дрейф ловит:
|
||
оба джоба зовутся с одинаковыми db/run_id/params/ctx, и наборы kwargs сравниваются.
|
||
|
||
Расхождение допускается ровно одно — `cookies`, ради которого override и заведён.
|
||
"""
|
||
from scraper_kit.orchestration.scheduler import (
|
||
_job_domclick_city_sweep as kit_job,
|
||
)
|
||
|
||
from app.services.product_handlers import _job_domclick_city_sweep as product_job
|
||
|
||
params = {"city_id": 4, "pages_per_anchor": 7, "request_delay_sec": 3.0}
|
||
|
||
# Патчить sweep надо В ПРОСТРАНСTВЕ ИМЁН ВЫЗЫВАЮЩЕГО: kit-джоба связала имя
|
||
# `run_domclick_city_sweep` на импорте модуля, продуктовая импортирует его внутри
|
||
# тела. Патч по одному только `pipeline` kit-джобу не перехватит — она позовёт
|
||
# настоящий свип и уйдёт в сеть.
|
||
# db и ctx общие на оба захвата: иначе сравнение спотыкается не о дрейф, а о то,
|
||
# что ctx.config — разные MagicMock-объекты в двух вызовах.
|
||
db = MagicMock()
|
||
ctx = MagicMock(proxy_provider=None, shutdown_requested=lambda: False)
|
||
|
||
async def _capture(job, patch_sweep_in: str, patch_resume_in: str) -> dict:
|
||
with (
|
||
patch("app.services.domclick_session.load_session", MagicMock(return_value=None)),
|
||
patch(patch_sweep_in, AsyncMock()) as run_mock,
|
||
patch(patch_resume_in, MagicMock(return_value=None)),
|
||
):
|
||
await job(db, 1, params, ctx)
|
||
return run_mock.await_args.kwargs
|
||
|
||
kit_kwargs = await _capture(
|
||
kit_job,
|
||
"scraper_kit.orchestration.scheduler.run_domclick_city_sweep",
|
||
"scraper_kit.orchestration.scheduler._pick_resume",
|
||
)
|
||
product_kwargs = await _capture(
|
||
product_job,
|
||
f"{PFX}.run_domclick_city_sweep",
|
||
"app.services.product_handlers.kit_pick_resume",
|
||
)
|
||
|
||
assert set(product_kwargs) - set(kit_kwargs) == {"cookies"}, (
|
||
"override приобрёл аргументы сверх kit-native помимо cookies"
|
||
)
|
||
assert not set(kit_kwargs) - set(product_kwargs), (
|
||
"kit-native получил аргумент, которого нет в override — свип по расписанию "
|
||
"поедет без него (дрейф #3264)"
|
||
)
|
||
for key, kit_value in kit_kwargs.items():
|
||
assert product_kwargs[key] == kit_value, f"override разошёлся с kit-native по {key!r}"
|