"""Загрузчик реестра РНС/РВЭ Свердловской области из открытого 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. Две схемы (тянем обе, sverdregion первым): ``agate_sverdregion`` (регион-агрегат, стабильные карточки, покрывает и ЕКБ) и ``agate_ekbgo`` (ЕКБ, свежее на ~неделю, но часть старых ключей битая на портале). Один документ может встретиться в обеих → бизнес-ключ ``(doc_group, doc_num)``; при конфликте предпочитаем запись с более поздним ``date_reg``. Инкрементальность: список группы сверяется с БД по (source_schema, source_key, date_reg); карточки (второй запрос — дорогой) тянутся ТОЛЬКО для новых / изменившихся документов. Politeness: единый httpx-клиент с limits(max_connections=5), timeout=20с, 2 ретрая на сетевых ошибках. Карточки (5xx-битые записи портала) НЕ ретраятся — deterministically broken, не транзиент. Circuit breaker: 50 карточек подряд с ошибкой → прерываем группу (битый сегмент). UPSERT — per-row SAVEPOINT (битая строка не валит батч). Периодический db.commit() (каждые 200 записей + после каждой schema/group), чтобы партиал пережил soft_time_limit-kill. """ 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" # Схемы источника — обе тянем. Порядок ВАЖЕН: sverdregion первым. # sverdregion (регион-агрегат) отдаёт карточки стабильно и покрывает в т.ч. ЕКБ-доки # через агрегацию, поэтому он — базовый источник. ekbgo вторым как freshness-энхансер # (свежее на ~неделю), НО у него большой сегмент старых ключей (префикс ~100001007xxx) # детерминированно отдаёт 500 на карточках (сломано на стороне портала, 2026-07-04) — # гоняем его после того, как регион уже покрыл основную массу (см. circuit breaker ниже). SCHEMAS: tuple[str, ...] = ("agate_sverdregion", "agate_ekbgo") # Группа источника (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 # ретраи (helper делает суммарно до 1 + _MAX_RETRIES попыток) _RETRY_BACKOFF_S = 1.5 _CARD_SLEEP_S = 0.05 # вежливая пауза между карточками (второй, «дорогой» запрос) _COMMIT_EVERY = 200 # периодический commit внутри группы (партиал переживает time-limit) # Circuit breaker: столько карточек ПОДРЯД с ошибкой fetch → прерываем группу (портал # отдаёт битый сегмент старых ключей детерминированным 5xx; остаток догоним будущими ранами). _CARD_FAIL_BREAKER = 50 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, *, retries_5xx: int = _MAX_RETRIES) -> Any: """GET path → JSON с ретраями на 5xx / сетевых ошибках. Сетевые Timeout/TransportError ретраятся всегда (до 1 + _MAX_RETRIES попыток) — это транзиент. 5xx ретраятся ``retries_5xx`` раз: списки группы — с дефолтом (транзиентная нагрузка портала), карточки — с ``retries_5xx=0`` (5xx карточки детерминированно битые записи сегмента портала, не транзиент). 4xx → сразу raise. """ last_exc: Exception | None = None for attempt in range(_MAX_RETRIES + 1): try: resp = client.get(path) if resp.status_code >= 500: last_exc = httpx.HTTPStatusError( f"{resp.status_code} on {path}", request=resp.request, response=resp ) # 5xx: ретраим только пока attempt в пределах retries_5xx (0 → сразу raise). if attempt >= retries_5xx: raise last_exc 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. 5xx карточки НЕ ретраятся (retries_5xx=0): битый сегмент старых ключей портала отдаёт детерминированный 500 — 3 попытки с backoff = ~12с потерянного времени/карточку. """ data = _get_json(client, f"/{schema}/gisogddocs/{_SECTION}/{key}", retries_5xx=0) 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[]), -- CAST обязателен: без него ни одно вхождение :geojson не даёт Postgres -- тип параметра (ST_GeomFromGeoJSON перегружена text/json/jsonb) → -- psycopg.errors.AmbiguousParameter на КАЖДОЙ строке (прод-прогон #2367). CASE WHEN CAST(:geojson AS text) IS NULL THEN NULL ELSE ST_Multi(ST_SetSRID( ST_GeomFromGeoJSON(CAST(:geojson AS text)), 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, card_failed, group_aborted}. """ 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, "card_failed": 0, # каждая упавшая карточка (5xx / сеть / не-dict) "group_aborted": 0, # групп прервано circuit breaker'ом (битый сегмент портала) } 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) # Commit после КАЖДОЙ (schema, group), а не один раз в конце: партиал # должен пережить soft_time_limit-kill (иначе теряем весь прогон). 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. Периодический commit каждые ``_COMMIT_EVERY`` обработанных записей (партиал переживёт time-limit kill). Circuit breaker: ``_CARD_FAIL_BREAKER`` карточек ПОДРЯД с ошибкой fetch → прерываем группу (битый сегмент портала — остаток догоним будущими ранами). Любая успешная карточка сбрасывает счётчик подряд-фейлов. """ processed = 0 # успешно обработанных (upsert/skip) с последнего commit consecutive_fail = 0 # карточек подряд с ошибкой fetch (для circuit breaker) 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 consecutive_fail = 0 # skip — не фейл карточки, сбрасываем breaker processed += 1 if processed % _COMMIT_EVERY == 0: db.commit() continue card: dict[str, Any] | None try: card = fetch_doc_card(client, schema, source_key) except Exception as exc: logger.warning("gisogd66: карточка %s/%s упала: %s", schema, source_key, exc) card = None finally: time.sleep(_CARD_SLEEP_S) if card is None: # Неуспех fetch (5xx / сеть / не-dict): считаем и двигаем circuit breaker. counts["card_failed"] += 1 consecutive_fail += 1 if consecutive_fail >= _CARD_FAIL_BREAKER: logger.error( "gisogd66: %s/%s: %d карточек подряд 5xx — прерываю группу " "(битый сегмент портала)", schema, doc_group, _CARD_FAIL_BREAKER, ) counts["group_aborted"] += 1 return continue consecutive_fail = 0 # успешная карточка сбрасывает breaker 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 processed += 1 if processed % _COMMIT_EVERY == 0: db.commit() 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", ]