All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 50s
The СДЕЛКИ open-data portal moved off /opendata/ (Bitrix redesign); the old flat dataset URL now returns HTTP 200 with Content-Type text/html (soft-404) instead of 404, so check_new_quarter_available() falsely reported every quarter as available. /opendata/f.json also no longer serves JSON. Rewrite the check to parse the new https://rosreestr.gov.ru/data-sets/ Apache autoindex: decode each folder/file href as cp1251 percent-encoding (portal does not use utf-8), locate the target quarter folder and dataset_СДЕЛКИ file, then HEAD it and require Content-Type: application/zip AND Content-Length > 100 KB before treating it as available — a bare 200 is no longer sufficient. Also send a browser User-Agent and verify=False (RU TLS root CA, same pattern as sber_index.py) since rosreestr.gov.ru 403s plain httpx requests. Public signatures and the poll_rosreestr_new_quarter() return dict are unchanged. rosreestr_dataset_url() now builds a best-effort cp1251-encoded link into /data-sets/ (verified byte-for-byte against the live portal) for the alert message; detection itself never depends on this guess.
419 lines
20 KiB
Python
419 lines
20 KiB
Python
"""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,
|
||
}
|