"""Scheduled poll: detect when a new Rosreestr quarter becomes available (#888). Граница ответственности модуля: - DETECT: определить, появился ли новый квартал (next after latest loaded) в открытых данных Росреестра. - ALERT: залогировать actionable-сообщение, если квартал доступен. - НЕ скачивает много-гигабайтный ZIP и НЕ вызывает shell-loaders — это ручной ops-шаг. После алерта оператор запускает: data/sql/02_load_all_quarters.sh tradein-mvp/deploy/import-rosreestr.sh Архив открытых данных Росреестра (сделки ДКП/ДДУ) публикуется на портале: https://rosreestr.gov.ru/opendata/ Файлы именуются по шаблону: dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip Индексный JSON живёт по адресу: https://rosreestr.gov.ru/opendata/f.json (список всех датасетов) Проверяем HEAD-запросом (без скачивания) прямой ссылки на следующий квартал. Формат ссылки: https://rosreestr.gov.ru/opendata/dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip При сетевой ошибке / HTTP 5xx — логируем warning, возвращаем available=False. Статус 404 → квартал не опубликован → available=False (штатный случай до начала августа). """ from __future__ import annotations import logging from typing import Any import httpx from sqlalchemy import text from sqlalchemy.orm import Session logger = logging.getLogger(__name__) # Базовый URL открытых данных Росреестра (прямая ссылка на ZIP-файл квартала). # Шаблон подтверждён по документации портала opendata.rosreestr.gov.ru. _ROSREESTR_DATASET_URL_TEMPLATE = ( "https://rosreestr.gov.ru/opendata/" "dataset_%D0%A1%D0%94%D0%95%D0%9B%D0%9A%D0%98_r-r_01-92_y_{year}_q_{quarter}.csv.zip" ) # Таймаут HEAD-запроса. Росреестр может быть медленным — 15s достаточно. _HTTP_TIMEOUT = 15.0 def _next_quarter(year: int, quarter: int) -> tuple[int, int]: """Вернуть (year, quarter) для следующего квартала. >>> _next_quarter(2026, 1) (2026, 2) >>> _next_quarter(2026, 4) (2027, 1) """ if quarter < 4: return year, quarter + 1 return year + 1, 1 def _period_start_to_quarter(period_start_date: Any) -> tuple[int, int]: """Конвертировать дату начала периода в (year, quarter). period_start_date — дата первого числа квартала (e.g. 2026-01-01 → Q1 2026). Принимает datetime.date, datetime.datetime или ISO-строку. """ if hasattr(period_start_date, "year"): year = period_start_date.year month = period_start_date.month else: # ISO string fallback import datetime d = datetime.date.fromisoformat(str(period_start_date)[:10]) year = d.year month = d.month quarter = (month - 1) // 3 + 1 return year, quarter def latest_loaded_quarter(db: Session) -> tuple[int, int] | None: """Вернуть (year, quarter) последнего загруженного квартала Росреестра. Запрашивает MAX(period_start_date) FROM deals WHERE source='rosreestr'. Возвращает None если таблица пуста или данных от rosreestr нет. Использует синхронный Session (SQLAlchemy, psycopg v3 через SQLAlchemy dialect). """ row = db.execute( text( """ SELECT MAX(deal_date) AS max_date FROM deals WHERE source = 'rosreestr' AND deal_date IS NOT NULL """ ) ).fetchone() if row is None or row[0] is None: logger.info( "rosreestr_poll: no rosreestr deals found in DB — cannot determine latest quarter" ) return None year, quarter = _period_start_to_quarter(row[0]) logger.info( "rosreestr_poll: latest loaded quarter = Q%d %d (derived from max deal_date=%s)", quarter, year, row[0], ) return year, quarter def rosreestr_dataset_url(year: int, quarter: int) -> str: """Сформировать URL датасета Росреестра для заданного квартала. Возвращает прямую ссылку на ZIP-архив (percent-encoded шаблон). """ return _ROSREESTR_DATASET_URL_TEMPLATE.format(year=year, quarter=quarter) async def check_new_quarter_available( client: httpx.AsyncClient, year: int, quarter: int, ) -> bool: """Проверить, опубликован ли датасет Росреестра для (year, quarter). HEAD-запрос к прямой ссылке на ZIP — без скачивания файла. Возвращает True если сервер ответил 200 (или 2xx/3xx с redirect). Возвращает False при 404 (квартал ещё не опубликован) или сетевой ошибке. Никогда не поднимает исключения в вызывающий код (scheduler-safe). """ url = rosreestr_dataset_url(year, quarter) try: resp = await client.head(url, follow_redirects=True) if resp.status_code == 200: logger.info( "rosreestr_poll: Q%d %d available at %s (HTTP %d)", quarter, year, url, resp.status_code, ) return True if resp.status_code == 404: logger.info( "rosreestr_poll: Q%d %d not yet published (HTTP 404)", quarter, year, ) return False # Другие коды (403, 5xx, etc.) — логируем как warning, не кидаем logger.warning( "rosreestr_poll: unexpected HTTP %d for Q%d %d url=%s — treating as unavailable", resp.status_code, quarter, year, url, ) return False except httpx.TimeoutException: logger.warning( "rosreestr_poll: timeout checking Q%d %d url=%s — treating as unavailable", quarter, year, url, ) return False except httpx.RequestError as exc: logger.warning( "rosreestr_poll: network error checking Q%d %d: %s — treating as unavailable", quarter, year, exc, ) return False except Exception as exc: logger.warning( "rosreestr_poll: unexpected error checking Q%d %d: %s — treating as unavailable", quarter, year, exc, ) return False async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]: """Orchestrator: определить следующий квартал после загруженного, проверить наличие. Шаги: 1. Найти MAX(deal_date) WHERE source='rosreestr' → (loaded_year, loaded_quarter). 2. Вычислить next = _next_quarter(loaded_year, loaded_quarter). 3. HEAD-запросом проверить наличие датасета на rosreestr.gov.ru. 4. Если доступен — логировать actionable INFO с инструкцией по запуску ingest. 5. Вернуть dict с результатом. Если в БД нет данных rosreestr → предполагаем 2025Q4, проверяем 2026Q1. Это fallback — в штатном режиме данные всегда есть после initial load. Возвращает: {"available": bool, "year": int, "quarter": int, "latest_loaded_year": int | None, "latest_loaded_quarter": int | None} """ # 1. Текущий максимальный загруженный квартал latest = latest_loaded_quarter(db) if latest is None: # Fallback: нет данных — проверяем 2026Q1 как первую точку loaded_year, loaded_quarter = 2025, 4 logger.warning( "rosreestr_poll: no deals in DB — using fallback baseline 2025Q4, " "checking 2026Q1 availability" ) else: loaded_year, loaded_quarter = latest # 2. Следующий квартал next_year, next_quarter = _next_quarter(loaded_year, loaded_quarter) logger.info( "rosreestr_poll: checking Q%d %d (next after loaded Q%d %d)", next_quarter, next_year, loaded_quarter, loaded_year, ) # 3. Проверка наличия async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client: available = await check_new_quarter_available(client, next_year, next_quarter) # 4. Алерт если доступен if available: logger.info( "rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d. " "Run ingest to load: " "data/sql/02_load_all_quarters.sh + tradein-mvp/deploy/import-rosreestr.sh", next_quarter, next_year, ) return { "available": available, "year": next_year, "quarter": next_quarter, "latest_loaded_year": loaded_year if latest is not None else None, "latest_loaded_quarter": loaded_quarter if latest is not None else None, }