All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-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 4m43s
Прод-факты #3118: QRATOR рубит свип внутри 1-2-й корзины при любом старте (buckets_completed ≤ 1 из 6), banned-прогоны собирают 59–1815 лотов. Сдвиг #2854 лишь распределяет потери; чекпоинт превращает случайную ротацию в систематический обход — шесть прогонов закрывают шесть корзин. Моё раннее «домклику чекпоинтить нечего» (замер в #3074 по средним 3 мин) опровергнуто данными #3118 — этот коммит исправляет тот вывод кодом. Зеркально yandex-чекпоинту (#3074): провайдер — skip_buckets + имена завершённых корзин + гард «цепочка накопила все 6» (честный no-op); buckets_total сжимается до объёма ЭТОГО прогона; пайплайн — done_buckets = унаследованное ∪ завершённое, heartbeat с мержем jsonb до финализации; планировщик — generic _pick_resume (#2845). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
144 lines
5.9 KiB
Python
144 lines
5.9 KiB
Python
"""#3118/#3074: чекпоинты для domclick_city_sweep — корзина ROOM_BUCKETS как единица.
|
||
|
||
Прод-факты (#3118): QRATOR обрывает свип внутри 1-2-й корзины при ЛЮБОМ старте
|
||
(`buckets_completed ≤ 1` из 6 во всех прогонах), banned-прогоны при этом собирают
|
||
59–1815 лотов. Сдвиг #2854 лишь распределяет потери; чекпоинт превращает
|
||
случайную ротацию в систематический обход — шесть прогонов закрывают шесть
|
||
корзин. Моё раннее «чекпоинтить нечего» (комментарий в #3074) опровергнуто
|
||
данными #3118 — этот файл и есть исправление того вывода кодом.
|
||
|
||
Три слоя (зеркально yandex #3074): провайдер skip_buckets + имена завершённых;
|
||
пайплайн — resume-чтение и done_buckets в counters (мерж jsonb, финализаторы
|
||
не затирают); планировщик — generic _pick_resume (#2845) бесплатно.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from types import SimpleNamespace
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from scraper_kit.orchestration import scheduler as sched
|
||
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickBlockedError, DomClickScraper
|
||
|
||
# ── 1. Провайдер: скип, имена, порядок с ротацией #2854 ──────────────────────
|
||
|
||
|
||
async def _no_sleep() -> None:
|
||
pass
|
||
|
||
|
||
def _scraper() -> DomClickScraper:
|
||
s = DomClickScraper(SimpleNamespace(scraper_proxy_url=None))
|
||
s.sleep_between_requests = _no_sleep # type: ignore[method-assign]
|
||
return s
|
||
|
||
|
||
async def _run_fetch_city(
|
||
scraper: DomClickScraper,
|
||
*,
|
||
start: int = 0,
|
||
skip: set[str] | None = None,
|
||
block_on: str | None = None,
|
||
) -> list[str]:
|
||
"""fetch_city с замоканными сеткой и обходом корзины; возвращает порядок
|
||
корзин, по которым РЕАЛЬНО пошёл обход."""
|
||
visited: list[str] = []
|
||
|
||
async def _fake_sweep_bucket(*, rooms: str, **_kw: Any) -> None:
|
||
visited.append(rooms)
|
||
if block_on is not None and rooms == block_on:
|
||
raise DomClickBlockedError("QRATOR")
|
||
|
||
class _FakeFetcherCtx:
|
||
async def __aenter__(self) -> SimpleNamespace:
|
||
return SimpleNamespace(report_ban=lambda *_a, **_k: None)
|
||
|
||
async def __aexit__(self, *_exc: Any) -> None:
|
||
return None
|
||
|
||
with (
|
||
patch.object(scraper, "_sweep_bucket", _fake_sweep_bucket),
|
||
patch(
|
||
"scraper_kit.providers._base.build_browser_fetcher",
|
||
lambda *_a, **_k: _FakeFetcherCtx(),
|
||
),
|
||
):
|
||
await scraper.fetch_city(city_id=1, pages=1, start_bucket_index=start, skip_buckets=skip)
|
||
return visited
|
||
|
||
|
||
async def test_skip_buckets_are_never_fetched() -> None:
|
||
"""Корзины из чекпоинта не получают ни одного обхода; buckets_total честно
|
||
сжимается до объёма ЭТОГО прогона (иначе honest-status читал бы
|
||
возобновлённый прогон как вечно-частичный)."""
|
||
s = _scraper()
|
||
visited = await _run_fetch_city(s, start=0, skip={"st", "1", "2"})
|
||
assert visited == ["3", "4", "5+"], visited
|
||
assert s.buckets_total == 3
|
||
assert s.completed_buckets == ["3", "4", "5+"]
|
||
|
||
|
||
async def test_block_midway_records_completed_names() -> None:
|
||
"""Блок на 2-й корзине: имена завершённых до блока — источник чекпоинта."""
|
||
s = _scraper()
|
||
visited = await _run_fetch_city(s, start=1, block_on="2")
|
||
# старт со сдвигом 1: порядок 1,2,... — блок на '2' после завершения '1'
|
||
assert visited[:2] == ["1", "2"]
|
||
assert s.completed_buckets == ["1"], s.completed_buckets
|
||
assert s.blocked is True
|
||
|
||
|
||
async def test_all_skipped_is_honest_noop() -> None:
|
||
"""Цепочка накопила все 6 корзин → пустой обход без падения (гард)."""
|
||
s = _scraper()
|
||
visited = await _run_fetch_city(s, skip=set(ROOM_BUCKETS))
|
||
assert visited == []
|
||
assert s.completed_buckets == []
|
||
|
||
|
||
# ── 2. Планировщик: диспатч отдаёт точку (зеркало test_930/test_3074) ────────
|
||
|
||
|
||
def _candidate() -> SimpleNamespace:
|
||
return SimpleNamespace(
|
||
prev_id=6001,
|
||
prev_status="banned",
|
||
prev_counters={"done_buckets": ["st", "1"]},
|
||
same_params=True,
|
||
age_h=20.0,
|
||
interval_days="1",
|
||
)
|
||
|
||
|
||
class _FakeDb:
|
||
def __init__(self, row: Any) -> None:
|
||
self.row = row
|
||
|
||
def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||
if params and "counters" in params:
|
||
return MagicMock()
|
||
return MagicMock(fetchone=lambda: self.row)
|
||
|
||
def commit(self) -> None:
|
||
pass
|
||
|
||
|
||
async def test_scheduler_hands_checkpoint_to_domclick_sweep() -> None:
|
||
"""Красный на main по значению: kwargs без resume_run_id → None != 6001."""
|
||
db = _FakeDb(_candidate())
|
||
captured: dict[str, Any] = {}
|
||
|
||
async def _spy(*_a: Any, **kw: Any) -> None:
|
||
captured.update(kw)
|
||
|
||
with patch.object(sched, "run_domclick_city_sweep", _spy):
|
||
await sched._job_domclick_city_sweep(db, 7000, {}, MagicMock())
|
||
|
||
assert captured.get("resume_run_id") == 6001, (
|
||
"планировщик не отдал чекпоинт домклик-свипу — прогон пойдёт с нуля"
|
||
)
|