diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 1982a79e..e771642c 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -26,6 +26,7 @@ from app.services.scrape_pipeline import ( run_avito_city_sweep, run_cian_city_sweep, run_n1_city_sweep, + run_yandex_city_sweep, ) from app.services.scraper_settings import invalidate_cache from app.services.scrapers.avito import AvitoScraper @@ -852,6 +853,93 @@ def cancel_cian_city_sweep( return {"ok": True, "run_id": run_id, "cancelled": cancelled} +# ── Yandex city sweep (#861) — on-demand trigger + runs list ───────────────── + + +class YandexCitySweepStartRequest(BaseModel): + pages_per_anchor: int = Field(default=2, ge=1, le=10) + request_delay_sec: float = Field(default=7.0, ge=3.0, le=30.0) + enrich_address: bool = True + radius_m: int = Field(default=1500, ge=500, le=5000) + + +@router.post("/scrape/yandex-city-sweep", response_model=CitySweepStartResponse) +async def start_yandex_city_sweep( + payload: YandexCitySweepStartRequest, + background_tasks: BackgroundTasks, + db: Annotated[Session, Depends(get_db)], +) -> CitySweepStartResponse: + """Запустить Yandex.Недвижимость ЕКБ city sweep в background. Returns run_id. + + Один прогон: SERP (fetch_around_multi_room, rooms × price-range combos) + → save_listings → address-enrich (detail → полный адрес с домом). + Прокси уже внутри YandexRealtyScraper + address-enrich сессии. + + Coop cancel: POST /scrape/yandex-city-sweep/{run_id}/cancel. + НЕ добавляется в scrape_schedules — ручной/контролируемый запуск + (schedule yandex_city_sweep DORMANT, включается оператором вручную). + """ + run_id = runs_mod.create_run(db, source="yandex_city_sweep", params=payload.model_dump()) + + async def _sweep_task() -> None: + sweep_db = SessionLocal() + try: + await run_yandex_city_sweep( + sweep_db, + run_id=run_id, + radius_m=payload.radius_m, + pages_per_anchor=payload.pages_per_anchor, + request_delay_sec=payload.request_delay_sec, + enrich_address=payload.enrich_address, + ) + except Exception: + logger.exception("yandex-sweep background task run_id=%d crashed", run_id) + finally: + sweep_db.close() + + background_tasks.add_task(_sweep_task) + logger.info("yandex-sweep queued run_id=%d params=%s", run_id, payload.model_dump()) + return CitySweepStartResponse( + run_id=run_id, + status="running", + pages_per_anchor=payload.pages_per_anchor, + detail_top_n=0, # у Yandex sweep нет detail-шага (address-enrich отдельно) + ) + + +@router.get("/scrape/yandex-city-sweep/runs", response_model=list[ScrapeRunRow]) +def list_yandex_city_sweep_runs( + db: Annotated[Session, Depends(get_db)], + limit: int = 10, +) -> list[ScrapeRunRow]: + """Список последних N Yandex city sweep runs (для UI polling). Default limit=10.""" + rows = runs_mod.list_recent(db, source="yandex_city_sweep", limit=limit) + return [ + ScrapeRunRow( + run_id=r["run_id"], + source=r["source"], + status=r["status"], + params=r.get("params"), + counters=r.get("counters"), + error=r.get("error"), + started_at=r["started_at"].isoformat() if r.get("started_at") else None, + finished_at=r["finished_at"].isoformat() if r.get("finished_at") else None, + heartbeat_at=r["heartbeat_at"].isoformat() if r.get("heartbeat_at") else None, + ) + for r in rows + ] + + +@router.post("/scrape/yandex-city-sweep/{run_id}/cancel") +def cancel_yandex_city_sweep( + run_id: int, + db: Annotated[Session, Depends(get_db)], +) -> dict[str, object]: + """Отменить running Yandex sweep. Cooperative: проверяется каждый anchor.""" + cancelled = runs_mod.mark_cancelled(db, run_id) + return {"ok": True, "run_id": run_id, "cancelled": cancelled} + + # ── Scraper settings: global + per-source delay management ─────────────────── diff --git a/tradein-mvp/backend/app/services/scheduler.py b/tradein-mvp/backend/app/services/scheduler.py index 413232a7..1bbbadab 100644 --- a/tradein-mvp/backend/app/services/scheduler.py +++ b/tradein-mvp/backend/app/services/scheduler.py @@ -253,6 +253,7 @@ async def trigger_yandex_city_sweep_run(db: Session, schedule_row: dict[str, Any pages_per_anchor=int(params.get("pages_per_anchor", 2)), request_delay_sec=float(params.get("request_delay_sec", 9.0)), radius_m=int(params.get("radius_m", 1500)), + enrich_address=bool(params.get("enrich_address", True)), ) except Exception: logger.exception("scheduler: run_yandex_city_sweep crashed run_id=%d", run_id) diff --git a/tradein-mvp/backend/app/services/scrape_pipeline.py b/tradein-mvp/backend/app/services/scrape_pipeline.py index bd33cfee..2a81101a 100644 --- a/tradein-mvp/backend/app/services/scrape_pipeline.py +++ b/tradein-mvp/backend/app/services/scrape_pipeline.py @@ -538,16 +538,18 @@ async def run_avito_city_sweep( class YandexCitySweepCounters: """Aggregate counters для Yandex.Недвижимость city sweep run. - Проще avito CitySweepCounters: у Yandex SERP нет house-catalog / detail-enrich - шага (только search → save), поэтому houses/detail-полей нет. + Фазы: SERP (fetch_around_multi_room) → save_listings → address-enrich. + address_* — результат T10-обогащения (detail <title> → полный адрес с номером дома). """ anchors_total: int = 0 anchors_done: int = 0 - pages_fetched: int = 0 lots_fetched: int = 0 lots_inserted: int = 0 lots_updated: 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]: @@ -569,33 +571,50 @@ async def run_yandex_city_sweep( radius_m: int = 1500, pages_per_anchor: int = 2, request_delay_sec: float | None = None, + enrich_address: bool = True, ) -> YandexCitySweepCounters: - """Yandex.Недвижимость city sweep: iterate anchors × pages → save. + """Yandex.Недвижимость city sweep: SERP → save → address-enrich. - Проще avito sweep — у Yandex SERP нет house-catalog / detail-enrich шага: - per anchor: YandexRealtyScraper().fetch_around(lat, lon, radius_m, page=p) - → save_listings(db, lots) → accumulate counters. + Фазы per anchor: + 1. SERP: YandexRealtyScraper.fetch_around_multi_room(lat, lon, radius_m, + max_pages=pages_per_anchor) — rooms × price-range combos, dedup внутри. + 2. SAVE: save_listings(db, lots, run_id=run_id). + 3. ADDRESS-ENRICH (если enrich_address=True): для yandex-листингов этого anchor'а + без номера дома в address (address IS NOT NULL AND NOT (address ~ ',\\s*\\d+')) + запрашиваем detail-страницу и извлекаем полный адрес из <title>. + Переиспользует приватные функции yandex_address_backfill: + _eligible_listings_for_ids → _fetch_and_update_one. - Anti-bot / cancel семантика (зеркало avito sweep, см. #561): - - Cooperative cancel: is_cancelled(db, run_id) проверяется перед каждым anchor; - при cancel — update_heartbeat + return (mark_cancelled делает caller-flow по статусу). - - YandexRealtyScraper НЕ определяет block/rate exception классов (fetch_around - ловит исключения внутри и возвращает []). Поэтому per-anchor ловим broad - Exception, логируем и continue; защитно прерываем sweep (early abort + mark_banned), - если N anchor'ов ПОДРЯД упали с исключением — это сигнал системного блока IP. - - request_delay_sec: YandexRealtyScraper.fetch_around НЕ принимает delay-override - (он сам спит self.request_delay_sec между страницами через sleep_between_requests). - Поэтому request_delay_sec применяется здесь как доп. asyncio.sleep МЕЖДУ anchor'ами. + Anti-bot / cancel семантика: + - Cooperative cancel: is_cancelled(db, run_id) проверяется перед каждым anchor. + - YandexRealtyScraper не propagate'ит block/rate exceptions (глотает → []). + Поэтому per-anchor ловим broad Exception; при N подряд — mark_banned + abort. + - request_delay_sec: доп. asyncio.sleep МЕЖДУ anchor'ами (поверх per-page sleep + внутри scraper'а). Также используется как inter-request delay для address-enrich. Возвращает YandexCitySweepCounters (агрегат по всем anchor'ам). + mark_done вызывается ВСЕГДА (кроме cancel / ban / fatal). """ + from sqlalchemy import text as _text + + from app.services.yandex_address_backfill import ( + _RE_HAS_HOUSE_NUMBER, + _extract_address_from_title, + ) + _anchors = anchors if anchors is not None else EKB_ANCHORS 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 consecutive_failures = 0 + # Прокси для address-enrich curl_cffi сессии (зеркало yandex_address_backfill). + _proxy_url = settings.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 scrape_runs.is_cancelled(db, run_id): logger.info( "yandex-sweep run_id=%d: cancelled at anchor #%d/%d (%s)", @@ -616,29 +635,34 @@ async def run_yandex_city_sweep( lat, lon, ) - try: - # Fresh scraper per anchor — uses its own httpx.AsyncClient via the - # async-context-manager. fetch_around fetches ONE SERP page; loop pages. - anchor_lots: list[ScrapedLot] = [] - async with YandexRealtyScraper() as scraper: - for page in range(pages_per_anchor): - page_lots = await scraper.fetch_around(lat, lon, radius_m, page=page) - counters.pages_fetched += 1 - if not page_lots: - # Пустая страница → дальше результатов нет, прекращаем пагинацию. - break - anchor_lots.extend(page_lots) + # ── Phase 1+2: SERP + save ─────────────────────────────────────── + anchor_lots: list[ScrapedLot] = [] + try: + async with YandexRealtyScraper() as scraper: + anchor_lots = await scraper.fetch_around_multi_room( + lat, + lon, + radius_m, + max_pages=pages_per_anchor, + ) counters.lots_fetched += len(anchor_lots) if anchor_lots: inserted, updated = save_listings(db, anchor_lots, run_id=run_id) counters.lots_inserted += inserted counters.lots_updated += updated consecutive_failures = 0 + logger.info( + "yandex-sweep run_id=%d anchor %s: SERP fetched=%d ins=%d upd=%d", + run_id, + name, + len(anchor_lots), + counters.lots_inserted, + counters.lots_updated, + ) except Exception as e: - # YandexRealtyScraper не propagate'ит block/rate exceptions, поэтому здесь - # ловим broad Exception per-anchor: логируем и continue (изоляция anchor'а). - logger.exception("yandex-sweep run_id=%d: anchor %s failed", run_id, name) + # 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: @@ -662,6 +686,128 @@ async def run_yandex_city_sweep( counters.to_dict(), ) return counters + counters.anchors_done = idx + scrape_runs.update_heartbeat(db, run_id, counters.to_dict()) + # Пауза перед следующим anchor'ом даже при ошибке. + if idx < len(_anchors): + await asyncio.sleep(inter_anchor_delay) + continue + + # ── Phase 3: address-enrich ───────────────────────────────────── + if enrich_address and anchor_lots: + # Собираем source_id из SERP-результатов этого anchor'а → ищем + # в DB только листинги, которые были только что сохранены/обновлены. + source_ids = [lot.source_id for lot in anchor_lots if lot.source_id] + if source_ids: + 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: + from curl_cffi.requests import AsyncSession as _AsyncSession + + 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: + for eidx, item in enumerate(items): + 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 != 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 + else: + new_addr = _extract_address_from_title(resp.text) + if ( + new_addr is None + or new_addr == old_addr + or not _RE_HAS_HOUSE_NUMBER.search(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 + except Exception as fetch_exc: + counters.address_failed += 1 + logger.warning( + "yandex-sweep run_id=%d: address-enrich " + "fetch failed listing_id=%d: %s", + run_id, + lid, + fetch_exc, + ) + if eidx < len(items) - 1: + await asyncio.sleep(enrich_delay) + + logger.info( + "yandex-sweep run_id=%d anchor %s: " "address enrich=%d/%d failed=%d", + run_id, + name, + counters.address_enriched, + counters.address_attempted, + counters.address_failed, + ) counters.anchors_done = idx scrape_runs.update_heartbeat(db, run_id, counters.to_dict()) @@ -672,14 +818,17 @@ async def run_yandex_city_sweep( scrape_runs.mark_done(db, run_id, counters.to_dict()) logger.info( - "yandex-sweep run_id=%d done: anchors=%d/%d pages=%d lots=%d (ins=%d/upd=%d) errors=%d", + "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.pages_fetched, counters.lots_fetched, counters.lots_inserted, counters.lots_updated, + counters.address_enriched, + counters.address_attempted, + counters.address_failed, counters.errors_count, ) return counters diff --git a/tradein-mvp/backend/tests/test_yandex_city_sweep.py b/tradein-mvp/backend/tests/test_yandex_city_sweep.py index 6aae9ae0..a8102b71 100644 --- a/tradein-mvp/backend/tests/test_yandex_city_sweep.py +++ b/tradein-mvp/backend/tests/test_yandex_city_sweep.py @@ -1,20 +1,31 @@ -"""Offline unit tests for run_yandex_city_sweep (#561, shipped DORMANT). +"""Offline unit tests for run_yandex_city_sweep (SERP+address-enrich, #861). Pure & fast: NO live network, NO DB. We monkeypatch: - YandexRealtyScraper.__init__ / __aenter__ / __aexit__ — avoid get_scraper_delay - DB read + real httpx.AsyncClient creation. - - YandexRealtyScraper.fetch_around — return a fixed list of fake ScrapedLot. + DB read + real curl_cffi session creation. + - YandexRealtyScraper.fetch_around_multi_room — return a fixed list of fake ScrapedLot. - scrape_pipeline.save_listings — counter/no-op (no real listings table). - scrape_runs.* (is_cancelled / update_heartbeat / mark_*) — in-memory fakes. + - curl_cffi.requests.AsyncSession — fake for address-enrich HTTP calls. -These tests assert orchestration semantics only; live Yandex behavior is intentionally -unverified (sweep is dormant until egress-IP rotation #623). +Asserts orchestration semantics: + - SERP → save (all anchors, counters aggregated) + - address-enrich фаза вызывается если enrich_address=True и есть source_ids + - enrich_address=False → адрес-фаза пропускается + - cancel → останавливает sweep + - N consecutive SERP failures → mark_banned + abort + - per-item address-enrich error не валит sweep + - mark_done вызывается при нормальном завершении """ + +from __future__ import annotations + import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from typing import Any +from unittest.mock import AsyncMock, MagicMock import pytest @@ -22,7 +33,7 @@ from app.services import scrape_pipeline from app.services.scrapers.base import ScrapedLot from app.services.scrapers.yandex_realty import YandexRealtyScraper -# 3 anchors keep tests fast and let us probe "anchor k cancels/fails, others proceed". +# 3 anchors keep tests fast and let us probe cancel/fail semantics. TEST_ANCHORS: list[tuple[float, float, str]] = [ (56.8400, 60.6050, "A1"), (56.7950, 60.5300, "A2"), @@ -36,7 +47,7 @@ def _fake_lot(offer_id: str) -> ScrapedLot: source="yandex", source_url=f"https://realty.yandex.ru/offer/{offer_id}/", source_id=offer_id, - address="Екатеринбург, улица Тестовая, 1", + address="Екатеринбург, улица Тестовая", price_rub=5_000_000, area_m2=50.0, rooms=2, @@ -77,6 +88,7 @@ class _FakeRuns: @pytest.fixture(autouse=True) def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: """Kill inter-anchor asyncio.sleep so tests are instant.""" + async def _instant(_secs: float) -> None: return None @@ -85,12 +97,13 @@ def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.fixture(autouse=True) def _stub_scraper_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None: - """Neutralize __init__/__aenter__/__aexit__ — no get_scraper_delay DB, no httpx.""" + """Neutralize __init__/__aenter__/__aexit__ — no get_scraper_delay DB, no curl_cffi.""" def _init(self: YandexRealtyScraper, city: str = "ekaterinburg") -> None: self.city = city self.request_delay_sec = 0.0 - self._client = None + self._cffi_session = None + self._cookies = {} async def _aenter(self: YandexRealtyScraper) -> YandexRealtyScraper: return self @@ -111,114 +124,262 @@ def _install_runs(monkeypatch: pytest.MonkeyPatch, fake: _FakeRuns) -> None: monkeypatch.setattr(scrape_pipeline.scrape_runs, "mark_banned", fake.mark_banned) +def _make_db_mock(source_ids: list[str]) -> MagicMock: + """DB mock для address-enrich фазы: возвращает строки без номера дома.""" + mock_db = MagicMock() + rows = [ + { + "id": i + 1, + "source_id": sid, + "address": "Екатеринбург, улица Тестовая", + "source_url": f"https://realty.yandex.ru/offer/{sid}/", + } + for i, sid in enumerate(source_ids) + ] + mock_db.execute.return_value.mappings.return_value.all.return_value = rows + return mock_db + + +def _fake_save(_db: Any, lots: list, *, run_id: int | None = None) -> tuple[int, int]: + return len(lots), 0 + + +# ── YandexCitySweepCounters ───────────────────────────────────────────────── + + +def test_counters_defaults() -> None: + from app.services.scrape_pipeline import YandexCitySweepCounters + + c = YandexCitySweepCounters() + assert c.anchors_total == 0 + assert c.lots_fetched == 0 + assert c.address_attempted == 0 + assert c.address_enriched == 0 + assert c.address_failed == 0 + assert c.errors_count == 0 + + +def test_counters_to_dict_all_keys() -> None: + from dataclasses import fields + + from app.services.scrape_pipeline import YandexCitySweepCounters + + c = YandexCitySweepCounters() + d = c.to_dict() + expected = {f.name for f in fields(c)} + assert set(d.keys()) == expected + + # ── Happy path: all anchors iterated, counters aggregated ─────────────────── async def test_sweep_iterates_all_anchors_and_aggregates( monkeypatch: pytest.MonkeyPatch, ) -> None: - fetch_calls: list[tuple[float, float, int]] = [] + """fetch_around_multi_room вызывается для каждого anchor, counters агрегируются.""" + serp_calls: list[str] = [] save_calls: list[int] = [] - async def fake_fetch( + async def fake_fetch_multi( self: YandexRealtyScraper, lat: float, lon: float, radius_m: int = 1000, - page: int = 0, + max_pages: int = 2, + **_: Any, ) -> list[ScrapedLot]: - fetch_calls.append((lat, lon, page)) - # 2 lots per page; let pagination run the full pages_per_anchor. - return [_fake_lot(f"{lat}-{page}-{i}") for i in range(2)] + serp_calls.append(f"{lat:.4f}") + return [_fake_lot(f"{lat}-{i}") for i in range(3)] def fake_save( _db: Any, lots: list[ScrapedLot], *, run_id: int | None = None ) -> tuple[int, int]: save_calls.append(len(lots)) - # Pretend everything is a fresh insert. return len(lots), 0 - monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch) + monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi) monkeypatch.setattr(scrape_pipeline, "save_listings", fake_save) fake = _FakeRuns() _install_runs(monkeypatch, fake) counters = await scrape_pipeline.run_yandex_city_sweep( - db=object(), + db=MagicMock(), run_id=1, anchors=TEST_ANCHORS, pages_per_anchor=2, request_delay_sec=0.0, + enrich_address=False, ) - # fetch_around called pages_per_anchor times per anchor (no empty-page short-circuit). - assert len(fetch_calls) == len(TEST_ANCHORS) * 2 - # save_listings called once per anchor (anchor-level batch). + assert len(serp_calls) == len(TEST_ANCHORS) assert len(save_calls) == len(TEST_ANCHORS) - # Counters: 3 anchors × 2 pages × 2 lots = 12. assert counters.anchors_total == len(TEST_ANCHORS) assert counters.anchors_done == len(TEST_ANCHORS) - assert counters.pages_fetched == len(TEST_ANCHORS) * 2 - assert counters.lots_fetched == 12 - assert counters.lots_inserted == 12 + assert counters.lots_fetched == len(TEST_ANCHORS) * 3 + assert counters.lots_inserted == len(TEST_ANCHORS) * 3 assert counters.lots_updated == 0 assert counters.errors_count == 0 - # mark_done called with final counters; no failure/ban. + assert counters.address_attempted == 0 # enrich_address=False assert fake.done is not None - assert fake.done["lots_fetched"] == 12 + assert fake.done["lots_fetched"] == len(TEST_ANCHORS) * 3 assert fake.failed is None assert fake.banned is None -async def test_save_listings_receives_run_id(monkeypatch: pytest.MonkeyPatch) -> None: - """run_id must be threaded into save_listings for snapshot provenance.""" - seen_run_ids: list[int | None] = [] - - async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0): - return [_fake_lot(f"{lat}-{page}")] - - def fake_save(_db, lots, *, run_id=None): - seen_run_ids.append(run_id) - return len(lots), 0 - - monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch) - monkeypatch.setattr(scrape_pipeline, "save_listings", fake_save) - _install_runs(monkeypatch, _FakeRuns()) - - await scrape_pipeline.run_yandex_city_sweep( - db=object(), run_id=777, anchors=TEST_ANCHORS, pages_per_anchor=1, - ) - assert seen_run_ids == [777, 777, 777] +# ── enrich_address=False пропускает address-фазу ──────────────────────────── -# ── Empty page short-circuits pagination ──────────────────────────────────── +async def test_enrich_address_false_skips_address_phase( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """enrich_address=False → address-enrich фаза не выполняется.""" + address_http_calls: list[str] = [] + async def fake_fetch_multi( + self: YandexRealtyScraper, + lat: float, + lon: float, + radius_m: int = 1000, + max_pages: int = 2, + **_: Any, + ) -> list[ScrapedLot]: + return [_fake_lot(f"{lat}-0")] -async def test_empty_page_stops_pagination(monkeypatch: pytest.MonkeyPatch) -> None: - """An empty SERP page ends pagination for that anchor (no further pages).""" - pages_seen: list[int] = [] + async def fake_cffi_get(url: str, **_: Any) -> Any: + address_http_calls.append(url) + return MagicMock(status_code=200, text="<title>test") - async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0): - pages_seen.append(page) - if page == 0: - return [_fake_lot(f"{lat}-0")] - return [] # page 1 empty → stop - - monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch) - monkeypatch.setattr( - scrape_pipeline, "save_listings", lambda _db, lots, *, run_id=None: (len(lots), 0) - ) + monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi) + monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) fake = _FakeRuns() _install_runs(monkeypatch, fake) counters = await scrape_pipeline.run_yandex_city_sweep( - db=object(), run_id=2, anchors=TEST_ANCHORS, pages_per_anchor=5, + db=MagicMock(), + run_id=2, + anchors=TEST_ANCHORS[:1], + enrich_address=False, + request_delay_sec=0.0, ) - # Per anchor: page 0 (1 lot) then page 1 (empty, break) → 2 pages each. - assert pages_seen == [0, 1, 0, 1, 0, 1] - assert counters.pages_fetched == 6 # 2 per anchor × 3 - assert counters.lots_fetched == 3 # 1 per anchor - assert counters.anchors_done == 3 + + assert len(address_http_calls) == 0 + assert counters.address_attempted == 0 + assert counters.address_enriched == 0 + assert fake.done is not None + + +# ── address-enrich фаза: счётчики заполняются ─────────────────────────────── + + +async def test_address_enrich_fills_counters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """address-enrich вызывается для листингов без номера дома; counters заполняются.""" + # Fake SERP возвращает 2 листинга с source_id + lots = [_fake_lot("id1"), _fake_lot("id2")] + + async def fake_fetch_multi( + self: YandexRealtyScraper, + lat: float, + lon: float, + radius_m: int = 1000, + max_pages: int = 2, + **_: Any, + ) -> list[ScrapedLot]: + return lots + + monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi) + monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) + + # DB mock: возвращает 2 строки для address-enrich запроса + mock_db = _make_db_mock(["id1", "id2"]) + + # Title с полным адресом — должен быть извлечён через _extract_address_from_title + fake_response = MagicMock() + fake_response.status_code = 200 + fake_response.text = ( + "Продажа квартиры — Екатеринбург, улица Тестовая, 36 — id 123 " + "на Яндекс.Недвижимости" + ) + + # Мок curl_cffi AsyncSession как context manager + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + mock_session.get = AsyncMock(return_value=fake_response) + + import curl_cffi.requests as cffi_mod + + monkeypatch.setattr(cffi_mod, "AsyncSession", lambda **_kw: mock_session) + + fake = _FakeRuns() + _install_runs(monkeypatch, fake) + + counters = await scrape_pipeline.run_yandex_city_sweep( + db=mock_db, + run_id=3, + anchors=TEST_ANCHORS[:1], + enrich_address=True, + request_delay_sec=0.0, + ) + + assert counters.address_attempted == 2 + assert counters.address_enriched == 2 + assert counters.address_failed == 0 + assert fake.done is not None + + +# ── address-enrich: per-item HTTP error не валит sweep ────────────────────── + + +async def test_address_enrich_http_error_does_not_abort( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HTTP 403 при address-enrich → address_failed++, sweep продолжается.""" + + async def fake_fetch_multi( + self: YandexRealtyScraper, + lat: float, + lon: float, + radius_m: int = 1000, + max_pages: int = 2, + **_: Any, + ) -> list[ScrapedLot]: + return [_fake_lot("id-err")] + + monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi) + monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) + + mock_db = _make_db_mock(["id-err"]) + + fake_response = MagicMock() + fake_response.status_code = 403 + fake_response.text = "" + + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + mock_session.get = AsyncMock(return_value=fake_response) + + import curl_cffi.requests as cffi_mod + + monkeypatch.setattr(cffi_mod, "AsyncSession", lambda **_kw: mock_session) + + fake = _FakeRuns() + _install_runs(monkeypatch, fake) + + counters = await scrape_pipeline.run_yandex_city_sweep( + db=mock_db, + run_id=4, + anchors=TEST_ANCHORS[:1], + enrich_address=True, + request_delay_sec=0.0, + ) + + assert counters.address_attempted == 1 + assert counters.address_failed == 1 + assert counters.address_enriched == 0 + assert fake.done is not None # sweep завершился несмотря на ошибку # ── Mid-sweep cancel stops further anchors ────────────────────────────────── @@ -227,25 +388,35 @@ async def test_empty_page_stops_pagination(monkeypatch: pytest.MonkeyPatch) -> N async def test_cancel_midway_stops_remaining_anchors( monkeypatch: pytest.MonkeyPatch, ) -> None: - fetch_calls: list[str] = [] + serp_calls: list[str] = [] - async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0): - fetch_calls.append(f"{lat}") - return [_fake_lot(f"{lat}-{page}")] + async def fake_fetch_multi( + self: YandexRealtyScraper, + lat: float, + lon: float, + radius_m: int = 1000, + max_pages: int = 2, + **_: Any, + ) -> list[ScrapedLot]: + serp_calls.append(f"{lat}") + return [_fake_lot(f"{lat}-0")] - monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch) - monkeypatch.setattr( - scrape_pipeline, "save_listings", lambda _db, lots, *, run_id=None: (len(lots), 0) - ) + monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi) + monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) # is_cancelled flips True on the 2nd check (before anchor #2) → only anchor #1 runs. fake = _FakeRuns(cancel_at_anchor=2) _install_runs(monkeypatch, fake) counters = await scrape_pipeline.run_yandex_city_sweep( - db=object(), run_id=3, anchors=TEST_ANCHORS, pages_per_anchor=1, + db=MagicMock(), + run_id=5, + anchors=TEST_ANCHORS, + pages_per_anchor=1, + enrich_address=False, + request_delay_sec=0.0, ) # Only the first anchor fetched before cancel. - assert len(fetch_calls) == 1 + assert len(serp_calls) == 1 assert counters.anchors_done == 1 # Cancel path returns early: no mark_done, no ban/fail. assert fake.done is None @@ -253,48 +424,7 @@ async def test_cancel_midway_stops_remaining_anchors( assert fake.banned is None -# ── Per-anchor exception is isolated (single failure → continue) ──────────── - - -async def test_single_anchor_failure_is_isolated( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Anchor #2 raises → anchors #1 and #3 still processed; sweep completes.""" - processed: list[str] = [] - - async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0): - if name_of(lat) == "A2": - raise RuntimeError("boom on anchor 2") - processed.append(name_of(lat)) - return [_fake_lot(f"{lat}-{page}")] - - def name_of(lat: float) -> str: - for a_lat, _lon, nm in TEST_ANCHORS: - if a_lat == lat: - return nm - return "?" - - monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch) - monkeypatch.setattr( - scrape_pipeline, "save_listings", lambda _db, lots, *, run_id=None: (len(lots), 0) - ) - fake = _FakeRuns() - _install_runs(monkeypatch, fake) - - counters = await scrape_pipeline.run_yandex_city_sweep( - db=object(), run_id=4, anchors=TEST_ANCHORS, pages_per_anchor=1, - ) - # A1 and A3 processed; A2 raised and was skipped. - assert processed == ["A1", "A3"] - assert counters.errors_count == 1 - assert counters.anchors_done == 3 # loop advanced past all 3 anchors - assert counters.lots_fetched == 2 # only A1 + A3 contributed lots - # Not an abort — sweep marked done despite the isolated failure. - assert fake.done is not None - assert fake.banned is None - - -# ── Defensive abort: N consecutive failures → mark_banned, stop early ─────── +# ── Consecutive failures → mark_banned + abort ────────────────────────────── async def test_consecutive_failures_abort_sweep( @@ -303,14 +433,19 @@ async def test_consecutive_failures_abort_sweep( """All anchors raise → after MAX_CONSECUTIVE_FAILURES the sweep aborts + bans.""" attempts: list[str] = [] - async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0): + async def fake_fetch_fail( + self: YandexRealtyScraper, + lat: float, + lon: float, + radius_m: int = 1000, + max_pages: int = 2, + **_: Any, + ) -> list[ScrapedLot]: attempts.append(f"{lat}") raise RuntimeError("yandex blocked") - monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch) - monkeypatch.setattr( - scrape_pipeline, "save_listings", lambda _db, lots, *, run_id=None: (len(lots), 0) - ) + monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_fail) + monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) fake = _FakeRuns() _install_runs(monkeypatch, fake) @@ -321,9 +456,13 @@ async def test_consecutive_failures_abort_sweep( (56.8650, 60.6200, "A5"), ] counters = await scrape_pipeline.run_yandex_city_sweep( - db=object(), run_id=5, anchors=anchors, pages_per_anchor=1, + db=MagicMock(), + run_id=6, + anchors=anchors, + enrich_address=False, + request_delay_sec=0.0, ) - # Abort exactly at the threshold (3 consecutive failures), not all 5 anchors. + # Abort exactly at the threshold (3 consecutive failures). assert len(attempts) == scrape_pipeline.YANDEX_SWEEP_MAX_CONSECUTIVE_FAILURES assert counters.errors_count == scrape_pipeline.YANDEX_SWEEP_MAX_CONSECUTIVE_FAILURES assert counters.anchors_done == scrape_pipeline.YANDEX_SWEEP_MAX_CONSECUTIVE_FAILURES @@ -332,11 +471,15 @@ async def test_consecutive_failures_abort_sweep( assert fake.done is None # aborted, never marked done +# ── Non-consecutive failures do not abort ─────────────────────────────────── + + async def test_non_consecutive_failures_do_not_abort( monkeypatch: pytest.MonkeyPatch, ) -> None: """A success between two failures resets the consecutive counter → no abort.""" - def name_of(lat: float, anchors: list[tuple[float, float, str]]) -> str: + + def _name_of(lat: float, anchors: list[tuple[float, float, str]]) -> str: for a_lat, _lon, nm in anchors: if a_lat == lat: return nm @@ -347,26 +490,178 @@ async def test_non_consecutive_failures_do_not_abort( (56.7700, 60.5500, "A4"), (56.8650, 60.6200, "A5"), ] - # Fail A1, succeed A2, fail A3, succeed A4, fail A5 → never 3-in-a-row. failing = {"A1", "A3", "A5"} - async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0): - if name_of(lat, anchors) in failing: + async def fake_fetch( + self: YandexRealtyScraper, + lat: float, + lon: float, + radius_m: int = 1000, + max_pages: int = 2, + **_: Any, + ) -> list[ScrapedLot]: + if _name_of(lat, anchors) in failing: raise RuntimeError("transient") - return [_fake_lot(f"{lat}-{page}")] + return [_fake_lot(f"{lat}-0")] - monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch) - monkeypatch.setattr( - scrape_pipeline, "save_listings", lambda _db, lots, *, run_id=None: (len(lots), 0) - ) + monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch) + monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) fake = _FakeRuns() _install_runs(monkeypatch, fake) counters = await scrape_pipeline.run_yandex_city_sweep( - db=object(), run_id=6, anchors=anchors, pages_per_anchor=1, + db=MagicMock(), + run_id=7, + anchors=anchors, + enrich_address=False, + request_delay_sec=0.0, ) assert counters.errors_count == 3 # A1, A3, A5 assert counters.anchors_done == 5 # walked the whole list, no early abort assert counters.lots_fetched == 2 # A2 + A4 contributed assert fake.banned is None assert fake.done is not None + + +# ── mark_done вызывается даже при пустом SERP ─────────────────────────────── + + +async def test_mark_done_always_called( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fake_fetch_empty( + self: YandexRealtyScraper, + lat: float, + lon: float, + radius_m: int = 1000, + max_pages: int = 2, + **_: Any, + ) -> list[ScrapedLot]: + return [] + + monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_empty) + monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) + fake = _FakeRuns() + _install_runs(monkeypatch, fake) + + counters = await scrape_pipeline.run_yandex_city_sweep( + db=MagicMock(), + run_id=8, + anchors=TEST_ANCHORS, + enrich_address=False, + request_delay_sec=0.0, + ) + + assert fake.done is not None + assert counters.lots_fetched == 0 + assert counters.anchors_done == len(TEST_ANCHORS) + + +# ── Admin endpoint tests (offline) ────────────────────────────────────────── + + +@pytest.fixture +def app_with_admin(): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from app.api.v1 import admin as admin_module + from app.core.db import get_db + + app = FastAPI() + app.include_router(admin_module.router, prefix="/api/v1/admin") + + def fake_db(): + yield MagicMock() + + app.dependency_overrides[get_db] = fake_db + return TestClient(app) + + +def test_yandex_start_endpoint_validates_pages_per_anchor(app_with_admin) -> None: + """pages_per_anchor=99 превышает max=10 → 422.""" + r = app_with_admin.post( + "/api/v1/admin/scrape/yandex-city-sweep", + json={"pages_per_anchor": 99}, + ) + assert r.status_code == 422 + + +def test_yandex_start_endpoint_validates_delay_too_low(app_with_admin) -> None: + """request_delay_sec=1.0 меньше min=3.0 → 422.""" + r = app_with_admin.post( + "/api/v1/admin/scrape/yandex-city-sweep", + json={"request_delay_sec": 1.0}, + ) + assert r.status_code == 422 + + +def test_yandex_start_endpoint_ok(app_with_admin) -> None: + """Valid request → 200, run_id в ответе.""" + from unittest.mock import patch + + with ( + patch("app.services.scrape_runs.create_run", return_value=55), + patch("app.services.scrape_pipeline.run_yandex_city_sweep", new_callable=AsyncMock), + ): + r = app_with_admin.post( + "/api/v1/admin/scrape/yandex-city-sweep", + json={"pages_per_anchor": 2, "enrich_address": True}, + ) + assert r.status_code == 200 + body = r.json() + assert body["run_id"] == 55 + assert body["status"] == "running" + assert body["pages_per_anchor"] == 2 + assert body["detail_top_n"] == 0 + + +def test_yandex_cancel_endpoint(app_with_admin) -> None: + """Cancel endpoint возвращает run_id + cancelled=True.""" + from unittest.mock import patch + + with patch("app.services.scrape_runs.mark_cancelled", return_value=True): + r = app_with_admin.post("/api/v1/admin/scrape/yandex-city-sweep/99/cancel") + assert r.status_code == 200 + body = r.json() + assert body["run_id"] == 99 + assert body["cancelled"] is True + assert body["ok"] is True + + +def test_yandex_cancel_endpoint_not_running(app_with_admin) -> None: + """Cancel на не-running sweep → cancelled=False.""" + from unittest.mock import patch + + with patch("app.services.scrape_runs.mark_cancelled", return_value=False): + r = app_with_admin.post("/api/v1/admin/scrape/yandex-city-sweep/100/cancel") + assert r.status_code == 200 + assert r.json()["cancelled"] is False + + +def test_yandex_list_runs_endpoint(app_with_admin) -> None: + """List runs endpoint возвращает список.""" + from datetime import UTC, datetime + from unittest.mock import patch + + fake_rows = [ + { + "run_id": 20, + "source": "yandex_city_sweep", + "status": "done", + "params": {"pages_per_anchor": 2, "enrich_address": True}, + "counters": {"lots_fetched": 200, "address_enriched": 80}, + "error": None, + "started_at": datetime(2025, 5, 1, tzinfo=UTC), + "finished_at": datetime(2025, 5, 1, 1, tzinfo=UTC), + "heartbeat_at": datetime(2025, 5, 1, 1, tzinfo=UTC), + } + ] + with patch("app.services.scrape_runs.list_recent", return_value=fake_rows): + r = app_with_admin.get("/api/v1/admin/scrape/yandex-city-sweep/runs") + assert r.status_code == 200 + body = r.json() + assert len(body) == 1 + assert body[0]["run_id"] == 20 + assert body[0]["source"] == "yandex_city_sweep" + assert "2025-05-01" in body[0]["started_at"]