363 lines
15 KiB
Python
363 lines
15 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 io
|
||
import logging
|
||
import re
|
||
from datetime import UTC, datetime
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING, Any
|
||
from urllib.parse import urlsplit
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
if TYPE_CHECKING:
|
||
from datetime import date
|
||
|
||
from app.services.scrapers.stealth import BrowserSession
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Базовая директория для скачанных бинарников планировок. Зеркалит конвенцию
|
||
# domrf_kn.py:PHOTOS_DIR_DEFAULT (data/raw/domrf_photos) — тот же raw-корень,
|
||
# отдельная папка. Файлы: {base}/{obj_id}/{safe_ods_id}.{ext}.
|
||
FLAT_PLANS_DIR_DEFAULT = Path("data/raw/domrf_flat_plans")
|
||
|
||
# Разрешённые расширения картинок планировок. png — дефолт (DOM.РФ отдаёт планы
|
||
# растром), svg на всякий случай (векторные схемы встречаются).
|
||
_ALLOWED_PLAN_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".svg")
|
||
|
||
# Замена небезопасных для имени файла символов в ods_id (напр. "65136/1/1.4.3").
|
||
_ODS_UNSAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||
|
||
|
||
# ── 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
|
||
|
||
|
||
# ── image download ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _safe_ods_filename(ods_id: str) -> str:
|
||
"""Превратить ods_id (напр. '65136/1/1.4.3') в безопасное имя файла.
|
||
|
||
Слэши и прочие небезопасные символы → '_'. Результат используется как
|
||
basename в {base_dir}/{obj_id}/{safe}.{ext}.
|
||
"""
|
||
return _ODS_UNSAFE_RE.sub("_", ods_id).strip("_") or "plan"
|
||
|
||
|
||
def _ext_from_url(url: str) -> str:
|
||
"""Определить расширение файла по URL планировки. Default .png.
|
||
|
||
DOM.РФ отдаёт планы преимущественно PNG; расширение берём из path, если
|
||
оно в белом списке, иначе .png (file-API URL /api/ext/file/... часто без
|
||
расширения — контент-тип уточнится по факту, но png — безопасный дефолт).
|
||
"""
|
||
path = urlsplit(url).path.lower()
|
||
for ext in _ALLOWED_PLAN_EXTS:
|
||
if path.endswith(ext):
|
||
return ".jpg" if ext == ".jpeg" else ext
|
||
return ".png"
|
||
|
||
|
||
def _image_dims(data: bytes) -> tuple[int | None, int | None]:
|
||
"""Получить (width_px, height_px) картинки через Pillow. None при ошибке.
|
||
|
||
Pillow (PIL) уже зависимость репо (pyproject: pillow>=10.4.0, используется в
|
||
app.services.photos.thumbs). SVG Pillow не парсит — вернёт (None, None),
|
||
это ОК (размеры для векторных планов необязательны).
|
||
"""
|
||
try:
|
||
from PIL import Image
|
||
|
||
with Image.open(io.BytesIO(data)) as im:
|
||
return int(im.width), int(im.height)
|
||
except Exception as exc:
|
||
logger.debug("flat plan image dims parse failed: %s", exc)
|
||
return None, None
|
||
|
||
|
||
async def download_plan_image(
|
||
session: BrowserSession,
|
||
plan_image_url: str,
|
||
ods_id: str,
|
||
obj_id: int,
|
||
base_dir: Path | None = None,
|
||
) -> dict[str, Any] | None:
|
||
"""Скачать бинарник планировки квартиры и вернуть метаданные для upsert.
|
||
|
||
Реиспользует BrowserSession.download_binary — тот же паттерн что download_photos
|
||
в domrf_kn.py: тянет файл через browser context (те же cookies / TLS-fingerprint,
|
||
что прошли WAF), с лёгким throttle (jitter 200–500 мс встроен в download_binary).
|
||
|
||
Файл сохраняется в {base_dir}/{obj_id}/{safe_ods_id}.{ext}. Если файл уже есть
|
||
на диске — повторно НЕ качаем (idempotent, экономит WAF-бюджет), но всё равно
|
||
возвращаем метаданные (размеры/размер читаются с диска).
|
||
|
||
Args:
|
||
session: Активный BrowserSession (bootstrapped + warm_up).
|
||
plan_image_url: Абсолютный URL картинки планировки.
|
||
ods_id: Идентификатор квартиры (для имени файла).
|
||
obj_id: ID объекта DOM.РФ (подпапка).
|
||
base_dir: Корень для сохранения (None = FLAT_PLANS_DIR_DEFAULT).
|
||
|
||
Returns:
|
||
dict с ключами local_path, width_px, height_px, size_bytes, downloaded_at —
|
||
для передачи в upsert_flat_plans. None при ошибке скачивания.
|
||
"""
|
||
root = base_dir or FLAT_PLANS_DIR_DEFAULT
|
||
obj_dir = root / str(obj_id)
|
||
ext = _ext_from_url(plan_image_url)
|
||
local = obj_dir / f"{_safe_ods_filename(ods_id)}{ext}"
|
||
|
||
# Idempotent: уже скачан — читаем метаданные с диска, не жжём WAF-запрос.
|
||
if local.exists() and local.stat().st_size > 0:
|
||
data = local.read_bytes()
|
||
width_px, height_px = _image_dims(data)
|
||
logger.debug("flat plan already on disk ods_id=%s path=%s", ods_id, local)
|
||
return {
|
||
"local_path": str(local),
|
||
"width_px": width_px,
|
||
"height_px": height_px,
|
||
"size_bytes": local.stat().st_size,
|
||
"downloaded_at": datetime.now(UTC),
|
||
}
|
||
|
||
try:
|
||
data = await session.download_binary(plan_image_url)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"download_plan_image failed ods_id=%s obj_id=%s url=%s: %s",
|
||
ods_id,
|
||
obj_id,
|
||
plan_image_url,
|
||
exc,
|
||
)
|
||
return None
|
||
|
||
if not data:
|
||
logger.warning("download_plan_image empty body ods_id=%s url=%s", ods_id, plan_image_url)
|
||
return None
|
||
|
||
obj_dir.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
local.write_bytes(data)
|
||
except OSError as exc:
|
||
logger.warning("download_plan_image write failed ods_id=%s path=%s: %s", ods_id, local, exc)
|
||
return None
|
||
|
||
width_px, height_px = _image_dims(data)
|
||
logger.info(
|
||
"download_plan_image ok ods_id=%s obj_id=%s bytes=%d dims=%sx%s",
|
||
ods_id,
|
||
obj_id,
|
||
len(data),
|
||
width_px,
|
||
height_px,
|
||
)
|
||
return {
|
||
"local_path": str(local),
|
||
"width_px": width_px,
|
||
"height_px": height_px,
|
||
"size_bytes": len(data),
|
||
"downloaded_at": datetime.now(UTC),
|
||
}
|