feat(22c): domrf_kn_flat_plans new table + parser stub #308
2 changed files with 285 additions and 0 deletions
241
backend/app/services/scrapers/flat_plans.py
Normal file
241
backend/app/services/scrapers/flat_plans.py
Normal file
|
|
@ -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}"
|
||||||
|
)
|
||||||
44
data/sql/114_22c_flat_plans.sql
Normal file
44
data/sql/114_22c_flat_plans.sql
Normal file
|
|
@ -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;
|
||||||
Loading…
Add table
Reference in a new issue