feat(scrapers): wire N1.ru city sweep — pipeline + scheduler + admin endpoint (#575)
n1.py scraper existed and was wired into the generic point-/scrape endpoint, but had no city-sweep + schedule entry. Mirror the yandex sweep (N1 is a simple regional SERP — search → save, no houses/detail enrich): - scrape_pipeline.run_n1_city_sweep + N1CitySweepCounters (anchors × pages → save_listings; cooperative cancel; abort after N consecutive anchor failures). - scheduler.trigger_n1_city_sweep_run + dispatch (source='n1_city_sweep'). - data/sql/084: scrape_schedules seed, enabled=true (vs dormant avito/yandex) — #575 activates N1 as a source; one-line UPDATE to disable if it blocks. - POST /api/v1/admin/scrape/n1 (+ /scrape/n1/runs) for on-demand city-sweep. estimator.sources_used is derived from {lot.source} — 'n1' flows in automatically once n1 listings exist (no estimator change). Tests: test_n1_city_sweep.py (5) — aggregate, run_id threading, empty-page stop, mid-sweep cancel, consecutive-failure abort. ruff clean; 32 scheduler/sweep regression tests pass. Refs #575
This commit is contained in:
parent
34a13d71c3
commit
8204802d36
5 changed files with 714 additions and 130 deletions
|
|
@ -21,7 +21,7 @@ from app.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate
|
|||
from app.services import cian_session as cian_session_svc
|
||||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.geocoder import geocode
|
||||
from app.services.scrape_pipeline import run_avito_city_sweep
|
||||
from app.services.scrape_pipeline import run_avito_city_sweep, run_n1_city_sweep
|
||||
from app.services.scraper_settings import invalidate_cache
|
||||
from app.services.scrapers.avito import AvitoScraper
|
||||
from app.services.scrapers.avito_detail import fetch_detail, save_detail_enrichment
|
||||
|
|
@ -106,7 +106,9 @@ async def scrape_around(
|
|||
async with scraper_cls() as scraper:
|
||||
if source == "yandex" and payload.deep_yandex:
|
||||
lots = await scraper.fetch_around_multi_room(
|
||||
payload.lat, payload.lon, payload.radius_m,
|
||||
payload.lat,
|
||||
payload.lon,
|
||||
payload.radius_m,
|
||||
sorts=("DATE_DESC", "PRICE", "AREA_DESC"),
|
||||
pages=(0, 1),
|
||||
)
|
||||
|
|
@ -123,9 +125,7 @@ async def scrape_around(
|
|||
payload.lat, payload.lon, payload.radius_m
|
||||
)
|
||||
else:
|
||||
lots = await scraper.fetch_around(
|
||||
payload.lat, payload.lon, payload.radius_m
|
||||
)
|
||||
lots = await scraper.fetch_around(payload.lat, payload.lon, payload.radius_m)
|
||||
inserted, updated = save_listings(db, lots)
|
||||
results.append(
|
||||
ScrapeResult(source=source, fetched=len(lots), inserted=inserted, updated=updated)
|
||||
|
|
@ -172,7 +172,8 @@ async def geocode_missing(
|
|||
if target == "listings"
|
||||
else ""
|
||||
)
|
||||
rows = db.execute(
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT id, address
|
||||
|
|
@ -187,7 +188,10 @@ async def geocode_missing(
|
|||
"""
|
||||
),
|
||||
{"limit": limit},
|
||||
).mappings().all()
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
# Бюджет по времени: геокодинг упирается в Nominatim (1 req/sec + typo-тиры
|
||||
# на провалах), 100 адресов могут не уложиться в cron-таймаут `curl -m 320`.
|
||||
|
|
@ -201,7 +205,8 @@ async def geocode_missing(
|
|||
if time.monotonic() - started > budget_sec:
|
||||
logger.info(
|
||||
"geocode-missing: бюджет %.0fс исчерпан, обработано %d — cron продолжит",
|
||||
budget_sec, geocoded + skipped,
|
||||
budget_sec,
|
||||
geocoded + skipped,
|
||||
)
|
||||
break
|
||||
clean = _clean_address_for_geocode(row["address"])
|
||||
|
|
@ -328,7 +333,8 @@ async def get_geocode_backfill_status(
|
|||
|
||||
Включает per-source breakdown и оценку времени при Nominatim 1 req/sec.
|
||||
"""
|
||||
stats = db.execute(
|
||||
stats = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
|
|
@ -342,21 +348,28 @@ async def get_geocode_backfill_status(
|
|||
ORDER BY pending_geocode DESC
|
||||
"""
|
||||
)
|
||||
).mappings().all()
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
total_pending = sum(s["pending_geocode"] for s in stats)
|
||||
unique_addresses_pending = db.execute(
|
||||
unique_addresses_pending = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT address) FROM listings
|
||||
WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) >= 5
|
||||
"""
|
||||
)
|
||||
).scalar() or 0
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
cache_size = db.execute(
|
||||
text("SELECT COUNT(*) FROM geocode_cache WHERE expires_at > NOW()")
|
||||
).scalar() or 0
|
||||
cache_size = (
|
||||
db.execute(text("SELECT COUNT(*) FROM geocode_cache WHERE expires_at > NOW()")).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return {
|
||||
"total_pending_listings": total_pending,
|
||||
|
|
@ -389,6 +402,7 @@ async def trigger_geocode_missing_listings(
|
|||
from app.core.db import SessionLocal
|
||||
|
||||
if background:
|
||||
|
||||
async def _bg_task() -> None:
|
||||
bg_db = SessionLocal()
|
||||
try:
|
||||
|
|
@ -657,6 +671,72 @@ def cancel_avito_city_sweep(
|
|||
return {"ok": True, "run_id": run_id, "cancelled": cancelled}
|
||||
|
||||
|
||||
# ── N1.ru city sweep (#575) — on-demand trigger + runs list ──────────────────
|
||||
|
||||
|
||||
@router.post("/scrape/n1", response_model=CitySweepStartResponse)
|
||||
async def start_n1_city_sweep(
|
||||
payload: CitySweepStartRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> CitySweepStartResponse:
|
||||
"""Запустить N1.ru ЕКБ city sweep в background (#575). Returns run_id.
|
||||
|
||||
Зеркало avito-city-sweep, но N1 — простой SERP (search → save), без
|
||||
houses/detail-enrich: поля enrich_houses/detail_top_n из payload игнорируются.
|
||||
Помимо этого эндпоинта N1 крутится по расписанию (scrape_schedules
|
||||
source='n1_city_sweep', seed 084).
|
||||
"""
|
||||
run_id = runs_mod.create_run(db, source="n1_city_sweep", params=payload.model_dump())
|
||||
|
||||
async def _sweep_task() -> None:
|
||||
sweep_db = SessionLocal()
|
||||
try:
|
||||
await run_n1_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,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("n1-sweep background task run_id=%d crashed", run_id)
|
||||
finally:
|
||||
sweep_db.close()
|
||||
|
||||
background_tasks.add_task(_sweep_task)
|
||||
logger.info("n1-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, # N1 sweep не имеет detail-шага
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scrape/n1/runs", response_model=list[ScrapeRunRow])
|
||||
def list_n1_city_sweep_runs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
limit: int = 10,
|
||||
) -> list[ScrapeRunRow]:
|
||||
"""Список последних N N1-sweep runs (для UI polling). Default limit=10."""
|
||||
rows = runs_mod.list_recent(db, source="n1_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
|
||||
]
|
||||
|
||||
|
||||
# ── Scraper settings: global + per-source delay management ───────────────────
|
||||
|
||||
|
||||
|
|
@ -671,13 +751,17 @@ def list_scraper_settings(
|
|||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict:
|
||||
"""Список всех настроек задержки (per-source + global)."""
|
||||
rows = db.execute(
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT source, request_delay_sec, description, updated_at
|
||||
FROM scraper_settings
|
||||
ORDER BY source ASC
|
||||
""")
|
||||
).mappings().all()
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return {"settings": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
|
@ -700,7 +784,8 @@ def update_scraper_setting(
|
|||
detail=f"Path source={source!r} does not match body source={payload.source!r}",
|
||||
)
|
||||
|
||||
row = db.execute(
|
||||
row = (
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO scraper_settings (source, request_delay_sec, description, updated_at)
|
||||
VALUES (:s, CAST(:d AS numeric), :desc, NOW())
|
||||
|
|
@ -715,13 +800,14 @@ def update_scraper_setting(
|
|||
"d": payload.request_delay_sec,
|
||||
"desc": payload.description,
|
||||
},
|
||||
).mappings().one()
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
db.commit()
|
||||
|
||||
invalidate_cache(source)
|
||||
logger.info(
|
||||
"scraper-settings: updated source=%s delay=%.1f", source, payload.request_delay_sec
|
||||
)
|
||||
logger.info("scraper-settings: updated source=%s delay=%.1f", source, payload.request_delay_sec)
|
||||
return dict(row)
|
||||
|
||||
|
||||
|
|
@ -731,7 +817,8 @@ def update_scraper_setting(
|
|||
@router.get("/scrape/schedules", response_model=list[ScheduleConfig])
|
||||
def list_schedules(db: Annotated[Session, Depends(get_db)]) -> list[ScheduleConfig]:
|
||||
"""Список всех schedules. Сейчас single row 'avito_city_sweep', extensible."""
|
||||
rows = db.execute(
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, source, enabled, window_start_hour, window_end_hour,
|
||||
|
|
@ -741,7 +828,10 @@ def list_schedules(db: Annotated[Session, Depends(get_db)]) -> list[ScheduleConf
|
|||
ORDER BY source
|
||||
"""
|
||||
),
|
||||
).mappings().all()
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
ScheduleConfig(
|
||||
id=r["id"],
|
||||
|
|
@ -771,7 +861,8 @@ def update_schedule(
|
|||
# Compute new next_run_at если window изменился — recompute, иначе keep existing
|
||||
next_at = compute_next_run_at(payload.window_start_hour, payload.window_end_hour)
|
||||
|
||||
row = db.execute(
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scrape_schedules
|
||||
|
|
@ -796,7 +887,10 @@ def update_schedule(
|
|||
"params": json.dumps(payload.default_params, ensure_ascii=False),
|
||||
"next_at": next_at,
|
||||
},
|
||||
).mappings().fetchone()
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert row is not None, f"scrape_schedules upsert returned no row for source={source!r}"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
Sources:
|
||||
- avito_city_sweep → run_avito_city_sweep (scrape_pipeline.py)
|
||||
- yandex_city_sweep → run_yandex_city_sweep (scrape_pipeline.py, #561; shipped DORMANT)
|
||||
- n1_city_sweep → run_n1_city_sweep (scrape_pipeline.py, #575)
|
||||
- cian_history_backfill → run_cian_backfill (this module, #560)
|
||||
- rosreestr_dkp_import → import_rosreestr_dkp (this module, #563)
|
||||
- listing_source_snapshot → snapshot_listing_sources (tasks/listing_source_snapshot.py, #570;
|
||||
|
|
@ -19,6 +20,7 @@ Sources:
|
|||
re-derivation of the asking→sold ratios — no external calls,
|
||||
enabled by default; window after rosreestr_dkp_import)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
|
@ -33,7 +35,11 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.scrape_pipeline import run_avito_city_sweep, run_yandex_city_sweep
|
||||
from app.services.scrape_pipeline import (
|
||||
run_avito_city_sweep,
|
||||
run_n1_city_sweep,
|
||||
run_yandex_city_sweep,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -58,9 +64,7 @@ def compute_next_run_at(
|
|||
start_seconds = window_start_hour * 3600
|
||||
end_seconds = window_end_hour * 3600
|
||||
rand_seconds = random.randint(start_seconds, end_seconds - 1)
|
||||
return datetime.combine(tomorrow, time(0, 0), tzinfo=UTC) + timedelta(
|
||||
seconds=rand_seconds
|
||||
)
|
||||
return datetime.combine(tomorrow, time(0, 0), tzinfo=UTC) + timedelta(seconds=rand_seconds)
|
||||
else:
|
||||
# Cross-midnight (22..3 → 22:00-23:59 + 00:00-02:59)
|
||||
# Длина окна = (24-start) + end часов
|
||||
|
|
@ -231,6 +235,39 @@ async def trigger_yandex_city_sweep_run(db: Session, schedule_row: dict[str, Any
|
|||
return run_id
|
||||
|
||||
|
||||
async def trigger_n1_city_sweep_run(db: Session, schedule_row: dict[str, Any]) -> int | None:
|
||||
"""Создать новый scrape_runs + launch run_n1_city_sweep в asyncio.create_task (#575).
|
||||
|
||||
Зеркало trigger_yandex_city_sweep_run — N1 sweep простой (search → save, без
|
||||
houses/detail). Returns run_id (или None если skip — есть running run).
|
||||
"""
|
||||
run_id = _claim_run(db, schedule_row)
|
||||
if run_id is None:
|
||||
return None
|
||||
|
||||
params = schedule_row.get("default_params") or {}
|
||||
|
||||
async def _run() -> None:
|
||||
run_db = SessionLocal()
|
||||
try:
|
||||
await run_n1_city_sweep(
|
||||
run_db,
|
||||
run_id=run_id,
|
||||
pages_per_anchor=int(params.get("pages_per_anchor", 2)),
|
||||
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
||||
radius_m=int(params.get("radius_m", 1500)),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("scheduler: run_n1_city_sweep crashed run_id=%d", run_id)
|
||||
finally:
|
||||
run_db.close()
|
||||
|
||||
task = asyncio.create_task(_run())
|
||||
task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
|
||||
logger.info("scheduler: triggered n1_city_sweep run_id=%d", run_id)
|
||||
return run_id
|
||||
|
||||
|
||||
async def trigger_cian_backfill_run(db: Session, schedule_row: dict[str, Any]) -> int | None:
|
||||
"""Создать scrape_runs + launch run_cian_backfill в asyncio.create_task.
|
||||
|
||||
|
|
@ -409,9 +446,7 @@ async def trigger_listing_source_snapshot_run(
|
|||
return run_id
|
||||
|
||||
|
||||
async def trigger_asking_to_sold_ratio_run(
|
||||
db: Session, schedule_row: dict[str, Any]
|
||||
) -> int | None:
|
||||
async def trigger_asking_to_sold_ratio_run(db: Session, schedule_row: dict[str, Any]) -> int | None:
|
||||
"""Создать scrape_runs + launch recompute_asking_to_sold_ratios в executor (sync DB-only task).
|
||||
|
||||
Daily TRUE-MIRROR refresh асking→sold коэффициентов (#648 Stage 4): DELETE WHERE
|
||||
|
|
@ -436,9 +471,7 @@ async def trigger_asking_to_sold_ratio_run(
|
|||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, recompute_asking_to_sold_ratios, run_db, run_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"scheduler: recompute_asking_to_sold_ratios crashed run_id=%d", run_id
|
||||
)
|
||||
logger.exception("scheduler: recompute_asking_to_sold_ratios crashed run_id=%d", run_id)
|
||||
finally:
|
||||
run_db.close()
|
||||
|
||||
|
|
@ -498,9 +531,7 @@ def import_rosreestr_dkp(
|
|||
# Cleanup legacy synthetic rows (pre-#549, idempotent)
|
||||
try:
|
||||
deleted = db.execute(
|
||||
text(
|
||||
"DELETE FROM deals WHERE address = CAST(:addr AS text) RETURNING id"
|
||||
),
|
||||
text("DELETE FROM deals WHERE address = CAST(:addr AS text) RETURNING id"),
|
||||
{"addr": "Екатеринбург, реальная сделка"},
|
||||
).fetchall()
|
||||
db.commit()
|
||||
|
|
@ -530,7 +561,8 @@ def import_rosreestr_dkp(
|
|||
# FDW pushes WHERE + ORDER BY + LIMIT to gendesign-postgres automatically
|
||||
# (postgres_fdw 'use_remote_estimate' is off by default but clause push-down
|
||||
# still happens for simple predicates on the remote side).
|
||||
batch_rows = db.execute(
|
||||
batch_rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
id,
|
||||
|
|
@ -569,7 +601,10 @@ def import_rosreestr_dkp(
|
|||
LIMIT CAST(:batch_size AS int)
|
||||
"""),
|
||||
{"since": since, "last_id": last_id, "batch_size": batch_size},
|
||||
).mappings().all()
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
if not batch_rows:
|
||||
break
|
||||
|
|
@ -671,16 +706,15 @@ def import_rosreestr_dkp(
|
|||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"rosreestr_dkp_import run_id=%d failed at last_id=%d", run_id, last_id
|
||||
)
|
||||
logger.exception("rosreestr_dkp_import run_id=%d failed at last_id=%d", run_id, last_id)
|
||||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||||
raise
|
||||
|
||||
|
||||
def get_due_schedules(db: Session) -> list[dict[str, Any]]:
|
||||
"""SELECT scrape_schedules WHERE enabled AND (next_run_at IS NULL OR next_run_at <= NOW())."""
|
||||
rows = db.execute(
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, source, enabled, window_start_hour, window_end_hour,
|
||||
|
|
@ -690,7 +724,10 @@ def get_due_schedules(db: Session) -> list[dict[str, Any]]:
|
|||
AND (next_run_at IS NULL OR next_run_at <= NOW())
|
||||
"""
|
||||
),
|
||||
).mappings().all()
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
|
|
@ -713,6 +750,8 @@ async def scheduler_loop() -> None:
|
|||
await trigger_avito_city_sweep_run(db, sch)
|
||||
elif source == "yandex_city_sweep":
|
||||
await trigger_yandex_city_sweep_run(db, sch)
|
||||
elif source == "n1_city_sweep":
|
||||
await trigger_n1_city_sweep_run(db, sch)
|
||||
elif source == "cian_history_backfill":
|
||||
await trigger_cian_backfill_run(db, sch)
|
||||
elif source == "rosreestr_dkp_import":
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ from app.services.scrapers.avito_detail import fetch_detail, save_detail_enrichm
|
|||
from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
|
||||
from app.services.scrapers.avito_houses import fetch_house_catalog, save_house_catalog_enrichment
|
||||
from app.services.scrapers.base import ScrapedLot, save_listings
|
||||
from app.services.scrapers.n1 import N1Scraper
|
||||
from app.services.scrapers.yandex_realty import YandexRealtyScraper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -140,26 +141,27 @@ async def run_avito_pipeline(
|
|||
scraper._cffi = session
|
||||
try:
|
||||
lots = await scraper.fetch_around(
|
||||
lat, lon, radius_m,
|
||||
lat,
|
||||
lon,
|
||||
radius_m,
|
||||
pages=pages,
|
||||
delay_override_sec=request_delay_sec,
|
||||
)
|
||||
counters.lots_fetched = len(lots)
|
||||
logger.info(
|
||||
"pipeline:search anchor=(%.4f,%.4f) r=%dm fetched=%d",
|
||||
lat, lon, radius_m, len(lots),
|
||||
lat,
|
||||
lon,
|
||||
radius_m,
|
||||
len(lots),
|
||||
)
|
||||
except (AvitoBlockedError, AvitoRateLimitedError):
|
||||
logger.error(
|
||||
"pipeline:search BLOCKED by Avito at anchor=(%.4f,%.4f)", lat, lon
|
||||
)
|
||||
logger.error("pipeline:search BLOCKED by Avito at anchor=(%.4f,%.4f)", lat, lon)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("pipeline:search failed")
|
||||
counters.errors.append(f"search: {e}")
|
||||
return PipelineResult(
|
||||
lat, lon, radius_m, counters, enrich_houses, enrich_detail_top_n
|
||||
)
|
||||
return PipelineResult(lat, lon, radius_m, counters, enrich_houses, enrich_detail_top_n)
|
||||
|
||||
# ── Step 2: save listings ───────────────────────────────
|
||||
if lots:
|
||||
|
|
@ -192,14 +194,10 @@ async def run_avito_pipeline(
|
|||
save_house_catalog_enrichment(db, enrichment)
|
||||
counters.houses_enriched += 1
|
||||
except (AvitoBlockedError, AvitoRateLimitedError):
|
||||
logger.error(
|
||||
"pipeline:house BLOCKED at %s — propagating", house_path
|
||||
)
|
||||
logger.error("pipeline:house BLOCKED at %s — propagating", house_path)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"pipeline:house_enrich failed for %s: %s", house_path, e
|
||||
)
|
||||
logger.warning("pipeline:house_enrich failed for %s: %s", house_path, e)
|
||||
counters.houses_failed += 1
|
||||
counters.errors.append(f"house {house_path}: {e}")
|
||||
|
||||
|
|
@ -208,7 +206,8 @@ async def run_avito_pipeline(
|
|||
|
||||
# ── Step 5: enrich detail для top-N priority listings ───
|
||||
if enrich_detail_top_n > 0:
|
||||
priority_rows = db.execute(
|
||||
priority_rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT source_url
|
||||
FROM listings
|
||||
|
|
@ -238,7 +237,10 @@ async def run_avito_pipeline(
|
|||
"radius": radius_m * 2,
|
||||
"limit": enrich_detail_top_n,
|
||||
},
|
||||
).mappings().all()
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
consecutive_blocks = 0
|
||||
for idx, row in enumerate(priority_rows):
|
||||
|
|
@ -246,9 +248,7 @@ async def run_avito_pipeline(
|
|||
counters.detail_attempted += 1
|
||||
try:
|
||||
item_url = (
|
||||
urlparse(source_url).path
|
||||
if source_url.startswith("http")
|
||||
else source_url
|
||||
urlparse(source_url).path if source_url.startswith("http") else source_url
|
||||
)
|
||||
enrichment_detail = await fetch_detail(item_url, cffi_session=session)
|
||||
if save_detail_enrichment(db, enrichment_detail):
|
||||
|
|
@ -260,7 +260,10 @@ async def run_avito_pipeline(
|
|||
counters.errors.append(f"BLOCKED_DETAIL #{idx}: {e}")
|
||||
logger.warning(
|
||||
"pipeline:detail BLOCKED #%d/%d (consecutive=%d): %s",
|
||||
idx + 1, len(priority_rows), consecutive_blocks, e,
|
||||
idx + 1,
|
||||
len(priority_rows),
|
||||
consecutive_blocks,
|
||||
e,
|
||||
)
|
||||
if consecutive_blocks >= 3:
|
||||
logger.error(
|
||||
|
|
@ -275,9 +278,7 @@ async def run_avito_pipeline(
|
|||
except Exception as e:
|
||||
counters.detail_failed += 1
|
||||
counters.errors.append(f"detail {source_url}: {e}")
|
||||
logger.warning(
|
||||
"pipeline:detail_enrich failed for %s: %s", source_url, e
|
||||
)
|
||||
logger.warning("pipeline:detail_enrich failed for %s: %s", source_url, e)
|
||||
consecutive_blocks = 0
|
||||
|
||||
if idx < len(priority_rows) - 1:
|
||||
|
|
@ -287,10 +288,15 @@ async def run_avito_pipeline(
|
|||
logger.info(
|
||||
"pipeline:done anchor=(%.4f,%.4f) lots=%d (ins=%d/upd=%d) "
|
||||
"houses=%d/%d detail=%d/%d errors=%d",
|
||||
lat, lon,
|
||||
counters.lots_fetched, counters.lots_inserted, counters.lots_updated,
|
||||
counters.houses_enriched, counters.unique_houses,
|
||||
counters.detail_enriched, counters.detail_attempted,
|
||||
lat,
|
||||
lon,
|
||||
counters.lots_fetched,
|
||||
counters.lots_inserted,
|
||||
counters.lots_updated,
|
||||
counters.houses_enriched,
|
||||
counters.unique_houses,
|
||||
counters.detail_enriched,
|
||||
counters.detail_attempted,
|
||||
len(counters.errors),
|
||||
)
|
||||
return PipelineResult(lat, lon, radius_m, counters, enrich_houses, enrich_detail_top_n)
|
||||
|
|
@ -351,14 +357,22 @@ async def run_avito_city_sweep(
|
|||
if scrape_runs.is_cancelled(db, run_id):
|
||||
logger.info(
|
||||
"city-sweep run_id=%d: cancelled at anchor #%d/%d (%s)",
|
||||
run_id, idx, len(_anchors), name,
|
||||
run_id,
|
||||
idx,
|
||||
len(_anchors),
|
||||
name,
|
||||
)
|
||||
scrape_runs.update_heartbeat(db, run_id, counters.to_dict())
|
||||
return counters
|
||||
|
||||
logger.info(
|
||||
"city-sweep run_id=%d anchor #%d/%d (%s, %.4f, %.4f)",
|
||||
run_id, idx, len(_anchors), name, lat, lon,
|
||||
run_id,
|
||||
idx,
|
||||
len(_anchors),
|
||||
name,
|
||||
lat,
|
||||
lon,
|
||||
)
|
||||
try:
|
||||
result = await run_avito_pipeline(
|
||||
|
|
@ -385,7 +399,11 @@ async def run_avito_city_sweep(
|
|||
except (AvitoBlockedError, AvitoRateLimitedError) as e:
|
||||
logger.error(
|
||||
"city-sweep run_id=%d ABORT at anchor #%d/%d (%s) — blocked: %s",
|
||||
run_id, idx, len(_anchors), name, e,
|
||||
run_id,
|
||||
idx,
|
||||
len(_anchors),
|
||||
name,
|
||||
e,
|
||||
)
|
||||
counters.errors_count += 1
|
||||
counters.anchors_done = idx
|
||||
|
|
@ -393,9 +411,7 @@ async def run_avito_city_sweep(
|
|||
scrape_runs.mark_banned(db, run_id, str(e), counters.to_dict())
|
||||
return counters
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"city-sweep run_id=%d: anchor %s failed", run_id, name
|
||||
)
|
||||
logger.exception("city-sweep run_id=%d: anchor %s failed", run_id, name)
|
||||
counters.errors_count += 1
|
||||
|
||||
counters.anchors_done = idx
|
||||
|
|
@ -493,14 +509,22 @@ async def run_yandex_city_sweep(
|
|||
if scrape_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,
|
||||
run_id,
|
||||
idx,
|
||||
len(_anchors),
|
||||
name,
|
||||
)
|
||||
scrape_runs.update_heartbeat(db, run_id, counters.to_dict())
|
||||
return counters
|
||||
|
||||
logger.info(
|
||||
"yandex-sweep run_id=%d anchor #%d/%d (%s, %.4f, %.4f)",
|
||||
run_id, idx, len(_anchors), name, lat, lon,
|
||||
run_id,
|
||||
idx,
|
||||
len(_anchors),
|
||||
name,
|
||||
lat,
|
||||
lon,
|
||||
)
|
||||
try:
|
||||
# Fresh scraper per anchor — uses its own httpx.AsyncClient via the
|
||||
|
|
@ -508,9 +532,7 @@ async def run_yandex_city_sweep(
|
|||
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
|
||||
)
|
||||
page_lots = await scraper.fetch_around(lat, lon, radius_m, page=page)
|
||||
counters.pages_fetched += 1
|
||||
if not page_lots:
|
||||
# Пустая страница → дальше результатов нет, прекращаем пагинацию.
|
||||
|
|
@ -526,16 +548,19 @@ async def run_yandex_city_sweep(
|
|||
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
|
||||
)
|
||||
logger.exception("yandex-sweep run_id=%d: anchor %s 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,
|
||||
run_id,
|
||||
idx,
|
||||
len(_anchors),
|
||||
name,
|
||||
consecutive_failures,
|
||||
e,
|
||||
)
|
||||
counters.anchors_done = idx
|
||||
scrape_runs.update_heartbeat(db, run_id, counters.to_dict())
|
||||
|
|
@ -576,6 +601,155 @@ async def run_yandex_city_sweep(
|
|||
raise
|
||||
|
||||
|
||||
# ── N1.ru city sweep (#575) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class N1CitySweepCounters:
|
||||
"""Aggregate counters для N1.ru city sweep run.
|
||||
|
||||
Как Yandex: N1 SERP — простой search → save, без house-catalog / detail-enrich
|
||||
шага, поэтому houses/detail-полей нет.
|
||||
"""
|
||||
|
||||
anchors_total: int = 0
|
||||
anchors_done: int = 0
|
||||
pages_fetched: int = 0
|
||||
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)}
|
||||
|
||||
|
||||
# Defensive abort: N1Scraper.fetch_around глотает ошибки и возвращает [] (нет
|
||||
# block/rate exception-классов). Прерываем sweep после N подряд anchor'ов,
|
||||
# упавших с исключением — сигнал системного блока IP. Пустой результат (0 lots)
|
||||
# — НЕ ошибка, счётчик не растёт (зеркало yandex).
|
||||
N1_SWEEP_MAX_CONSECUTIVE_FAILURES = 3
|
||||
|
||||
|
||||
async def run_n1_city_sweep(
|
||||
db: Session,
|
||||
*,
|
||||
run_id: int,
|
||||
anchors: list[tuple[float, float, str]] | None = None,
|
||||
radius_m: int = 1500,
|
||||
pages_per_anchor: int = 2,
|
||||
request_delay_sec: float | None = None,
|
||||
) -> N1CitySweepCounters:
|
||||
"""N1.ru city sweep: iterate anchors × pages → save (#575).
|
||||
|
||||
Зеркало run_yandex_city_sweep — N1 это региональный SERP-портал ЕКБ без
|
||||
house-catalog / detail-enrich шага:
|
||||
per anchor: N1Scraper().fetch_around(lat, lon, radius_m, page=p)
|
||||
→ save_listings(db, lots) → accumulate counters.
|
||||
|
||||
Anti-bot / cancel семантика (зеркало yandex/avito sweep):
|
||||
- Cooperative cancel: is_cancelled(db, run_id) перед каждым anchor.
|
||||
- N1Scraper не определяет block/rate exception-классов (fetch_around ловит
|
||||
ошибки внутри → []). Per-anchor ловим broad Exception, логируем, continue;
|
||||
защитно прерываем (early abort + mark_banned) при N подряд исключениях.
|
||||
- request_delay_sec применяется как доп. asyncio.sleep МЕЖДУ anchor'ами
|
||||
(N1Scraper сам спит self.request_delay_sec между страницами).
|
||||
"""
|
||||
_anchors = anchors if anchors is not None else EKB_ANCHORS
|
||||
counters = N1CitySweepCounters(anchors_total=len(_anchors))
|
||||
inter_anchor_delay = request_delay_sec if request_delay_sec is not None else 7.0
|
||||
consecutive_failures = 0
|
||||
|
||||
try:
|
||||
for idx, (lat, lon, name) in enumerate(_anchors, start=1):
|
||||
if scrape_runs.is_cancelled(db, run_id):
|
||||
logger.info(
|
||||
"n1-sweep run_id=%d: cancelled at anchor #%d/%d (%s)",
|
||||
run_id,
|
||||
idx,
|
||||
len(_anchors),
|
||||
name,
|
||||
)
|
||||
scrape_runs.update_heartbeat(db, run_id, counters.to_dict())
|
||||
return counters
|
||||
|
||||
logger.info(
|
||||
"n1-sweep run_id=%d anchor #%d/%d (%s, %.4f, %.4f)",
|
||||
run_id,
|
||||
idx,
|
||||
len(_anchors),
|
||||
name,
|
||||
lat,
|
||||
lon,
|
||||
)
|
||||
try:
|
||||
anchor_lots: list[ScrapedLot] = []
|
||||
async with N1Scraper() as scraper:
|
||||
for page in range(1, pages_per_anchor + 1):
|
||||
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)
|
||||
|
||||
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
|
||||
except Exception as e:
|
||||
logger.exception("n1-sweep run_id=%d: anchor %s failed", run_id, name)
|
||||
counters.errors_count += 1
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures >= N1_SWEEP_MAX_CONSECUTIVE_FAILURES:
|
||||
logger.error(
|
||||
"n1-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
|
||||
scrape_runs.update_heartbeat(db, run_id, counters.to_dict())
|
||||
scrape_runs.mark_banned(
|
||||
db,
|
||||
run_id,
|
||||
f"n1 sweep aborted: {consecutive_failures} consecutive "
|
||||
f"anchor failures (last: {e})",
|
||||
counters.to_dict(),
|
||||
)
|
||||
return counters
|
||||
|
||||
counters.anchors_done = idx
|
||||
scrape_runs.update_heartbeat(db, run_id, counters.to_dict())
|
||||
|
||||
if idx < len(_anchors):
|
||||
await asyncio.sleep(inter_anchor_delay)
|
||||
|
||||
scrape_runs.mark_done(db, run_id, counters.to_dict())
|
||||
logger.info(
|
||||
"n1-sweep run_id=%d done: anchors=%d/%d pages=%d lots=%d (ins=%d/upd=%d) errors=%d",
|
||||
run_id,
|
||||
counters.anchors_done,
|
||||
counters.anchors_total,
|
||||
counters.pages_fetched,
|
||||
counters.lots_fetched,
|
||||
counters.lots_inserted,
|
||||
counters.lots_updated,
|
||||
counters.errors_count,
|
||||
)
|
||||
return counters
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("n1-sweep run_id=%d: fatal error", run_id)
|
||||
scrape_runs.mark_failed(db, run_id, str(exc), counters.to_dict())
|
||||
raise
|
||||
|
||||
|
||||
# ── Public re-exports ───────────────────────────────────────────
|
||||
__all__ = [
|
||||
"EKB_ANCHORS",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
-- 084_scrape_schedules_seed_n1_sweep.sql
|
||||
-- Seed row для N1.ru периодического city-sweep (#575).
|
||||
--
|
||||
-- N1 — региональный портал недвижимости ЕКБ. В отличие от avito/yandex sweep
|
||||
-- (засеяны DORMANT из-за анти-бот риска с prod-IP), N1 засеян ВКЛЮЧЁННЫМ
|
||||
-- (enabled = true): задача #575 — активировать N1 как источник listings
|
||||
-- (acceptance: ≥100 строк source='n1' за сутки). Anti-bot у регионального портала
|
||||
-- мягче, чем у Avito/Yandex; параметры консервативные. Capability полностью wired
|
||||
-- (run_n1_city_sweep + trigger_n1_city_sweep_run + dispatch в scheduler).
|
||||
--
|
||||
-- ЕСЛИ N1 начнёт блокировать prod-IP — выключить одной строкой:
|
||||
-- UPDATE scrape_schedules SET enabled = false WHERE source = 'n1_city_sweep';
|
||||
-- Ручной on-demand триггер для проверки до ночного окна — POST /api/v1/admin/scrape/n1.
|
||||
--
|
||||
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)).
|
||||
-- Idempotent: ON CONFLICT (source) DO NOTHING — безопасно запускать повторно.
|
||||
--
|
||||
-- n1_city_sweep — окно 02:00-05:00 UTC (05:00-08:00 МСК), как avito/yandex sweep.
|
||||
-- default_params: pages_per_anchor (страниц SERP на anchor), request_delay_sec
|
||||
-- (пауза между anchor'ами), radius_m (прокидывается в fetch_around).
|
||||
-- У N1 sweep НЕТ detail/houses-enrich шага (как у yandex).
|
||||
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO scrape_schedules (
|
||||
source,
|
||||
enabled,
|
||||
window_start_hour,
|
||||
window_end_hour,
|
||||
next_run_at,
|
||||
default_params
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'n1_city_sweep',
|
||||
true, -- #575: активируем N1 как источник
|
||||
2,
|
||||
5,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 2, "request_delay_sec": 7, "radius_m": 1500}'::jsonb
|
||||
)
|
||||
ON CONFLICT (source) DO NOTHING;
|
||||
|
||||
COMMENT ON TABLE scrape_schedules IS
|
||||
'In-app scheduler config (заменяет cron-script setup). '
|
||||
'Sources: avito_city_sweep, yandex_city_sweep (dormant, #561), '
|
||||
'n1_city_sweep (#575), cian_history_backfill, rosreestr_dkp_import.';
|
||||
|
||||
COMMIT;
|
||||
228
tradein-mvp/backend/tests/test_n1_city_sweep.py
Normal file
228
tradein-mvp/backend/tests/test_n1_city_sweep.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
"""Offline unit tests for run_n1_city_sweep (#575).
|
||||
|
||||
Pure & fast: NO live network, NO DB. Mirrors test_yandex_city_sweep — monkeypatch
|
||||
N1Scraper lifecycle + fetch_around, scrape_pipeline.save_listings, scrape_runs.*.
|
||||
Asserts orchestration semantics only (live N1 behavior verified by post-deploy QA).
|
||||
|
||||
NB: run_n1_city_sweep пагинирует page=1..pages_per_anchor (1-based), в отличие от
|
||||
yandex (0-based) — тесты учитывают это.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import scrape_pipeline
|
||||
from app.services.scrapers.base import ScrapedLot
|
||||
from app.services.scrapers.n1 import N1Scraper
|
||||
|
||||
TEST_ANCHORS: list[tuple[float, float, str]] = [
|
||||
(56.8400, 60.6050, "A1"),
|
||||
(56.7950, 60.5300, "A2"),
|
||||
(56.8970, 60.6100, "A3"),
|
||||
]
|
||||
|
||||
|
||||
def _fake_lot(offer_id: str) -> ScrapedLot:
|
||||
return ScrapedLot(
|
||||
source="n1",
|
||||
source_url=f"https://ekb.n1.ru/view/{offer_id}/",
|
||||
source_id=offer_id,
|
||||
address="Екатеринбург, улица Тестовая, 1",
|
||||
price_rub=5_000_000,
|
||||
area_m2=50.0,
|
||||
rooms=2,
|
||||
)
|
||||
|
||||
|
||||
class _FakeRuns:
|
||||
def __init__(self, *, cancel_at_anchor: int | None = None) -> None:
|
||||
self.cancel_at_anchor = cancel_at_anchor
|
||||
self.is_cancelled_calls = 0
|
||||
self.heartbeats: list[dict[str, int]] = []
|
||||
self.done: dict[str, int] | None = None
|
||||
self.failed: tuple[str, dict[str, int]] | None = None
|
||||
self.banned: tuple[str, dict[str, int]] | None = None
|
||||
|
||||
def is_cancelled(self, _db: Any, _run_id: int) -> bool:
|
||||
self.is_cancelled_calls += 1
|
||||
if self.cancel_at_anchor is None:
|
||||
return False
|
||||
return self.is_cancelled_calls >= self.cancel_at_anchor
|
||||
|
||||
def update_heartbeat(self, _db: Any, _run_id: int, counters: dict[str, int]) -> None:
|
||||
self.heartbeats.append(dict(counters))
|
||||
|
||||
def mark_done(self, _db: Any, _run_id: int, counters: dict[str, int]) -> None:
|
||||
self.done = dict(counters)
|
||||
|
||||
def mark_failed(self, _db: Any, _run_id: int, error: str, counters: dict[str, int]) -> None:
|
||||
self.failed = (error, dict(counters))
|
||||
|
||||
def mark_banned(self, _db: Any, _run_id: int, error: str, counters: dict[str, int]) -> None:
|
||||
self.banned = (error, dict(counters))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def _instant(_secs: float) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(scrape_pipeline.asyncio, "sleep", _instant)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_scraper_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Neutralize __init__/__aenter__/__aexit__ — no get_scraper_delay DB, no curl_cffi."""
|
||||
|
||||
def _init(self: N1Scraper) -> None:
|
||||
self.request_delay_sec = 0.0
|
||||
self._cffi = None
|
||||
|
||||
async def _aenter(self: N1Scraper) -> N1Scraper:
|
||||
return self
|
||||
|
||||
async def _aexit(self: N1Scraper, *_args: Any) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(N1Scraper, "__init__", _init)
|
||||
monkeypatch.setattr(N1Scraper, "__aenter__", _aenter)
|
||||
monkeypatch.setattr(N1Scraper, "__aexit__", _aexit)
|
||||
|
||||
|
||||
def _install_runs(monkeypatch: pytest.MonkeyPatch, fake: _FakeRuns) -> None:
|
||||
monkeypatch.setattr(scrape_pipeline.scrape_runs, "is_cancelled", fake.is_cancelled)
|
||||
monkeypatch.setattr(scrape_pipeline.scrape_runs, "update_heartbeat", fake.update_heartbeat)
|
||||
monkeypatch.setattr(scrape_pipeline.scrape_runs, "mark_done", fake.mark_done)
|
||||
monkeypatch.setattr(scrape_pipeline.scrape_runs, "mark_failed", fake.mark_failed)
|
||||
monkeypatch.setattr(scrape_pipeline.scrape_runs, "mark_banned", fake.mark_banned)
|
||||
|
||||
|
||||
async def test_sweep_iterates_all_anchors_and_aggregates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fetch_calls: list[tuple[float, float, int]] = []
|
||||
save_calls: list[int] = []
|
||||
|
||||
async def fake_fetch(self, lat, lon, radius_m=1000, rooms=None, page=1):
|
||||
fetch_calls.append((lat, lon, page))
|
||||
return [_fake_lot(f"{lat}-{page}-{i}") for i in range(2)]
|
||||
|
||||
def fake_save(_db, lots, *, run_id=None):
|
||||
save_calls.append(len(lots))
|
||||
return len(lots), 0
|
||||
|
||||
monkeypatch.setattr(N1Scraper, "fetch_around", fake_fetch)
|
||||
monkeypatch.setattr(scrape_pipeline, "save_listings", fake_save)
|
||||
fake = _FakeRuns()
|
||||
_install_runs(monkeypatch, fake)
|
||||
|
||||
counters = await scrape_pipeline.run_n1_city_sweep(
|
||||
db=object(), run_id=1, anchors=TEST_ANCHORS, pages_per_anchor=2, request_delay_sec=0.0
|
||||
)
|
||||
|
||||
assert len(fetch_calls) == len(TEST_ANCHORS) * 2
|
||||
assert [p for _, _, p in fetch_calls[:2]] == [1, 2] # 1-based pagination
|
||||
assert len(save_calls) == 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.errors_count == 0
|
||||
assert fake.done is not None and fake.done["lots_fetched"] == 12
|
||||
assert fake.failed is None and fake.banned is None
|
||||
|
||||
|
||||
async def test_save_listings_receives_run_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
seen: list[int | None] = []
|
||||
|
||||
async def fake_fetch(self, lat, lon, radius_m=1000, rooms=None, page=1):
|
||||
return [_fake_lot(f"{lat}-{page}")]
|
||||
|
||||
def fake_save(_db, lots, *, run_id=None):
|
||||
seen.append(run_id)
|
||||
return len(lots), 0
|
||||
|
||||
monkeypatch.setattr(N1Scraper, "fetch_around", fake_fetch)
|
||||
monkeypatch.setattr(scrape_pipeline, "save_listings", fake_save)
|
||||
_install_runs(monkeypatch, _FakeRuns())
|
||||
|
||||
await scrape_pipeline.run_n1_city_sweep(
|
||||
db=object(), run_id=777, anchors=TEST_ANCHORS, pages_per_anchor=1
|
||||
)
|
||||
assert seen == [777, 777, 777]
|
||||
|
||||
|
||||
async def test_empty_page_stops_pagination(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
pages_seen: list[int] = []
|
||||
|
||||
async def fake_fetch(self, lat, lon, radius_m=1000, rooms=None, page=1):
|
||||
pages_seen.append(page)
|
||||
if page == 1:
|
||||
return [_fake_lot(f"{lat}-1")]
|
||||
return [] # page 2 empty → stop
|
||||
|
||||
monkeypatch.setattr(N1Scraper, "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_n1_city_sweep(
|
||||
db=object(), run_id=2, anchors=TEST_ANCHORS, pages_per_anchor=5
|
||||
)
|
||||
assert pages_seen == [1, 2, 1, 2, 1, 2]
|
||||
assert counters.pages_fetched == 6
|
||||
assert counters.lots_fetched == 3
|
||||
assert counters.anchors_done == 3
|
||||
|
||||
|
||||
async def test_cancel_midway_stops_remaining(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fetch_calls: list[str] = []
|
||||
|
||||
async def fake_fetch(self, lat, lon, radius_m=1000, rooms=None, page=1):
|
||||
fetch_calls.append(f"{lat}")
|
||||
return [_fake_lot(f"{lat}-{page}")]
|
||||
|
||||
monkeypatch.setattr(N1Scraper, "fetch_around", fake_fetch)
|
||||
monkeypatch.setattr(
|
||||
scrape_pipeline, "save_listings", lambda _db, lots, *, run_id=None: (len(lots), 0)
|
||||
)
|
||||
fake = _FakeRuns(cancel_at_anchor=2)
|
||||
_install_runs(monkeypatch, fake)
|
||||
|
||||
counters = await scrape_pipeline.run_n1_city_sweep(
|
||||
db=object(), run_id=3, anchors=TEST_ANCHORS, pages_per_anchor=1
|
||||
)
|
||||
assert len(fetch_calls) == 1
|
||||
assert counters.anchors_done == 1
|
||||
assert fake.done is None and fake.banned is None
|
||||
|
||||
|
||||
async def test_consecutive_failures_abort_sweep(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
attempts: list[str] = []
|
||||
|
||||
async def fake_fetch(self, lat, lon, radius_m=1000, rooms=None, page=1):
|
||||
attempts.append(f"{lat}")
|
||||
raise RuntimeError("n1 blocked")
|
||||
|
||||
monkeypatch.setattr(N1Scraper, "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)
|
||||
|
||||
anchors = [*TEST_ANCHORS, (56.77, 60.55, "A4"), (56.865, 60.62, "A5")]
|
||||
counters = await scrape_pipeline.run_n1_city_sweep(
|
||||
db=object(), run_id=5, anchors=anchors, pages_per_anchor=1
|
||||
)
|
||||
n = scrape_pipeline.N1_SWEEP_MAX_CONSECUTIVE_FAILURES
|
||||
assert len(attempts) == n
|
||||
assert counters.errors_count == n
|
||||
assert counters.anchors_done == n
|
||||
assert fake.banned is not None and "consecutive" in fake.banned[0]
|
||||
assert fake.done is None
|
||||
Loading…
Add table
Reference in a new issue