"""Парсер и 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 io import logging import re from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING, Any from urllib.parse import urlsplit from sqlalchemy import text from sqlalchemy.orm import Session if TYPE_CHECKING: from datetime import date from app.services.scrapers.stealth import BrowserSession logger = logging.getLogger(__name__) # Базовая директория для скачанных бинарников планировок. Зеркалит конвенцию # domrf_kn.py:PHOTOS_DIR_DEFAULT (data/raw/domrf_photos) — тот же raw-корень, # отдельная папка. Файлы: {base}/{obj_id}/{safe_ods_id}.{ext}. FLAT_PLANS_DIR_DEFAULT = Path("data/raw/domrf_flat_plans") # Разрешённые расширения картинок планировок. png — дефолт (DOM.РФ отдаёт планы # растром), svg на всякий случай (векторные схемы встречаются). _ALLOWED_PLAN_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".svg") # Замена небезопасных для имени файла символов в ods_id (напр. "65136/1/1.4.3"). _ODS_UNSAFE_RE = re.compile(r"[^A-Za-z0-9._-]+") # ── 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 # ── image download ──────────────────────────────────────────────────────────── def _safe_ods_filename(ods_id: str) -> str: """Превратить ods_id (напр. '65136/1/1.4.3') в безопасное имя файла. Слэши и прочие небезопасные символы → '_'. Результат используется как basename в {base_dir}/{obj_id}/{safe}.{ext}. """ return _ODS_UNSAFE_RE.sub("_", ods_id).strip("_") or "plan" def _ext_from_url(url: str) -> str: """Определить расширение файла по URL планировки. Default .png. DOM.РФ отдаёт планы преимущественно PNG; расширение берём из path, если оно в белом списке, иначе .png (file-API URL /api/ext/file/... часто без расширения — контент-тип уточнится по факту, но png — безопасный дефолт). """ path = urlsplit(url).path.lower() for ext in _ALLOWED_PLAN_EXTS: if path.endswith(ext): return ".jpg" if ext == ".jpeg" else ext return ".png" def _image_dims(data: bytes) -> tuple[int | None, int | None]: """Получить (width_px, height_px) картинки через Pillow. None при ошибке. Pillow (PIL) уже зависимость репо (pyproject: pillow>=10.4.0, используется в app.services.photos.thumbs). SVG Pillow не парсит — вернёт (None, None), это ОК (размеры для векторных планов необязательны). """ try: from PIL import Image with Image.open(io.BytesIO(data)) as im: return int(im.width), int(im.height) except Exception as exc: logger.debug("flat plan image dims parse failed: %s", exc) return None, None async def download_plan_image( session: BrowserSession, plan_image_url: str, ods_id: str, obj_id: int, base_dir: Path | None = None, ) -> dict[str, Any] | None: """Скачать бинарник планировки квартиры и вернуть метаданные для upsert. Реиспользует BrowserSession.download_binary — тот же паттерн что download_photos в domrf_kn.py: тянет файл через browser context (те же cookies / TLS-fingerprint, что прошли WAF), с лёгким throttle (jitter 200–500 мс встроен в download_binary). Файл сохраняется в {base_dir}/{obj_id}/{safe_ods_id}.{ext}. Если файл уже есть на диске — повторно НЕ качаем (idempotent, экономит WAF-бюджет), но всё равно возвращаем метаданные (размеры/размер читаются с диска). Args: session: Активный BrowserSession (bootstrapped + warm_up). plan_image_url: Абсолютный URL картинки планировки. ods_id: Идентификатор квартиры (для имени файла). obj_id: ID объекта DOM.РФ (подпапка). base_dir: Корень для сохранения (None = FLAT_PLANS_DIR_DEFAULT). Returns: dict с ключами local_path, width_px, height_px, size_bytes, downloaded_at — для передачи в upsert_flat_plans. None при ошибке скачивания. """ root = base_dir or FLAT_PLANS_DIR_DEFAULT obj_dir = root / str(obj_id) ext = _ext_from_url(plan_image_url) local = obj_dir / f"{_safe_ods_filename(ods_id)}{ext}" # Idempotent: уже скачан — читаем метаданные с диска, не жжём WAF-запрос. if local.exists() and local.stat().st_size > 0: data = local.read_bytes() width_px, height_px = _image_dims(data) logger.debug("flat plan already on disk ods_id=%s path=%s", ods_id, local) return { "local_path": str(local), "width_px": width_px, "height_px": height_px, "size_bytes": local.stat().st_size, "downloaded_at": datetime.now(UTC), } try: data = await session.download_binary(plan_image_url) except Exception as exc: logger.warning( "download_plan_image failed ods_id=%s obj_id=%s url=%s: %s", ods_id, obj_id, plan_image_url, exc, ) return None if not data: logger.warning("download_plan_image empty body ods_id=%s url=%s", ods_id, plan_image_url) return None obj_dir.mkdir(parents=True, exist_ok=True) try: local.write_bytes(data) except OSError as exc: logger.warning("download_plan_image write failed ods_id=%s path=%s: %s", ods_id, local, exc) return None width_px, height_px = _image_dims(data) logger.info( "download_plan_image ok ods_id=%s obj_id=%s bytes=%d dims=%sx%s", ods_id, obj_id, len(data), width_px, height_px, ) return { "local_path": str(local), "width_px": width_px, "height_px": height_px, "size_bytes": len(data), "downloaded_at": datetime.now(UTC), }