241 lines
10 KiB
Python
241 lines
10 KiB
Python
"""Парсер и 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}"
|
||
)
|