All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 9s
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 1m43s
app.services.scrape_pipeline (3734 строк) имел 0 runtime-импортёров — Part C убрал
последнего вызывающего (admin.py, scheduler_main.py уже на
scraper_kit.orchestration.pipeline). Удаление:
- backend/app/services/scrape_pipeline.py — удалён целиком
Тесты (18 файлов, импортировавших scrape_pipeline):
DELETE (чистая legacy-оркестрация, kit-эквивалент покрыт в
test_scraper_kit_pipeline_parity{,2}.py):
- test_scrape_pipeline.py, test_scrape_pipeline_proxy_pool.py (proxy-pool DB-lease
вытеснен RealProxyProvider DI-адаптером, покрытым test_scraper_adapters_contracts.py)
- test_anchor_watchdog.py, test_ban_rotation_1790.py, test_provider_rotation_1848.py
- test_scraper_resilience_1949_1950.py, test_sweep_drain.py
CONVERT (parity-сравнение убрано, kit-only regression оставлен/усилен):
- test_scraper_kit_pipeline_parity.py, test_scraper_kit_pipeline_parity2.py —
_drive_old убран, _drive_new стал единственной веткой; ассёрты на конкретные
значения counters сохранены (это теперь основное regression-покрытие kit
orchestration)
- test_sweep_imv_phase.py — 6 IMV-phase тестов (enrich_imv true/false/no-touched/
crash/failed-counter/cancel) переведены на run_avito_city_sweep (kit) с DI
(config/matcher/enrichment); enrichment.process_houses_imv_batch — инжектируемый
Protocol-метод вместо прямого импорта app.services.house_imv_backfill
PARTIAL-EDIT (легаси sweep-behavior вырезан, admin/kit-DI/несвязанные тесты
оставлены):
- test_city_sweep.py — только retarget 4 импортов на scraper_kit.orchestration.pipeline
- test_cian_city_sweep.py, test_yandex_city_sweep.py — sweep-behavior секции удалены,
admin endpoint тесты (уже kit-DI) + anchor-timeout/watchdog формулы (retarget import)
оставлены
- test_domclick_sweep.py — sweep-wiring тесты удалены, Counters + _map_item оставлены
- test_avito_newbuilding_sweep.py — sweep-wiring тест #5 удалён, retarget
NewbuildingSweepCounters
- test_pipeline_browser_routing.py — 3 run_avito_pipeline теста удалены, browser-routing
на уровне fetch_detail/fetch_house_catalog/AvitoScraper не тронут
- test_scraper_proxy.py, tests/scrapers/test_avito_anti_bot.py — _avito_proxies()
переведён на kit (DI-параметр вместо settings-patch); pipeline-level тесты удалены
Stale-комментарии (scrape_pipeline.py как текущая реальность) поправлены в
admin.py, avito_detail_backfill.py, house_imv_backfill.py, yandex_price_history.py,
scrapers/avito_detail.py + kit-пакете (orchestration/__init__.py, orchestration
/pipeline.py, providers/avito/detail.py — strangler завершён, легаси удалён).
grep -rn scrape_pipeline app/ scripts/ packages/ — только past-tense упоминания
("удалён в Part E1", "перед его удалением") + один historical footnote
(contracts.py:138, provenance-заметка "собраны grep'ом по ... .py").
Backend suite: 3088 passed, 6 skipped (известный flake test_search_cache_hit #2208 —
не регрессия). ruff check чист на всех изменённых файлах.
367 lines
14 KiB
Python
367 lines
14 KiB
Python
"""Regression (F2): kit sweep-оркестраторы `scraper_kit.orchestration.pipeline`.
|
||
|
||
Kit — единственная orchestration-копия (#2397 Part E1 удалил legacy
|
||
`app.services.scrape_pipeline`; сравнивать «golden-parity» больше не с чем — этот
|
||
файл раньше гонял ОБА orchestrator'а side-by-side, теперь оставлена только
|
||
kit-сторона). Продолжение test_scraper_kit_pipeline_parity.py (F1 покрыл avito city
|
||
sweep). Здесь — остальные 7 sweep'ов (#2135 F2):
|
||
|
||
City sweep'ы (приоритет — активны в проде):
|
||
- run_yandex_city_sweep — combos SERP + save + price-history
|
||
- run_cian_city_sweep — SERP + newbuilding_only-фильтр + save
|
||
- run_domclick_city_sweep — BFF citywide + честный статус (done / failed)
|
||
Плюс:
|
||
- run_avito_newbuilding_sweep — citywide novostroyka SERP + save
|
||
Full load'ы (smoke — импорт + базовый прогон через on_bucket):
|
||
- run_avito_full_load / run_cian_full_load / run_yandex_full_load
|
||
|
||
Метод: гоняем kit orchestrator на мокнутом I/O, проверяем итоговый counters.to_dict()
|
||
+ последовательность (method, numeric-counters) вызовов scrape_runs. Без сети, без БД.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from types import SimpleNamespace
|
||
from typing import Any
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||
|
||
from scraper_kit.orchestration.pipeline import (
|
||
run_avito_full_load,
|
||
run_avito_newbuilding_sweep,
|
||
run_cian_city_sweep,
|
||
run_cian_full_load,
|
||
run_domclick_city_sweep,
|
||
run_yandex_city_sweep,
|
||
run_yandex_full_load,
|
||
)
|
||
|
||
PFX = "scraper_kit.orchestration.pipeline"
|
||
|
||
# ── recording scrape_runs ────────────────────────────────────────────────────
|
||
|
||
_NON_NUMERIC_KEYS = {"enrichment_abort_note", "done_buckets"}
|
||
|
||
|
||
class _RunsRecorder:
|
||
"""Записывает вызовы scrape_runs-финализаторов. is_cancelled всегда False."""
|
||
|
||
def __init__(self) -> None:
|
||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def is_cancelled(self, db: Any, run_id: int) -> bool:
|
||
return False
|
||
|
||
def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||
self.calls.append(("update_heartbeat", dict(counters)))
|
||
|
||
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||
self.calls.append(("mark_done", dict(counters)))
|
||
|
||
def mark_banned(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
|
||
self.calls.append(("mark_banned", dict(counters)))
|
||
|
||
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
|
||
self.calls.append(("mark_failed", dict(counters)))
|
||
|
||
|
||
def _normalize(calls: list[tuple[str, dict[str, Any]]]) -> list[tuple[str, dict[str, int]]]:
|
||
"""Оставить имя метода + числовые counters (убрать note/done_buckets)."""
|
||
out: list[tuple[str, dict[str, int]]] = []
|
||
for method, counters in calls:
|
||
numeric = {k: v for k, v in counters.items() if k not in _NON_NUMERIC_KEYS}
|
||
out.append((method, numeric))
|
||
return out
|
||
|
||
|
||
def _config() -> SimpleNamespace:
|
||
return SimpleNamespace(
|
||
scraper_fetch_mode="curl_cffi",
|
||
browser_http_endpoint="http://browser.test/fetch",
|
||
scraper_proxy_url=None,
|
||
avito_proxy_rotate_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_rotate_url=None,
|
||
cian_proxy_max_rotations=0,
|
||
yandex_proxy_rotate_url=None,
|
||
yandex_proxy_max_rotations=0,
|
||
scraper_skip_seen_today=False,
|
||
cian_full_load_per_fetch_timeout_s=0.0,
|
||
)
|
||
|
||
|
||
def _ctx_scraper(**attrs: Any) -> MagicMock:
|
||
"""MagicMock, пригодный и как `Cls()`, и как `async with Cls() as x`.
|
||
|
||
__aenter__ возвращает сам объект, __aexit__ — no-op. Доп. атрибуты/методы
|
||
задаются через kwargs (async-методы — AsyncMock).
|
||
"""
|
||
m = MagicMock()
|
||
m.__aenter__ = AsyncMock(return_value=m)
|
||
m.__aexit__ = AsyncMock(return_value=None)
|
||
for k, v in attrs.items():
|
||
setattr(m, k, v)
|
||
return m
|
||
|
||
|
||
def _async_session_cm() -> MagicMock:
|
||
sess = MagicMock()
|
||
sess.__aenter__ = AsyncMock(return_value=sess)
|
||
sess.__aexit__ = AsyncMock(return_value=None)
|
||
sess.close = AsyncMock()
|
||
return sess
|
||
|
||
|
||
_DriveResult = tuple[dict[str, int], list[tuple[str, dict[str, int]]]]
|
||
|
||
|
||
# ── Yandex city sweep ─────────────────────────────────────────────────────────
|
||
|
||
|
||
def _yandex_scraper(combos: list[tuple[str, list[Any]]]) -> MagicMock:
|
||
"""Fake YandexRealtyScraper: fetch_around_multi_room вызывает on_combo по combos."""
|
||
|
||
async def _fetch(*_a: Any, on_combo: Any = None, **_k: Any) -> None:
|
||
for label, lots in combos:
|
||
on_combo(label, lots)
|
||
|
||
return _ctx_scraper(fetch_around_multi_room=_fetch)
|
||
|
||
|
||
async def _drive_yandex_city() -> _DriveResult:
|
||
recorder = _RunsRecorder()
|
||
db = MagicMock()
|
||
combos = [
|
||
("2к-price0", [MagicMock(source_id="y1"), MagicMock(source_id="y2")]),
|
||
("1к-price0", [MagicMock(source_id="y3")]),
|
||
]
|
||
scraper = _yandex_scraper(combos)
|
||
save_mock = MagicMock(side_effect=[(2, 0), (1, 0)])
|
||
cfg = _config()
|
||
enrichment = MagicMock()
|
||
enrichment.record_yandex_price_history = MagicMock(return_value=5)
|
||
with (
|
||
patch(f"{PFX}.YandexRealtyScraper", return_value=scraper),
|
||
patch(f"{PFX}.save_listings", save_mock),
|
||
patch(f"{PFX}.runs", recorder),
|
||
):
|
||
counters = await run_yandex_city_sweep(
|
||
db,
|
||
config=cfg,
|
||
matcher=MagicMock(),
|
||
enrichment=enrichment,
|
||
run_id=1,
|
||
anchors=None,
|
||
pages_per_anchor=1,
|
||
request_delay_sec=0.0,
|
||
enrich_address=False,
|
||
)
|
||
return counters.to_dict(), _normalize(recorder.calls)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_yandex_city_sweep() -> None:
|
||
"""1 центральный anchor, 2 combos, save + price-history, enrich_address=False."""
|
||
counters, calls = await _drive_yandex_city()
|
||
assert counters["lots_fetched"] == 3
|
||
assert counters["lots_inserted"] == 3
|
||
assert counters["price_history_rows"] == 5
|
||
assert counters["anchors_done"] == 1
|
||
assert calls[-1][0] == "mark_done"
|
||
|
||
|
||
# ── Cian city sweep ───────────────────────────────────────────────────────────
|
||
|
||
|
||
def _cian_lot(segment: str) -> MagicMock:
|
||
return MagicMock(listing_segment=segment, house_source=None, house_ext_id=None)
|
||
|
||
|
||
async def _drive_cian_city() -> _DriveResult:
|
||
recorder = _RunsRecorder()
|
||
db = MagicMock()
|
||
# 3 novostroyki + 2 secondary → newbuilding_only оставит 3.
|
||
lots = [_cian_lot("novostroyki")] * 3 + [_cian_lot("vtorichnaya")] * 2
|
||
scraper = _ctx_scraper(fetch_around_multi_room=AsyncMock(return_value=lots))
|
||
save_mock = MagicMock(side_effect=[(3, 0)])
|
||
cfg = _config()
|
||
with (
|
||
patch(f"{PFX}.CianScraper", return_value=scraper),
|
||
patch(f"{PFX}.save_listings", save_mock),
|
||
patch(f"{PFX}.runs", recorder),
|
||
):
|
||
counters = await run_cian_city_sweep(
|
||
db,
|
||
config=cfg,
|
||
matcher=MagicMock(),
|
||
run_id=1,
|
||
anchors=[(56.84, 60.60, "A1")],
|
||
radius_m=1000,
|
||
pages_per_anchor=1,
|
||
request_delay_sec=0.0,
|
||
detail_top_n=0,
|
||
enrich_houses=False,
|
||
newbuilding_only=True,
|
||
)
|
||
return counters.to_dict(), _normalize(recorder.calls)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cian_city_sweep() -> None:
|
||
"""1 anchor, newbuilding_only отбрасывает вторичку, save новостроек, mark_done."""
|
||
counters, calls = await _drive_cian_city()
|
||
assert counters["lots_fetched"] == 5
|
||
assert counters["lots_dropped_secondary"] == 2
|
||
assert counters["lots_inserted"] == 3
|
||
assert counters["anchors_done"] == 1
|
||
assert calls[-1][0] == "mark_done"
|
||
|
||
|
||
# ── DomClick city sweep ───────────────────────────────────────────────────────
|
||
|
||
|
||
async def _drive_domclick(*, lots_n: int, blocked: bool) -> _DriveResult:
|
||
recorder = _RunsRecorder()
|
||
db = MagicMock()
|
||
lots = [MagicMock() for _ in range(lots_n)]
|
||
scraper = _ctx_scraper(
|
||
fetch_city=AsyncMock(return_value=lots),
|
||
blocked=blocked,
|
||
geo_filtered=0,
|
||
fetch_errors=0,
|
||
)
|
||
save_mock = MagicMock(side_effect=[(lots_n, 0)] if lots_n else [])
|
||
cfg = _config()
|
||
with (
|
||
patch(f"{PFX}.DomClickScraper", return_value=scraper),
|
||
patch(f"{PFX}.save_listings", save_mock),
|
||
patch(f"{PFX}.runs", recorder),
|
||
):
|
||
counters = await run_domclick_city_sweep(
|
||
db, config=cfg, matcher=MagicMock(), run_id=1, city_id=4, pages=1, request_delay_sec=0.0
|
||
)
|
||
return counters.to_dict(), _normalize(recorder.calls)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_domclick_city_sweep_done() -> None:
|
||
"""Citywide fetch_city → 4 lots saved → mark_done."""
|
||
counters, calls = await _drive_domclick(lots_n=4, blocked=False)
|
||
assert counters["lots_fetched"] == 4
|
||
assert counters["lots_inserted"] == 4
|
||
assert calls[-1][0] == "mark_done"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_domclick_city_sweep_blocked_failed() -> None:
|
||
"""QRATOR-блок + 0 lots → mark_failed (честный статус #1968)."""
|
||
counters, calls = await _drive_domclick(lots_n=0, blocked=True)
|
||
assert counters["lots_fetched"] == 0
|
||
assert counters["blocked"] == 1
|
||
assert calls[-1][0] == "mark_failed"
|
||
|
||
|
||
# ── Avito newbuilding sweep ───────────────────────────────────────────────────
|
||
|
||
|
||
async def _drive_nb_sweep() -> _DriveResult:
|
||
recorder = _RunsRecorder()
|
||
db = MagicMock()
|
||
lots = [MagicMock() for _ in range(6)]
|
||
scraper = MagicMock()
|
||
scraper._cffi = None
|
||
scraper._browser = None
|
||
scraper.fetch_newbuildings = AsyncMock(return_value=lots)
|
||
save_mock = MagicMock(side_effect=[(5, 1)])
|
||
cfg = _config()
|
||
with (
|
||
patch(f"{PFX}.AvitoScraper", return_value=scraper),
|
||
patch(f"{PFX}.save_listings", save_mock),
|
||
patch(f"{PFX}.runs", recorder),
|
||
patch(f"{PFX}.AsyncSession", return_value=_async_session_cm()),
|
||
):
|
||
counters = await run_avito_newbuilding_sweep(
|
||
db, config=cfg, matcher=MagicMock(), run_id=1, pages=2, request_delay_sec=0.0
|
||
)
|
||
return counters.to_dict(), _normalize(recorder.calls)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_avito_newbuilding_sweep() -> None:
|
||
"""Citywide novostroyka SERP → save → heartbeat → mark_done."""
|
||
counters, calls = await _drive_nb_sweep()
|
||
assert counters["lots_fetched"] == 6
|
||
assert counters["lots_inserted"] == 5
|
||
assert counters["lots_updated"] == 1
|
||
assert calls[-1][0] == "mark_done"
|
||
|
||
|
||
# ── Full loads (smoke через on_bucket) ─────────────────────────────────────────
|
||
|
||
|
||
def _full_load_scraper(buckets: list[tuple[str, list[Any]]]) -> MagicMock:
|
||
"""Fake scraper: fetch_all_secondary вызывает on_bucket по каждому бакету."""
|
||
|
||
async def _fetch(*_a: Any, on_bucket: Any = None, on_progress: Any = None, **_k: Any) -> None:
|
||
for key, lots in buckets:
|
||
on_bucket(key, lots)
|
||
|
||
scraper = _ctx_scraper(fetch_all_secondary=_fetch)
|
||
scraper._browser = None
|
||
return scraper
|
||
|
||
|
||
async def _drive_full_load(*, source: str) -> _DriveResult:
|
||
recorder = _RunsRecorder()
|
||
db = MagicMock()
|
||
buckets = [
|
||
("2к:0-5m", [MagicMock(source_id=f"{source}1"), MagicMock(source_id=f"{source}2")]),
|
||
("1к:0-5m", [MagicMock(source_id=f"{source}3")]),
|
||
]
|
||
scraper = _full_load_scraper(buckets)
|
||
save_mock = MagicMock(side_effect=[(2, 0), (1, 0)])
|
||
cfg = _config()
|
||
|
||
fn_map = {
|
||
"avito": run_avito_full_load,
|
||
"cian": run_cian_full_load,
|
||
"yandex": run_yandex_full_load,
|
||
}
|
||
scraper_target = {
|
||
"avito": f"{PFX}.AvitoScraper",
|
||
"cian": f"{PFX}.CianScraper",
|
||
"yandex": f"{PFX}.YandexRealtyScraper",
|
||
}[source]
|
||
fn = fn_map[source]
|
||
|
||
extra: dict[str, Any] = {}
|
||
if source == "yandex":
|
||
enrichment = MagicMock()
|
||
enrichment.record_yandex_price_history = MagicMock(return_value=0)
|
||
extra["enrichment"] = enrichment
|
||
with (
|
||
patch(scraper_target, return_value=scraper),
|
||
patch(f"{PFX}.save_listings", save_mock),
|
||
patch(f"{PFX}.runs", recorder),
|
||
):
|
||
counters = await fn(db, run_id=1, config=cfg, matcher=MagicMock(), **extra)
|
||
return counters.to_dict(), _normalize(recorder.calls)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("source", ["avito", "cian", "yandex"])
|
||
async def test_full_load_smoke(source: str) -> None:
|
||
"""Full load: 2 бакета через on_bucket → save + heartbeat → mark_done."""
|
||
counters, calls = await _drive_full_load(source=source)
|
||
assert counters["unique_fetched"] == 3
|
||
assert counters["saved_inserted"] == 3
|
||
assert counters["saved_updated"] == 0
|
||
assert calls[-1][0] == "mark_done"
|