feat(scrapers): extract + download DOM.РФ flat plan images (#2440)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 14m49s
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 14m49s
domrf_kn_flat_plans было пусто (0 строк) — блокер для #299 (Potrace production-wiring) и #300 (CubiCasa). domrf_catalog.py уже фетчит SSR- страницы каталога квартир, где лежит планировка, но _TextCollector сознательно дропал <img> как void-tag (#1608) — картинка никогда не извлекалась. parse_catalog_flat теперь возвращает plan_image_url: primary-стратегия парсит __NEXT_DATA__ pageProps JSON (Next.js SSR-паттерн), fallback — голый <img> по class-hint/соседству с текстом "Планировка". _TextCollector получает аддитивный self.images-сбор ДО void-tag early-return — не трогает существующий stack/buffer, регрессия по #1608 исключена (тест test_img_capture_preserves_text_blocks). download_plan_image_stub (NotImplementedError) заменён на рабочую async-загрузку через существующий BrowserSession.download_binary (те же cookies/TLS/throttle), Pillow для width/height, идемпотентна (skip при уже существующем файле). Двухфазный upsert: URL пишется первым, затем метаданные после скачивания — частичный прогресс переживает падение загрузки. Не заводит новый Celery beat — не подключено ни в один task (домрана предпосылка issue #2440: scrape_one_flat/scrape_catalog_batch сейчас нигде не вызываются). Ships inert до отдельного follow-up по wiring. Refs #2440, #299, #300
This commit is contained in:
parent
994eba0a16
commit
b6e5919cf3
4 changed files with 778 additions and 27 deletions
|
|
@ -33,15 +33,18 @@ Wiring (отдельный PR):
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.services.scrapers.flat_plans import download_plan_image, upsert_flat_plans
|
||||||
from app.services.scrapers.stealth import BASE_URL, BrowserSession, jitter_sleep
|
from app.services.scrapers.stealth import BASE_URL, BrowserSession, jitter_sleep
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -87,6 +90,51 @@ _STATUS_KW_RE = re.compile(
|
||||||
# не имеющим отношения к статусу продажи (#1609 follow-up, #1686).
|
# не имеющим отношения к статусу продажи (#1609 follow-up, #1686).
|
||||||
_STATUS_BADGE_CLS_RE = re.compile(r"(?<![a-z])status(?![a-z])", re.IGNORECASE)
|
_STATUS_BADGE_CLS_RE = re.compile(r"(?<![a-z])status(?![a-z])", re.IGNORECASE)
|
||||||
|
|
||||||
|
# ── plan-image extraction ─────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Страница квартиры каталога DOM.РФ — Next.js SSR (тот же стек что и страница
|
||||||
|
# объекта, см. domrf_catalog_object.py). План квартиры рендерится через next/image,
|
||||||
|
# поэтому реальный URL картинки живёт в JSON-блоке <script id="__NEXT_DATA__">
|
||||||
|
# внутри pageProps, а НЕ в готовом <img src>. Стратегия extraction (в порядке
|
||||||
|
# приоритета, best-effort — DOM.РФ может переименовать ключи без уведомления):
|
||||||
|
#
|
||||||
|
# 1. __NEXT_DATA__ pageProps: ищем значение под одним из кандидат-ключей
|
||||||
|
# (planImage / planImageUrl / layoutImage / floorPlan / scheme / image / photo).
|
||||||
|
# 2. Fallback: bare <img>, чей src/alt/class намекают на планировку
|
||||||
|
# (plan / layout / scheme / планировк), либо <img> рядом с текстом «Планировка».
|
||||||
|
#
|
||||||
|
# URL нормализуется в абсолютный через urljoin(BASE_URL, ...): DOM.РФ отдаёт файлы
|
||||||
|
# и абсолютными (https://xn--80az8a.../api/ext/file/... — см. documents.py), и
|
||||||
|
# относительными (/_next/image?url=..., /api/ext/file/...). Мусорные картинки
|
||||||
|
# (data:-inline base64, captcha, лого застройщика, миниатюры) отсекаются.
|
||||||
|
|
||||||
|
# Ключи в pageProps, под которыми может лежать URL планировки. Проверяются по
|
||||||
|
# порядку; первое непустое строковое значение с image-like URL выигрывает.
|
||||||
|
_NEXT_PLAN_KEY_RE = re.compile(
|
||||||
|
r"(?:plan|layout|scheme|планировк|floor)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Признак «это картинка» в значении: расширение файла ИЛИ file-API DOM.РФ.
|
||||||
|
_IMAGE_URL_RE = re.compile(
|
||||||
|
r"(?:\.(?:png|jpe?g|webp|svg)(?:\?|$)|/api/ext/file/|/_next/image)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Токены, по которым отсекаем НЕ-планировочные картинки (лого, аватары, иконки,
|
||||||
|
# captcha, миниатюры, банеры). Проверяются по src/alt/class.
|
||||||
|
_IMAGE_NOISE_RE = re.compile(
|
||||||
|
r"(?:logo|avatar|icon|captcha|robot|sprite|banner|placeholder|thumb|"
|
||||||
|
r"favicon|badge|marker|pin)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Признак «это планировка» в атрибутах <img> (src / alt / class).
|
||||||
|
_IMAGE_PLAN_HINT_RE = re.compile(
|
||||||
|
r"(?:plan|layout|scheme|планировк)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _classify_status_kw(matched_text: str) -> str | None:
|
def _classify_status_kw(matched_text: str) -> str | None:
|
||||||
"""Классифицировать совпавшее ключевое слово в STATUS_*.
|
"""Классифицировать совпавшее ключевое слово в STATUS_*.
|
||||||
|
|
@ -217,8 +265,19 @@ class _TextCollector(HTMLParser):
|
||||||
# Список записей: (class_hint, full_text)
|
# Список записей: (class_hint, full_text)
|
||||||
self.blocks: list[tuple[str, str]] = []
|
self.blocks: list[tuple[str, str]] = []
|
||||||
self._buf: list[str] = []
|
self._buf: list[str] = []
|
||||||
|
# Отдельный, аддитивный сбор <img>-атрибутов (issue #2440): нужен для
|
||||||
|
# extraction plan_image_url как fallback к __NEXT_DATA__. НЕ влияет на
|
||||||
|
# text-extraction — img остаётся void-тегом, фрейм в стек не пушится.
|
||||||
|
# Формат: (attrs_dict, index_в_blocks) — index привязывает картинку к
|
||||||
|
# позиции в потоке текстовых блоков для proximity-поиска «Планировка».
|
||||||
|
self.images: list[tuple[dict[str, str], int]] = []
|
||||||
|
|
||||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||||
|
if tag == "img":
|
||||||
|
# Собираем атрибуты img ДО early-return (void-тег): index = текущая
|
||||||
|
# длина blocks, т.е. картинка «привязана» к ближайшему предыдущему
|
||||||
|
# закрытому текстовому блоку. Text-extraction ниже не меняется.
|
||||||
|
self.images.append(({k: (v or "") for k, v in attrs}, len(self.blocks)))
|
||||||
if tag in self._VOID_TAGS:
|
if tag in self._VOID_TAGS:
|
||||||
return # void-теги не имеют endtag — не открываем фрейм (issue #1608)
|
return # void-теги не имеют endtag — не открываем фрейм (issue #1608)
|
||||||
attr_dict = {k: (v or "") for k, v in attrs}
|
attr_dict = {k: (v or "") for k, v in attrs}
|
||||||
|
|
@ -266,6 +325,134 @@ def _find_text_near(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_plan_url(url: str | None) -> str | None:
|
||||||
|
"""Нормализовать URL картинки планировки в абсолютный, отсеять мусор.
|
||||||
|
|
||||||
|
- data:-inline (base64) отбрасываем (это не удалённый файл, часто captcha/robot).
|
||||||
|
- Относительный путь ('/api/ext/file/...', '/_next/image?url=...') → absolute
|
||||||
|
через urljoin(BASE_URL, ...).
|
||||||
|
- Пустое/None → None.
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
u = url.strip()
|
||||||
|
if not u or u.lower().startswith("data:"):
|
||||||
|
return None
|
||||||
|
if u.startswith("//"):
|
||||||
|
# protocol-relative → https
|
||||||
|
return "https:" + u
|
||||||
|
if u.startswith("http://") or u.startswith("https://"):
|
||||||
|
return u
|
||||||
|
return urljoin(BASE_URL + "/", u.lstrip("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_plan_from_next_data(html: str) -> str | None:
|
||||||
|
"""Извлечь URL планировки из <script id="__NEXT_DATA__"> pageProps.
|
||||||
|
|
||||||
|
Страница квартиры каталога — Next.js SSR: план рендерится через next/image,
|
||||||
|
URL картинки живёт в JSON-блоке. Рекурсивно обходим pageProps и ищем строковое
|
||||||
|
значение под ключом, матчащим _NEXT_PLAN_KEY_RE, чьё значение выглядит как
|
||||||
|
image-URL (_IMAGE_URL_RE) и НЕ является шумом (_IMAGE_NOISE_RE).
|
||||||
|
|
||||||
|
Возвращает первый подходящий URL (нормализованный) или None.
|
||||||
|
"""
|
||||||
|
match = re.search(
|
||||||
|
r'<script\s+id=["\']__NEXT_DATA__["\'][^>]*>(.+?)</script>',
|
||||||
|
html,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
blob = json.loads(match.group(1).strip())
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
page_props = blob.get("props", {}).get("pageProps")
|
||||||
|
root: Any = page_props if isinstance(page_props, dict) else blob
|
||||||
|
|
||||||
|
# BFS по вложенному dict/list; ключ+значение проверяем на plan-hint.
|
||||||
|
stack: list[Any] = [root]
|
||||||
|
seen = 0
|
||||||
|
while stack and seen < 20_000: # cap: защита от патологически глубокого JSON
|
||||||
|
node = stack.pop()
|
||||||
|
seen += 1
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for key, value in node.items():
|
||||||
|
if (
|
||||||
|
isinstance(value, str)
|
||||||
|
and _NEXT_PLAN_KEY_RE.search(str(key))
|
||||||
|
and _IMAGE_URL_RE.search(value)
|
||||||
|
and not _IMAGE_NOISE_RE.search(value)
|
||||||
|
):
|
||||||
|
normalized = _normalize_plan_url(value)
|
||||||
|
if normalized:
|
||||||
|
return normalized
|
||||||
|
if isinstance(value, dict | list):
|
||||||
|
stack.append(value)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
stack.extend(node)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_plan_from_imgs(
|
||||||
|
images: list[tuple[dict[str, str], int]], blocks: list[tuple[str, str]]
|
||||||
|
) -> str | None:
|
||||||
|
"""Fallback: выбрать <img> планировки из собранных _TextCollector.images.
|
||||||
|
|
||||||
|
Приоритет:
|
||||||
|
1. <img>, чей src/alt/class содержит plan/layout/scheme/планировк — прямое
|
||||||
|
совпадение, самый надёжный сигнал.
|
||||||
|
2. <img> рядом (в потоке DOM) с текстовым блоком «Планировка».
|
||||||
|
Шумовые картинки (лого/иконки/captcha/миниатюры) отсекаются по _IMAGE_NOISE_RE,
|
||||||
|
inline data:-картинки и пустые src — через _normalize_plan_url.
|
||||||
|
"""
|
||||||
|
# Уровень 1: явный plan-hint в атрибутах.
|
||||||
|
for attrs, _idx in images:
|
||||||
|
src = attrs.get("src", "")
|
||||||
|
alt = attrs.get("alt", "")
|
||||||
|
cls = attrs.get("class", "")
|
||||||
|
haystack = f"{src} {alt} {cls}"
|
||||||
|
if _IMAGE_NOISE_RE.search(haystack):
|
||||||
|
continue
|
||||||
|
if _IMAGE_PLAN_HINT_RE.search(haystack):
|
||||||
|
normalized = _normalize_plan_url(src)
|
||||||
|
if normalized:
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
# Уровень 2: <img>, чей index стоит сразу после блока «Планировка».
|
||||||
|
plan_block_indices = [
|
||||||
|
i for i, (_cls, text) in enumerate(blocks) if re.search(r"планировк", text, re.IGNORECASE)
|
||||||
|
]
|
||||||
|
if plan_block_indices:
|
||||||
|
for attrs, idx in images:
|
||||||
|
src = attrs.get("src", "")
|
||||||
|
if _IMAGE_NOISE_RE.search(f"{src} {attrs.get('alt', '')} {attrs.get('class', '')}"):
|
||||||
|
continue
|
||||||
|
# картинка «принадлежит» блоку «Планировка», если её index в пределах
|
||||||
|
# ±2 текстовых блоков от лейбла.
|
||||||
|
if any(abs(idx - pbi) <= 2 for pbi in plan_block_indices):
|
||||||
|
normalized = _normalize_plan_url(src)
|
||||||
|
if normalized:
|
||||||
|
return normalized
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_plan_image_url(html: str, collector: _TextCollector) -> str | None:
|
||||||
|
"""Извлечь URL картинки планировки из SSR-HTML страницы квартиры.
|
||||||
|
|
||||||
|
Two-strategy (issue #2440):
|
||||||
|
1. __NEXT_DATA__ pageProps (primary — DOM.РФ Next.js SSR).
|
||||||
|
2. bare <img> fallback (по plan-hint атрибутам либо proximity к «Планировка»).
|
||||||
|
|
||||||
|
Возвращает абсолютный URL или None. Best-effort — структура DOM.РФ хрупкая.
|
||||||
|
"""
|
||||||
|
url = _extract_plan_from_next_data(html)
|
||||||
|
if url:
|
||||||
|
return url
|
||||||
|
return _extract_plan_from_imgs(collector.images, collector.blocks)
|
||||||
|
|
||||||
|
|
||||||
def parse_catalog_flat(html: str) -> dict[str, Any]:
|
def parse_catalog_flat(html: str) -> dict[str, Any]:
|
||||||
"""Извлечь поля из SSR-HTML страницы квартиры DOM.РФ.
|
"""Извлечь поля из SSR-HTML страницы квартиры DOM.РФ.
|
||||||
|
|
||||||
|
|
@ -277,6 +464,7 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
||||||
- ceiling_height_m (float) Высота потолков в метрах
|
- ceiling_height_m (float) Высота потолков в метрах
|
||||||
- section_no (int) Номер подъезда / секции
|
- section_no (int) Номер подъезда / секции
|
||||||
- catalog_updated_at (date) Дата обновления информации на странице
|
- catalog_updated_at (date) Дата обновления информации на странице
|
||||||
|
- plan_image_url (str) URL картинки планировки (issue #2440)
|
||||||
|
|
||||||
Парсинг хрупкий по природе (SSR HTML DOM.РФ меняется без уведомлений).
|
Парсинг хрупкий по природе (SSR HTML DOM.РФ меняется без уведомлений).
|
||||||
Все extraction best-effort — KeyError/AttributeError обёрнуты внутри.
|
Все extraction best-effort — KeyError/AttributeError обёрнуты внутри.
|
||||||
|
|
@ -468,6 +656,14 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# ── Шаг 4: plan_image_url (issue #2440) ──────────────────────────────────
|
||||||
|
# __NEXT_DATA__ pageProps (primary) → bare <img> fallback. collector.images
|
||||||
|
# собран на Шаге 1 тем же feed(). None если план не найден (напр. проданная
|
||||||
|
# квартира без планировки на странице).
|
||||||
|
plan_url = extract_plan_image_url(html, collector)
|
||||||
|
if plan_url:
|
||||||
|
result["plan_image_url"] = plan_url
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"parse_catalog_flat: extracted fields=%s",
|
"parse_catalog_flat: extracted fields=%s",
|
||||||
list(result.keys()),
|
list(result.keys()),
|
||||||
|
|
@ -544,16 +740,63 @@ def upsert_catalog_data(
|
||||||
# ── Per-flat scrape orchestration ─────────────────────────────────────────────
|
# ── Per-flat scrape orchestration ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_plan_image(
|
||||||
|
db: Session,
|
||||||
|
session: BrowserSession,
|
||||||
|
ods_id: str,
|
||||||
|
obj_id: int,
|
||||||
|
plan_url: str,
|
||||||
|
snapshot_date: date,
|
||||||
|
outcome: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Записать URL планировки в flat_plans, скачать бинарник, дописать метаданные.
|
||||||
|
|
||||||
|
Порядок (issue #2440):
|
||||||
|
1. upsert_flat_plans с одним только plan_image_url — URL зафиксирован даже
|
||||||
|
если скачивание упадёт (частичный прогресс не теряется).
|
||||||
|
2. download_plan_image — reuse BrowserSession (cookies/TLS/throttle).
|
||||||
|
3. повторный upsert_flat_plans с local_path/размерами — COALESCE внутри
|
||||||
|
upsert защищает уже скачанные файлы от перезаписи.
|
||||||
|
|
||||||
|
Мутирует outcome['plan_downloaded']. Ошибки логируются внутри download/upsert,
|
||||||
|
наружу не пробрасываются — планировка не должна ронять catalog-scrape квартиры.
|
||||||
|
"""
|
||||||
|
plan_row: dict[str, Any] = {
|
||||||
|
"ods_id": ods_id,
|
||||||
|
"obj_id": obj_id,
|
||||||
|
"plan_image_url": plan_url,
|
||||||
|
}
|
||||||
|
upsert_flat_plans(db, obj_id, [plan_row], snapshot_date)
|
||||||
|
|
||||||
|
meta = await download_plan_image(session, plan_url, ods_id, obj_id)
|
||||||
|
if meta:
|
||||||
|
outcome["plan_downloaded"] = True
|
||||||
|
upsert_flat_plans(db, obj_id, [{**plan_row, **meta}], snapshot_date)
|
||||||
|
|
||||||
|
|
||||||
async def scrape_one_flat(
|
async def scrape_one_flat(
|
||||||
session: BrowserSession,
|
session: BrowserSession,
|
||||||
db: Session,
|
db: Session,
|
||||||
ods_id: str,
|
ods_id: str,
|
||||||
catalog_url_hash: str,
|
catalog_url_hash: str,
|
||||||
|
obj_id: int | None = None,
|
||||||
|
snapshot_date: date | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Scrape одной квартиры: fetch HTML → parse → upsert.
|
"""Scrape одной квартиры: fetch HTML → parse → upsert (+ планировка).
|
||||||
|
|
||||||
Возвращает dict с результатом: {ods_id, success, fields_extracted, updated}.
|
Помимо catalog-полей (price/status/finishing/...) извлекает plan_image_url
|
||||||
Ошибки fetch/parse логируются, не бросаются — caller обрабатывает результат.
|
(issue #2440): пишет URL в domrf_kn_flat_plans через upsert_flat_plans, затем
|
||||||
|
скачивает бинарник (download_plan_image) и повторным upsert'ом дописывает
|
||||||
|
local_path/размеры (COALESCE-защита уже скачанных файлов внутри upsert).
|
||||||
|
|
||||||
|
Скачивание — inline в рамках того же BrowserSession: reuse cookies/TLS,
|
||||||
|
download_binary несёт свой лёгкий throttle (jitter 200–500 мс), отдельная
|
||||||
|
beat-задача не заводится (требование #2440). obj_id/snapshot_date нужны для
|
||||||
|
записи в flat_plans; если не переданы — plan-upsert пропускается с warning.
|
||||||
|
|
||||||
|
Возвращает dict с результатом: {ods_id, success, fields_extracted, updated,
|
||||||
|
plan_found, plan_downloaded}. Ошибки fetch/parse/download логируются, не
|
||||||
|
бросаются — caller обрабатывает результат.
|
||||||
"""
|
"""
|
||||||
outcome: dict[str, Any] = {
|
outcome: dict[str, Any] = {
|
||||||
"ods_id": ods_id,
|
"ods_id": ods_id,
|
||||||
|
|
@ -561,6 +804,8 @@ async def scrape_one_flat(
|
||||||
"success": False,
|
"success": False,
|
||||||
"fields_extracted": 0,
|
"fields_extracted": 0,
|
||||||
"updated": False,
|
"updated": False,
|
||||||
|
"plan_found": False,
|
||||||
|
"plan_downloaded": False,
|
||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -590,6 +835,19 @@ async def scrape_one_flat(
|
||||||
|
|
||||||
outcome["fields_extracted"] = len([v for v in data.values() if v is not None])
|
outcome["fields_extracted"] = len([v for v in data.values() if v is not None])
|
||||||
outcome["updated"] = upsert_catalog_data(db, ods_id, catalog_url_hash, data)
|
outcome["updated"] = upsert_catalog_data(db, ods_id, catalog_url_hash, data)
|
||||||
|
|
||||||
|
# ── plan_image_url → domrf_kn_flat_plans (issue #2440) ───────────────────
|
||||||
|
plan_url = data.get("plan_image_url")
|
||||||
|
if plan_url:
|
||||||
|
outcome["plan_found"] = True
|
||||||
|
if obj_id is not None and snapshot_date is not None:
|
||||||
|
await _handle_plan_image(db, session, ods_id, obj_id, plan_url, snapshot_date, outcome)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"catalog scrape ods_id=%s: plan_image_url found but obj_id/snapshot_date "
|
||||||
|
"missing — flat_plans upsert skipped",
|
||||||
|
ods_id,
|
||||||
|
)
|
||||||
# success отражает прохождение пайплайна fetch+parse без исключения; реально
|
# success отражает прохождение пайплайна fetch+parse без исключения; реально
|
||||||
# ли затронута строка в БД — см. outcome['updated']. Батч-статистика считает
|
# ли затронута строка в БД — см. outcome['updated']. Батч-статистика считает
|
||||||
# отдельный stats['updated'], чтобы не рапортовать ложно высокий success при
|
# отдельный stats['updated'], чтобы не рапортовать ложно высокий success при
|
||||||
|
|
@ -617,11 +875,12 @@ async def scrape_catalog_batch(
|
||||||
region_code: int = 66,
|
region_code: int = 66,
|
||||||
headed: bool = False,
|
headed: bool = False,
|
||||||
load_state: str | None = None,
|
load_state: str | None = None,
|
||||||
|
snapshot_date: date | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Scrape пачки квартир каталога DOM.РФ.
|
"""Scrape пачки квартир каталога DOM.РФ.
|
||||||
|
|
||||||
`flats` — список dict'ов с ключами {ods_id, catalog_url_hash}.
|
`flats` — список dict'ов с ключами {ods_id, catalog_url_hash [, obj_id]}.
|
||||||
Типовой источник: SELECT ods_id, catalog_url_hash FROM domrf_kn_flats
|
Типовой источник: SELECT ods_id, obj_id, catalog_url_hash FROM domrf_kn_flats
|
||||||
WHERE catalog_url_hash IS NOT NULL
|
WHERE catalog_url_hash IS NOT NULL
|
||||||
AND (catalog_updated_at IS NULL OR catalog_updated_at < NOW() - INTERVAL '30 days')
|
AND (catalog_updated_at IS NULL OR catalog_updated_at < NOW() - INTERVAL '30 days')
|
||||||
LIMIT :batch_size.
|
LIMIT :batch_size.
|
||||||
|
|
@ -629,18 +888,25 @@ async def scrape_catalog_batch(
|
||||||
Использует один BrowserSession на весь пакет (bootstrapped 1 раз).
|
Использует один BrowserSession на весь пакет (bootstrapped 1 раз).
|
||||||
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
|
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
|
||||||
|
|
||||||
|
obj_id/snapshot_date нужны для записи планировки в domrf_kn_flat_plans (issue
|
||||||
|
#2440). snapshot_date по умолчанию = сегодня (дата первичного обнаружения URL).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{total, success, updated, failed, fields_total}
|
{total, success, updated, failed, fields_total, plans_found, plans_downloaded}
|
||||||
- success: прошли fetch+parse без исключения
|
- success: прошли fetch+parse без исключения
|
||||||
- updated: реально затронули строку в БД (issue #1610) — отражает
|
- updated: реально затронули строку в БД (issue #1610) — отражает
|
||||||
фактическое число записанных квартир, в отличие от success
|
фактическое число записанных квартир, в отличие от success
|
||||||
|
- plans_found / plans_downloaded: планировки найдены / скачаны (#2440)
|
||||||
"""
|
"""
|
||||||
|
snap = snapshot_date or date.today()
|
||||||
stats: dict[str, Any] = {
|
stats: dict[str, Any] = {
|
||||||
"total": len(flats),
|
"total": len(flats),
|
||||||
"success": 0,
|
"success": 0,
|
||||||
"updated": 0,
|
"updated": 0,
|
||||||
"failed": 0,
|
"failed": 0,
|
||||||
"fields_total": 0,
|
"fields_total": 0,
|
||||||
|
"plans_found": 0,
|
||||||
|
"plans_downloaded": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
if not flats:
|
if not flats:
|
||||||
|
|
@ -648,9 +914,10 @@ async def scrape_catalog_batch(
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"scrape_catalog_batch: starting %d flats region=%d",
|
"scrape_catalog_batch: starting %d flats region=%d snapshot_date=%s",
|
||||||
len(flats),
|
len(flats),
|
||||||
region_code,
|
region_code,
|
||||||
|
snap,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with BrowserSession(
|
async with BrowserSession(
|
||||||
|
|
@ -660,20 +927,32 @@ async def scrape_catalog_batch(
|
||||||
# auth=None — страницы каталога публичные, Basic auth не нужен
|
# auth=None — страницы каталога публичные, Basic auth не нужен
|
||||||
auth=None,
|
auth=None,
|
||||||
) as session:
|
) as session:
|
||||||
|
# Warm-up: WAF DOM.РФ (2026-05-24) требует session cookies для SSR-страниц.
|
||||||
|
# Зеркалит scrape_catalog_objects. Idempotent на весь batch.
|
||||||
|
await session.warm_up()
|
||||||
|
|
||||||
for flat in flats:
|
for flat in flats:
|
||||||
ods_id = flat.get("ods_id", "")
|
ods_id = flat.get("ods_id", "")
|
||||||
catalog_url_hash = flat.get("catalog_url_hash", "")
|
catalog_url_hash = flat.get("catalog_url_hash", "")
|
||||||
|
obj_id_raw = flat.get("obj_id")
|
||||||
|
obj_id = int(obj_id_raw) if obj_id_raw is not None else None
|
||||||
if not ods_id or not catalog_url_hash:
|
if not ods_id or not catalog_url_hash:
|
||||||
logger.warning("scrape_catalog_batch: skip flat with missing ods_id/hash: %r", flat)
|
logger.warning("scrape_catalog_batch: skip flat with missing ods_id/hash: %r", flat)
|
||||||
stats["failed"] += 1
|
stats["failed"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
outcome = await scrape_one_flat(session, db, ods_id, catalog_url_hash)
|
outcome = await scrape_one_flat(
|
||||||
|
session, db, ods_id, catalog_url_hash, obj_id=obj_id, snapshot_date=snap
|
||||||
|
)
|
||||||
if outcome["success"]:
|
if outcome["success"]:
|
||||||
stats["success"] += 1
|
stats["success"] += 1
|
||||||
stats["fields_total"] += outcome["fields_extracted"]
|
stats["fields_total"] += outcome["fields_extracted"]
|
||||||
if outcome["updated"]:
|
if outcome["updated"]:
|
||||||
stats["updated"] += 1
|
stats["updated"] += 1
|
||||||
|
if outcome.get("plan_found"):
|
||||||
|
stats["plans_found"] += 1
|
||||||
|
if outcome.get("plan_downloaded"):
|
||||||
|
stats["plans_downloaded"] += 1
|
||||||
else:
|
else:
|
||||||
stats["failed"] += 1
|
stats["failed"] += 1
|
||||||
|
|
||||||
|
|
@ -689,11 +968,14 @@ async def scrape_catalog_batch(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d fields_total=%d",
|
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d "
|
||||||
|
"fields_total=%d plans_found=%d plans_downloaded=%d",
|
||||||
stats["total"],
|
stats["total"],
|
||||||
stats["success"],
|
stats["success"],
|
||||||
stats["updated"],
|
stats["updated"],
|
||||||
stats["failed"],
|
stats["failed"],
|
||||||
stats["fields_total"],
|
stats["fields_total"],
|
||||||
|
stats["plans_found"],
|
||||||
|
stats["plans_downloaded"],
|
||||||
)
|
)
|
||||||
return stats
|
return stats
|
||||||
|
|
|
||||||
|
|
@ -33,15 +33,36 @@ Issue #297 sub-task 22c.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
from datetime import date
|
import re
|
||||||
from typing import Any
|
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 import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from app.services.scrapers.stealth import BrowserSession
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 ───────────────────────────────────────────────────────────
|
# ── payload parsing ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -212,30 +233,131 @@ def upsert_flat_plans(
|
||||||
return inserted
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
# ── download stub ─────────────────────────────────────────────────────────────
|
# ── image download ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def download_plan_image_stub(
|
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,
|
plan_image_url: str,
|
||||||
ods_id: str,
|
ods_id: str,
|
||||||
dest_dir: str | None = None,
|
obj_id: int,
|
||||||
) -> str:
|
base_dir: Path | None = None,
|
||||||
"""Заглушка для скачивания бинарника планировки.
|
) -> dict[str, Any] | None:
|
||||||
|
"""Скачать бинарник планировки квартиры и вернуть метаданные для upsert.
|
||||||
|
|
||||||
Реальная реализация — отдельный Celery task (22d-track, issue #299).
|
Реиспользует BrowserSession.download_binary — тот же паттерн что download_photos
|
||||||
До реализации бросает NotImplementedError, чтобы случайный вызов
|
в 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:
|
Args:
|
||||||
plan_image_url: URL картинки планировки.
|
session: Активный BrowserSession (bootstrapped + warm_up).
|
||||||
|
plan_image_url: Абсолютный URL картинки планировки.
|
||||||
ods_id: Идентификатор квартиры (для имени файла).
|
ods_id: Идентификатор квартиры (для имени файла).
|
||||||
dest_dir: Директория для сохранения (None = MEDIA_ROOT/flat_plans/).
|
obj_id: ID объекта DOM.РФ (подпапка).
|
||||||
|
base_dir: Корень для сохранения (None = FLAT_PLANS_DIR_DEFAULT).
|
||||||
|
|
||||||
Raises:
|
Returns:
|
||||||
NotImplementedError: всегда — до реализации.
|
dict с ключами local_path, width_px, height_px, size_bytes, downloaded_at —
|
||||||
|
для передачи в upsert_flat_plans. None при ошибке скачивания.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError(
|
root = base_dir or FLAT_PLANS_DIR_DEFAULT
|
||||||
"download_plan_image_stub: скачивание планировок не реализовано. "
|
obj_dir = root / str(obj_id)
|
||||||
"Реальный downloader — отдельный Celery task (issue #299, 22d-track). "
|
ext = _ext_from_url(plan_image_url)
|
||||||
f"URL={plan_image_url!r} ods_id={ods_id!r} dest_dir={dest_dir!r}"
|
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),
|
||||||
|
}
|
||||||
|
|
|
||||||
211
backend/tests/services/scrapers/test_domrf_flat_plan_url.py
Normal file
211
backend/tests/services/scrapers/test_domrf_flat_plan_url.py
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
"""Тесты для extraction plan_image_url из страницы квартиры DOM.РФ (issue #2440).
|
||||||
|
|
||||||
|
Покрывает:
|
||||||
|
- __NEXT_DATA__ pageProps (primary): URL плана под plan-hint ключом.
|
||||||
|
- bare <img> fallback: plan-hint в атрибутах / proximity к «Планировка».
|
||||||
|
- нормализация URL (относительный → absolute; data:-inline / шум отбрасываются).
|
||||||
|
- интеграция с parse_catalog_flat: ключ plan_image_url в результате.
|
||||||
|
- _TextCollector НЕ ломает text-extraction при наличии <img> (регрессия #1608).
|
||||||
|
|
||||||
|
Фикстуры инлайновые (минимальный реалистичный HTML), без live-fetch — DOM.РФ
|
||||||
|
каталог-квартир это Next.js SSR (тот же стек что страница объекта в
|
||||||
|
domrf_catalog_object.py, где план живёт в __NEXT_DATA__, а не в <img src>).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.scrapers.domrf_catalog import (
|
||||||
|
_normalize_plan_url,
|
||||||
|
_TextCollector,
|
||||||
|
extract_plan_image_url,
|
||||||
|
parse_catalog_flat,
|
||||||
|
)
|
||||||
|
from app.services.scrapers.stealth import BASE_URL
|
||||||
|
|
||||||
|
# Абсолютный file-API URL DOM.РФ (реальный паттерн, см. documents.py:17).
|
||||||
|
PLAN_ABS_URL = f"{BASE_URL}/api/ext/file/plan-12345.png"
|
||||||
|
# Относительный next/image URL (Next.js image optimizer).
|
||||||
|
PLAN_REL_NEXT = "/_next/image?url=%2Fapi%2Fext%2Ffile%2Fplan-777.png&w=1200&q=75"
|
||||||
|
|
||||||
|
|
||||||
|
def _html_with_next_data(page_props: dict) -> str:
|
||||||
|
"""SSR HTML с __NEXT_DATA__ блоком, содержащим pageProps."""
|
||||||
|
blob = json.dumps({"props": {"pageProps": page_props}})
|
||||||
|
return (
|
||||||
|
"<!doctype html><html><head>"
|
||||||
|
f'<script id="__NEXT_DATA__" type="application/json">{blob}</script>'
|
||||||
|
"</head><body>"
|
||||||
|
"<h1>Квартира 2-комнатная</h1>"
|
||||||
|
"<div>4 500 000 ₽</div>"
|
||||||
|
"</body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _collector_for(html: str) -> _TextCollector:
|
||||||
|
c = _TextCollector()
|
||||||
|
c.feed(html)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
# ── __NEXT_DATA__ primary path ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_from_next_data_plan_image_key() -> None:
|
||||||
|
"""URL под ключом planImage в pageProps → извлекается (absolute)."""
|
||||||
|
html = _html_with_next_data({"planImage": PLAN_ABS_URL, "buildingClass": "Комфорт"})
|
||||||
|
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_from_next_data_layout_key_nested() -> None:
|
||||||
|
"""URL под вложенным ключом layoutImageUrl → извлекается (BFS обход)."""
|
||||||
|
html = _html_with_next_data(
|
||||||
|
{"flat": {"info": {"layoutImageUrl": PLAN_ABS_URL}}, "price": 4_500_000}
|
||||||
|
)
|
||||||
|
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_from_next_data_relative_url_normalized() -> None:
|
||||||
|
"""Относительный next/image URL → абсолютный через BASE_URL."""
|
||||||
|
html = _html_with_next_data({"floorPlanUrl": PLAN_REL_NEXT})
|
||||||
|
got = extract_plan_image_url(html, _collector_for(html))
|
||||||
|
assert got is not None
|
||||||
|
assert got.startswith(BASE_URL)
|
||||||
|
assert "plan-777" in got
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_from_next_data_ignores_non_plan_keys() -> None:
|
||||||
|
"""Картинка под НЕ-plan ключом (developerLogo) не должна ловиться."""
|
||||||
|
html = _html_with_next_data(
|
||||||
|
{"developerLogo": f"{BASE_URL}/api/ext/file/logo.png", "buildingClass": "Бизнес"}
|
||||||
|
)
|
||||||
|
assert extract_plan_image_url(html, _collector_for(html)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_from_next_data_ignores_noise_url() -> None:
|
||||||
|
"""plan-ключ, но URL содержит logo/thumb (шум) → отбрасывается."""
|
||||||
|
html = _html_with_next_data({"planThumb": f"{BASE_URL}/api/ext/file/plan-thumb.png"})
|
||||||
|
# 'thumb' в URL → _IMAGE_NOISE_RE → None
|
||||||
|
assert extract_plan_image_url(html, _collector_for(html)) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── bare <img> fallback ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_from_img_class_hint() -> None:
|
||||||
|
"""Нет __NEXT_DATA__: <img class='flat-plan__image'> → извлекается по class."""
|
||||||
|
html = (
|
||||||
|
"<!doctype html><html><body>"
|
||||||
|
f'<img class="flat-plan__image" alt="" src="{PLAN_ABS_URL}">'
|
||||||
|
"</body></html>"
|
||||||
|
)
|
||||||
|
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_from_img_src_hint() -> None:
|
||||||
|
"""<img> без plan-класса, но src содержит 'plan' → извлекается."""
|
||||||
|
html = f'<!doctype html><html><body><img src="{PLAN_ABS_URL}"></body></html>'
|
||||||
|
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_from_img_proximity_to_label() -> None:
|
||||||
|
"""<img> без plan-hint в атрибутах, но рядом с блоком «Планировка»."""
|
||||||
|
plain = f"{BASE_URL}/api/ext/file/imgabc.png"
|
||||||
|
html = (
|
||||||
|
"<!doctype html><html><body>"
|
||||||
|
"<div>Планировка</div>"
|
||||||
|
f'<img alt="" src="{plain}">'
|
||||||
|
"</body></html>"
|
||||||
|
)
|
||||||
|
got = extract_plan_image_url(html, _collector_for(html))
|
||||||
|
assert got == plain
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_from_img_skips_logo_and_captcha() -> None:
|
||||||
|
"""Лого/captcha <img> игнорируются, даже если стоят первыми."""
|
||||||
|
html = (
|
||||||
|
"<!doctype html><html><body>"
|
||||||
|
f'<img class="header-logo" src="{BASE_URL}/api/ext/file/logo.png">'
|
||||||
|
'<img class="robot" src="data:image/jpeg;base64,AAAA">'
|
||||||
|
"<div>Планировка квартиры</div>"
|
||||||
|
f'<img class="plan-view" src="{PLAN_ABS_URL}">'
|
||||||
|
"</body></html>"
|
||||||
|
)
|
||||||
|
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_plan_returns_none() -> None:
|
||||||
|
"""Нет ни __NEXT_DATA__ плана, ни plan-img → None."""
|
||||||
|
html = (
|
||||||
|
"<!doctype html><html><body>"
|
||||||
|
f'<img class="header-logo" src="{BASE_URL}/api/ext/file/logo.png">'
|
||||||
|
"<p>Площадь 45 м²</p>"
|
||||||
|
"</body></html>"
|
||||||
|
)
|
||||||
|
assert extract_plan_image_url(html, _collector_for(html)) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── URL normalization ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw,expected_prefix",
|
||||||
|
[
|
||||||
|
(PLAN_ABS_URL, PLAN_ABS_URL),
|
||||||
|
("/api/ext/file/plan.png", f"{BASE_URL}/api/ext/file/plan.png"),
|
||||||
|
("//cdn.example/plan.png", "https://cdn.example/plan.png"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_normalize_plan_url_variants(raw: str, expected_prefix: str) -> None:
|
||||||
|
assert _normalize_plan_url(raw) == expected_prefix
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["", None, "data:image/png;base64,AAAA", " "])
|
||||||
|
def test_normalize_plan_url_rejects_junk(raw: str | None) -> None:
|
||||||
|
assert _normalize_plan_url(raw) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── integration: parse_catalog_flat ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_catalog_flat_includes_plan_image_url() -> None:
|
||||||
|
"""parse_catalog_flat кладёт plan_image_url в результат при наличии плана."""
|
||||||
|
html = _html_with_next_data({"planImage": PLAN_ABS_URL, "price": 4_500_000})
|
||||||
|
result = parse_catalog_flat(html)
|
||||||
|
assert result.get("plan_image_url") == PLAN_ABS_URL
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_catalog_flat_omits_plan_when_absent() -> None:
|
||||||
|
"""Нет плана → ключ plan_image_url отсутствует (а не None)."""
|
||||||
|
html = "<!doctype html><html><body><p>Площадь 45 м²</p></body></html>"
|
||||||
|
result = parse_catalog_flat(html)
|
||||||
|
assert "plan_image_url" not in result
|
||||||
|
|
||||||
|
|
||||||
|
# ── regression: <img> capture must not break text-extraction (#1608) ──────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_img_capture_preserves_text_blocks() -> None:
|
||||||
|
"""Наличие <img> (void-тег) не рассинхронизирует стек — текст блоков цел."""
|
||||||
|
html = (
|
||||||
|
"<!doctype html><html><body>"
|
||||||
|
'<div class="price-block">4 500 000 ₽</div>'
|
||||||
|
f'<img src="{PLAN_ABS_URL}">'
|
||||||
|
'<div class="status-badge">Продана</div>'
|
||||||
|
"</body></html>"
|
||||||
|
)
|
||||||
|
collector = _collector_for(html)
|
||||||
|
texts = [t for _cls, t in collector.blocks]
|
||||||
|
assert any("4 500 000" in t for t in texts)
|
||||||
|
assert any("Продана" in t for t in texts)
|
||||||
|
# и картинка захвачена в отдельный список
|
||||||
|
assert len(collector.images) == 1
|
||||||
|
assert collector.images[0][0].get("src") == PLAN_ABS_URL
|
||||||
|
|
||||||
|
# parse_catalog_flat всё ещё корректно извлекает status/price при наличии img
|
||||||
|
result = parse_catalog_flat(html)
|
||||||
|
assert result.get("status") == "sold"
|
||||||
|
assert result.get("price_rub") == 4_500_000
|
||||||
136
backend/tests/services/scrapers/test_flat_plan_download.py
Normal file
136
backend/tests/services/scrapers/test_flat_plan_download.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""Тесты для download_plan_image() из flat_plans.py (issue #2440).
|
||||||
|
|
||||||
|
Покрывает:
|
||||||
|
- успешное скачивание: файл на диске, width/height/size_bytes через Pillow.
|
||||||
|
- idempotency: повторный вызов не качает заново (download_binary не дёргается).
|
||||||
|
- ошибка скачивания → None, файл не создаётся.
|
||||||
|
- _safe_ods_filename / _ext_from_url helpers.
|
||||||
|
|
||||||
|
Использует фейковый BrowserSession с in-memory PNG (Pillow генерит 3x2 картинку) —
|
||||||
|
без сети и без реального Playwright.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app.services.scrapers.flat_plans import (
|
||||||
|
_ext_from_url,
|
||||||
|
_safe_ods_filename,
|
||||||
|
download_plan_image,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _png_bytes(w: int = 3, h: int = 2) -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", (w, h), (10, 20, 30)).save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
"""Минимальный BrowserSession-дублёр: download_binary отдаёт заданные байты."""
|
||||||
|
|
||||||
|
def __init__(self, payload: bytes | None, raise_exc: Exception | None = None) -> None:
|
||||||
|
self._payload = payload
|
||||||
|
self._raise = raise_exc
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def download_binary(self, url: str) -> bytes:
|
||||||
|
self.calls += 1
|
||||||
|
if self._raise is not None:
|
||||||
|
raise self._raise
|
||||||
|
assert self._payload is not None
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"ods,expected",
|
||||||
|
[
|
||||||
|
("65136/1/1.4.3", "65136_1_1.4.3"),
|
||||||
|
("plain", "plain"),
|
||||||
|
("a//b", "a_b"),
|
||||||
|
("///", "plan"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_safe_ods_filename(ods: str, expected: str) -> None:
|
||||||
|
assert _safe_ods_filename(ods) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url,expected",
|
||||||
|
[
|
||||||
|
("https://x/plan.png", ".png"),
|
||||||
|
("https://x/plan.JPG", ".jpg"),
|
||||||
|
("https://x/plan.jpeg", ".jpg"),
|
||||||
|
("https://x/api/ext/file/abc", ".png"), # no ext → default png
|
||||||
|
("https://x/plan.svg", ".svg"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ext_from_url(url: str, expected: str) -> None:
|
||||||
|
assert _ext_from_url(url) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ── download ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path: str) -> bytes:
|
||||||
|
"""Sync file read — вынесено из async-теста, чтобы не тревожить ASYNC240."""
|
||||||
|
return Path(path).read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def _exists(path: Path) -> bool:
|
||||||
|
return path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_download_writes_file_and_dims(tmp_path: Path) -> None:
|
||||||
|
png = _png_bytes(3, 2)
|
||||||
|
sess: Any = _FakeSession(png)
|
||||||
|
meta = await download_plan_image(
|
||||||
|
sess, "https://x/api/ext/file/plan1.png", "65136/1/1.4.3", 65136, base_dir=tmp_path
|
||||||
|
)
|
||||||
|
assert meta is not None
|
||||||
|
assert meta["width_px"] == 3
|
||||||
|
assert meta["height_px"] == 2
|
||||||
|
assert meta["size_bytes"] == len(png)
|
||||||
|
assert meta["downloaded_at"] is not None
|
||||||
|
p = Path(meta["local_path"])
|
||||||
|
assert _read(str(p)) == png
|
||||||
|
# путь: {base}/{obj_id}/{safe_ods}.png
|
||||||
|
assert p.parent.name == "65136"
|
||||||
|
assert p.name == "65136_1_1.4.3.png"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_download_idempotent_skips_second_fetch(tmp_path: Path) -> None:
|
||||||
|
png = _png_bytes(4, 4)
|
||||||
|
sess: Any = _FakeSession(png)
|
||||||
|
m1 = await download_plan_image(sess, "https://x/plan.png", "1/2/3", 42, base_dir=tmp_path)
|
||||||
|
assert m1 is not None
|
||||||
|
assert sess.calls == 1
|
||||||
|
# второй вызов: файл уже на диске → download_binary НЕ дёргается
|
||||||
|
m2 = await download_plan_image(sess, "https://x/plan.png", "1/2/3", 42, base_dir=tmp_path)
|
||||||
|
assert m2 is not None
|
||||||
|
assert sess.calls == 1 # не увеличилось
|
||||||
|
assert m2["local_path"] == m1["local_path"]
|
||||||
|
assert m2["size_bytes"] == len(png)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_download_failure_returns_none(tmp_path: Path) -> None:
|
||||||
|
sess: Any = _FakeSession(None, raise_exc=RuntimeError("binary http 403"))
|
||||||
|
meta = await download_plan_image(sess, "https://x/plan.png", "9/9/9", 99, base_dir=tmp_path)
|
||||||
|
assert meta is None
|
||||||
|
# файл не создан
|
||||||
|
assert not _exists(tmp_path / "99")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_download_empty_body_returns_none(tmp_path: Path) -> None:
|
||||||
|
sess: Any = _FakeSession(b"")
|
||||||
|
meta = await download_plan_image(sess, "https://x/plan.png", "9/9/9", 99, base_dir=tmp_path)
|
||||||
|
assert meta is None
|
||||||
Loading…
Add table
Reference in a new issue