feat(tradein): yandex city-sweep — один прогон SERP+address-enrich #867

Merged
bot-reviewer merged 1 commit from feat/tradein-yandex-full-sweep into main 2026-05-31 07:43:14 +00:00
4 changed files with 702 additions and 169 deletions

View file

@ -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 <title> полный адрес с домом).
Прокси уже внутри 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 ───────────────────

View file

@ -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)

View file

@ -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

View file

@ -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</title>")
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 = (
"<title>Продажа квартиры — Екатеринбург, улица Тестовая, 36 — id 123 "
"на Яндекс.Недвижимости</title>"
)
# Мок 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"]