"""Celery task: ingest ОКН-объектов ЕКБ/Свердл.обл из АИС ЕГРКН (#1066, #1159). Обходит расширенный список ``(address, category_type)``-проходов: помимо базовых «Екатеринбург»/«Свердловская» (3 категории каждая) добавлены варианты подстрок адреса — районы ЕКБ, городские округа, обиходные склонения области. Запрос ``Maps/searchMap`` фильтрует ОКН по подстроке в поле ``cob_address``: объекты, у которых адрес начинается с улицы без слова «Екатеринбург», ловятся только запросом по району/округу. Это закрывает coverage gap (428 vs ~798 ЕГРОКН). Один объект может встречаться в нескольких проходах. Дедупликация по ``source_id``: первый проход устанавливает category (самая «высокая» категория первой встретившейся). Порядок проходов: federal → regional → local; «Екатеринбург» раньше областных подстрок → при конфликте source_id выбирается наиболее значимая категория. После сбора уникальных фич воркер догружает per-object detail-карточку ``Show/Show?id=…``: searchMap отдаёт ``properties={id}``, а название/адрес/датировка/ категория охраны лежат отдельно. ``raw_props`` обогащается полями ``name``, ``address``, ``protection_category``, ``object_kind``, ``dating``, ``protection_doc``, ``regnumber`` (#1159 acceptance 2). Detail-фейлы не валят прогон — объект остаётся с базовыми полями (минимум {id}). UPSERT: ON CONFLICT (source_id) DO UPDATE обновляет geom, raw_props, fetched_at (category не меняется при повторном импорте — first wins). SAVEPOINT per-row через ``db.begin_nested()``. Beat: еженедельно в воскресенье в 04:30 МСК — до ird-harvest (понедельник 05:00). WAF-hygiene (#2445 item D3): пэйсинг и 429/5xx backoff между HTTP-запросами живут в ``okn_egrkn_client`` (``_wait_rate_limit`` — общий для 33 list-проходов и до ``_DETAIL_FETCH_LIMIT`` detail-запросов, один WAF-хост). Здесь, в ``_enrich_with_detail``, — circuit breaker: ``_DETAIL_FAIL_BREAKER`` детейл-фейлов ПОДРЯД → прерываем detail-loop (не долбим WAF до конца лимита при устойчивом бане), аналогично ``gisogd66.py`` (``_CARD_FAIL_BREAKER``). """ from __future__ import annotations import json import logging from typing import Any from sqlalchemy import text from app.core.db import SessionLocal from app.services.scrapers.okn_egrkn_client import fetch_okn_detail, fetch_okn_points from app.workers.celery_app import celery_app logger = logging.getLogger(__name__) # Маппинг cob_category_type → строковое название категории _CATEGORY_MAP: dict[int, str] = { 1: "federal", 2: "regional", 4: "local", } # Категории по приоритету (federal → regional → local): порядок важен, # при конфликте source_id у одного и того же объекта побеждает более высокая. _CATEGORY_ORDER: list[int] = [1, 2, 4] # Подстроки для cob_address. ЕГРКН не возвращает все объекты «Свердловской обл.» # по одному запросу — обходим расширенным списком: # - город целиком («Екатеринбург»), # - области («Свердловская», «Свердловской области»), # - районы ЕКБ — для записей без слова «Екатеринбург», только улица + район. # Порядок: «Екатеринбург» раньше других → при дедупе district-вариант не # перетирает category «городского» прохода. _ADDRESS_PASSES: list[str] = [ "Екатеринбург", "г. Екатеринбург", "Кировский", "Ленинский", "Октябрьский", "Орджоникидзевский", "Чкаловский", "Верх-Исетский", "Железнодорожный", "Свердловская", "Свердловской области", ] # Декартово произведение (address × category) — генерируется единожды при импорте. # Категория федеральная идёт первой по каждому address: при конфликте source_id # (тот же объект встречается в нескольких категориях) federal/regional перебивают # local в той же address-strata. _PASSES: list[tuple[str, int]] = [ (addr, cat) for addr in _ADDRESS_PASSES for cat in _CATEGORY_ORDER ] # Лимит per-run на detail-fetch. ~700 объектов * ~0.3 s сетевой задержки ≈ 4 min, # плюс retries — приемлемо для weekly cron. Жёсткий cap защищает от runaway # при сетевом шторме. _DETAIL_FETCH_LIMIT = 1500 # Логгировать прогресс detail-fetch каждые N объектов. _DETAIL_PROGRESS_EVERY = 100 # Circuit breaker (#2445 item D3, аналогично gisogd66._CARD_FAIL_BREAKER): столько # detail-фейлов ПОДРЯД (raise ИЛИ пустой detail считается "не подряд", см. ниже) # → прерываем detail-loop. WAF-бан обычно даёт устойчивую серию ошибок; долбить # оставшиеся до ~1500 detail-запросов бессмысленно и рискованно для IP-репутации. _DETAIL_FAIL_BREAKER = 20 _UPSERT_SQL = text(""" INSERT INTO okn_objects (source_id, category, geom, raw_props, fetched_at) VALUES ( CAST(:source_id AS text), CAST(:category AS text), ST_SetSRID( ST_MakePoint( CAST(:lon AS double precision), CAST(:lat AS double precision) ), 4326 ), CAST(:raw_props AS jsonb), now() ) ON CONFLICT (source_id) DO UPDATE SET geom = EXCLUDED.geom, raw_props = EXCLUDED.raw_props, fetched_at = EXCLUDED.fetched_at """) def _extract_coords(feature: dict[str, Any]) -> tuple[float, float] | None: """Извлечь (lon, lat) из фичи ЕГРКН. ЕГРКН возвращает coordinates в порядке [lat, lon] (Яндекс-конвенция). Для PostGIS берём: lon=coordinates[1], lat=coordinates[0]. Валидный bbox ЕКБ + ближайшего окружения (55–58°N, 59–62°E). """ try: geom = feature.get("geometry") or {} coords = geom.get("coordinates") if not coords or len(coords) < 2: return None lat = float(coords[0]) lon = float(coords[1]) # Базовая санитарная проверка — координаты должны попасть в Урал if not (55.0 <= lat <= 58.5 and 58.0 <= lon <= 63.0): logger.warning( "okn_objects_sync: подозрительные координаты lat=%.4f lon=%.4f, пропускаем", lat, lon, ) return None return lon, lat except (TypeError, ValueError, KeyError) as exc: logger.warning("okn_objects_sync: ошибка разбора координат фичи: %s", exc) return None def _enrich_with_detail( to_upsert: list[dict[str, Any]], *, detail_fetcher: Any = fetch_okn_detail, limit: int = _DETAIL_FETCH_LIMIT, fail_breaker: int = _DETAIL_FAIL_BREAKER, ) -> int: """Обогатить raw_props detail-полями (name, address, …). Принимает список row-dict'ов, уже подготовленных к UPSERT. Для каждой строки вызывает ``detail_fetcher(source_id)`` и сливает результат в JSON ``raw_props`` (detail-поля имеют приоритет над сохранёнными ранее). Записи без detail-полей сохраняются с тем же ``raw_props={id}`` — детали могут быть недоступны (объект удалён из реестра / detail-endpoint лежит). Пэйсинг и 429/5xx backoff — на уровне HTTP-клиента (``okn_egrkn_client``), общий rate-limit между списком и detail-запросами. Circuit breaker здесь (#2445 item D3): ``fail_breaker`` вызовов ``detail_fetcher`` ПОДРЯД, завершившихся исключением (сетевая ошибка / устойчивый WAF-бан после исчерпания ретраев клиента) → прерываем цикл, не долбим оставшийся лимит. Успешный вызов (даже с пустым detail — объект без карточки) сбрасывает счётчик подряд-фейлов. Args: to_upsert: список row-dict для UPSERT (мутируется in-place). detail_fetcher: callable source_id → dict (для DI в тестах). limit: максимум detail-вызовов за прогон. fail_breaker: детейл-фейлов ПОДРЯД до прерывания цикла. Returns: Число фич, для которых detail-поля были успешно подмёрджены. """ enriched = 0 consecutive_fail = 0 total = min(len(to_upsert), limit) for idx, row in enumerate(to_upsert[:limit], 1): try: detail = detail_fetcher(row["source_id"]) except Exception as exc: logger.warning( "okn_objects_sync: detail-fetch source_id=%s raised: %s", row["source_id"], exc, ) detail = {} consecutive_fail += 1 if consecutive_fail >= fail_breaker: logger.error( "okn_objects_sync: %d detail-фейлов подряд — прерываю " "detail-loop (устойчивый бан/сбой WAF), обработано %d/%d", fail_breaker, idx, total, ) break continue else: consecutive_fail = 0 if not detail: continue try: base_props = json.loads(row["raw_props"]) if row["raw_props"] else {} except (TypeError, ValueError): base_props = {} if not isinstance(base_props, dict): base_props = {} merged = {**base_props, **detail} row["raw_props"] = json.dumps(merged, ensure_ascii=False) enriched += 1 if idx % _DETAIL_PROGRESS_EVERY == 0: logger.info( "okn_objects_sync: detail-fetch progress %d/%d (enriched=%d)", idx, total, enriched, ) logger.info( "okn_objects_sync: detail-fetch done enriched=%d of %d (limit=%d)", enriched, total, limit, ) return enriched @celery_app.task(name="tasks.okn_objects_sync.sync_okn_objects", queue="celery") def sync_okn_objects() -> dict[str, int]: """Ingest ОКН-объектов из ЕГРКН в okn_objects (#1066, #1159). Проходит ``_PASSES`` — декартово (адресная подстрока × category_type), дедуплицирует по source_id (первый проход устанавливает category), догружает detail-карточку для каждого уникального source_id и UPSERT'ит в БД с SAVEPOINT per-row. Returns: {"okn": N, "enriched": M} — N успешно обработанных фич (upsert), M фич, для которых detail-карточка была подмёрджена в raw_props. """ # Дедуп по source_id в памяти: source_id → category (first-wins) seen: dict[str, str] = {} # Очередь для UPSERT: row-dict для исполнения _UPSERT_SQL. to_upsert: list[dict[str, Any]] = [] # Пэйсинг между 33 list-запросами (#2445 item D3): fetch_okn_points → _do_request # → okn_egrkn_client._wait_rate_limit перед КАЖДЫМ POST (общий rate-limit с # detail-запросами, один WAF-хост okn-mk.mkrf.ru). Ничего дополнительно спать # здесь не нужно — пэйсинг/backoff инкапсулированы в клиенте. for address, category_type in _PASSES: category = _CATEGORY_MAP[category_type] logger.info( "okn_objects_sync: запрос address=%r category_type=%d (%s)", address, category_type, category, ) features = fetch_okn_points(address, category_type) logger.info( "okn_objects_sync: получено %d фич для address=%r category=%s", len(features), address, category, ) for feature in features: source_id = str(feature.get("id") or "").strip() if not source_id: logger.warning("okn_objects_sync: фича без id, пропускаем: %s", feature) continue coords = _extract_coords(feature) if coords is None: continue lon, lat = coords if source_id in seen: # Объект уже встречался в более приоритетном проходе — пропускаем continue seen[source_id] = category props = feature.get("properties") or {} to_upsert.append( { "source_id": source_id, "category": category, "lon": lon, "lat": lat, "raw_props": json.dumps(props, ensure_ascii=False), } ) logger.info("okn_objects_sync: всего уникальных фич для UPSERT: %d", len(to_upsert)) # Догрузить detail (#1159 acceptance 2). Не валит прогон при сетевых ошибках. enriched = _enrich_with_detail(to_upsert) inserted = 0 errors = 0 with SessionLocal() as db: for row in to_upsert: try: with db.begin_nested(): db.execute(_UPSERT_SQL, row) inserted += 1 except Exception as exc: logger.warning( "okn_objects_sync: UPSERT source_id=%s failed: %s", row["source_id"], exc, ) errors += 1 db.commit() logger.info( "okn_objects_sync: done inserted/updated=%d enriched=%d errors=%d", inserted, enriched, errors, ) return {"okn": inserted, "enriched": enriched} __all__ = ["sync_okn_objects"]