gendesign/backend/app/services/scrapers/flat_plans.py
bot-backend eea9c99e73
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m2s
Deploy / build-worker (push) Successful in 3m20s
Deploy / deploy (push) Successful in 1m35s
feat(scrapers): extract + download DOM.РФ flat plan images (#2440) (#2441)
2026-07-05 16:23:29 +00:00

363 lines
15 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 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 200500 мс встроен в 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),
}