"""Парсер и DB-writer для планировок квартир DOM.РФ (domrf_kn_flat_plans). Источник данных --------------- План квартиры отображается на странице /сервисы/каталог-квартир/квартира/{catalog_hash} в блоке «Планировка». URL картинки встроен в SSR-HTML страницы и недоступен через kn-API list-endpoint (/сервисы/api/kn/object) или flat-table endpoint (/portal-kn/api/sales/portal/table). Audit-результат (2026-05-17, obj_id=65136) ------------------------------------------ - kn_object_place_66 payload: plan_image / planImage / planUrl — NOT FOUND. - portal/table flat items: поля planImageUrl / layoutUrl — NOT FOUND. - Вывод: plan image URL находится ТОЛЬКО в SSR-HTML каталога. Структура модуля ---------------- - `extract_flat_plans(raw_payload)` — парсит plan_image_url из flat-table payload (заглушка — возвращает пустой список до реализации SSR-scraper или endpoint). - `upsert_flat_plans(db, obj_id, plans, snapshot_date)` — UPSERT в domrf_kn_flat_plans с COALESCE-защитой скачанных файлов. - `download_plan_image_stub()` — NotImplementedError placeholder. Реальная загрузка — отдельный Celery task в рамках 22d-track / #299. Связанные файлы --------------- data/sql/100_22c_flat_plans.sql — DDL таблицы. backend/app/services/scrapers/domrf_kn.py — основной kn-scraper (НЕ трогаем в этом PR; wiring через отдельный PR после реализации SSR-scraper). Issue #297 sub-task 22c. """ from __future__ import annotations import logging from datetime import date from typing import Any from sqlalchemy import text from sqlalchemy.orm import Session logger = logging.getLogger(__name__) # ── payload parsing ─────────────────────────────────────────────────────────── def extract_flat_plans(raw_payload: dict[str, Any]) -> list[dict[str, Any]]: """Извлечь plan_image_url из flat-table payload. Текущий статус: plan image URL в kn-API flat-table payload ОТСУТСТВУЕТ (audit 2026-05-17). Функция зарезервирована для будущей интеграции: - если DOM.РФ добавит поле в portal/table, - или при подключении SSR-scraper каталога (22d-track). Формат входных данных (portal/table response): { "externalId": 65136, "entrances": [ { "entranceNumber": 1, "floors": [ { "floorNumber": 4, "flats": [ { "odsId": "65136/1/1.4.3", "elemId": "d8c7a8103f26c52e427ace5a996706ba", "totalArea": 36.22, ... # планировка НЕ содержится в этом payload } ] } ] } ] } Возвращает список dict с ключами: ods_id str — идентификатор квартиры plan_image_url str — URL картинки планировки obj_id int — внешний ID объекта При отсутствии нужного поля возвращает пустой список. """ obj_id = raw_payload.get("externalId") plans: list[dict[str, Any]] = [] for entrance in raw_payload.get("entrances") or []: for floor_data in entrance.get("floors") or []: for flat in floor_data.get("flats") or []: ods_id = flat.get("odsId") if not ods_id: continue # Пробуем несколько возможных имён поля — на случай если API # в будущем добавит это поле под одним из вариантов. plan_url = ( flat.get("planImageUrl") or flat.get("layoutImageUrl") or flat.get("planUrl") or flat.get("layoutUrl") or flat.get("imageUrl") ) if not plan_url: continue plans.append( { "ods_id": ods_id, "obj_id": obj_id, "plan_image_url": plan_url, } ) if not plans: logger.debug( "extract_flat_plans: obj_id=%s — plan_image_url не найден в payload " "(ожидаемо: API не содержит это поле, нужен SSR-scraper каталога)", obj_id, ) return plans # ── DB write ────────────────────────────────────────────────────────────────── _UPSERT_FLAT_PLAN_SQL = text( """ INSERT INTO domrf_kn_flat_plans ( ods_id, obj_id, plan_image_url, local_path, width_px, height_px, size_bytes, downloaded_at, snapshot_date, scraped_at ) VALUES ( :ods_id, :obj_id, :plan_image_url, :local_path, :width_px, :height_px, :size_bytes, :downloaded_at, :snapshot_date, NOW() ) ON CONFLICT (ods_id) DO UPDATE SET plan_image_url = EXCLUDED.plan_image_url, obj_id = EXCLUDED.obj_id, snapshot_date = EXCLUDED.snapshot_date, scraped_at = NOW(), local_path = COALESCE(domrf_kn_flat_plans.local_path, EXCLUDED.local_path), downloaded_at = COALESCE(domrf_kn_flat_plans.downloaded_at, EXCLUDED.downloaded_at), width_px = COALESCE(domrf_kn_flat_plans.width_px, EXCLUDED.width_px), height_px = COALESCE(domrf_kn_flat_plans.height_px, EXCLUDED.height_px), size_bytes = COALESCE(domrf_kn_flat_plans.size_bytes, EXCLUDED.size_bytes) """ ) def upsert_flat_plans( db: Session, obj_id: int, plans: list[dict[str, Any]], snapshot_date: date, ) -> int: """UPSERT планировок квартир в domrf_kn_flat_plans. Для каждой строки из `plans` (список dict от `extract_flat_plans` или SSR-scraper) выполняет INSERT ON CONFLICT UPDATE. COALESCE-логика: уже скачанные файлы (local_path, downloaded_at, width_px, height_px, size_bytes) НЕ перезаписываются — только обновляются plan_image_url и snapshot_date. Args: db: SQLAlchemy Session. obj_id: ID объекта DOM.РФ (для логирования). plans: Список dict с ключами ods_id, plan_image_url [, obj_id]. snapshot_date: Дата snapshot, в котором найден URL. Returns: Количество успешно обработанных строк. """ inserted = 0 for plan in plans: ods_id = plan.get("ods_id") plan_url = plan.get("plan_image_url") if not ods_id or not plan_url: logger.warning( "upsert_flat_plans obj=%s: пропущена запись без ods_id/plan_image_url: %s", obj_id, plan, ) continue try: with db.begin_nested(): db.execute( _UPSERT_FLAT_PLAN_SQL, { "ods_id": ods_id, "obj_id": plan.get("obj_id") or obj_id, "plan_image_url": plan_url, "local_path": plan.get("local_path"), "width_px": plan.get("width_px"), "height_px": plan.get("height_px"), "size_bytes": plan.get("size_bytes"), "downloaded_at": plan.get("downloaded_at"), "snapshot_date": snapshot_date, }, ) inserted += 1 except Exception as e: logger.warning("upsert_flat_plans obj=%s ods_id=%s failed: %s", obj_id, ods_id, e) if inserted: logger.info("upsert_flat_plans obj=%s: %d планировок записано", obj_id, inserted) return inserted # ── download stub ───────────────────────────────────────────────────────────── def download_plan_image_stub( plan_image_url: str, ods_id: str, dest_dir: str | None = None, ) -> str: """Заглушка для скачивания бинарника планировки. Реальная реализация — отдельный Celery task (22d-track, issue #299). До реализации бросает NotImplementedError, чтобы случайный вызов не прошёл незаметно. Args: plan_image_url: URL картинки планировки. ods_id: Идентификатор квартиры (для имени файла). dest_dir: Директория для сохранения (None = MEDIA_ROOT/flat_plans/). Raises: NotImplementedError: всегда — до реализации. """ raise NotImplementedError( "download_plan_image_stub: скачивание планировок не реализовано. " "Реальный downloader — отдельный Celery task (issue #299, 22d-track). " f"URL={plan_image_url!r} ods_id={ods_id!r} dest_dir={dest_dir!r}" )