gendesign/backend/app/services/scrapers/gisogd66.py
bot-backend 1b96ec1baf
Some checks failed
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Failing after 1m56s
CI / backend-tests (pull_request) Successful in 14m37s
feat(scrapers): реестр РНС/РВЭ ГИСОГД-СО из открытого API (#2367 PR-1)
Ранний сигнал future-supply: разрешения на строительство появляются в
ГИСОГД до проектных деклараций ЕИСЖС. Старый minstroy-путь мёртв (WAF),
портал gisogd66.midural.ru отдаёт открытый JSON-API без авторизации.

Миграция 187_gisogd_permits (RS+RV, UNIQUE(doc_group,doc_num), GIST geom
4326, GIN cad_nums) + инкрементальный httpx-лоадер (схемы agate_ekbgo +
agate_sverdregion, сверка по (source_key,date_reg), карточки только для
новых/изменённых, SAVEPOINT per-row, WHERE-гейт предпочтения позднего
date_reg) + Celery weekly (вт 06:30 МСК, limit 3600/3300) + on-demand
admin-триггер + freshness-реестр (data-table, 14/45).

Refs #2367
2026-07-04 07:58:59 +05:00

440 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Загрузчик реестра РНС/РВЭ Свердловской области из открытого API ГИСОГД-СО (#2367).
Источник: ``gisogd66.midural.ru`` — региональная ГИС органов гос. власти Свердловской
области. Открытый JSON-API раздела 13 («Сведения о документах в деле о земельном
участке»), БЕЗ авторизации (bare curl 200, разведка 2026-07-04).
Три эндпоинта (все GET JSON, base ``https://gisogd66.midural.ru/api/v1/{schema}``):
* ``/gisogddocgroups/razdel13`` — группы документов раздела (DocRS/DocRV/…);
* ``/gisogddocgroupdocs/razdel13/{group}`` — весь список группы одним ответом (без
пагинации): key, name, docNum, dateDoc, numReg, dateReg;
* ``/gisogddocs/razdel13/{key}`` — карточка документа: spatialUnit (кад.№,
может быть несколько через запятую / отсутствовать), infosetGeoJson (GeoJSON Polygon
WGS84 lon/lat, может отсутствовать — hasGeometry=false), approvedOrganization, docNum,
dateDoc, dateReg.
Две схемы (тянем обе): ``agate_ekbgo`` (ЕКБ, свежая) и ``agate_sverdregion`` (регион-
агрегат, отстаёт ~неделю). Один документ может встретиться в обеих → бизнес-ключ
``(doc_group, doc_num)``; при конфликте предпочитаем запись с более поздним ``date_reg``.
Инкрементальность: список группы сверяется с БД по (source_schema, source_key, date_reg);
карточки (второй запрос — дорогой) тянутся ТОЛЬКО для новых / изменившихся документов.
Politeness: единый httpx-клиент с limits(max_connections=5), timeout=20с, 2 ретрая на 5xx,
малый sleep между карточками. UPSERT — per-row SAVEPOINT (битая строка не валит батч).
"""
from __future__ import annotations
import json
import logging
import re
import time
from datetime import date, datetime
from typing import Any
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
logger = logging.getLogger(__name__)
# ── Константы источника ───────────────────────────────────────────────────────
GISOGD_BASE = "https://gisogd66.midural.ru/api/v1"
_SECTION = "razdel13"
# Схемы источника — обе тянем (ekbgo свежее, sverdregion — регион-агрегат).
SCHEMAS: tuple[str, ...] = ("agate_ekbgo", "agate_sverdregion")
# Группа источника (doc group key) → наш doc_group-код в БД (CHECK IN ('RS','RV')).
GROUP_CODE: dict[str, str] = {"DocRS": "RS", "DocRV": "RV"}
_HTTP_TIMEOUT = 20.0
_MAX_CONNECTIONS = 5
_MAX_RETRIES = 2 # ретраи на 5xx (helper делает суммарно до 1 + _MAX_RETRIES попыток)
_RETRY_BACKOFF_S = 1.5
_CARD_SLEEP_S = 0.05 # вежливая пауза между карточками (второй, «дорогой» запрос)
USER_AGENT = "GenDesign/1.0 (+https://gendsgn.ru) Site Finder gisogd permits loader"
# Валидация кадастрового номера: NN:NN:NNNNNN(N):N (6-7 знаков в третьем блоке).
_CAD_RE = re.compile(r"^\d{2}:\d{2}:\d{6,7}:\d+$")
# ── HTTP helpers (module-level — мокаются в тестах) ───────────────────────────
def _make_client() -> httpx.Client:
"""httpx.Client с пулом (max_connections=5), таймаутом и UA. Закрывать вызывающим."""
return httpx.Client(
base_url=GISOGD_BASE,
timeout=_HTTP_TIMEOUT,
follow_redirects=True,
headers={"User-Agent": USER_AGENT},
limits=httpx.Limits(max_connections=_MAX_CONNECTIONS),
)
def _get_json(client: httpx.Client, path: str) -> Any:
"""GET path → JSON с ретраями на 5xx / сетевых ошибках.
До (1 + _MAX_RETRIES) попыток с линейным backoff. 4xx → сразу raise (не ретраим).
"""
last_exc: Exception | None = None
for attempt in range(_MAX_RETRIES + 1):
try:
resp = client.get(path)
if resp.status_code >= 500:
# 5xx — транзиентная ошибка сервера, ретраим.
last_exc = httpx.HTTPStatusError(
f"{resp.status_code} on {path}", request=resp.request, response=resp
)
logger.warning(
"gisogd66: %s (попытка %d) на %s", resp.status_code, attempt + 1, path
)
time.sleep(_RETRY_BACKOFF_S * (attempt + 1))
continue
resp.raise_for_status()
return resp.json()
except (httpx.TimeoutException, httpx.TransportError) as exc:
last_exc = exc
logger.warning(
"gisogd66: сетевая ошибка (попытка %d) на %s: %s", attempt + 1, path, exc
)
time.sleep(_RETRY_BACKOFF_S * (attempt + 1))
assert last_exc is not None
raise last_exc
def fetch_group_list(client: httpx.Client, schema: str, group: str) -> list[dict[str, Any]]:
"""Весь список документов группы (без пагинации) → list of dict.
Поля элемента: key, name, docNum, dateDoc, numReg, dateReg. Пустой список при
неожиданном (не-list) ответе.
"""
data = _get_json(client, f"/{schema}/gisogddocgroupdocs/{_SECTION}/{group}")
if not isinstance(data, list):
logger.warning("gisogd66: %s/%s список не-list (%s)", schema, group, type(data).__name__)
return []
logger.info("gisogd66: %s/%s список документов=%d", schema, group, len(data))
return data
def fetch_doc_card(client: httpx.Client, schema: str, key: str | int) -> dict[str, Any] | None:
"""Карточка документа → dict (или None при не-dict / ошибке карточки).
Поля: spatialUnit, infosetGeoJson, hasGeometry, approvedOrganization, docNum,
dateDoc, dateReg, name.
"""
data = _get_json(client, f"/{schema}/gisogddocs/{_SECTION}/{key}")
if not isinstance(data, dict):
logger.warning("gisogd66: карточка %s/%s не-dict (%s)", schema, key, type(data).__name__)
return None
return data
# ── Парсинг полей ─────────────────────────────────────────────────────────────
def _parse_date(value: Any) -> date | None:
"""ISO-datetime / date-строка источника → date. None если пусто / не парсится.
Источник отдаёт «2026-06-02T00:00:00» (dateDoc) и «2026-06-19T15:55:11» (dateReg).
"""
if not value:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
s = str(value).strip()
if not s:
return None
# Обрезаем таймзону-суффикс 'Z' если вдруг придёт, парсим ISO.
try:
return datetime.fromisoformat(s.replace("Z", "+00:00")).date()
except ValueError:
pass
for fmt in ("%Y-%m-%d", "%d.%m.%Y"):
try:
return datetime.strptime(s[:10], fmt).date()
except ValueError:
continue
logger.debug("gisogd66: не распарсил дату %r", value)
return None
def parse_cad_nums(spatial_unit: Any) -> list[str]:
"""spatialUnit → список валидных кадастровых номеров (может быть несколько через запятую).
Split по запятой, strip, валидация по _CAD_RE. Невалидные (мусор) отбрасываются с
warning-логом. Отсутствие / пусто → []. Дубликаты схлопываются (сохраняя порядок).
"""
if not spatial_unit:
return []
out: list[str] = []
seen: set[str] = set()
for part in str(spatial_unit).split(","):
cad = part.strip()
if not cad:
continue
if not _CAD_RE.match(cad):
logger.warning(
"gisogd66: невалидный кадастровый номер %r в spatialUnit — отброшен", cad
)
continue
if cad not in seen:
seen.add(cad)
out.append(cad)
return out
def extract_geojson(card: dict[str, Any]) -> str | None:
"""infosetGeoJson карточки → GeoJSON-строка (для ST_GeomFromGeoJSON) или None.
Источник кладёт GeoJSON КАК СТРОКУ в поле infosetGeoJson. Пусто / hasGeometry=false
/ невалидный JSON → None (лоадер запишет geom=NULL). Приведение Polygon→MultiPolygon
делается в SQL через ST_Multi — здесь возвращаем сырой GeoJSON как есть.
"""
raw = card.get("infosetGeoJson")
if not raw:
return None
if isinstance(raw, dict):
# На случай если источник когда-нибудь отдаст объект, а не строку.
return json.dumps(raw)
s = str(raw).strip()
if not s:
return None
try:
json.loads(s) # sanity-парс: битую строку не отправляем в PostGIS
except (ValueError, TypeError) as exc:
logger.warning("gisogd66: битый infosetGeoJson (%s) — geom=NULL", exc)
return None
return s
# ── Инкрементальность ─────────────────────────────────────────────────────────
def _existing_reg_dates(db: Session, schema: str) -> dict[str, date | None]:
"""{source_key: date_reg} для уже загруженных из этой схемы записей.
Используется для инкрементальной сверки: карточку тянем только если key новый ИЛИ
date_reg в списке отличается от сохранённого.
"""
rows = db.execute(
text("SELECT source_key, date_reg FROM gisogd_permits WHERE source_schema = :schema"),
{"schema": schema},
).all()
return {str(r[0]): r[1] for r in rows}
# ── UPSERT ────────────────────────────────────────────────────────────────────
def _upsert_permit(db: Session, rec: dict[str, Any]) -> str:
"""UPSERT одной записи в gisogd_permits по (doc_group, doc_num). Per-row SAVEPOINT.
Конфликт-резолв: при коллизии бизнес-ключа обновляем ТОЛЬКО если у новой записи
date_reg НЕ старше сохранённой (EXCLUDED.date_reg >= existing) — предпочитаем
более позднюю регистрацию (или запись без даты не затирает датированную).
Returns: 'inserted' | 'updated' | 'skipped_unchanged'.
"""
result = db.execute(
text("""
INSERT INTO gisogd_permits
(doc_group, doc_num, doc_name, date_doc, date_reg,
approved_organization, source_schema, source_key,
cad_nums, geom, fetched_at, updated_at)
VALUES (
:doc_group, :doc_num, :doc_name, :date_doc, :date_reg,
:approved_org, :source_schema, :source_key,
CAST(:cad_nums AS text[]),
CASE WHEN :geojson IS NULL THEN NULL
ELSE ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(:geojson), 4326)) END,
NOW(), NOW()
)
ON CONFLICT (doc_group, doc_num) DO UPDATE
SET doc_name = EXCLUDED.doc_name,
date_doc = EXCLUDED.date_doc,
date_reg = EXCLUDED.date_reg,
approved_organization = EXCLUDED.approved_organization,
source_schema = EXCLUDED.source_schema,
source_key = EXCLUDED.source_key,
cad_nums = EXCLUDED.cad_nums,
geom = EXCLUDED.geom,
updated_at = NOW()
WHERE EXCLUDED.date_reg IS NOT DISTINCT FROM gisogd_permits.date_reg
OR EXCLUDED.date_reg > gisogd_permits.date_reg
OR gisogd_permits.date_reg IS NULL
RETURNING (xmax = 0) AS is_insert
"""),
{
"doc_group": rec["doc_group"],
"doc_num": rec["doc_num"],
"doc_name": rec["doc_name"],
"date_doc": rec["date_doc"],
"date_reg": rec["date_reg"],
"approved_org": rec["approved_organization"],
"source_schema": rec["source_schema"],
"source_key": rec["source_key"],
"cad_nums": rec["cad_nums"],
"geojson": rec["geojson"],
},
).scalar()
if result is None:
# WHERE-гейт не пропустил апдейт (у нас более старый date_reg) → без изменений.
return "skipped_unchanged"
return "inserted" if result else "updated"
# ── Оркестрация ───────────────────────────────────────────────────────────────
def load_gisogd_permits(db: Session | None = None) -> dict[str, int]:
"""Инкрементально загрузить РНС/РВЭ ГИСОГД-СО (обе схемы, обе группы) в gisogd_permits.
Для каждой (schema, group): список → сверка с БД по (source_key, date_reg) →
карточки только для новых/изменённых → UPSERT по (doc_group, doc_num) с
предпочтением позднего date_reg. ``db`` для совместимости сигнатуры; None → своя
SessionLocal.
Returns: счётчики {listed, fetched_details, inserted, updated, skipped_unchanged,
no_cad, no_geom}.
"""
owns_session = db is None
if db is None:
db = SessionLocal()
counts = {
"listed": 0,
"fetched_details": 0,
"inserted": 0,
"updated": 0,
"skipped_unchanged": 0,
"no_cad": 0,
"no_geom": 0,
}
client = _make_client()
try:
for schema in SCHEMAS:
existing = _existing_reg_dates(db, schema)
for group_key, doc_group in GROUP_CODE.items():
try:
items = fetch_group_list(client, schema, group_key)
except Exception as exc:
logger.exception("gisogd66: список %s/%s упал: %s", schema, group_key, exc)
continue
counts["listed"] += len(items)
_sync_group(db, client, schema, doc_group, items, existing, counts)
db.commit()
except Exception as exc:
db.rollback()
logger.exception("gisogd66: outer tx rolled back: %s", exc)
raise
finally:
client.close()
if owns_session:
db.close()
logger.info("load_gisogd_permits done: %s", counts)
return counts
def _sync_group(
db: Session,
client: httpx.Client,
schema: str,
doc_group: str,
items: list[dict[str, Any]],
existing: dict[str, date | None],
counts: dict[str, int],
) -> None:
"""Обработать одну (schema, group): инкрементальная сверка + карточки + UPSERT."""
for item in items:
key = item.get("key")
if key is None:
continue
source_key = str(key)
list_reg = _parse_date(item.get("dateReg"))
# Инкрементальность: пропускаем карточку, если key уже есть И date_reg совпал.
if source_key in existing and existing[source_key] == list_reg:
counts["skipped_unchanged"] += 1
continue
try:
card = fetch_doc_card(client, schema, source_key)
except Exception as exc:
logger.warning("gisogd66: карточка %s/%s упала: %s", schema, source_key, exc)
continue
finally:
time.sleep(_CARD_SLEEP_S)
if card is None:
continue
counts["fetched_details"] += 1
rec = _build_record(item, card, schema, doc_group, source_key, list_reg)
if rec is None:
continue
if not rec["cad_nums"]:
counts["no_cad"] += 1
if rec["geojson"] is None:
counts["no_geom"] += 1
try:
with db.begin_nested(): # SAVEPOINT — откат только этой строки
outcome = _upsert_permit(db, rec)
except Exception as exc:
logger.warning("gisogd66: upsert %s %r упал: %s", doc_group, rec["doc_num"], exc)
continue
counts[outcome] += 1
def _build_record(
item: dict[str, Any],
card: dict[str, Any],
schema: str,
doc_group: str,
source_key: str,
list_reg: date | None,
) -> dict[str, Any] | None:
"""Список-элемент + карточка → нормализованная запись для UPSERT. None если нет doc_num.
doc_num берём из карточки, fallback на список; strip обязателен (в списке ~25%
номеров с ведущими/хвостовыми пробелами). Даты предпочитаем из карточки, fallback
на список. cad_nums из spatialUnit карточки, geojson из infosetGeoJson.
"""
raw_num = card.get("docNum") or item.get("docNum")
doc_num = str(raw_num).strip() if raw_num else ""
if not doc_num:
logger.warning("gisogd66: карточка %s/%s без docNum — пропуск", schema, source_key)
return None
return {
"doc_group": doc_group,
"doc_num": doc_num,
"doc_name": card.get("name") or item.get("name"),
"date_doc": _parse_date(card.get("dateDoc")) or _parse_date(item.get("dateDoc")),
"date_reg": _parse_date(card.get("dateReg")) or list_reg,
"approved_organization": card.get("approvedOrganization"),
"source_schema": schema,
"source_key": source_key,
"cad_nums": parse_cad_nums(card.get("spatialUnit")),
"geojson": extract_geojson(card),
}
__all__ = [
"GROUP_CODE",
"SCHEMAS",
"extract_geojson",
"fetch_doc_card",
"fetch_group_list",
"load_gisogd_permits",
"parse_cad_nums",
]