gendesign/backend/app/workers/tasks/scrape_nspd.py
lekss361 25b73035a1 sprint1: nspd scraper industrialization, per-bucket elasticity, cadastre cross-check, sentry releases
- NSPD-skraper переехал в backend/app/services/scrapers/nspd_kn.py +
  Celery task scrape_nspd_region (beat: 20-е февраля/мая/авг/нояб).
  Redis lock 3h, WAF auto-retry, heartbeat в nspd_scrape_runs.
- Recommend_mix Tier 3: per-bucket elasticity через регрессию по
  «доминирующему bucket» каждого ЖК. Weighted-elasticity для inverse-mode.
  UI показывает разброс эластичностей и переключение regression/fallback.
- Cadastre vs market cross-check: spatial-join cad_buildings →
  ekb_districts_geom; cadastre_vs_market_pct в scope, аномалии
  (>+50% / <-30%) подсвечены в UI.
- Sentry release tracking (#4): IMAGE_TAG → backend/.env.runtime →
  sentry_sdk.init(release=...). Compose v2 env_file optional path.

Schemas: 63_schema_nspd_runs.sql (cad_buildings + nspd_scrape_runs/log
формализуют то, что уже жило в проде через 61_import_nspd_batch.py),
64_v_zk_rosreestr_velocity.sql (refresh с cad_buildings).
2026-04-30 21:51:19 +03:00

104 lines
3.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Celery task wrapper для NSPD-скрейпа кадастровых кварталов."""
from __future__ import annotations
import logging
from contextlib import contextmanager
from typing import Any
import redis
from app.core.config import settings
from app.services.scrapers.nspd_kn import WafBlockedError, run_region_scrape
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# NSPD-скрейпы 700+ cad-кварталов на регион занимают ~2 часа (600мс/req × 2 req/cad).
# Lock TTL 3 часа — потолок с запасом, не оставляет зомби-lock на долго.
_LOCK_TTL_SECONDS = 3 * 60 * 60
def _lock_key(region_code: int) -> str:
return f"scrape:nspd:lock:{region_code}"
def force_release_lock(region_code: int) -> bool:
"""Принудительно удалить Redis-lock для emergency manual trigger."""
key = _lock_key(region_code)
r = redis.Redis.from_url(settings.redis_url)
try:
return bool(r.delete(key))
except Exception as e:
logger.warning("force_release_lock %s failed: %s", key, e)
return False
@contextmanager
def _region_lock(region_code: int):
key = _lock_key(region_code)
r = redis.Redis.from_url(settings.redis_url)
acquired = r.set(key, "1", nx=True, ex=_LOCK_TTL_SECONDS)
try:
yield bool(acquired)
finally:
if acquired:
try:
r.delete(key)
except Exception:
pass
@celery_app.task(
bind=True,
name="tasks.scrape_nspd.scrape_nspd_region",
max_retries=2,
autoretry_for=(WafBlockedError,),
retry_backoff=600, # 10 мин начальный, удваивается
retry_backoff_max=7200, # потолок 2 часа
)
def scrape_nspd_region(
self: Any,
region_code: int = 66,
triggered_by: str = "beat",
limit: int | None = None,
rate_ms: int = 600,
) -> dict[str, Any]:
"""Запуск NSPD-скрейпа одного региона. По расписанию: 20-е число
февраля/мая/августа/ноября (после квартальных публикаций rosreestr).
Args:
region_code: код региона rosreestr (66 = Свердл).
triggered_by: 'beat' | 'manual' | 'resume' — для журналирования.
limit: ограничить N cad-кварталов (smoke-тест).
rate_ms: пауза между запросами (по умолчанию 600мс — наблюдаемый предел WAF).
Returns:
dict с run_id и итогами (pending/ok/failed/buildings/requests/elapsed_s),
либо {'skipped': True, 'reason': 'lock_held'} если уже идёт прогон.
"""
with _region_lock(region_code) as got_lock:
if not got_lock:
logger.warning(
"scrape_nspd_region SKIPPED region=%s — другой sweep идёт (Redis lock)",
region_code,
)
return {
"skipped": True,
"reason": "lock_held",
"region_code": region_code,
"lock_key": _lock_key(region_code),
}
logger.info(
"scrape_nspd_region start region=%s trigger=%s limit=%s",
region_code,
triggered_by,
limit,
)
return run_region_scrape(
region_code=region_code,
triggered_by=triggered_by,
limit=limit,
rate_ms=rate_ms,
)