diff --git a/backend/app/services/scrapers/flat_plans.py b/backend/app/services/scrapers/flat_plans.py new file mode 100644 index 00000000..5583cc6c --- /dev/null +++ b/backend/app/services/scrapers/flat_plans.py @@ -0,0 +1,241 @@ +"""Парсер и 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}" + ) diff --git a/data/sql/114_22c_flat_plans.sql b/data/sql/114_22c_flat_plans.sql new file mode 100644 index 00000000..6ba6f4c1 --- /dev/null +++ b/data/sql/114_22c_flat_plans.sql @@ -0,0 +1,44 @@ +-- Migration 100: domrf_kn_flat_plans — планировки квартир (22c) +-- +-- Хранит URL картинки планировки для каждой квартиры DOM.РФ. +-- Источник данных: каталог-квартир SSR-страница / flats_plans endpoint. +-- Скачивание бинарников — Celery task (future PR, 22d-track). +-- +-- Phase 3 / #297 sub-task 22c. + +BEGIN; + +CREATE TABLE IF NOT EXISTS domrf_kn_flat_plans ( + ods_id text PRIMARY KEY, + obj_id bigint NOT NULL, + plan_image_url text NOT NULL, + local_path text, + width_px int, + height_px int, + size_bytes int, + downloaded_at timestamptz, + snapshot_date date NOT NULL, + scraped_at timestamptz DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS domrf_kn_flat_plans_obj_idx + ON domrf_kn_flat_plans (obj_id); + +COMMENT ON TABLE domrf_kn_flat_plans IS + 'Планировки квартир DOM.РФ: URL изображения + метаданные после скачивания. ' + 'PK = ods_id (совпадает с domrf_kn_flats.ods_id). ' + 'Источник: каталог-квартир SSR / flats_plans endpoint. ' + 'Issue #297 sub-task 22c.'; + +COMMENT ON COLUMN domrf_kn_flat_plans.ods_id IS + 'Идентификатор квартиры DOM.РФ (напр. 65136/1/1.4.3) — PK и FK на domrf_kn_flats.ods_id.'; +COMMENT ON COLUMN domrf_kn_flat_plans.plan_image_url IS + 'Абсолютный URL картинки планировки с сервера DOM.РФ (cdn или основной домен).'; +COMMENT ON COLUMN domrf_kn_flat_plans.local_path IS + 'Путь к локальной копии файла после скачивания (относительно MEDIA_ROOT или абсолютный).'; +COMMENT ON COLUMN domrf_kn_flat_plans.downloaded_at IS + 'Момент успешного скачивания бинарника. NULL = ещё не скачан.'; +COMMENT ON COLUMN domrf_kn_flat_plans.snapshot_date IS + 'Дата snapshot DOM.РФ, в котором впервые обнаружен URL.'; + +COMMIT;