From e176ae99af2b246f33d3aee2c57420b6c3ddfa37 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 2 Jul 2026 20:27:51 +0300 Subject: [PATCH] feat(scraper-kit): copy remaining sweep orchestrators (yandex/cian/domclick/full-load), strangler (#2135 F2) --- .../backend/app/services/scraper_adapters.py | 16 + .../test_scraper_kit_pipeline_parity2.py | 481 ++++ .../scraper-kit/src/scraper_kit/contracts.py | 30 + .../src/scraper_kit/orchestration/pipeline.py | 1988 ++++++++++++++++- 4 files changed, 2508 insertions(+), 7 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py diff --git a/tradein-mvp/backend/app/services/scraper_adapters.py b/tradein-mvp/backend/app/services/scraper_adapters.py index 55ec419f..e193747f 100644 --- a/tradein-mvp/backend/app/services/scraper_adapters.py +++ b/tradein-mvp/backend/app/services/scraper_adapters.py @@ -28,9 +28,16 @@ from app.services.house_imv_backfill import ( ) from app.services.matching import match_or_create_house as _match_or_create_house from app.services.matching import upsert_listing_source as _upsert_listing_source +from app.services.yandex_address_backfill import ( + _RE_HAS_HOUSE_NUMBER, + _extract_address_from_title, +) from app.services.yandex_address_backfill import ( backfill_yandex_addresses as _backfill_yandex_addresses, ) +from app.services.yandex_price_history import ( + record_yandex_price_history as _record_yandex_price_history, +) if TYPE_CHECKING: from sqlalchemy.orm import Session @@ -261,6 +268,15 @@ class RealEnrichmentJobs: heartbeat=heartbeat, ) + def record_yandex_price_history(self, db: Session, lots: list[Any]) -> int: + return _record_yandex_price_history(db, lots) + + def extract_address_from_title(self, html: str) -> str | None: + return _extract_address_from_title(html) + + def address_has_house_number(self, address: str) -> bool: + return bool(_RE_HAS_HOUSE_NUMBER.search(address)) + __all__ = [ "RealEnrichmentJobs", diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py new file mode 100644 index 00000000..aeb6c0f4 --- /dev/null +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py @@ -0,0 +1,481 @@ +"""Golden-parity (F2): kit sweep-оркестраторы ≡ `app.services.scrape_pipeline`. + +Продолжение test_scraper_kit_pipeline_parity.py (F1 покрыл avito city sweep). Здесь — +остальные 7 sweep'ов, скопированных в `scraper_kit.orchestration.pipeline` (#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-parity — импорт + базовый прогон через on_bucket): + - run_avito_full_load / run_cian_full_load / run_yandex_full_load + +Метод — как в F1: гоняем ОБА orchestrator'а на идентичном мокнутом I/O, сравниваем +итоговый counters.to_dict() + последовательность (method, numeric-counters) вызовов +scrape_runs. Развязка `app.*` (config/matcher/enrichment/runs/shutdown инжектируются) +не должна менять ветвление/счётчики. Без сети, без БД. +""" + +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 as new_avito_full_load, +) +from scraper_kit.orchestration.pipeline import ( + run_avito_newbuilding_sweep as new_nb_sweep, +) +from scraper_kit.orchestration.pipeline import ( + run_cian_city_sweep as new_cian_city_sweep, +) +from scraper_kit.orchestration.pipeline import ( + run_cian_full_load as new_cian_full_load, +) +from scraper_kit.orchestration.pipeline import ( + run_domclick_city_sweep as new_domclick_city_sweep, +) +from scraper_kit.orchestration.pipeline import ( + run_yandex_city_sweep as new_yandex_city_sweep, +) +from scraper_kit.orchestration.pipeline import ( + run_yandex_full_load as new_yandex_full_load, +) + +from app.services.scrape_pipeline import ( + run_avito_full_load as old_avito_full_load, +) +from app.services.scrape_pipeline import ( + run_avito_newbuilding_sweep as old_nb_sweep, +) +from app.services.scrape_pipeline import ( + run_cian_city_sweep as old_cian_city_sweep, +) +from app.services.scrape_pipeline import ( + run_cian_full_load as old_cian_full_load, +) +from app.services.scrape_pipeline import ( + run_domclick_city_sweep as old_domclick_city_sweep, +) +from app.services.scrape_pipeline import ( + run_yandex_city_sweep as old_yandex_city_sweep, +) +from app.services.scrape_pipeline import ( + run_yandex_full_load as old_yandex_full_load, +) + +OLD_PFX = "app.services.scrape_pipeline" +NEW_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: + """Инжектируемый config (kit) + тот же объект как patched settings (old).""" + 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]]]] + + +async def _assert_parity(old_res: _DriveResult, new_res: _DriveResult) -> dict[str, int]: + old_counters, old_calls = old_res + new_counters, new_calls = new_res + assert ( + new_counters == old_counters + ), f"counters diverged:\n old={old_counters}\n new={new_counters}" + assert ( + new_calls == old_calls + ), f"scrape_runs calls diverged:\n old={old_calls}\n new={new_calls}" + return new_counters + + +# ── 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(*, new: bool) -> _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() + kwargs = dict( + run_id=1, + anchors=None, + pages_per_anchor=1, + request_delay_sec=0.0, + enrich_address=False, + ) + if new: + enrichment = MagicMock() + enrichment.record_yandex_price_history = MagicMock(return_value=5) + with ( + patch(f"{NEW_PFX}.YandexRealtyScraper", return_value=scraper), + patch(f"{NEW_PFX}.save_listings", save_mock), + patch(f"{NEW_PFX}.runs", recorder), + ): + counters = await new_yandex_city_sweep( + db, config=cfg, matcher=MagicMock(), enrichment=enrichment, **kwargs + ) + else: + with ( + patch(f"{OLD_PFX}.YandexRealtyScraper", return_value=scraper), + patch(f"{OLD_PFX}.save_listings", save_mock), + patch(f"{OLD_PFX}.record_yandex_price_history", MagicMock(return_value=5)), + patch(f"{OLD_PFX}.scrape_runs", recorder), + patch(f"{OLD_PFX}.settings", cfg), + patch(f"{OLD_PFX}.shutdown_requested", return_value=False), + ): + counters = await old_yandex_city_sweep(db, **kwargs) + return counters.to_dict(), _normalize(recorder.calls) + + +@pytest.mark.asyncio +async def test_yandex_city_sweep_parity() -> None: + """1 центральный anchor, 2 combos, save + price-history, enrich_address=False.""" + counters = await _assert_parity( + await _drive_yandex_city(new=False), await _drive_yandex_city(new=True) + ) + assert counters["lots_fetched"] == 3 + assert counters["lots_inserted"] == 3 + assert counters["price_history_rows"] == 5 + assert counters["anchors_done"] == 1 + + +# ── 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(*, new: bool) -> _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() + kwargs = dict( + 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, + ) + if new: + with ( + patch(f"{NEW_PFX}.CianScraper", return_value=scraper), + patch(f"{NEW_PFX}.save_listings", save_mock), + patch(f"{NEW_PFX}.runs", recorder), + ): + counters = await new_cian_city_sweep(db, config=cfg, matcher=MagicMock(), **kwargs) + else: + with ( + patch("app.services.scrapers.cian.CianScraper", return_value=scraper), + patch(f"{OLD_PFX}.save_listings", save_mock), + patch(f"{OLD_PFX}.scrape_runs", recorder), + patch(f"{OLD_PFX}.settings", cfg), + patch(f"{OLD_PFX}.shutdown_requested", return_value=False), + ): + counters = await old_cian_city_sweep(db, **kwargs) + return counters.to_dict(), _normalize(recorder.calls) + + +@pytest.mark.asyncio +async def test_cian_city_sweep_parity() -> None: + """1 anchor, newbuilding_only отбрасывает вторичку, save новостроек, mark_done.""" + counters = await _assert_parity( + await _drive_cian_city(new=False), await _drive_cian_city(new=True) + ) + assert counters["lots_fetched"] == 5 + assert counters["lots_dropped_secondary"] == 2 + assert counters["lots_inserted"] == 3 + assert counters["anchors_done"] == 1 + + +# ── DomClick city sweep ─────────────────────────────────────────────────────── + + +async def _drive_domclick(*, new: bool, 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() + kwargs = dict(run_id=1, city_id=4, pages=1, request_delay_sec=0.0) + if new: + with ( + patch(f"{NEW_PFX}.DomClickScraper", return_value=scraper), + patch(f"{NEW_PFX}.save_listings", save_mock), + patch(f"{NEW_PFX}.runs", recorder), + ): + counters = await new_domclick_city_sweep(db, config=cfg, matcher=MagicMock(), **kwargs) + else: + with ( + patch("app.services.scrapers.domclick.DomClickScraper", return_value=scraper), + patch(f"{OLD_PFX}.save_listings", save_mock), + patch(f"{OLD_PFX}.scrape_runs", recorder), + patch(f"{OLD_PFX}.settings", cfg), + patch(f"{OLD_PFX}.shutdown_requested", return_value=False), + ): + counters = await old_domclick_city_sweep(db, **kwargs) + return counters.to_dict(), _normalize(recorder.calls) + + +@pytest.mark.asyncio +async def test_domclick_city_sweep_parity_done() -> None: + """Citywide fetch_city → 4 lots saved → mark_done.""" + counters = await _assert_parity( + await _drive_domclick(new=False, lots_n=4, blocked=False), + await _drive_domclick(new=True, lots_n=4, blocked=False), + ) + assert counters["lots_fetched"] == 4 + assert counters["lots_inserted"] == 4 + + +@pytest.mark.asyncio +async def test_domclick_city_sweep_parity_blocked_failed() -> None: + """QRATOR-блок + 0 lots → mark_failed (честный статус #1968).""" + counters = await _assert_parity( + await _drive_domclick(new=False, lots_n=0, blocked=True), + await _drive_domclick(new=True, lots_n=0, blocked=True), + ) + assert counters["lots_fetched"] == 0 + assert counters["blocked"] == 1 + + +# ── Avito newbuilding sweep ─────────────────────────────────────────────────── + + +async def _drive_nb_sweep(*, new: bool) -> _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() + kwargs = dict(run_id=1, pages=2, request_delay_sec=0.0) + if new: + with ( + patch(f"{NEW_PFX}.AvitoScraper", return_value=scraper), + patch(f"{NEW_PFX}.save_listings", save_mock), + patch(f"{NEW_PFX}.runs", recorder), + patch(f"{NEW_PFX}.AsyncSession", return_value=_async_session_cm()), + ): + counters = await new_nb_sweep(db, config=cfg, matcher=MagicMock(), **kwargs) + else: + with ( + patch(f"{OLD_PFX}.AvitoScraper", return_value=scraper), + patch(f"{OLD_PFX}.save_listings", save_mock), + patch(f"{OLD_PFX}.scrape_runs", recorder), + patch(f"{OLD_PFX}.settings", cfg), + patch(f"{OLD_PFX}.shutdown_requested", return_value=False), + patch(f"{OLD_PFX}.AsyncSession", return_value=_async_session_cm()), + ): + counters = await old_nb_sweep(db, **kwargs) + return counters.to_dict(), _normalize(recorder.calls) + + +@pytest.mark.asyncio +async def test_avito_newbuilding_sweep_parity() -> None: + """Citywide novostroyka SERP → save → heartbeat → mark_done.""" + counters = await _assert_parity( + await _drive_nb_sweep(new=False), await _drive_nb_sweep(new=True) + ) + assert counters["lots_fetched"] == 6 + assert counters["lots_inserted"] == 5 + assert counters["lots_updated"] == 1 + + +# ── Full loads (smoke-parity через 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(*, new: bool, 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() + + old_map = { + "avito": (old_avito_full_load, new_avito_full_load, f"{OLD_PFX}.AvitoScraper"), + "cian": (old_cian_full_load, new_cian_full_load, "app.services.scrapers.cian.CianScraper"), + "yandex": ( + old_yandex_full_load, + new_yandex_full_load, + "app.services.scrapers.yandex_realty.YandexRealtyScraper", + ), + } + old_fn, new_fn, old_scraper_target = old_map[source] + + if new: + new_scraper_target = { + "avito": f"{NEW_PFX}.AvitoScraper", + "cian": f"{NEW_PFX}.CianScraper", + "yandex": f"{NEW_PFX}.YandexRealtyScraper", + }[source] + extra: dict[str, Any] = {} + if source == "yandex": + enrichment = MagicMock() + enrichment.record_yandex_price_history = MagicMock(return_value=0) + extra["enrichment"] = enrichment + with ( + patch(new_scraper_target, return_value=scraper), + patch(f"{NEW_PFX}.save_listings", save_mock), + patch(f"{NEW_PFX}.runs", recorder), + ): + counters = await new_fn(db, run_id=1, config=cfg, matcher=MagicMock(), **extra) + else: + import contextlib + + ctx = [ + patch(old_scraper_target, return_value=scraper), + patch(f"{OLD_PFX}.save_listings", save_mock), + patch(f"{OLD_PFX}.scrape_runs", recorder), + patch(f"{OLD_PFX}.settings", cfg), + patch(f"{OLD_PFX}.shutdown_requested", return_value=False), + ] + if source == "yandex": + ctx.append(patch(f"{OLD_PFX}.record_yandex_price_history", MagicMock(return_value=0))) + with contextlib.ExitStack() as stack: + for cm in ctx: + stack.enter_context(cm) + counters = await old_fn(db, run_id=1) + return counters.to_dict(), _normalize(recorder.calls) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("source", ["avito", "cian", "yandex"]) +async def test_full_load_smoke_parity(source: str) -> None: + """Full load: 2 бакета через on_bucket → save + heartbeat → mark_done. Counters ≡.""" + counters = await _assert_parity( + await _drive_full_load(new=False, source=source), + await _drive_full_load(new=True, source=source), + ) + assert counters["unique_fetched"] == 3 + assert counters["saved_inserted"] == 3 + assert counters["saved_updated"] == 0 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py index a865d384..e2e065d9 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py @@ -208,6 +208,36 @@ class EnrichmentJobs(Protocol): """ ... + # ── Yandex sweep-хелперы (#2135 F2) ────────────────────────────────────── + # Продуктовая логика, дёргаемая yandex city/full sweep-оркестраторами напрямую + # (не через отдельные backfill-джобы). Инжектируются вместо прямых импортов + # app.services.yandex_price_history / yandex_address_backfill. + + def record_yandex_price_history(self, db: Session, lots: list[Any]) -> int: + """Записать price-history из gate price.previous/trend. Проксирует + record_yandex_price_history. Returns число вставленных строк истории. + + `lots` — список kit-`ScrapedLot` (тип оставлен `Any`, чтобы не тянуть + доменный тип в контракт). Читается duck-typing'ом (source_id/price/…). + """ + ... + + def extract_address_from_title(self, html: str) -> str | None: + """Извлечь полный адрес из detail-страницы Yandex. + + Проксирует `_extract_address_from_title` (yandex_address_backfill). + Returns адрес с номером дома или None если не распарсилось. + """ + ... + + def address_has_house_number(self, address: str) -> bool: + """True если адрес содержит номер дома (`,\\s*\\d+`). + + Проксирует regex `_RE_HAS_HOUSE_NUMBER` из yandex_address_backfill — + gate для решения, обновлять ли address на обогащённый. + """ + ... + __all__ = [ "EnrichmentJobs", diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index c2e5dcbf..49ddda42 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -12,14 +12,22 @@ Strangler-COPY боевого `app.services.scrape_pipeline` (шаг оркес - провайдеры/база → `scraper_kit.providers.*` / `scraper_kit.base` Скопирована КРИТИЧНАЯ логика оркестрации (#2135): - - `run_avito_pipeline` — одиночный anchor (SEARCH→SAVE→GROUP→HOUSES→DETAIL) - - `run_avito_city_sweep` — полный city-sweep + ban/rotation + partial-intake + IMV - -Остаток (yandex/cian/domclick city-sweep, *_full_load, newbuilding sweep) — -follow-up: та же механическая развязка, но не входит в первый срез #2135. + - `run_avito_pipeline` — одиночный anchor (SEARCH→SAVE→GROUP→HOUSES→DETAIL) + - `run_avito_city_sweep` — city-sweep + ban/rotation + partial-intake + IMV (F1) + - `run_avito_newbuilding_sweep`— citywide novostroyka-обход + save (F2) + - `run_yandex_city_sweep` — combos (room×price) + save + address-enrich (F2) + - `run_cian_city_sweep` — anchor SERP → detail(+price-hist) → newbuilding (F2) + - `run_cian_full_load` — region-wide bisection + resume/checkpoint (F2) + - `run_yandex_full_load` — region-wide bisection + price-history (F2) + - `run_avito_full_load` — citywide bisection (exhaustive/incremental) (F2) + - `run_domclick_city_sweep` — BFF citywide sweep + честный статус (F2) Ban-rotation state-machine сведена в единый хелпер `_try_rotate_within_budget` -(в оригинале инлайн-дублировалась в house/detail/house-detail фазах). +(в оригинале инлайн-дублировалась в house/detail/house-detail/enrich фазах). + +Yandex sweep'ы дёргают продуктовую логику (price-history + address-parse) через +инжектируемый `EnrichmentJobs` (record_yandex_price_history / extract_address_from_title +/ address_has_house_number) вместо прямых импортов app.services.yandex_*. """ from __future__ import annotations @@ -30,7 +38,8 @@ import random from collections.abc import Callable from contextlib import AsyncExitStack from dataclasses import dataclass, field, fields -from typing import TYPE_CHECKING +from datetime import date, timedelta +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from curl_cffi.requests import AsyncSession @@ -46,6 +55,19 @@ from scraper_kit.providers.avito.houses import ( save_house_catalog_enrichment, ) from scraper_kit.providers.avito.serp import AvitoScraper +from scraper_kit.providers.cian.detail import fetch_detail as cian_fetch_detail +from scraper_kit.providers.cian.detail import save_detail_enrichment as cian_save_detail_enrichment +from scraper_kit.providers.cian.newbuilding import ( + fetch_newbuilding, + save_newbuilding_enrichment, +) +from scraper_kit.providers.cian.serp import CianScraper +from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickScraper +from scraper_kit.providers.yandex.serp import ( + DEFAULT_PRICE_RANGES, + ROOM_PATH, + YandexRealtyScraper, +) if TYPE_CHECKING: from sqlalchemy.orm import Session @@ -1410,3 +1432,1955 @@ async def run_avito_city_sweep( logger.exception("city-sweep run_id=%d: fatal error", run_id) runs.mark_failed(db, run_id, str(exc), counters.to_dict()) raise + + +# ── Avito newbuilding (novostroyka) citywide sweep ──────────────── + + +@dataclass +class NewbuildingSweepCounters: + """Aggregate counters для Avito novostroyka citywide sweep run.""" + + lots_fetched: int = 0 + lots_inserted: int = 0 + lots_updated: int = 0 + errors_count: int = 0 + + def to_dict(self) -> dict[str, int]: + return {f.name: getattr(self, f.name) for f in fields(self)} + + +async def run_avito_newbuilding_sweep( + db: Session, + *, + run_id: int, + config: ScraperConfig, + matcher: HouseMatcher, + shutdown_requested: Callable[[], bool] = lambda: False, + pages: int = 20, + request_delay_sec: float = 7.0, + region_code: int = DEFAULT_REGION_CODE, +) -> NewbuildingSweepCounters: + """Citywide-обход ЕКБ-выборки только новостроек (novostroyka-filter) → save. + + В отличие от run_avito_city_sweep (anchor-based fetch_around + houses/detail/IMV), + это простой citywide paginated-обход через AvitoScraper.fetch_newbuildings: + dedicated novostroyka-SERP отдаёт 100% new-build карточки (listing_segment= + "novostroyki", newbuilding_id/newbuilding_url заполнены парсером). Сохраняем через + стандартный save_listings, ведём SERP-счётчики через runs. + + - Single shared AsyncSession/BrowserFetcher на весь sweep (один TLS fingerprint) + - AvitoBlockedError/AvitoRateLimitedError → mark_banned (status='banned') + - is_cancelled-чек до старта SERP-фазы + + Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо + прямых импортов app.* (см. scraper_kit.contracts). + """ + counters = NewbuildingSweepCounters() + + browser_mode = config.scraper_fetch_mode == "browser" + async with AsyncExitStack() as stack: + session: AsyncSession | None = None + shared_bf: BrowserFetcher | None = None + if browser_mode: + shared_bf = await stack.enter_async_context( + BrowserFetcher(source="avito", endpoint=config.browser_http_endpoint) + ) + else: + session = await stack.enter_async_context( + AsyncSession( + impersonate="chrome120", + timeout=25, + headers=_CHROME_HEADERS, + proxies=_avito_proxies(config), + ) + ) + try: + if runs.is_cancelled(db, run_id): + logger.info("nb-sweep run_id=%d: cancelled before SERP phase", run_id) + runs.mark_done(db, run_id, counters.to_dict()) + return counters + elif shutdown_requested(): + logger.info( + "nb-sweep run_id=%d: SIGTERM-drain — stopping before SERP phase", run_id + ) + runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.mark_done(db, run_id, counters.to_dict()) + return counters + + scraper = AvitoScraper(config) + if browser_mode: + scraper._browser = shared_bf + # Shared-browser режим: _cffi=None → curl_cffi-fallback на firewall + # пропускается (by design). Логируем для observability. + logger.debug("avito pipeline-mode: shared browser only, no cffi fallback") + else: + scraper._cffi = session + + try: + lots: list[ScrapedLot] = await scraper.fetch_newbuildings( + pages=pages, + delay_override_sec=request_delay_sec, + ) + except (AvitoBlockedError, AvitoRateLimitedError) as e: + logger.error("nb-sweep run_id=%d: SERP BLOCKED — %s", run_id, e) + runs.mark_banned(db, run_id, str(e), counters.to_dict()) + return counters + + counters.lots_fetched += len(lots) + if lots: + try: + ins, upd = save_listings(db, lots, matcher=matcher, region_code=region_code) + counters.lots_inserted += ins + counters.lots_updated += upd + except Exception as save_exc: + logger.exception( + "nb-sweep run_id=%d: save_listings failed: %s", run_id, save_exc + ) + counters.errors_count += 1 + try: + db.rollback() + except Exception: + pass + + runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.mark_done(db, run_id, counters.to_dict()) + logger.info( + "nb-sweep run_id=%d done: lots=%d (ins=%d/upd=%d) errors=%d", + run_id, + counters.lots_fetched, + counters.lots_inserted, + counters.lots_updated, + counters.errors_count, + ) + return counters + + except Exception as exc: + logger.exception("nb-sweep run_id=%d: fatal error", run_id) + runs.mark_failed(db, run_id, str(exc), counters.to_dict()) + raise + + +# ── Yandex city sweep ─────────────────────────────────────────── + +# Константы для расчёта watchdog-таймаута combos-режима Yandex sweep. +# В combos-режиме единственный "anchor" делает num_combos × max_pages fetch'ей; +# каждый fetch занимает request_delay_sec + сетевой overhead. ADDRESS_ENRICH_BUDGET_S +# добавляет буфер на address-enrich фазу (per-listing HTTP + sleep × N листингов). +_YANDEX_COMBOS_PER_FETCH_S: float = 12.0 # network + parse margin (секунды на страницу) +_YANDEX_ADDRESS_ENRICH_BUDGET_S: float = 300.0 # бюджет на address-enrich фазу + + +@dataclass +class YandexCitySweepCounters: + """Aggregate counters для Yandex.Недвижимость city sweep run. + + Фазы: SERP (fetch_around_multi_room) → save_listings инкрементально → address-enrich. + address_* — результат T10-обогащения (detail <title> → полный адрес с номером дома). + combos_skipped — сколько (room×price) combo пропущено из-за таймаутов gate-API. + """ + + anchors_total: int = 0 + anchors_done: int = 0 + lots_fetched: int = 0 + lots_inserted: int = 0 + lots_updated: int = 0 + price_history_rows: int = 0 + combos_skipped: int = 0 + address_attempted: int = 0 + address_enriched: int = 0 + address_failed: int = 0 + errors_count: int = 0 + + def to_dict(self) -> dict[str, int]: + return {f.name: getattr(self, f.name) for f in fields(self)} + + +# Defensive abort threshold: у YandexRealtyScraper нет block/rate-exception классов +# (fetch_around глотает ошибки и возвращает []). Чтобы не молотить Yandex впустую при +# системном блоке/бане IP, прерываем sweep после N подряд anchor'ов, упавших с +# исключением. NB: пустой результат (0 lots) — это НЕ ошибка, счётчик не растёт. +YANDEX_SWEEP_MAX_CONSECUTIVE_FAILURES = 3 + + +async def run_yandex_city_sweep( + db: Session, + *, + run_id: int, + config: ScraperConfig, + matcher: HouseMatcher, + enrichment: EnrichmentJobs, + shutdown_requested: Callable[[], bool] = lambda: False, + anchors: list[tuple[float, float, str]] | None = None, + radius_m: int = 25000, + pages_per_anchor: int = 2, + request_delay_sec: float | None = None, + enrich_address: bool = True, + rooms_list: list[str] | None = None, + price_ranges: list[tuple[int | None, int | None]] | None = None, + segments: list[str] | None = None, + region_code: int = DEFAULT_REGION_CODE, +) -> YandexCitySweepCounters: + """Yandex.Недвижимость city sweep: rooms × price combos от центра ЕКБ → save → address-enrich. + + Вместо 5 географических anchor'ов итерирует ВСЕ combinations (room × price_range) + из одной центральной точки ЕКБ (56.8400, 60.6050). radius_m=25000 охватывает весь + город; price/room segmentation обходит Yandex SERP ~575-card cap без geo-шрапнели. + + Фазы (единственный центральный якорь): + 1. SERP: YandexRealtyScraper.fetch_around_multi_room(...) — combos mode (on_combo save). + 2. ADDRESS-ENRICH (если enrich_address=True): для yandex-листингов без номера дома + запрашиваем detail-страницу и извлекаем полный адрес из <title>. + + Инжекция (#2135 F2): config/matcher/shutdown_requested + продуктовые хелперы + (record_yandex_price_history / extract_address_from_title / address_has_house_number) + через enrichment (EnrichmentJobs) вместо прямых импортов app.services.yandex_*. + + Возвращает YandexCitySweepCounters. mark_done вызывается ВСЕГДА (кроме cancel/ban/fatal). + """ + # EKB center — единственный anchor для citywide combos sweep. + # anchors-параметр принимается для backward-compat тестов. + if anchors is not None: + _anchors: list[tuple[float, float, str]] = anchors + else: + _anchors = [(56.8400, 60.6050, "Центр (combos)")] + + _rooms_list = rooms_list or list(ROOM_PATH.keys()) + _price_ranges = price_ranges or DEFAULT_PRICE_RANGES + # newFlat-сегменты: оба прохода (vtorichka + novostroyki) по одним combos. + _segments = segments or ["NO", "YES"] + + counters = YandexCitySweepCounters(anchors_total=len(_anchors)) + inter_anchor_delay = request_delay_sec if request_delay_sec is not None else 7.0 + enrich_delay = request_delay_sec if request_delay_sec is not None else 3.0 + _resolved_delay = request_delay_sec if request_delay_sec is not None else 9.0 + consecutive_failures = 0 + yandex_rotations_done = 0 # #1848: бюджет IP-ротаций на весь sweep + + # Вычисляем watchdog-таймаут для combos-режима (центр, anchors=None). + _num_combos = len(_rooms_list) * len(_price_ranges) + if anchors is None and _num_combos > 0: + _sweep_timeout = max( + ANCHOR_TIMEOUT_SEC, + len(_segments) + * _num_combos + * pages_per_anchor + * (_resolved_delay + _YANDEX_COMBOS_PER_FETCH_S) + + _YANDEX_ADDRESS_ENRICH_BUDGET_S, + ) + else: + # Explicit anchors (backward-compat тесты или ручной override) — legacy timeout. + _sweep_timeout = ANCHOR_TIMEOUT_SEC + + # Прокси для address-enrich curl_cffi сессии (зеркало yandex_address_backfill). + _proxy_url = config.scraper_proxy_url + _proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None + + try: + for idx, (lat, lon, name) in enumerate(_anchors, start=1): + # ── Cooperative cancel ─────────────────────────────────────────── + if runs.is_cancelled(db, run_id): + logger.info( + "yandex-sweep run_id=%d: cancelled at anchor #%d/%d (%s)", + run_id, + idx, + len(_anchors), + name, + ) + runs.update_heartbeat(db, run_id, counters.to_dict()) + return counters + elif shutdown_requested(): + # SIGTERM-drain: чистый стоп на границе anchor'а → mark_done(partial). + logger.info( + "yandex-sweep run_id=%d: SIGTERM-drain — stopping at anchor #%d/%d (%s)", + run_id, + idx, + len(_anchors), + name, + ) + runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.mark_done(db, run_id, counters.to_dict()) + return counters + + logger.info( + "yandex-sweep run_id=%d center-combos #%d/%d (%s, %.4f, %.4f) " + "radius_m=%d rooms=%d price_ranges=%d", + run_id, + idx, + len(_anchors), + name, + lat, + lon, + radius_m, + len(_rooms_list), + len(_price_ranges), + ) + + # ── Per-anchor phases под watchdog-таймаутом ──────────────────── + anchor_lots: list[ScrapedLot] = [] + _anchor_timed_out = False + _lat, _lon, _name = lat, lon, name + _combo_lots_accumulator: list[ScrapedLot] = anchor_lots + + async def _yandex_anchor_phases( + _a_lat: float = _lat, + _a_lon: float = _lon, + _a_name: str = _name, + _al: list[ScrapedLot] = _combo_lots_accumulator, + ) -> None: + """Все фазы одного anchor'а (SERP + address-enrich).""" + nonlocal consecutive_failures, yandex_rotations_done + + # ── Phase 1+2: SERP + инкрементальный save per-combo ────── + def _on_combo( + combo_label: str, + new_lots: list[ScrapedLot], + _accumulator: list[ScrapedLot] = _al, + ) -> None: + """Callback: вызывается scraper'ом после каждого combo с новыми лотами.""" + _accumulator.extend(new_lots) + counters.lots_fetched += len(new_lots) + try: + ins, upd = save_listings( + db, new_lots, matcher=matcher, region_code=region_code, run_id=run_id + ) + counters.lots_inserted += ins + counters.lots_updated += upd + except Exception as save_exc: + counters.errors_count += 1 + logger.warning( + "yandex-sweep run_id=%d combo %s: save_listings failed: %s", + run_id, + combo_label, + save_exc, + ) + try: + db.rollback() + except Exception: + pass + runs.update_heartbeat(db, run_id, counters.to_dict()) + logger.debug( + "yandex-sweep run_id=%d combo %s: saved %d lots " + "(ins=%d upd=%d total_fetched=%d)", + run_id, + combo_label, + len(new_lots), + counters.lots_inserted, + counters.lots_updated, + counters.lots_fetched, + ) + + async with YandexRealtyScraper(config) as scraper: + await scraper.fetch_around_multi_room( + _a_lat, + _a_lon, + radius_m, + max_pages=pages_per_anchor, + rooms_list=_rooms_list, + price_ranges=_price_ranges, + segments=_segments, + on_combo=_on_combo, + ) + # _al накоплен on_combo; save уже вызван per-combo. + + # Price-history из gate price.previous/trend (graceful — провал + # истории НЕ должен ронять сейв листингов). + if _al: + try: + counters.price_history_rows += enrichment.record_yandex_price_history( + db, _al + ) + except Exception as ph_exc: + logger.warning( + "yandex-sweep run_id=%d: price-history failed: %s", + run_id, + ph_exc, + ) + try: + db.rollback() + except Exception: + pass + + consecutive_failures = 0 + logger.info( + "yandex-sweep run_id=%d center-combos %s: fetched=%d ins=%d upd=%d", + run_id, + _a_name, + counters.lots_fetched, + counters.lots_inserted, + counters.lots_updated, + ) + + # ── Phase 3: address-enrich ──────────────────────────────── + if not (enrich_address and _al): + return + source_ids = [lot.source_id for lot in _al if lot.source_id] + if not source_ids: + return + id_rows = ( + db.execute( + text(""" + SELECT id, source_id, address, source_url + FROM listings + WHERE source = 'yandex' + AND source_id = ANY(CAST(:ids AS text[])) + AND address IS NOT NULL + AND NOT (address ~ ',\\s*\\d+') + AND source_url IS NOT NULL + """), + {"ids": source_ids}, + ) + .mappings() + .all() + ) + items = [dict(r) for r in id_rows] + counters.address_attempted += len(items) + + if items: + # #1848: rotation tracking для Yandex address-enrich (stuck-proxy) + _yandex_enrich_consec_fail = 0 + _yandex_enrich_abort = 3 # abort address-enrich после N подряд + + async with AsyncSession( + impersonate="chrome120", + timeout=30.0, + proxies=_proxies, + headers={"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8"}, + ) as enrich_session: + eidx = 0 + while eidx < len(items): + item = items[eidx] + lid: int = item["id"] + old_addr: str = item["address"] + url: str = item["source_url"] + try: + resp = await enrich_session.get(url, allow_redirects=True) + if resp.status_code not in {200}: + logger.warning( + "yandex-sweep run_id=%d address-enrich:" + " HTTP %d listing_id=%d", + run_id, + resp.status_code, + lid, + ) + counters.address_failed += 1 + # 503/429 = прокси заблокирован → считаем как failure + if resp.status_code in {429, 503}: + _yandex_enrich_consec_fail += 1 + else: + _yandex_enrich_consec_fail = 0 + else: + new_addr = enrichment.extract_address_from_title(resp.text) + if ( + new_addr is None + or new_addr == old_addr + or not enrichment.address_has_house_number(new_addr) + ): + pass + else: + try: + db.execute( + text(""" + UPDATE listings + SET address = :addr, + geocode_tried_at = NULL + WHERE id = CAST(:id AS bigint) + """), + {"addr": new_addr, "id": lid}, + ) + db.commit() + counters.address_enriched += 1 + logger.info( + "yandex-sweep run_id=%d: " + "address updated listing_id=%d " + "%r -> %r", + run_id, + lid, + old_addr, + new_addr, + ) + except Exception as upd_exc: + counters.address_failed += 1 + counters.errors_count += 1 + logger.warning( + "yandex-sweep run_id=%d: " + "address DB update failed " + "listing_id=%d: %s", + run_id, + lid, + upd_exc, + ) + try: + db.rollback() + except Exception: + pass + _yandex_enrich_consec_fail = 0 + except Exception as fetch_exc: + counters.address_failed += 1 + _yandex_enrich_consec_fail += 1 + logger.warning( + "yandex-sweep run_id=%d: address-enrich " + "fetch failed listing_id=%d " + "(consecutive=%d): %s", + run_id, + lid, + _yandex_enrich_consec_fail, + fetch_exc, + ) + # Ротация Yandex-прокси при stuck/ban (#1848) + if _yandex_enrich_consec_fail >= _yandex_enrich_abort: + rotated, yandex_rotations_done = await _try_rotate_within_budget( + config, + reason=( + f"yandex enrich consecutive={_yandex_enrich_consec_fail}" + ), + rotations_done=yandex_rotations_done, + source="yandex", + ) + if rotated: + _yandex_enrich_consec_fail = 0 + logger.info( + "yandex-sweep run_id=%d: enrich ROTATE — " + "IP changed, reset consecutive " + "(rotation #%d/%d)", + run_id, + yandex_rotations_done, + config.yandex_proxy_max_rotations, + ) + # retry текущего item после ротации + continue + logger.error( + "yandex-sweep run_id=%d: address-enrich ABORT — " + "%d consecutive failures (proxy likely stuck/banned)", + run_id, + _yandex_enrich_consec_fail, + ) + break + if eidx < len(items) - 1: + await asyncio.sleep(enrich_delay) + eidx += 1 + + logger.info( + "yandex-sweep run_id=%d anchor %s: address enrich=%d/%d failed=%d", + run_id, + _a_name, + counters.address_enriched, + counters.address_attempted, + counters.address_failed, + ) + + try: + await asyncio.wait_for(_yandex_anchor_phases(), timeout=_sweep_timeout) + except TimeoutError: + logger.warning( + "yandex-sweep run_id=%d: anchor #%d/%d (%s, %.4f, %.4f) " + "timed out after %.0fs — skipping", + run_id, + idx, + len(_anchors), + name, + lat, + lon, + _sweep_timeout, + ) + counters.errors_count += 1 + consecutive_failures += 1 + _anchor_timed_out = True + if consecutive_failures >= YANDEX_SWEEP_MAX_CONSECUTIVE_FAILURES: + logger.error( + "yandex-sweep run_id=%d ABORT at anchor #%d/%d (%s) — " + "%d consecutive failures (last: timeout): " + "IP likely blocked or proxy hung", + run_id, + idx, + len(_anchors), + name, + consecutive_failures, + ) + counters.anchors_done = idx + runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.mark_banned( + db, + run_id, + f"yandex sweep aborted: {consecutive_failures} consecutive " + f"anchor failures (last: anchor timeout {_sweep_timeout:.0f}s)", + counters.to_dict(), + ) + return counters + counters.anchors_done = idx + runs.update_heartbeat(db, run_id, counters.to_dict()) + if idx < len(_anchors): + await asyncio.sleep(inter_anchor_delay) + continue + except Exception as e: + # YandexRealtyScraper не propagate'ит block/rate exceptions — ловим broad. + logger.exception("yandex-sweep run_id=%d: anchor %s SERP failed", run_id, name) + counters.errors_count += 1 + consecutive_failures += 1 + if consecutive_failures >= YANDEX_SWEEP_MAX_CONSECUTIVE_FAILURES: + logger.error( + "yandex-sweep run_id=%d ABORT at anchor #%d/%d (%s) — " + "%d consecutive failures (IP likely blocked): %s", + run_id, + idx, + len(_anchors), + name, + consecutive_failures, + e, + ) + counters.anchors_done = idx + runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.mark_banned( + db, + run_id, + f"yandex sweep aborted: {consecutive_failures} consecutive " + f"anchor failures (last: {e})", + counters.to_dict(), + ) + return counters + counters.anchors_done = idx + runs.update_heartbeat(db, run_id, counters.to_dict()) + # Пауза перед следующим anchor'ом даже при ошибке. + if idx < len(_anchors): + await asyncio.sleep(inter_anchor_delay) + continue + + counters.anchors_done = idx + runs.update_heartbeat(db, run_id, counters.to_dict()) + + # Доп. пауза между anchor'ами (поверх per-page sleep внутри scraper'а). + if idx < len(_anchors): + await asyncio.sleep(inter_anchor_delay) + + runs.mark_done(db, run_id, counters.to_dict()) + logger.info( + "yandex-sweep run_id=%d done: anchors=%d/%d lots=%d (ins=%d/upd=%d) " + "address=%d/%d failed=%d errors=%d", + run_id, + counters.anchors_done, + counters.anchors_total, + counters.lots_fetched, + counters.lots_inserted, + counters.lots_updated, + counters.address_enriched, + counters.address_attempted, + counters.address_failed, + counters.errors_count, + ) + return counters + + except Exception as exc: + logger.exception("yandex-sweep run_id=%d: fatal error", run_id) + runs.mark_failed(db, run_id, str(exc), counters.to_dict()) + raise + + +# ── Cian city sweep (#860) ────────────────────────────────────────────────── + + +@dataclass +class CianCitySweepCounters: + """Aggregate counters для Cian city sweep run. + + Поля симметричны CitySweepCounters, но без imv_* (у Cian нет IMV-фазы). + detail_* — обогащение через cian_detail (вкл. price-history). + houses_* — обогащение newbuilding/ЖК через cian_newbuilding. + """ + + anchors_total: int = 0 + anchors_done: int = 0 + lots_fetched: int = 0 + lots_dropped_secondary: int = 0 # вторичка, отброшенная при newbuilding_only=True + lots_inserted: int = 0 + lots_updated: int = 0 + detail_attempted: int = 0 + detail_enriched: int = 0 + detail_failed: int = 0 + houses_attempted: int = 0 + houses_enriched: int = 0 + houses_failed: int = 0 + errors_count: int = 0 + + def to_dict(self) -> dict[str, int]: + return {f.name: getattr(self, f.name) for f in fields(self)} + + +# Defensive abort threshold: если N подряд anchor'ов упали с исключением +# (не просто вернули пустой результат) — прерываем sweep. +CIAN_SWEEP_MAX_CONSECUTIVE_FAILURES = 3 + + +async def run_cian_city_sweep( + db: Session, + *, + run_id: int, + config: ScraperConfig, + matcher: HouseMatcher, + shutdown_requested: Callable[[], bool] = lambda: False, + anchors: list[tuple[float, float, str]] | None = None, + radius_m: int = 1500, + pages_per_anchor: int = 3, + request_delay_sec: float = 5.0, + detail_top_n: int = 10, + enrich_houses: bool = True, + newbuilding_only: bool = True, + region_code: int = DEFAULT_REGION_CODE, +) -> CianCitySweepCounters: + """Cian newbuilding city sweep: SERP → detail(+price-history) → newbuilding/houses. + + Симметрично run_avito_city_sweep. Использует EKB_ANCHORS (общий список). + + newbuilding_only (default True): в SERP-фазе сохраняются только лоты с + listing_segment == "novostroyki" — вторичку отбрасываем, ею авторитетно владеет + run_cian_full_load (exhaustive региональный сбор). + + Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо + прямых импортов app.* (см. scraper_kit.contracts). + + Cooperative cancel: runs.is_cancelled(db, run_id) перед каждым anchor. + mark_done вызывается ВСЕГДА (finally outer). + """ + _anchors = anchors if anchors is not None else EKB_ANCHORS + counters = CianCitySweepCounters(anchors_total=len(_anchors)) + consecutive_failures = 0 + cian_rotations_done = 0 # #1848: бюджет IP-ротаций на весь sweep + + try: + for idx, (lat, lon, name) in enumerate(_anchors, start=1): + # Cooperative cancel перед каждым anchor + if runs.is_cancelled(db, run_id): + logger.info( + "cian-sweep run_id=%d: cancelled at anchor #%d/%d (%s)", + run_id, + idx, + len(_anchors), + name, + ) + runs.update_heartbeat(db, run_id, counters.to_dict()) + return counters + elif shutdown_requested(): + # SIGTERM-drain: чистый стоп на границе anchor'а → mark_done(partial). + logger.info( + "cian-sweep run_id=%d: SIGTERM-drain — stopping at anchor #%d/%d (%s)", + run_id, + idx, + len(_anchors), + name, + ) + runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.mark_done(db, run_id, counters.to_dict()) + return counters + + logger.info( + "cian-sweep run_id=%d anchor #%d/%d (%s, %.4f, %.4f)", + run_id, + idx, + len(_anchors), + name, + lat, + lon, + ) + + # ── Per-anchor phases под watchdog-таймаутом ──────────────────── + anchor_lots: list[ScrapedLot] = [] + _c_lat, _c_lon, _c_name = lat, lon, name + + async def _cian_anchor_phases( + _a_lat: float = _c_lat, + _a_lon: float = _c_lon, + _a_name: str = _c_name, + ) -> None: + """Все фазы одного cian anchor'а (SERP + detail + houses).""" + nonlocal anchor_lots, consecutive_failures, cian_rotations_done + + # ── Phase 1+2: SERP + save ───────────────────────────────── + async with CianScraper(config) as scraper: + anchor_lots = await scraper.fetch_around_multi_room( + _a_lat, + _a_lon, + radius_m, + pages=pages_per_anchor, + ) + counters.lots_fetched += len(anchor_lots) + # ── Фильтр вторички (newbuilding_only) ───────────────────── + if newbuilding_only: + _before = len(anchor_lots) + anchor_lots = [ + lot for lot in anchor_lots if lot.listing_segment == "novostroyki" + ] + counters.lots_dropped_secondary += _before - len(anchor_lots) + if anchor_lots: + inserted, updated = save_listings( + db, anchor_lots, matcher=matcher, region_code=region_code, run_id=run_id + ) + counters.lots_inserted += inserted + counters.lots_updated += updated + consecutive_failures = 0 + logger.info( + "cian-sweep run_id=%d anchor %s: SERP fetched=%d nb_kept=%d " + "dropped_secondary=%d ins=%d upd=%d", + run_id, + _a_name, + counters.lots_fetched, + len(anchor_lots), + counters.lots_dropped_secondary, + counters.lots_inserted, + counters.lots_updated, + ) + + # ── Phase 3: detail enrichment (top-N) ──────────────────── + if detail_top_n > 0: + priority_rows = ( + db.execute( + text(""" + SELECT id, source_url + FROM listings + WHERE source = 'cian' + AND source_url IS NOT NULL + AND detail_enriched_at IS NULL + AND scraped_at > NOW() - INTERVAL '2 hours' + ORDER BY scraped_at DESC NULLS LAST + LIMIT :lim + """), + {"lim": detail_top_n}, + ) + .mappings() + .all() + ) + # #1848: Cian bans/stuck-proxy tracking + rotation (зеркало avito) + _cian_detail_consec_failures = 0 + _cian_detail_abort = 3 # abort detail-фазы после N подряд + didx = 0 + while didx < len(priority_rows): + row = priority_rows[didx] + listing_id: int = row["id"] + source_url: str = row["source_url"] + counters.detail_attempted += 1 + try: + cian_enrichment = await cian_fetch_detail(source_url) + if cian_enrichment is not None: + cian_save_detail_enrichment(db, listing_id, cian_enrichment) + counters.detail_enriched += 1 + else: + counters.detail_failed += 1 + logger.warning( + "cian-sweep run_id=%d: detail returned None for " + "listing_id=%d url=%s", + run_id, + listing_id, + source_url, + ) + _cian_detail_consec_failures = 0 + except Exception as exc: + counters.detail_failed += 1 + counters.errors_count += 1 + _cian_detail_consec_failures += 1 + logger.warning( + "cian-sweep run_id=%d: detail failed listing_id=%d " + "(consecutive=%d): %s", + run_id, + listing_id, + _cian_detail_consec_failures, + exc, + ) + if _cian_detail_consec_failures >= _cian_detail_abort: + # Попытка ротации Cian-прокси перед abort'ом (#1848) + rotated, cian_rotations_done = await _try_rotate_within_budget( + config, + reason=( + f"cian detail consecutive={_cian_detail_consec_failures}" + ), + rotations_done=cian_rotations_done, + source="cian", + ) + if rotated: + _cian_detail_consec_failures = 0 + logger.info( + "cian-sweep run_id=%d: detail ROTATE — " + "IP changed, reset consecutive " + "(rotation #%d/%d)", + run_id, + cian_rotations_done, + config.cian_proxy_max_rotations, + ) + # retry текущего detail (не инкрементируем didx) + continue + logger.error( + "cian-sweep run_id=%d: detail ABORT — " + "%d consecutive failures (proxy likely banned/stuck)", + run_id, + _cian_detail_consec_failures, + ) + break + if didx < len(priority_rows) - 1: + jitter = random.uniform(0.8, 1.2) + await asyncio.sleep(request_delay_sec * jitter) + didx += 1 + logger.info( + "cian-sweep run_id=%d anchor %s: detail=%d/%d failed=%d", + run_id, + _a_name, + counters.detail_enriched, + counters.detail_attempted, + counters.detail_failed, + ) + + # ── Phase 4: newbuilding/houses enrichment ───────────────── + if not (enrich_houses and anchor_lots): + return + cian_nb_ext_ids = { + lot.house_ext_id + for lot in anchor_lots + if lot.house_source == "cian_newbuilding" and lot.house_ext_id + } + if not cian_nb_ext_ids: + return + nb_id_list = list(cian_nb_ext_ids) + try: + house_rows = ( + db.execute( + text(""" + SELECT h.id, h.cian_zhk_url + FROM houses h + JOIN house_sources hs ON hs.house_id = h.id + WHERE hs.ext_source = 'cian_newbuilding' + AND hs.ext_id = ANY(CAST(:ids AS text[])) + AND h.cian_zhk_url IS NOT NULL + """), + {"ids": nb_id_list}, + ) + .mappings() + .all() + ) + except Exception as exc: + logger.exception( + "cian-sweep run_id=%d anchor %s: houses DB query failed — " + "skipping houses phase (%d ids): %s", + run_id, + _a_name, + len(nb_id_list), + exc, + ) + counters.houses_failed += len(nb_id_list) + try: + db.rollback() + except Exception: + pass + return + for hidx, hrow in enumerate(house_rows): + house_id_val: int = hrow["id"] + zhk_url: str = hrow["cian_zhk_url"] + counters.houses_attempted += 1 + try: + nb_enrichment = await fetch_newbuilding(zhk_url) + if nb_enrichment is not None: + save_newbuilding_enrichment(db, house_id_val, nb_enrichment) + counters.houses_enriched += 1 + else: + counters.houses_failed += 1 + logger.warning( + "cian-sweep run_id=%d: newbuilding returned None " + "house_id=%d url=%s", + run_id, + house_id_val, + zhk_url, + ) + except Exception as exc: + counters.houses_failed += 1 + counters.errors_count += 1 + logger.warning( + "cian-sweep run_id=%d: houses failed house_id=%d: %s", + run_id, + house_id_val, + exc, + ) + try: + db.rollback() + except Exception: + pass + if hidx < len(house_rows) - 1: + await asyncio.sleep(request_delay_sec) + logger.info( + "cian-sweep run_id=%d anchor %s: houses=%d/%d failed=%d", + run_id, + _a_name, + counters.houses_enriched, + counters.houses_attempted, + counters.houses_failed, + ) + + try: + await asyncio.wait_for(_cian_anchor_phases(), timeout=ANCHOR_TIMEOUT_SEC) + except TimeoutError: + logger.warning( + "cian-sweep run_id=%d: anchor #%d/%d (%s, %.4f, %.4f) " + "timed out after %ds — skipping", + run_id, + idx, + len(_anchors), + name, + lat, + lon, + ANCHOR_TIMEOUT_SEC, + ) + counters.errors_count += 1 + consecutive_failures += 1 + if consecutive_failures >= CIAN_SWEEP_MAX_CONSECUTIVE_FAILURES: + logger.error( + "cian-sweep run_id=%d ABORT at anchor #%d/%d (%s) — " + "%d consecutive failures (last: timeout): " + "IP likely blocked or proxy hung", + run_id, + idx, + len(_anchors), + name, + consecutive_failures, + ) + counters.anchors_done = idx + runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.mark_banned( + db, + run_id, + f"cian sweep aborted: {consecutive_failures} consecutive " + f"anchor failures (last: anchor timeout {ANCHOR_TIMEOUT_SEC}s)", + counters.to_dict(), + ) + return counters + counters.anchors_done = idx + runs.update_heartbeat(db, run_id, counters.to_dict()) + continue + except Exception as e: + logger.exception("cian-sweep run_id=%d: anchor %s SERP failed", run_id, name) + counters.errors_count += 1 + consecutive_failures += 1 + if consecutive_failures >= CIAN_SWEEP_MAX_CONSECUTIVE_FAILURES: + logger.error( + "cian-sweep run_id=%d ABORT at anchor #%d/%d (%s) — " + "%d consecutive SERP failures (IP likely blocked): %s", + run_id, + idx, + len(_anchors), + name, + consecutive_failures, + e, + ) + counters.anchors_done = idx + runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.mark_banned( + db, + run_id, + f"cian sweep aborted: {consecutive_failures} consecutive " + f"anchor SERP failures (last: {e})", + counters.to_dict(), + ) + return counters + counters.anchors_done = idx + runs.update_heartbeat(db, run_id, counters.to_dict()) + continue + + counters.anchors_done = idx + runs.update_heartbeat(db, run_id, counters.to_dict()) + + # Пауза между anchor'ами (поверх per-request sleep внутри scraper'а) + if idx < len(_anchors): + await asyncio.sleep(request_delay_sec) + + runs.mark_done(db, run_id, counters.to_dict()) + logger.info( + "cian-sweep run_id=%d done: anchors=%d/%d lots=%d (ins=%d/upd=%d) " + "detail=%d/%d houses=%d/%d errors=%d", + run_id, + counters.anchors_done, + counters.anchors_total, + counters.lots_fetched, + counters.lots_inserted, + counters.lots_updated, + counters.detail_enriched, + counters.detail_attempted, + counters.houses_enriched, + counters.houses_attempted, + counters.errors_count, + ) + return counters + + except Exception as exc: + logger.exception("cian-sweep run_id=%d: fatal error", run_id) + runs.mark_failed(db, run_id, str(exc), counters.to_dict()) + raise + + +# ── Cian exhaustive full load (region-wide, без anchor'ов) ───────────────── + + +@dataclass +class CianFullLoadCounters: + """Aggregate counters для run_cian_full_load. + + Без anchor-полей — единственный региональный проход. + detail_* — опциональное обогащение detail-страниц (если enrich_detail=True). + """ + + unique_fetched: int = 0 + saved_inserted: int = 0 + saved_updated: int = 0 + detail_attempted: int = 0 + detail_enriched: int = 0 + detail_failed: int = 0 + errors_count: int = 0 + + def to_dict(self) -> dict[str, int]: + return {f.name: getattr(self, f.name) for f in fields(self)} + + +async def run_cian_full_load( + db: Session, + *, + run_id: int, + config: ScraperConfig, + matcher: HouseMatcher, + shutdown_requested: Callable[[], bool] = lambda: False, + price_cap_per_bucket: int = 1400, + detail_top_n: int = 0, + request_delay_sec: float = 4.0, + enrich_detail: bool = False, + concurrency: int = 5, + resume_run_id: int | None = None, + region_code: int = DEFAULT_REGION_CODE, +) -> CianFullLoadCounters: + """Exhaustive региональный сбор Cian ЕКБ вторички (БЕЗ anchor'ов). + + Один проход с партиционированием по КОМНАТНОСТИ × ЦЕНЕ (адаптивное бинарное + деление) охватывает весь ЕКБ. Инкрементальный save: on_bucket коммитит каждый + leaf-бакет в БД сразу после сбора. + + Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо + прямых импортов app.* (см. scraper_kit.contracts). + Cooperative cancel: runs.is_cancelled проверяется per-bucket. + """ + counters = CianFullLoadCounters() + + # ── Checkpoint/resume: читаем done_buckets из прошлого run ─────────────── + skip_set: set[str] = set() + if resume_run_id is not None: + _prev_row = db.execute( + text("SELECT counters FROM scrape_runs WHERE id = CAST(:rid AS bigint)"), + {"rid": resume_run_id}, + ).fetchone() + if _prev_row is not None and _prev_row.counters: + _prev_counters: dict = ( + _prev_row.counters if isinstance(_prev_row.counters, dict) else {} + ) + skip_set = set(_prev_counters.get("done_buckets", [])) + logger.info( + "cian-full-load run_id=%d: resuming from run %d — %d buckets already done", + run_id, + resume_run_id, + len(skip_set), + ) + + done: set[str] = set(skip_set) # накапливаем завершённые бакеты этого прогона + + def _on_bucket(bucket_key: str, lots: list) -> None: # type: ignore[type-arg] + """Инкрементальный save после каждого leaf-бакета. Дописывает bucket_key в done.""" + nonlocal done + if runs.is_cancelled(db, run_id): + logger.info("cian-full-load run_id=%d: cancel detected in on_bucket", run_id) + raise RuntimeError("cancelled") + elif shutdown_requested(): + # SIGTERM-drain: тот же sentinel-механизм, что и user-cancel, но отдельный + # маркер "shutdown" → handler финализирует mark_done(partial), не mark_failed. + logger.info( + "cian-full-load run_id=%d: SIGTERM-drain — stopping at bucket %s", + run_id, + bucket_key, + ) + raise RuntimeError("shutdown") + if not lots: + done.add(bucket_key) + runs.update_heartbeat( + db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)} + ) + return + inserted, updated = save_listings( + db, + lots, + matcher=matcher, + region_code=region_code, + run_id=run_id, + skip_seen_today=config.scraper_skip_seen_today, + ) + # save_listings вызывает db.commit() внутри — данные в БД сразу + counters.saved_inserted += inserted + counters.saved_updated += updated + counters.unique_fetched += len(lots) + done.add(bucket_key) + runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) + logger.info( + "cian-full-load run_id=%d: bucket %s saved ins=%d upd=%d total_unique=%d", + run_id, + bucket_key, + inserted, + updated, + counters.unique_fetched, + ) + + def _on_progress(unique_count: int) -> None: + """Heartbeat per room-bucket (на случай пустых бакетов без on_bucket вызовов).""" + runs.update_heartbeat(db, run_id, counters.to_dict()) + + # #1949: фоновый heartbeat — обновляет heartbeat_at каждые 60s независимо от on_bucket. + async def _background_heartbeat() -> None: + """Периодический heartbeat каждые 60s, не завязанный на прогресс бакетов.""" + while True: + await asyncio.sleep(60.0) + try: + runs.update_heartbeat(db, run_id, counters.to_dict()) + except Exception: + pass + + _hb_task: asyncio.Task[None] | None = None + try: + _hb_task = asyncio.create_task(_background_heartbeat()) + + async with CianScraper(config) as scraper: + scraper.request_delay_sec = request_delay_sec + + # #1949: per-fetch hard-cancel для browser-fetch'ей внутри bucket-gather. + _fetch_timeout = config.cian_full_load_per_fetch_timeout_s + if _fetch_timeout > 0 and scraper._browser is not None: + _orig_fetch = scraper._browser.fetch + + async def _timed_fetch( + url: str, + _f: Any = _orig_fetch, + _t: float = _fetch_timeout, + _rid: int = run_id, + ) -> str: + try: + return await asyncio.wait_for(_f(url), timeout=_t) + except TimeoutError: + logger.warning( + "cian-full-load run_id=%d: browser-fetch timed out " + "after %.0fs — url=%s", + _rid, + _t, + url, + ) + raise + + scraper._browser.fetch = _timed_fetch # type: ignore[method-assign] + logger.info( + "cian-full-load run_id=%d: per-fetch timeout %.0fs enabled", + run_id, + _fetch_timeout, + ) + + await scraper.fetch_all_secondary( + price_cap_per_bucket=price_cap_per_bucket, + concurrency=concurrency, + secondary_only=True, + on_bucket=_on_bucket, + on_progress=_on_progress, + skip_buckets=skip_set if skip_set else None, + ) + + logger.info( + "cian-full-load run_id=%d: fetch done — unique=%d ins=%d upd=%d", + run_id, + counters.unique_fetched, + counters.saved_inserted, + counters.saved_updated, + ) + runs.update_heartbeat(db, run_id, counters.to_dict()) + + # ── Опциональный detail-энричмент ───────────────────────────────────── + if enrich_detail and detail_top_n > 0: + priority_rows = ( + db.execute( + text(""" + SELECT id, source_url + FROM listings + WHERE source = 'cian' + AND source_url IS NOT NULL + AND detail_enriched_at IS NULL + AND scraped_at > NOW() - INTERVAL '4 hours' + ORDER BY scraped_at DESC NULLS LAST + LIMIT :lim + """), + {"lim": detail_top_n}, + ) + .mappings() + .all() + ) + for didx, row in enumerate(priority_rows): + if runs.is_cancelled(db, run_id): + logger.info("cian-full-load run_id=%d: cancelled during detail enrich", run_id) + break + elif shutdown_requested(): + logger.info( + "cian-full-load run_id=%d: SIGTERM-drain — stopping detail at #%d/%d", + run_id, + didx + 1, + len(priority_rows), + ) + break + listing_id: int = row["id"] + source_url: str = row["source_url"] + counters.detail_attempted += 1 + try: + cian_enrichment = await cian_fetch_detail(source_url) + if cian_enrichment is not None: + cian_save_detail_enrichment(db, listing_id, cian_enrichment) + counters.detail_enriched += 1 + else: + counters.detail_failed += 1 + except Exception as exc: + counters.detail_failed += 1 + counters.errors_count += 1 + logger.warning( + "cian-full-load run_id=%d: detail failed listing_id=%d: %s", + run_id, + listing_id, + exc, + ) + if didx < len(priority_rows) - 1: + await asyncio.sleep(request_delay_sec * random.uniform(0.8, 1.2)) + + logger.info( + "cian-full-load run_id=%d: detail done — enriched=%d/%d failed=%d", + run_id, + counters.detail_enriched, + counters.detail_attempted, + counters.detail_failed, + ) + runs.update_heartbeat(db, run_id, counters.to_dict()) + + runs.mark_done(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) + logger.info( + "cian-full-load run_id=%d done: unique=%d ins=%d upd=%d detail=%d/%d errors=%d", + run_id, + counters.unique_fetched, + counters.saved_inserted, + counters.saved_updated, + counters.detail_enriched, + counters.detail_attempted, + counters.errors_count, + ) + return counters + + except RuntimeError as exc: + # on_bucket кидает RuntimeError("cancelled") при cooperative cancel, + # RuntimeError("shutdown") при кооперативном SIGTERM-drain (#1182 Phase 3a). + if str(exc) == "cancelled": + logger.info( + "cian-full-load run_id=%d: cancelled — partial results unique=%d ins=%d upd=%d", + run_id, + counters.unique_fetched, + counters.saved_inserted, + counters.saved_updated, + ) + runs.mark_done(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) + return counters + if str(exc) == "shutdown": + logger.info( + "cian-full-load run_id=%d: SIGTERM-drain — partial results unique=%d ins=%d upd=%d", + run_id, + counters.unique_fetched, + counters.saved_inserted, + counters.saved_updated, + ) + runs.mark_done(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) + return counters + logger.exception("cian-full-load run_id=%d: fatal error", run_id) + runs.mark_failed(db, run_id, str(exc), counters.to_dict()) + raise + + except Exception as exc: + logger.exception("cian-full-load run_id=%d: fatal error", run_id) + runs.mark_failed(db, run_id, str(exc), counters.to_dict()) + raise + + finally: + # #1949: отменяем фоновый heartbeat при любом завершении (return / raise / cancel). + if _hb_task is not None: + _hb_task.cancel() + try: + await _hb_task + except asyncio.CancelledError: + pass + + +# ── Yandex exhaustive full load (region-wide, без anchor'ов) ────────────────── + + +@dataclass +class YandexFullLoadCounters: + """Aggregate counters для run_yandex_full_load. + + Без detail_* полей — Yandex full load не делает detail-обогащение. + """ + + unique_fetched: int = 0 + saved_inserted: int = 0 + saved_updated: int = 0 + price_history_rows: int = 0 + errors_count: int = 0 + + def to_dict(self) -> dict[str, int]: + return {f.name: getattr(self, f.name) for f in fields(self)} + + +async def run_yandex_full_load( + db: Session, + *, + run_id: int, + config: ScraperConfig, + matcher: HouseMatcher, + enrichment: EnrichmentJobs, + shutdown_requested: Callable[[], bool] = lambda: False, + price_cap_per_bucket: int = 500, + request_delay_sec: float = 2.0, + concurrency: int = 4, + resume_run_id: int | None = None, + region_code: int = DEFAULT_REGION_CODE, +) -> YandexFullLoadCounters: + """Exhaustive региональный сбор Yandex ЕКБ вторички (БЕЗ anchor'ов). + + Один проход с партиционированием по КОМНАТНОСТИ × ЦЕНЕ (адаптивное бинарное + деление) охватывает весь ЕКБ. Инкрементальный save: on_bucket коммитит каждый + leaf-бакет в БД сразу после сбора. + + Инжекция (#2135 F2): config/matcher/enrichment/shutdown_requested приходят снаружи + вместо прямых импортов app.* (см. scraper_kit.contracts). record_yandex_price_history + зовётся через enrichment (EnrichmentJobs Protocol). + Cooperative cancel: runs.is_cancelled проверяется per-bucket. + """ + counters = YandexFullLoadCounters() + + skip_set: set[str] = set() + if resume_run_id is not None: + _prev_row = db.execute( + text("SELECT counters FROM scrape_runs WHERE id = CAST(:rid AS bigint)"), + {"rid": resume_run_id}, + ).fetchone() + if _prev_row is not None and _prev_row.counters: + _prev_counters: dict = ( + _prev_row.counters if isinstance(_prev_row.counters, dict) else {} + ) + skip_set = set(_prev_counters.get("done_buckets", [])) + logger.info( + "yandex-full-load run_id=%d: resuming from run %d — %d buckets already done", + run_id, + resume_run_id, + len(skip_set), + ) + done: set[str] = set(skip_set) + + def _on_bucket(bucket_key: str, lots: list) -> None: # type: ignore[type-arg] + """Инкрементальный save после каждого leaf-бакета.""" + nonlocal done + if runs.is_cancelled(db, run_id): + logger.info("yandex-full-load run_id=%d: cancel detected in on_bucket", run_id) + raise RuntimeError("cancelled") + elif shutdown_requested(): + logger.info( + "yandex-full-load run_id=%d: SIGTERM-drain — stopping at bucket %s", + run_id, + bucket_key, + ) + raise RuntimeError("shutdown") + if not lots: + done.add(bucket_key) + runs.update_heartbeat( + db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)} + ) + return + inserted, updated = save_listings( + db, + lots, + matcher=matcher, + region_code=region_code, + run_id=run_id, + skip_seen_today=config.scraper_skip_seen_today, + ) + # save_listings вызывает db.commit() внутри — данные в БД сразу + counters.saved_inserted += inserted + counters.saved_updated += updated + counters.unique_fetched += len(lots) + # Price-history из gate price.previous/trend (graceful — провал истории + # НЕ должен ронять сейв листингов/бакета). + try: + counters.price_history_rows += enrichment.record_yandex_price_history(db, lots) + except Exception as ph_exc: + logger.warning( + "yandex-full-load run_id=%d: price-history failed bucket=%s: %s", + run_id, + bucket_key, + ph_exc, + ) + try: + db.rollback() + except Exception: + pass + done.add(bucket_key) + runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) + logger.info( + "yandex-full-load run_id=%d: bucket=%s saved ins=%d upd=%d total_unique=%d", + run_id, + bucket_key, + inserted, + updated, + counters.unique_fetched, + ) + + def _on_progress(unique_count: int) -> None: + """Heartbeat per room-bucket (на случай пустых бакетов без on_bucket вызовов).""" + runs.update_heartbeat(db, run_id, counters.to_dict()) + + try: + async with YandexRealtyScraper(config) as scraper: + scraper.request_delay_sec = request_delay_sec + + await scraper.fetch_all_secondary( + price_cap_per_bucket=price_cap_per_bucket, + concurrency=concurrency, + on_bucket=_on_bucket, + on_progress=_on_progress, + skip_buckets=skip_set if skip_set else None, + ) + + logger.info( + "yandex-full-load run_id=%d: fetch done — unique=%d ins=%d upd=%d", + run_id, + counters.unique_fetched, + counters.saved_inserted, + counters.saved_updated, + ) + runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.mark_done(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) + logger.info( + "yandex-full-load run_id=%d done: unique=%d ins=%d upd=%d errors=%d", + run_id, + counters.unique_fetched, + counters.saved_inserted, + counters.saved_updated, + counters.errors_count, + ) + return counters + + except RuntimeError as exc: + # on_bucket кидает RuntimeError("cancelled") при cooperative cancel, + # RuntimeError("shutdown") при кооперативном SIGTERM-drain (#1182 Phase 3a). + if str(exc) == "cancelled": + logger.info( + "yandex-full-load run_id=%d: cancelled — partial results unique=%d ins=%d upd=%d", + run_id, + counters.unique_fetched, + counters.saved_inserted, + counters.saved_updated, + ) + runs.mark_done(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) + return counters + if str(exc) == "shutdown": + logger.info( + "yandex-full-load run_id=%d: SIGTERM-drain — partial results " + "unique=%d ins=%d upd=%d", + run_id, + counters.unique_fetched, + counters.saved_inserted, + counters.saved_updated, + ) + runs.mark_done(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) + return counters + logger.exception("yandex-full-load run_id=%d: fatal error", run_id) + runs.mark_failed(db, run_id, str(exc), counters.to_dict()) + raise + + except Exception as exc: + logger.exception("yandex-full-load run_id=%d: fatal error", run_id) + runs.mark_failed(db, run_id, str(exc), counters.to_dict()) + raise + + +# ── Avito exhaustive full load (citywide, без anchor'ов) ────────────────────── + + +@dataclass +class AvitoFullLoadCounters: + """Aggregate counters для run_avito_full_load. + + Зеркало YandexFullLoadCounters — citywide room×price bisection без anchor/detail. + """ + + unique_fetched: int = 0 + saved_inserted: int = 0 + saved_updated: int = 0 + errors_count: int = 0 + + def to_dict(self) -> dict[str, int]: + return {f.name: getattr(self, f.name) for f in fields(self)} + + +async def run_avito_full_load( + db: Session, + *, + run_id: int, + config: ScraperConfig, + matcher: HouseMatcher, + shutdown_requested: Callable[[], bool] = lambda: False, + price_cap_per_bucket: int = 1400, + request_delay_sec: float = 7.0, + concurrency: int = 5, + secondary_only: bool = True, + resume_run_id: int | None = None, + incremental_days: int | None = None, + region_code: int = DEFAULT_REGION_CODE, +) -> AvitoFullLoadCounters: + """Exhaustive ИЛИ инкрементальный региональный сбор Avito ЕКБ вторички (БЕЗ anchor'ов). + + Один проход с партиционированием по КОМНАТНОСТИ × ЦЕНЕ (адаптивное бинарное + деление ценового диапазона через `pmin`/`pmax`) охватывает весь ЕКБ. + + incremental_days: если задан — shallow инкрементальный обход (since-cutoff). + + Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо + прямых импортов app.* (см. scraper_kit.contracts). + Cooperative cancel: runs.is_cancelled проверяется per-bucket. + AvitoBlockedError/AvitoRateLimitedError → mark_banned (status='banned'). + """ + counters = AvitoFullLoadCounters() + + # ── Инкрементальный режим: since-cutoff для shallow обхода ──────────────── + since: date | None = None + if incremental_days is not None: + since = date.today() - timedelta(days=incremental_days) + logger.info( + "avito-full-load run_id=%d: INCREMENTAL mode — since=%s (last %d days)", + run_id, + since.isoformat(), + incremental_days, + ) + + # ── Checkpoint/resume: читаем done_buckets из прошлого run ─────────────── + skip_set: set[str] = set() + if resume_run_id is not None: + _prev_row = db.execute( + text("SELECT counters FROM scrape_runs WHERE id = CAST(:rid AS bigint)"), + {"rid": resume_run_id}, + ).fetchone() + if _prev_row is not None and _prev_row.counters: + _prev_counters: dict = ( + _prev_row.counters if isinstance(_prev_row.counters, dict) else {} + ) + skip_set = set(_prev_counters.get("done_buckets", [])) + logger.info( + "avito-full-load run_id=%d: resuming from run %d — %d buckets already done", + run_id, + resume_run_id, + len(skip_set), + ) + done: set[str] = set(skip_set) + + def _on_bucket(bucket_key: str, lots: list) -> None: # type: ignore[type-arg] + """Инкрементальный save после каждого leaf-бакета.""" + nonlocal done + if runs.is_cancelled(db, run_id): + logger.info("avito-full-load run_id=%d: cancel detected in on_bucket", run_id) + raise RuntimeError("cancelled") + elif shutdown_requested(): + logger.info( + "avito-full-load run_id=%d: SIGTERM-drain — stopping at bucket %s", + run_id, + bucket_key, + ) + raise RuntimeError("shutdown") + if not lots: + done.add(bucket_key) + runs.update_heartbeat( + db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)} + ) + return + inserted, updated = save_listings( + db, + lots, + matcher=matcher, + region_code=region_code, + run_id=run_id, + skip_seen_today=config.scraper_skip_seen_today, + ) + # save_listings вызывает db.commit() внутри — данные в БД сразу + counters.saved_inserted += inserted + counters.saved_updated += updated + counters.unique_fetched += len(lots) + done.add(bucket_key) + runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) + logger.info( + "avito-full-load run_id=%d: bucket=%s saved ins=%d upd=%d total_unique=%d", + run_id, + bucket_key, + inserted, + updated, + counters.unique_fetched, + ) + + def _on_progress(unique_count: int) -> None: + """Heartbeat per room-bucket (на случай пустых бакетов без on_bucket вызовов).""" + runs.update_heartbeat(db, run_id, counters.to_dict()) + + try: + async with AvitoScraper(config) as scraper: + scraper.request_delay_sec = request_delay_sec + + await scraper.fetch_all_secondary( + price_cap_per_bucket=price_cap_per_bucket, + concurrency=concurrency, + secondary_only=secondary_only, + on_bucket=_on_bucket, + on_progress=_on_progress, + skip_buckets=skip_set if skip_set else None, + since=since, + ) + + logger.info( + "avito-full-load run_id=%d: fetch done — unique=%d ins=%d upd=%d", + run_id, + counters.unique_fetched, + counters.saved_inserted, + counters.saved_updated, + ) + runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.mark_done(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) + logger.info( + "avito-full-load run_id=%d done: unique=%d ins=%d upd=%d errors=%d", + run_id, + counters.unique_fetched, + counters.saved_inserted, + counters.saved_updated, + counters.errors_count, + ) + return counters + + except (AvitoBlockedError, AvitoRateLimitedError) as exc: + # IP-бан/rate-limit: partial results уже сохранены через on_bucket. + logger.error("avito-full-load run_id=%d: blocked/rate-limited — %s", run_id, exc) + counters.errors_count += 1 + runs.mark_banned( + db, + run_id, + f"avito full load aborted: {exc}", + {**counters.to_dict(), "done_buckets": sorted(done)}, + ) + return counters + + except RuntimeError as exc: + # on_bucket кидает RuntimeError("cancelled") при cooperative cancel, + # RuntimeError("shutdown") при кооперативном SIGTERM-drain (#1182 Phase 3a). + if str(exc) == "cancelled": + logger.info( + "avito-full-load run_id=%d: cancelled — partial results unique=%d ins=%d upd=%d", + run_id, + counters.unique_fetched, + counters.saved_inserted, + counters.saved_updated, + ) + runs.mark_done(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) + return counters + if str(exc) == "shutdown": + logger.info( + "avito-full-load run_id=%d: SIGTERM-drain — partial results " + "unique=%d ins=%d upd=%d", + run_id, + counters.unique_fetched, + counters.saved_inserted, + counters.saved_updated, + ) + runs.mark_done(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) + return counters + logger.exception("avito-full-load run_id=%d: fatal error", run_id) + runs.mark_failed(db, run_id, str(exc), counters.to_dict()) + raise + + except Exception as exc: + logger.exception("avito-full-load run_id=%d: fatal error", run_id) + runs.mark_failed(db, run_id, str(exc), counters.to_dict()) + raise + + +# ── DomClick citywide sweep (без anchor'ов) ──────────────────────────────── + + +@dataclass +class DomClickCitySweepCounters: + """Aggregate counters для DomClick citywide sweep run. + + DomClick BFF — citywide (city_id), не geo-bbox: anchor-loop отсутствует. + Один проход fetch_city перебирает ROOM_BUCKETS × pages внутри scraper'а. + """ + + lots_fetched: int = 0 + lots_inserted: int = 0 + lots_updated: int = 0 + pages_fetched: int = 0 + errors_count: int = 0 + blocked: int = 0 # 1 если QRATOR-блок был во время sweep + geo_filtered: int = 0 # число офферов отфильтрованных geo-guard + + def to_dict(self) -> dict[str, int]: + return {f.name: getattr(self, f.name) for f in fields(self)} + + +# Дефолтные параметры sweep'а (EKB city_id=4). +DOMCLICK_DEFAULT_CITY_ID: int = 4 +DOMCLICK_DEFAULT_ROOMS: list[int] = [0, 1, 2, 3, 4] # vestigial; scraper sweeps all buckets +# Число BFF-бакетов (st/1/2/3/4/5+) — фиксировано; используется для watchdog. +_DOMCLICK_NUM_BUCKETS: int = 6 +# Оценка времени одного fetch'а (network + parse) для watchdog. +_DOMCLICK_PER_FETCH_S: float = 12.0 +# Буфер сверху расчётного бюджета (cold browser start, save-фаза, price-splits). +_DOMCLICK_SWEEP_BUDGET_S: float = 300.0 + + +async def run_domclick_city_sweep( + db: Session, + *, + run_id: int, + config: ScraperConfig, + matcher: HouseMatcher, + shutdown_requested: Callable[[], bool] = lambda: False, + city_id: int = DOMCLICK_DEFAULT_CITY_ID, + rooms: list[int] | None = None, + pages: int = 100, + request_delay_sec: float | None = None, + region_code: int = DEFAULT_REGION_CODE, +) -> DomClickCitySweepCounters: + """DomClick citywide sweep через BFF JSON API. + + Структурно зеркалит run_cian_city_sweep / run_yandex_city_sweep, но CITYWIDE: + DomClick не поддерживает geo-radius (fetch_around → NotImplementedError), + поэтому anchor-loop отсутствует. DomClickScraper.fetch_city перебирает все + ROOM_BUCKETS (st/1/2/3/4/5+) через BrowserFetcher → shared mobile proxy. + + Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо + прямых импортов app.* (см. scraper_kit.contracts). + + ЧЕСТНЫЙ СТАТУС (#1968): если scraper сообщил QRATOR-блок И lots == 0 → + mark_failed. Иначе mark_done. + + Возвращает DomClickCitySweepCounters. + """ + _resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0 + counters = DomClickCitySweepCounters() + + # Watchdog: 6 buckets × pages × per_fetch + budget. + _num_fetches = _DOMCLICK_NUM_BUCKETS * max(1, pages) + _sweep_timeout = max( + ANCHOR_TIMEOUT_SEC, + int(_num_fetches * (_resolved_delay + _DOMCLICK_PER_FETCH_S) + _DOMCLICK_SWEEP_BUDGET_S), + ) + + # Мутируемый контейнер для захвата scraper-ссылки из замыкания. + _scraper_ref: list[DomClickScraper] = [] + + try: + # ── Cooperative cancel перед SERP-фазой ────────────────────────────── + if runs.is_cancelled(db, run_id): + logger.info("domclick-sweep run_id=%d: cancelled before SERP", run_id) + runs.update_heartbeat(db, run_id, counters.to_dict()) + return counters + elif shutdown_requested(): + # SIGTERM-drain до SERP-фазы: финализируем run как mark_done(partial), + # минуя honest-status (это чистый drain, не QRATOR-блок). + logger.info("domclick-sweep run_id=%d: SIGTERM-drain — stopping before SERP", run_id) + runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.mark_done(db, run_id, counters.to_dict()) + return counters + + logger.info( + "domclick-sweep run_id=%d: BFF citywide sweep city_id=%d " + "buckets=%d pages_cap=%d (watchdog %ds)", + run_id, + city_id, + len(ROOM_BUCKETS), + pages, + _sweep_timeout, + ) + + lots: list[ScrapedLot] = [] + + async def _domclick_phase() -> None: + """Единственная citywide-фаза: fetch_city + save.""" + nonlocal lots + async with DomClickScraper(config) as _scraper: + _scraper_ref.append(_scraper) + if request_delay_sec is not None: + _scraper.request_delay_sec = _resolved_delay + lots = await _scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages) + counters.lots_fetched += len(lots) + if lots: + inserted, updated = save_listings( + db, lots, matcher=matcher, region_code=region_code, run_id=run_id + ) + counters.lots_inserted += inserted + counters.lots_updated += updated + + try: + await asyncio.wait_for(_domclick_phase(), timeout=_sweep_timeout) + except TimeoutError: + logger.warning( + "domclick-sweep run_id=%d: SERP phase timed out after %ds — partial results", + run_id, + _sweep_timeout, + ) + counters.errors_count += 1 + except Exception: + logger.exception("domclick-sweep run_id=%d: SERP phase failed", run_id) + counters.errors_count += 1 + + # Перенести счётчики scraper'а в counters (если scraper был создан). + if _scraper_ref: + _s = _scraper_ref[0] + counters.blocked = 1 if _s.blocked else 0 + counters.geo_filtered = _s.geo_filtered + # Не-block fetch-ошибки скрейпера учитываем в errors_count. + counters.errors_count += _s.fetch_errors + + # pages_fetched: worst-case число страниц (buckets × pages cap). + counters.pages_fetched = _num_fetches + runs.update_heartbeat(db, run_id, counters.to_dict()) + + # ── ЧЕСТНЫЙ СТАТУС (#1968) ──────────────────────────────────────────── + if counters.lots_fetched == 0 and (counters.blocked or counters.errors_count > 0): + logger.error( + "domclick-sweep run_id=%d: 0 listings with blocked=%d errors=%d " + "— marking failed", + run_id, + counters.blocked, + counters.errors_count, + ) + runs.mark_failed( + db, + run_id, + "QRATOR block or fetch errors — 0 listings", + counters.to_dict(), + ) + else: + runs.mark_done(db, run_id, counters.to_dict()) + + logger.info( + "domclick-sweep run_id=%d done: lots=%d (ins=%d/upd=%d) " + "pages=%d errors=%d blocked=%d geo_filtered=%d", + run_id, + counters.lots_fetched, + counters.lots_inserted, + counters.lots_updated, + counters.pages_fetched, + counters.errors_count, + counters.blocked, + counters.geo_filtered, + ) + return counters + + except Exception as exc: + logger.exception("domclick-sweep run_id=%d: fatal error", run_id) + runs.mark_failed(db, run_id, str(exc), counters.to_dict()) + raise -- 2.45.3