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