Merge pull request 'fix(tradein/domclick): свип ходил на QRATOR без кук и вис на PoW каждым запросом (#3264)' (#3265) from fix/3264-domclick-sweep-cookies into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m3s
Deploy Trade-In / build-backend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 1m38s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s

Reviewed-on: #3265
This commit is contained in:
lekss361 2026-08-30 07:02:00 +00:00
commit 8169d6f1ec
4 changed files with 378 additions and 5 deletions

View file

@ -3,8 +3,12 @@
`build_product_handlers(ctx)` собирает реестр НЕ-sweep source'ов, тело которых
осталось в `app` (rosreestr_dkp / sber_index / deactivate_stale_* / *_backfill /
cadastral_geo_match / house_* / proxy_healthcheck / ). Kit-native sweep-оркестраторы
(avito/yandex/cian/domclick city/full-load) регистрируются самим `build_registry`
через `_default_kit_handlers` здесь их НЕТ.
(avito/yandex/cian/domclick full-load) регистрируются самим `build_registry` через
`_default_kit_handlers` здесь их НЕТ. Исключение: `domclick_city_sweep` (#3264) —
kit-native тело осталось прежним (`run_domclick_city_sweep`), но здесь переопределён
Handler, который ДО вызова читает БД за куки сессии (kit не имеет права на app.*/БД,
см. докстринг `_job_domclick_city_sweep` ниже); `build_registry` явно допускает такое
переопределение продуктовым ключом.
Дизайн-инвариант: НЕ дублируем логику. Каждый `job` переиспользует существующую боевую
джоб-функцию (`app.services.scheduler.import_rosreestr_dkp`, `app.tasks.*`,
@ -32,6 +36,9 @@ from scraper_kit.orchestration.scheduler import (
from scraper_kit.orchestration.scheduler import (
_defer_next_run_at as kit_defer_next_run_at,
)
from scraper_kit.orchestration.scheduler import (
_pick_resume as kit_pick_resume,
)
if TYPE_CHECKING:
from scraper_kit.orchestration.scheduler import SchedulerContext
@ -419,6 +426,52 @@ async def _job_domclick_detail_backfill(
await run_domclick_detail_backfill(db, run_id=run_id, params=params)
# ── domclick_city_sweep — override kit-native с инъекцией куки сессии (#3264) ─
# Kit-native `_job_domclick_city_sweep` (scraper_kit.orchestration.scheduler) ходит на
# bff-search-web.domclick.ru БЕЗ кук: kit не имеет права импортировать app.* / читать БД
# (strangler-инвариант #2133). Каждый запрос свипа поэтому был обязан решать QRATOR PoW
# с нуля — прод 30.08 (прогон 5330): 9/9 запросов зависли на challenge, 0 листингов.
# `domclick_detail_backfill` (добор карточек) уже решает эту задачу инъекцией того же
# снимка (domclick_session.load_session) — здесь тот же приём для свипа.
#
# Регистрируем ЗДЕСЬ (app-side) под тем же ключом "domclick_city_sweep", а не правим
# kit: build_registry явно допускает переопределение kit-native продуктовым Handler'ом
# (см. докстринг build_registry — "последнее слово за продуктом"), а строгий запрет на
# app.* внутри kit при этом не нарушается — БД читает только этот модуль.
async def _job_domclick_city_sweep(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
from app.services import domclick_session as domclick_session_svc
# None — сессии в БД нет либо протухла (load_session сам фильтрует
# expires_at_estimate > NOW()). Это НЕ авария: свип продолжает работать без
# инъекции, ровно как до #3264, только логируем факт один раз для видимости.
cookies = domclick_session_svc.load_session(db)
if cookies is None:
logger.info(
"domclick_city_sweep run_id=%d: куки Sber ID сессии отсутствуют/протухли — "
"свип идёт без инъекции (см. #3264)",
run_id,
)
await run_domclick_city_sweep(
db,
run_id=run_id,
config=ctx.config,
matcher=ctx.matcher,
shutdown_requested=ctx.shutdown_requested,
proxy_provider=ctx.proxy_provider,
city_id=int(params.get("city_id", 4)),
rooms=params.get("rooms"),
pages=int(params.get("pages_per_anchor", 5)),
request_delay_sec=float(params.get("request_delay_sec", 6.0)),
resume_run_id=kit_pick_resume(db, run_id),
cookies=cookies,
)
# ── house_coords_from_listings — sync set-based UPDATE в executor (#2771) ─────
async def _job_house_coords_from_listings(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -709,6 +762,8 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"domclick_detail_backfill": Handler(
_job_domclick_detail_backfill, "domclick_detail_backfill"
),
# Override kit-native (#3264) — инъекция куки сессии, см. докстринг job'а выше.
"domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"),
"cadastral_geo_match": Handler(_job_cadastral_geo_match, "cadastral_geo_match"),
"osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"),
"house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"),

View file

@ -0,0 +1,297 @@
"""#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}"

View file

@ -4289,6 +4289,7 @@ async def run_domclick_city_sweep(
request_delay_sec: float | None = None,
region_code: int = DEFAULT_REGION_CODE,
resume_run_id: int | None = None,
cookies: dict[str, str] | None = None,
) -> DomClickCitySweepCounters:
"""DomClick citywide sweep через BFF JSON API.
@ -4310,6 +4311,13 @@ async def run_domclick_city_sweep(
прогон не доделал работу). Ноль лотов с fetch-ошибками, но без блока mark_failed.
Иначе mark_done.
cookies (#3264): снимок Sber ID сессии (dict cookie_name→value), инжектируется
снаружи strangler-путём (#2133) — kit сам не читает app.services.domclick_session /
БД. Без него sweep ходит на bff-search-web.domclick.ru БЕЗ кук и обязан решать
QRATOR PoW с нуля на каждый запрос (прод 30.08, прогон 5330 9/9 запросов
зависли на challenge). None (дефолт, сессии в БД нет/протухла) прежнее
поведение, без инъекции.
Возвращает DomClickCitySweepCounters.
"""
_resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0
@ -4376,7 +4384,9 @@ async def run_domclick_city_sweep(
async def _domclick_phase() -> None:
"""Единственная citywide-фаза: fetch_city + save."""
nonlocal lots
async with DomClickScraper(config, proxy_provider=proxy_provider) as _scraper:
async with DomClickScraper(
config, proxy_provider=proxy_provider, cookies=cookies
) as _scraper:
_scraper_ref.append(_scraper)
if request_delay_sec is not None:
_scraper.request_delay_sec = _resolved_delay

View file

@ -253,6 +253,7 @@ class DomClickScraper(BaseScraper):
*,
delay_provider: Callable[[str], float] | None = None,
proxy_provider: ProxyProvider | None = None,
cookies: dict[str, str] | None = None,
) -> None:
super().__init__()
# Strangler-инжекция (#2133): config даёт browser_http_endpoint для
@ -263,6 +264,16 @@ class DomClickScraper(BaseScraper):
# ЕДИНСТВЕННЫМ sweep-источником без пула (#2160 P4 wiring gap) и потому ходил
# через статический SCRAPER_PROXY_URL сайдкара.
self._proxy_provider = proxy_provider
# #3264: снимок cookies авторизованной test-аккаунт сессии (Sber ID), тот же
# приём, что уже работает в detail.py fetch_detail(). Свип раньше ходил на
# bff-search-web.domclick.ru БЕЗ кук и был обязан решать QRATOR PoW с нуля на
# каждый запрос — прод 30.08 (прогон 5330) показал 9/9 запросов зависших на
# PoW-челлендже. Strangler-инжекция (#2133): kit не читает app.* / БД сам,
# снимок приходит параметром снаружи (см. run_domclick_city_sweep →
# scheduler._job_domclick_city_sweep, который грузит его через
# app.services.domclick_session.load_session). None (дефолт) — сессии в БД
# нет/протухла, свип работает как раньше, без инъекции.
self._cookies = cookies
if delay_provider is not None:
self.request_delay_sec = delay_provider(self.name)
self.parse_failures: int = 0
@ -471,7 +482,7 @@ class DomClickScraper(BaseScraper):
"""
url = _build_count_url(rooms, price_gte, price_lte)
logger.debug("domklik: count url=%s", url)
html = await fetcher.fetch(url)
html = await fetcher.fetch(url, cookies=self._cookies) # #3264: снимок Sber ID сессии, обходит QRATOR PoW
try:
data = _extract_json(html)
except DomClickBlockedError:
@ -560,7 +571,7 @@ class DomClickScraper(BaseScraper):
break
url = _build_offers_url(rooms, price_gte, price_lte, offset)
logger.debug("domklik: offers url=%s", url)
html = await fetcher.fetch(url)
html = await fetcher.fetch(url, cookies=self._cookies) # #3264: снимок Sber ID сессии, обходит QRATOR PoW
try:
data = _extract_json(html)
except DomClickBlockedError: