chore(tradein): деактивировать N1.ru scraper — источник переехал в Cian
All checks were successful
CI / openapi-codegen-check (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
All checks were successful
CI / openapi-codegen-check (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
N1.ru перестал быть самостоятельным источником (переехал в Cian, подтверждено владельцем 2026-06). Активный скрап прекращён; 389 исторических listings в БД сохраняются без изменений. Убрано: - app/services/scrapers/n1.py (удалён) - run_n1_city_sweep + N1CitySweepCounters + N1_SWEEP_MAX_CONSECUTIVE_FAILURES из scrape_pipeline.py - trigger_n1_city_sweep_run + dispatch elif в scheduler.py - POST /scrape/n1 + GET /scrape/n1/runs endpoints из admin.py - N1Scraper из scrape_around dispatch dict и ScrapeRequest.multi_room_n1 - "n1": 5.0 из scraper_settings._DEFAULT_DELAY_BY_SOURCE - tests/test_n1_city_sweep.py, tests/test_n1_floor_parse.py (удалены) Добавлено: - data/sql/114_disable_n1_sweep.sql: UPDATE scrape_schedules SET enabled=false WHERE source='n1_city_sweep' (idempotent, строка сохраняется для audit-истории) Сохранено (историческое): - schemas/search.py Literal[..."n1"] — API-фильтр исторических listings - config.py scrape_allowed_hosts n1.ru / ekaterinburg.n1.ru - exporters/trade_in_pdf.py — цвет/лейбл N1 для отображения старых записей - geocoder.py и admin.py docstring N1-комментарии где не активный код 1802 passed, 2 deselected (test_search_cache_hit, test_cian_valuation.cache_hit)
This commit is contained in:
parent
f160ad3859
commit
c07b5d7c5d
9 changed files with 27 additions and 940 deletions
|
|
@ -27,7 +27,6 @@ from app.services.scrape_pipeline import (
|
||||||
run_avito_city_sweep,
|
run_avito_city_sweep,
|
||||||
run_cian_city_sweep,
|
run_cian_city_sweep,
|
||||||
run_cian_full_load,
|
run_cian_full_load,
|
||||||
run_n1_city_sweep,
|
|
||||||
run_yandex_city_sweep,
|
run_yandex_city_sweep,
|
||||||
run_yandex_full_load,
|
run_yandex_full_load,
|
||||||
)
|
)
|
||||||
|
|
@ -44,7 +43,6 @@ from app.services.scrapers.avito_imv import (
|
||||||
from app.services.scrapers.base import save_listings
|
from app.services.scrapers.base import save_listings
|
||||||
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||||
from app.services.scrapers.cian import CianScraper
|
from app.services.scrapers.cian import CianScraper
|
||||||
from app.services.scrapers.n1 import N1Scraper
|
|
||||||
from app.services.scrapers.yandex_detail import YandexDetailScraper
|
from app.services.scrapers.yandex_detail import YandexDetailScraper
|
||||||
from app.services.scrapers.yandex_newbuilding import YandexNewbuildingScraper
|
from app.services.scrapers.yandex_newbuilding import YandexNewbuildingScraper
|
||||||
from app.services.scrapers.yandex_realty import YandexRealtyScraper
|
from app.services.scrapers.yandex_realty import YandexRealtyScraper
|
||||||
|
|
@ -67,14 +65,14 @@ def _assert_allowed_url(url: str) -> None:
|
||||||
raise HTTPException(status_code=400, detail="host not allowed")
|
raise HTTPException(status_code=400, detail="host not allowed")
|
||||||
|
|
||||||
|
|
||||||
_ALL_SOURCES = ["avito", "cian", "yandex", "n1"]
|
_ALL_SOURCES = ["avito", "cian", "yandex"]
|
||||||
|
|
||||||
|
|
||||||
class ScrapeRequest(BaseModel):
|
class ScrapeRequest(BaseModel):
|
||||||
lat: float
|
lat: float
|
||||||
lon: float
|
lon: float
|
||||||
radius_m: int = Field(default=1000, ge=100, le=20000)
|
radius_m: int = Field(default=1000, ge=100, le=20000)
|
||||||
sources: list[Literal["avito", "cian", "yandex", "n1"]] = Field(
|
sources: list[Literal["avito", "cian", "yandex"]] = Field(
|
||||||
default_factory=lambda: list(_ALL_SOURCES)
|
default_factory=lambda: list(_ALL_SOURCES)
|
||||||
)
|
)
|
||||||
# Если True — скрейп Yandex по 5 сегментам комнат, не одним общим запросом.
|
# Если True — скрейп Yandex по 5 сегментам комнат, не одним общим запросом.
|
||||||
|
|
@ -83,8 +81,6 @@ class ScrapeRequest(BaseModel):
|
||||||
deep_yandex: bool = False
|
deep_yandex: bool = False
|
||||||
# Если True — скрейп Cian по 4 сегментам комнат отдельно (~4× лотов).
|
# Если True — скрейп Cian по 4 сегментам комнат отдельно (~4× лотов).
|
||||||
multi_room_cian: bool = False
|
multi_room_cian: bool = False
|
||||||
# Если True — скрейп N1 по 4 сегментам комнат отдельно (~4× лотов).
|
|
||||||
multi_room_n1: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class ScrapeResult(BaseModel):
|
class ScrapeResult(BaseModel):
|
||||||
|
|
@ -120,7 +116,6 @@ async def scrape_around(
|
||||||
"avito": AvitoScraper,
|
"avito": AvitoScraper,
|
||||||
"cian": CianScraper,
|
"cian": CianScraper,
|
||||||
"yandex": YandexRealtyScraper,
|
"yandex": YandexRealtyScraper,
|
||||||
"n1": N1Scraper,
|
|
||||||
}.get(source)
|
}.get(source)
|
||||||
if scraper_cls is None:
|
if scraper_cls is None:
|
||||||
continue
|
continue
|
||||||
|
|
@ -141,10 +136,6 @@ async def scrape_around(
|
||||||
lots = await scraper.fetch_around_multi_room(
|
lots = await scraper.fetch_around_multi_room(
|
||||||
payload.lat, payload.lon, payload.radius_m
|
payload.lat, payload.lon, payload.radius_m
|
||||||
)
|
)
|
||||||
elif source == "n1" and payload.multi_room_n1:
|
|
||||||
lots = await scraper.fetch_around_multi_room(
|
|
||||||
payload.lat, payload.lon, payload.radius_m
|
|
||||||
)
|
|
||||||
else:
|
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)
|
inserted, updated = save_listings(db, lots)
|
||||||
|
|
@ -792,72 +783,6 @@ def cancel_avito_city_sweep(
|
||||||
return {"ok": True, "run_id": run_id, "cancelled": cancelled}
|
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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Cian city sweep (#860) — on-demand trigger + runs list ───────────────────
|
# ── Cian city sweep (#860) — on-demand trigger + runs list ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
Sources:
|
Sources:
|
||||||
- avito_city_sweep → run_avito_city_sweep (scrape_pipeline.py)
|
- avito_city_sweep → run_avito_city_sweep (scrape_pipeline.py)
|
||||||
- yandex_city_sweep → run_yandex_city_sweep (scrape_pipeline.py, #561; shipped DORMANT)
|
- 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)
|
- cian_history_backfill → run_cian_backfill (this module, #560)
|
||||||
- rosreestr_dkp_import → import_rosreestr_dkp (this module, #563)
|
- rosreestr_dkp_import → import_rosreestr_dkp (this module, #563)
|
||||||
- listing_source_snapshot → snapshot_listing_sources (tasks/listing_source_snapshot.py, #570;
|
- listing_source_snapshot → snapshot_listing_sources (tasks/listing_source_snapshot.py, #570;
|
||||||
|
|
@ -81,7 +80,6 @@ from app.services import scrape_runs as runs_mod
|
||||||
from app.services.scrape_pipeline import (
|
from app.services.scrape_pipeline import (
|
||||||
run_avito_city_sweep,
|
run_avito_city_sweep,
|
||||||
run_cian_city_sweep,
|
run_cian_city_sweep,
|
||||||
run_n1_city_sweep,
|
|
||||||
run_yandex_city_sweep,
|
run_yandex_city_sweep,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -333,39 +331,6 @@ async def trigger_yandex_city_sweep_run(db: Session, schedule_row: dict[str, Any
|
||||||
return run_id
|
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:
|
async def trigger_cian_backfill_run(db: Session, schedule_row: dict[str, Any]) -> int | None:
|
||||||
"""Создать scrape_runs + launch run_cian_backfill в asyncio.create_task.
|
"""Создать scrape_runs + launch run_cian_backfill в asyncio.create_task.
|
||||||
|
|
||||||
|
|
@ -1289,8 +1254,6 @@ async def scheduler_loop() -> None:
|
||||||
await trigger_avito_city_sweep_run(db, sch)
|
await trigger_avito_city_sweep_run(db, sch)
|
||||||
elif source == "yandex_city_sweep":
|
elif source == "yandex_city_sweep":
|
||||||
await trigger_yandex_city_sweep_run(db, sch)
|
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":
|
elif source == "cian_history_backfill":
|
||||||
await trigger_cian_backfill_run(db, sch)
|
await trigger_cian_backfill_run(db, sch)
|
||||||
elif source == "cian_city_sweep":
|
elif source == "cian_city_sweep":
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateL
|
||||||
from app.services.scrapers.avito_houses import fetch_house_catalog, save_house_catalog_enrichment
|
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.base import ScrapedLot, save_listings
|
||||||
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||||
from app.services.scrapers.n1 import N1Scraper
|
|
||||||
from app.services.scrapers.yandex_realty import YandexRealtyScraper
|
from app.services.scrapers.yandex_realty import YandexRealtyScraper
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -1037,155 +1036,6 @@ async def run_yandex_city_sweep(
|
||||||
raise
|
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
|
|
||||||
|
|
||||||
|
|
||||||
# ── Cian city sweep (#860) ──────────────────────────────────────────────────
|
# ── Cian city sweep (#860) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ _DEFAULT_DELAY_BY_SOURCE: dict[str, float] = {
|
||||||
"avito": 7.0,
|
"avito": 7.0,
|
||||||
"cian": 5.0,
|
"cian": 5.0,
|
||||||
"domklik": 8.0,
|
"domklik": 8.0,
|
||||||
"n1": 5.0,
|
|
||||||
"yandex": 5.0,
|
"yandex": 5.0,
|
||||||
"yandex_detail": 5.0,
|
"yandex_detail": 5.0,
|
||||||
"yandex_newbuilding": 5.0,
|
"yandex_newbuilding": 5.0,
|
||||||
|
|
|
||||||
|
|
@ -1,271 +0,0 @@
|
||||||
"""N1.ru scraper — вторичка ЕКБ (ekaterinburg.n1.ru).
|
|
||||||
|
|
||||||
N1.ru — региональный портал недвижимости (хорошо представлен на Урале).
|
|
||||||
SSR HTML с DOM-карточками `data-test="offers-list-item"`.
|
|
||||||
|
|
||||||
Использует curl_cffi (impersonate=chrome120) — как Cian/Avito.
|
|
||||||
|
|
||||||
Карточка:
|
|
||||||
- a[href^="/view/NNN/"] — ссылка на объявление + offer_id
|
|
||||||
текст ссылки = "1/11 2-к, Репина, 75/2 стр." (этаж/этажность + комнаты + адрес)
|
|
||||||
Этаж/этажность — leading-токен «N/M» перед сегментом комнат.
|
|
||||||
- [data-test="offers-list-item-param-total-area"] — "79 м2"
|
|
||||||
- [data-test="offers-list-item-param-price"] — "15 700 000"
|
|
||||||
- img.src — фото
|
|
||||||
|
|
||||||
Координаты карточки не отдаёт → оставляем lat/lon = None,
|
|
||||||
geocode-missing проставит реальные по адресу.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from curl_cffi.requests import AsyncSession
|
|
||||||
from selectolax.parser import HTMLParser
|
|
||||||
|
|
||||||
from app.services.scraper_settings import get_scraper_delay
|
|
||||||
from app.services.scrapers.base import BaseScraper, ScrapedLot
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class N1Scraper(BaseScraper):
|
|
||||||
"""N1.ru vtorichka parser. Источник = 'n1'."""
|
|
||||||
|
|
||||||
name = "n1"
|
|
||||||
base_url = "https://ekaterinburg.n1.ru"
|
|
||||||
# Класс-дефолт; реальное значение загружается из scraper_settings при создании экземпляра.
|
|
||||||
request_delay_sec = 5.0
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.request_delay_sec = get_scraper_delay(self.name)
|
|
||||||
self._cffi: AsyncSession | None = None
|
|
||||||
|
|
||||||
async def __aenter__(self) -> N1Scraper:
|
|
||||||
await super().__aenter__()
|
|
||||||
self._cffi = AsyncSession(
|
|
||||||
impersonate="chrome120",
|
|
||||||
timeout=25,
|
|
||||||
headers={
|
|
||||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
||||||
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
|
||||||
"Sec-Fetch-Dest": "document",
|
|
||||||
"Sec-Fetch-Mode": "navigate",
|
|
||||||
"Sec-Fetch-Site": "none",
|
|
||||||
"Upgrade-Insecure-Requests": "1",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *args: Any) -> None:
|
|
||||||
if self._cffi is not None:
|
|
||||||
await self._cffi.close()
|
|
||||||
await super().__aexit__(*args)
|
|
||||||
|
|
||||||
async def fetch_around(
|
|
||||||
self,
|
|
||||||
lat: float,
|
|
||||||
lon: float,
|
|
||||||
radius_m: int = 1000,
|
|
||||||
rooms: int | None = None,
|
|
||||||
page: int = 1,
|
|
||||||
) -> list[ScrapedLot]:
|
|
||||||
self._anchor_lat = lat
|
|
||||||
self._anchor_lon = lon
|
|
||||||
self._anchor_radius_m = radius_m
|
|
||||||
|
|
||||||
url = self._build_url(rooms, page)
|
|
||||||
try:
|
|
||||||
assert self._cffi is not None
|
|
||||||
response = await self._cffi.get(url)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("n1 fetch failed for %s", url)
|
|
||||||
return []
|
|
||||||
if response.status_code != 200:
|
|
||||||
logger.warning("n1 returned %d for %s", response.status_code, url)
|
|
||||||
return []
|
|
||||||
|
|
||||||
lots = self._parse_html(response.text)
|
|
||||||
logger.info(
|
|
||||||
"n1: %d lots around (%.4f, %.4f) rooms=%s page=%d",
|
|
||||||
len(lots),
|
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
rooms,
|
|
||||||
page,
|
|
||||||
)
|
|
||||||
await self.sleep_between_requests()
|
|
||||||
return lots
|
|
||||||
|
|
||||||
async def fetch_around_multi_room(
|
|
||||||
self,
|
|
||||||
lat: float,
|
|
||||||
lon: float,
|
|
||||||
radius_m: int = 1000,
|
|
||||||
pages: int = 8,
|
|
||||||
) -> list[ScrapedLot]:
|
|
||||||
"""Скрейп N1 по сегментам комнат 1/2/3/4 × N страниц — расширяет выборку."""
|
|
||||||
seen: dict[str, ScrapedLot] = {}
|
|
||||||
for rooms in (1, 2, 3, 4):
|
|
||||||
for page in range(1, pages + 1):
|
|
||||||
try:
|
|
||||||
lots = await self.fetch_around(lat, lon, radius_m, rooms=rooms, page=page)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("n1 multi-room fetch failed rooms=%s page=%d", rooms, page)
|
|
||||||
continue
|
|
||||||
if not lots:
|
|
||||||
break # пустая страница → дальше нет смысла
|
|
||||||
for lot in lots:
|
|
||||||
key = lot.source_id or lot.source_url
|
|
||||||
if key and key not in seen:
|
|
||||||
seen[key] = lot
|
|
||||||
logger.info("n1 multi-room: %d unique lots around (%.4f, %.4f)", len(seen), lat, lon)
|
|
||||||
return list(seen.values())
|
|
||||||
|
|
||||||
def _build_url(self, rooms: int | None = None, page: int = 1) -> str:
|
|
||||||
"""URL каталога вторички ЕКБ.
|
|
||||||
|
|
||||||
N1 фильтр комнат: /kupit/kvartiry/vtorichka/1-komnatnye/ итд.
|
|
||||||
Пагинация: ?page=N.
|
|
||||||
"""
|
|
||||||
rooms_segment = {
|
|
||||||
1: "1-komnatnye/",
|
|
||||||
2: "2-komnatnye/",
|
|
||||||
3: "3-komnatnye/",
|
|
||||||
4: "4-komnatnye/",
|
|
||||||
}.get(rooms or 0, "")
|
|
||||||
url = f"{self.base_url}/kupit/kvartiry/vtorichka/{rooms_segment}"
|
|
||||||
if page > 1:
|
|
||||||
url += f"?page={page}"
|
|
||||||
return url
|
|
||||||
|
|
||||||
def _parse_html(self, html: str) -> list[ScrapedLot]:
|
|
||||||
tree = HTMLParser(html)
|
|
||||||
cards = tree.css('[data-test="offers-list-item"]')
|
|
||||||
return [lot for lot in (self._card_to_lot(c) for c in cards) if lot is not None]
|
|
||||||
|
|
||||||
def _card_to_lot(self, card: Any) -> ScrapedLot | None:
|
|
||||||
try:
|
|
||||||
# Ссылка на объявление /view/NNN/
|
|
||||||
link = card.css_first('a[href*="/view/"]')
|
|
||||||
if link is None:
|
|
||||||
return None
|
|
||||||
href = link.attributes.get("href", "")
|
|
||||||
m_id = re.search(r"/view/(\d+)/", href)
|
|
||||||
offer_id = m_id.group(1) if m_id else None
|
|
||||||
url = href if href.startswith("http") else f"{self.base_url}{href}"
|
|
||||||
|
|
||||||
# Заголовок ссылки: "1/11 2-к, Репина, 75/2 стр." (этаж/этажность + комнаты + адрес)
|
|
||||||
title = link.text(strip=True) or ""
|
|
||||||
floor, total_floors = _extract_floor_from_title(title)
|
|
||||||
rooms = _extract_rooms(title)
|
|
||||||
# Адрес = title без ведущего «N/M» и без префикса комнат
|
|
||||||
address = re.sub(
|
|
||||||
r"^\s*(\d+-к|студи[яи])\s*,?\s*", "", title, flags=re.IGNORECASE
|
|
||||||
).strip()
|
|
||||||
if not address:
|
|
||||||
address = "Екатеринбург (N1)"
|
|
||||||
|
|
||||||
# Площадь
|
|
||||||
area_el = card.css_first('[data-test="offers-list-item-param-total-area"]')
|
|
||||||
area = _extract_area(area_el.text(strip=True) if area_el else "")
|
|
||||||
|
|
||||||
# Цена
|
|
||||||
price_el = card.css_first('[data-test="offers-list-item-param-price"]')
|
|
||||||
price = _extract_price(price_el.text(strip=True) if price_el else "")
|
|
||||||
if not price:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Координаты НЕ ставим — N1 card не отдаёт. Оставляем None,
|
|
||||||
# geocode-missing проставит реальные по address (N1 отдаёт всё ЕКБ,
|
|
||||||
# jitter вокруг якоря был бы фейком).
|
|
||||||
lat = lon = None
|
|
||||||
|
|
||||||
# Фото
|
|
||||||
photos: list[str] = []
|
|
||||||
for img in card.css("img")[:5]:
|
|
||||||
src = img.attributes.get("src", "")
|
|
||||||
if src and src.startswith("http") and src not in photos:
|
|
||||||
photos.append(src)
|
|
||||||
|
|
||||||
return ScrapedLot(
|
|
||||||
source="n1",
|
|
||||||
source_url=url,
|
|
||||||
source_id=offer_id,
|
|
||||||
address=address,
|
|
||||||
lat=lat,
|
|
||||||
lon=lon,
|
|
||||||
rooms=rooms,
|
|
||||||
area_m2=area,
|
|
||||||
floor=floor,
|
|
||||||
total_floors=total_floors,
|
|
||||||
price_rub=price,
|
|
||||||
photo_urls=photos,
|
|
||||||
raw_payload={"title": title},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("n1 card parse failed")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_RE_ROOMS = re.compile(r"(\d+)\s*-?\s*к", re.IGNORECASE)
|
|
||||||
_RE_STUDIO = re.compile(r"студи", re.IGNORECASE)
|
|
||||||
_RE_AREA = re.compile(r"([\d]+(?:[.,]\d+)?)")
|
|
||||||
_RE_DIGITS = re.compile(r"\d")
|
|
||||||
|
|
||||||
# Ведущий токен «N/M» в тексте карточки N1: «1/11 2-к, Репина, 75/2 стр.»
|
|
||||||
# Якорь ^ гарантирует: только если N/M стоит В НАЧАЛЕ строки (до сегмента комнат).
|
|
||||||
# Дробь в адресе вида «75/2» в середине строки НЕ совпадёт.
|
|
||||||
_RE_FLOOR_LEADING = re.compile(r"^\s*(\d{1,3})\s*/\s*(\d{1,3})\b")
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_rooms(text: str) -> int | None:
|
|
||||||
if _RE_STUDIO.search(text):
|
|
||||||
return 0
|
|
||||||
m = _RE_ROOMS.search(text)
|
|
||||||
return int(m.group(1)) if m else None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_area(text: str) -> float | None:
|
|
||||||
m = _RE_AREA.search(text)
|
|
||||||
if m:
|
|
||||||
try:
|
|
||||||
return float(m.group(1).replace(",", "."))
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_price(text: str) -> int | None:
|
|
||||||
digits = "".join(_RE_DIGITS.findall(text))
|
|
||||||
return int(digits) if digits else None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_floor_from_title(text: str) -> tuple[int | None, int | None]:
|
|
||||||
"""Извлекает этаж и этажность из ведущего токена «N/M» в тексте карточки N1.
|
|
||||||
|
|
||||||
N1 SERP отдаёт текст вида «1/11 2-к, Репина, 75/2 стр.» — этаж/этажность
|
|
||||||
идут первыми, до сегмента комнат. Регулярка привязана к началу строки (^),
|
|
||||||
поэтому дробь в номере дома («75/2») в середине строки не захватывается.
|
|
||||||
|
|
||||||
Sanity-проверки:
|
|
||||||
- floor >= 1
|
|
||||||
- total_floors >= floor
|
|
||||||
- total_floors <= 100
|
|
||||||
|
|
||||||
Возвращает (None, None) при отсутствии совпадения или нарушении диапазона.
|
|
||||||
"""
|
|
||||||
m = _RE_FLOOR_LEADING.match(text)
|
|
||||||
if m is None:
|
|
||||||
return None, None
|
|
||||||
floor = int(m.group(1))
|
|
||||||
total_floors = int(m.group(2))
|
|
||||||
if floor < 1 or total_floors < floor or total_floors > 100:
|
|
||||||
return None, None
|
|
||||||
return floor, total_floors
|
|
||||||
18
tradein-mvp/backend/data/sql/114_disable_n1_sweep.sql
Normal file
18
tradein-mvp/backend/data/sql/114_disable_n1_sweep.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- Миграция 114: деактивировать N1.ru city sweep.
|
||||||
|
--
|
||||||
|
-- N1.ru — региональный портал ЕКБ — переехал в Cian (подтверждено владельцем 2026-06).
|
||||||
|
-- Активный скрап прекращается. Строку НЕ удаляем — нужна для audit-истории run'ов.
|
||||||
|
-- Исторические listings source='n1' (389 записей в БД) сохраняются без изменений.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
UPDATE scrape_schedules
|
||||||
|
SET enabled = false
|
||||||
|
WHERE source = 'n1_city_sweep'
|
||||||
|
AND enabled = true; -- idempotent: повторный запуск не трогает уже-disabled
|
||||||
|
|
||||||
|
COMMENT ON TABLE scrape_schedules IS
|
||||||
|
'Периодические задачи: avito/yandex/cian city sweep, rosreestr import, '
|
||||||
|
'listing_source_snapshot. N1 city sweep деактивирован (2026-06, переехал в cian).';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
"""Unit tests for N1 floor/total_floors extraction (#795).
|
|
||||||
|
|
||||||
Tests _extract_floor_from_title() directly (pure function, no network/DB).
|
|
||||||
Also covers _card_to_lot integration via a minimal fake card HTML node.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.services.scrapers.n1 import _extract_floor_from_title
|
|
||||||
|
|
||||||
# ── _extract_floor_from_title: direct unit tests ────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"text, expected_floor, expected_total",
|
|
||||||
[
|
|
||||||
# Typical N1 card: floor 1 of 11
|
|
||||||
("1/11 2-к, Репина, 75/2 стр.", 1, 11),
|
|
||||||
# Floor in middle of building
|
|
||||||
("5/9 3-к, Ленина, 10 стр.", 5, 9),
|
|
||||||
# Top floor
|
|
||||||
("17/17 1-к, Малышева, 12", 17, 17),
|
|
||||||
# Leading whitespace tolerated
|
|
||||||
(" 3/16 студия, Вайнера, 5", 3, 16),
|
|
||||||
# Three-digit total floors (edge of range)
|
|
||||||
("2/100 2-к, Тверитина, 8", 2, 100),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_extract_floor_happy_path(text: str, expected_floor: int, expected_total: int) -> None:
|
|
||||||
floor, total = _extract_floor_from_title(text)
|
|
||||||
assert floor == expected_floor
|
|
||||||
assert total == expected_total
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"text",
|
|
||||||
[
|
|
||||||
# No leading N/M at all — classic old format without floor token
|
|
||||||
("2-к, Репина, 75/2 стр."),
|
|
||||||
# House number fraction in middle — must NOT be parsed as floor
|
|
||||||
("Репина, 75/2 стр."),
|
|
||||||
# N/M not at string start (preceded by other text)
|
|
||||||
("квартира 3/9, Ленина, 1"),
|
|
||||||
# floor=0 is invalid
|
|
||||||
("0/9 1-к, Тестовая, 1"),
|
|
||||||
# total_floors < floor — sanity fail
|
|
||||||
("9/5 2-к, Тестовая, 1"),
|
|
||||||
# total_floors > 100 — sanity fail
|
|
||||||
("2/101 1-к, Тестовая, 1"),
|
|
||||||
# Empty string
|
|
||||||
(""),
|
|
||||||
# Only digits, no slash
|
|
||||||
("5 2-к, Тестовая, 1"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_extract_floor_returns_none(text: str) -> None:
|
|
||||||
floor, total = _extract_floor_from_title(text)
|
|
||||||
assert floor is None
|
|
||||||
assert total is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_house_number_fraction_not_misparsed() -> None:
|
|
||||||
"""«75/2 стр.» appearing after rooms segment must not match floor regex."""
|
|
||||||
# The fraction 75/2 comes AFTER the rooms prefix, not at string start.
|
|
||||||
floor, total = _extract_floor_from_title("3-к, Репина, 75/2 стр.")
|
|
||||||
assert floor is None
|
|
||||||
assert total is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_leading_fraction_after_comma() -> None:
|
|
||||||
"""Fraction embedded mid-string via comma-separated address token."""
|
|
||||||
floor, total = _extract_floor_from_title("ул. 8 Марта, 51/3, 2-к")
|
|
||||||
assert floor is None
|
|
||||||
assert total is None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Integration: _card_to_lot via fake selectolax node ──────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeAttr(dict):
|
|
||||||
"""Minimal attributes dict for a fake HTML node."""
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeNode:
|
|
||||||
"""Minimal selectolax node stub for _card_to_lot tests."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
href: str = "/view/12345/",
|
|
||||||
title_text: str = "1/11 2-к, Репина, 75/2 стр.",
|
|
||||||
area_text: str = "52 м2",
|
|
||||||
price_text: str = "8 500 000",
|
|
||||||
img_src: str = "https://img.n1.ru/photo.jpg",
|
|
||||||
) -> None:
|
|
||||||
self._href = href
|
|
||||||
self._title_text = title_text
|
|
||||||
self._area_text = area_text
|
|
||||||
self._price_text = price_text
|
|
||||||
self._img_src = img_src
|
|
||||||
|
|
||||||
def css_first(self, selector: str) -> _FakeNode | None:
|
|
||||||
if 'a[href*="/view/"]' in selector:
|
|
||||||
return self
|
|
||||||
if "total-area" in selector:
|
|
||||||
return _FakeTextNode(self._area_text)
|
|
||||||
if "param-price" in selector:
|
|
||||||
return _FakeTextNode(self._price_text)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def css(self, selector: str) -> list[_FakeNode]:
|
|
||||||
if selector == "img":
|
|
||||||
return [_FakeImgNode(self._img_src)]
|
|
||||||
return []
|
|
||||||
|
|
||||||
@property
|
|
||||||
def attributes(self) -> dict:
|
|
||||||
return {"href": self._href}
|
|
||||||
|
|
||||||
def text(self, strip: bool = False) -> str:
|
|
||||||
t = self._title_text
|
|
||||||
return t.strip() if strip else t
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeTextNode:
|
|
||||||
def __init__(self, text: str) -> None:
|
|
||||||
self._text = text
|
|
||||||
|
|
||||||
def text(self, strip: bool = False) -> str:
|
|
||||||
return self._text.strip() if strip else self._text
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeImgNode:
|
|
||||||
def __init__(self, src: str) -> None:
|
|
||||||
self.attributes = {"src": src}
|
|
||||||
|
|
||||||
|
|
||||||
def _call_card_to_lot(card: _FakeNode): # type: ignore[return]
|
|
||||||
from app.services.scrapers.n1 import N1Scraper
|
|
||||||
|
|
||||||
scraper = object.__new__(N1Scraper)
|
|
||||||
scraper.base_url = "https://ekaterinburg.n1.ru" # type: ignore[attr-defined]
|
|
||||||
return scraper._card_to_lot(card) # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
|
|
||||||
def test_card_to_lot_parses_floor() -> None:
|
|
||||||
card = _FakeNode(title_text="1/11 2-к, Репина, 75/2 стр.")
|
|
||||||
lot = _call_card_to_lot(card)
|
|
||||||
assert lot is not None
|
|
||||||
assert lot.floor == 1
|
|
||||||
assert lot.total_floors == 11
|
|
||||||
|
|
||||||
|
|
||||||
def test_card_to_lot_floor_none_when_no_token() -> None:
|
|
||||||
card = _FakeNode(title_text="2-к, Репина, 75/2 стр.")
|
|
||||||
lot = _call_card_to_lot(card)
|
|
||||||
assert lot is not None
|
|
||||||
assert lot.floor is None
|
|
||||||
assert lot.total_floors is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_card_to_lot_listing_date_is_none() -> None:
|
|
||||||
"""N1 SERP has no date — listing_date must remain None."""
|
|
||||||
card = _FakeNode(title_text="3/9 1-к, Малышева, 12")
|
|
||||||
lot = _call_card_to_lot(card)
|
|
||||||
assert lot is not None
|
|
||||||
assert lot.listing_date is None
|
|
||||||
|
|
@ -149,7 +149,7 @@ def test_invalidate_cache_specific_source():
|
||||||
mock_open, _ = _mock_session_uniform(6.0)
|
mock_open, _ = _mock_session_uniform(6.0)
|
||||||
with patch(_PATCH_TARGET, mock_open):
|
with patch(_PATCH_TARGET, mock_open):
|
||||||
ss.get_scraper_delay("yandex") # +2 (yandex + global)
|
ss.get_scraper_delay("yandex") # +2 (yandex + global)
|
||||||
ss.invalidate_cache("yandex") # clears yandex key
|
ss.invalidate_cache("yandex") # clears yandex key
|
||||||
ss.get_scraper_delay("yandex") # +1 (yandex stale; global still cached)
|
ss.get_scraper_delay("yandex") # +1 (yandex stale; global still cached)
|
||||||
assert mock_open.call_count == 3
|
assert mock_open.call_count == 3
|
||||||
|
|
||||||
|
|
@ -159,7 +159,7 @@ def test_invalidate_cache_all():
|
||||||
mock_open, _ = _mock_session_uniform(6.0)
|
mock_open, _ = _mock_session_uniform(6.0)
|
||||||
with patch(_PATCH_TARGET, mock_open):
|
with patch(_PATCH_TARGET, mock_open):
|
||||||
ss.get_scraper_delay("yandex") # +2 (yandex + global)
|
ss.get_scraper_delay("yandex") # +2 (yandex + global)
|
||||||
ss.invalidate_cache() # clears everything
|
ss.invalidate_cache() # clears everything
|
||||||
ss.get_scraper_delay("yandex") # +2 (both stale)
|
ss.get_scraper_delay("yandex") # +2 (both stale)
|
||||||
assert mock_open.call_count == 4
|
assert mock_open.call_count == 4
|
||||||
|
|
||||||
|
|
@ -169,8 +169,8 @@ def test_invalidate_alias_invalidates_umbrella():
|
||||||
mock_open, _ = _mock_session_uniform(6.0)
|
mock_open, _ = _mock_session_uniform(6.0)
|
||||||
with patch(_PATCH_TARGET, mock_open):
|
with patch(_PATCH_TARGET, mock_open):
|
||||||
ss.get_scraper_delay("yandex_detail") # +2 (yandex + global)
|
ss.get_scraper_delay("yandex_detail") # +2 (yandex + global)
|
||||||
ss.invalidate_cache("yandex_detail") # clears yandex (via alias)
|
ss.invalidate_cache("yandex_detail") # clears yandex (via alias)
|
||||||
ss.get_scraper_delay("yandex_detail") # +1 (yandex stale; global fresh)
|
ss.get_scraper_delay("yandex_detail") # +1 (yandex stale; global fresh)
|
||||||
assert mock_open.call_count == 3
|
assert mock_open.call_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -214,11 +214,11 @@ def test_missing_per_source_row_uses_class_default(monkeypatch):
|
||||||
"""Missing source row → _DEFAULT_DELAY_BY_SOURCE. global=0 → no floor."""
|
"""Missing source row → _DEFAULT_DELAY_BY_SOURCE. global=0 → no floor."""
|
||||||
invalidate_cache()
|
invalidate_cache()
|
||||||
|
|
||||||
mock_db = _make_mock_session({"n1": None, "global": 0.0})
|
mock_db = _make_mock_session({"domklik": None, "global": 0.0})
|
||||||
monkeypatch.setattr(ss_mod, "_open_session", lambda: mock_db)
|
monkeypatch.setattr(ss_mod, "_open_session", lambda: mock_db)
|
||||||
|
|
||||||
delay = get_scraper_delay("n1")
|
delay = get_scraper_delay("domklik")
|
||||||
assert delay == ss_mod._DEFAULT_DELAY_BY_SOURCE["n1"]
|
assert delay == ss_mod._DEFAULT_DELAY_BY_SOURCE["domklik"]
|
||||||
|
|
||||||
|
|
||||||
def test_global_missing_row_treated_as_zero(monkeypatch):
|
def test_global_missing_row_treated_as_zero(monkeypatch):
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue