"""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/ с индексом /opendata/f.json. Этот портал был переделан на Bitrix — прямые ссылки на ZIP теперь отвечают HTTP 200 с Content-Type: text/html (soft-404 "заглушка"), а /opendata/f.json больше не отдаёт JSON. Старая версия поллера трактовала любой HTTP 200 как "квартал доступен" и из-за soft-404 стала ложно репортить КАЖДЫЙ квартал как доступный — это и есть баг #issue, который чинит этот модуль. АКТУАЛЬНОЕ (2026-07) расположение датасетов — открытый Apache autoindex без авторизации: https://rosreestr.gov.ru/data-sets/ Структура: - Папки кварталов названы в кодировке Windows-1251 (percent-encoded href — именно cp1251, НЕ utf-8), например "1 квартал 2026г." → href="1%20%EA%E2%E0%F0%F2%E0%EB%202026%E3./". Плюс папка "Архив до 2023г. включительно/" для старых периодов. - Внутри папки квартала лежит dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip (тоже cp1251-кодированный href, например "dataset_%D1%C4%C5%CB%CA%C8_r-r_01-92_y_2026_q_1.csv.zip"), рядом — dataset_КАДАСТРСТОИМОСТЬ_* файлы (не наши). Вместо того чтобы вручную высчитывать percent-encoding (хрупко: имена папок расставляют руками, возможны отличия в пробелах/пунктуации между кварталами) — поллер запрашивает autoindex HTML и парсит href'ы, декодируя каждый percent-encoded href как cp1251 (urllib.parse.unquote(href, encoding="cp1251")), затем ищет папку/файл по совпадению с ожидаемым (год, квартал) в декодированном имени. См. check_new_quarter_available(). КЛЮЧЕВАЯ проверка доступности (защита от soft-404, см. "ИСТОРИЯ" выше): найденный файл датасета считается ДОСТУПНЫМ только если HEAD-ответ имеет Content-Type: application/zip И Content-Length больше ~100 KB. Голый HTTP 200 недостаточен. rosreestr.gov.ru отдаёт сертификат от российского TLS root CA, которому нет доверия в стандартном trust store — соединение делаем с verify=False, как sber_index.py для sberindex.ru (см. #922, тот же паттерн: публичные неавторизованные открытые данные, TLS verify отключаем осознанно). Сервер также отвечает HTTP 403 без браузерного User-Agent — шлём Chrome UA (тот же паттерн, что DEFAULT_UA в zhkh_flats_loader.py). При сетевой ошибке / HTTP 5xx / таймауте — логируем warning, возвращаем available=False. Отсутствие папки/файла квартала → available=False (штатный случай до публикации квартала, до начала следующего месяца после конца квартала). """ from __future__ import annotations import logging import re from typing import Any from urllib.parse import quote, unquote, urljoin import httpx from sqlalchemy import text from sqlalchemy.orm import Session logger = logging.getLogger(__name__) # Открытый Apache autoindex открытых данных Росреестра (см. докстринг модуля). _DATA_SETS_BASE_URL = "https://rosreestr.gov.ru/data-sets/" # Браузерный UA — без него rosreestr.gov.ru отвечает HTTP 403. # Тот же паттерн, что DEFAULT_UA в app/services/zhkh_flats_loader.py. _BROWSER_USER_AGENT = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" ) # Порог отличия реального ZIP-архива от soft-404 заглушки/пустого файла. _MIN_DATASET_SIZE_BYTES = 100_000 # 100 KB # Таймаут запросов. Росреестр может быть медленным — 15s достаточно. _HTTP_TIMEOUT = 15.0 _HREF_RE = re.compile(r'href="([^"]+)"', re.IGNORECASE) 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: """Сформировать best-effort прямую ссылку на ZIP датасета (для alert-сообщения оператору). ВНИМАНИЕ: это ДЕТЕРМИНИРОВАННАЯ догадка по известному шаблону именования — папка "{quarter} квартал {year}г.", файл "dataset_СДЕЛКИ_r-r_01-92_y_{year}_q_{quarter}.csv.zip" — закодированная как cp1251 percent-encoding (подтверждено вручную против реального URL портала). Она НЕ используется для самой проверки доступности: check_new_quarter_available() парсит реальный autoindex и не полагается на угаданный URL, потому что Росреестр расставляет имена папок вручную и они могут отличаться в мелочах (лишний пробел, пунктуация) от квартала к кварталу. Если угаданная ссылка не откроется — ориентир для оператора: _DATA_SETS_BASE_URL (https://rosreestr.gov.ru/data-sets/), там нужно найти папку глазами. """ folder_name = f"{quarter} квартал {year}г." file_name = f"dataset_СДЕЛКИ_r-r_01-92_y_{year}_q_{quarter}.csv.zip" folder_enc = quote(folder_name.encode("cp1251"), safe="") file_enc = quote(file_name.encode("cp1251"), safe="") return f"{_DATA_SETS_BASE_URL}{folder_enc}/{file_enc}" def _decode_cp1251_href(href: str) -> str: """Декодировать percent-encoded href как cp1251 (портал кодирует кириллицу так, НЕ utf-8).""" try: return unquote(href, encoding="cp1251", errors="strict") except (UnicodeDecodeError, LookupError): # href без кириллицы (например "../") или неожиданная кодировка — fallback. return unquote(href) def _normalize_ws(s: str) -> str: """Схлопнуть повторяющиеся пробелы — Росреестр расставляет имена папок вручную.""" return " ".join(s.split()) def _extract_hrefs(html: str) -> list[str]: """Достать все href="..." из HTML autoindex-страницы (простой regex, не наш HTML).""" return _HREF_RE.findall(html) def _find_quarter_folder_href(html: str, year: int, quarter: int) -> str | None: """Найти (не декодированный) href папки квартала (year, quarter) в HTML /data-sets/. Сравнение — по декодированному (cp1251) и нормализованному по пробелам имени папки, ожидаемый паттерн "{quarter} квартал {year}г.". """ expected_name = f"{quarter} квартал {year}г." for href in _extract_hrefs(html): decoded = _normalize_ws(_decode_cp1251_href(href).rstrip("/")) if decoded == expected_name: return href return None def _find_dataset_file_href(html: str, year: int, quarter: int) -> str | None: """Найти href файла dataset_СДЕЛКИ_..._y_{year}_q_{quarter}.csv.zip в HTML папки квартала.""" expected_suffix = f"_y_{year}_q_{quarter}.csv.zip" for href in _extract_hrefs(html): decoded = _decode_cp1251_href(href) if decoded.startswith("dataset_СДЕЛКИ") and decoded.endswith(expected_suffix): return href return None async def check_new_quarter_available( client: httpx.AsyncClient, year: int, quarter: int, ) -> bool: """Проверить, опубликован ли датасет СДЕЛКИ Росреестра для (year, quarter). Шаги (см. докстринг модуля за подробностями): 1. GET autoindex _DATA_SETS_BASE_URL, найти папку квартала по декодированному (cp1251) имени "{quarter} квартал {year}г.". 2. GET найденную папку, найти файл dataset_СДЕЛКИ_..._y_{year}_q_{quarter}.csv.zip. 3. HEAD найденный файл — считаем ДОСТУПНЫМ, только если Content-Type начинается с application/zip И Content-Length > _MIN_DATASET_SIZE_BYTES. Голый HTTP 200 НЕ считается доступностью — это была причина бага (Bitrix-заглушка /opendata/ тоже отвечала 200). Отсутствие папки/файла квартала → available=False (нормально до публикации). Сетевая ошибка / таймаут / неожиданное исключение → warning, available=False. Никогда не поднимает исключения в вызывающий код (scheduler-safe). """ try: index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True) if index_resp.status_code != 200: logger.warning( "rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable", index_resp.status_code, _DATA_SETS_BASE_URL, quarter, year, ) return False folder_href = _find_quarter_folder_href(index_resp.text, year, quarter) if folder_href is None: logger.info( "rosreestr_poll: quarter folder for Q%d %d not found under %s — not yet published", quarter, year, _DATA_SETS_BASE_URL, ) return False folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href) folder_resp = await client.get(folder_url, follow_redirects=True) if folder_resp.status_code != 200: logger.warning( "rosreestr_poll: unexpected HTTP %d listing folder %s — " "treating Q%d %d as unavailable", folder_resp.status_code, folder_url, quarter, year, ) return False file_href = _find_dataset_file_href(folder_resp.text, year, quarter) if file_href is None: logger.info( "rosreestr_poll: dataset_СДЕЛКИ file for Q%d %d not found in folder %s", quarter, year, folder_url, ) return False file_url = urljoin(folder_url, file_href) file_resp = await client.head(file_url, follow_redirects=True) content_type = file_resp.headers.get("content-type", "") try: content_length = int(file_resp.headers.get("content-length") or "0") except ValueError: content_length = 0 if ( file_resp.status_code == 200 and content_type.startswith("application/zip") and content_length > _MIN_DATASET_SIZE_BYTES ): logger.info( "rosreestr_poll: Q%d %d available at %s (HTTP 200, %s, %d bytes)", quarter, year, file_url, content_type, content_length, ) return True logger.info( "rosreestr_poll: Q%d %d file found (%s) but failed availability check " "(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, " "treating as unavailable", quarter, year, file_url, file_resp.status_code, content_type, content_length, ) return False except httpx.TimeoutException: logger.warning( "rosreestr_poll: timeout checking Q%d %d under %s — treating as unavailable", quarter, year, _DATA_SETS_BASE_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. Проверить наличие датасета на rosreestr.gov.ru/data-sets/ (autoindex + Content-Type). 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. Проверка наличия (verify=False: см. докстринг модуля — RU root CA не в trust store; # браузерный UA: без него rosreestr.gov.ru отвечает 403) async with httpx.AsyncClient( timeout=_HTTP_TIMEOUT, verify=False, headers={"User-Agent": _BROWSER_USER_AGENT}, ) 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 (%s). " "Run ingest to load: data/sql/02_load_all_quarters.sh + " "tradein-mvp/deploy/import-rosreestr.sh " "(both live outside this repo — if they still point at the old " "rosreestr.gov.ru/opendata/ base, update them to %s first)", next_quarter, next_year, rosreestr_dataset_url(next_year, next_quarter), _DATA_SETS_BASE_URL, ) 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, }