- 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).
104 lines
3.5 KiB
Python
104 lines
3.5 KiB
Python
"""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,
|
||
)
|