gendesign/backend/app/services/scrapers/flat_plans.py
lekss361 b37e011aaa
Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 5s
Deploy / build-worker (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
feat(22c): domrf_kn_flat_plans new table + parser stub (#308)
2026-05-17 15:39:34 +00:00

241 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Парсер и 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}"
)