"""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, )