All checks were successful
CI Trade-In / changes (pull_request) Successful in 14s
CI / changes (pull_request) Successful in 16s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m26s
houses.material_walls уже заполнена словарём ДОМ.РФ (капремонт КР1.2, #2013): кирпич 2662, железобетонная панель 2107, иное 1850, монолит 754. Ветка писала туда сырую фразу карточки (Монолитный 2656, Кирпичный 2241, Панельный 1671, Монолитно-кирпичный 773) — колонка стала бы двухсловарной, и `WHERE material_walls = 'монолит'` перестал бы видеть весь Домклик. Ровно та болезнь, которую sale_type уже пережил в #2674. canon_wall_type / canon_floor_type стоят на границе записи в houses (как canon_sale_type — на границе записи в listings): Кирпичный→кирпич, Панельный→железобетонная панель, Монолитный/Монолитно-кирпичный→монолит, Блочный/Деревянный→иное, Железобетонный→Железобетонные (форма, уже лежащая в колонке). Незнакомое → None + warning раз на процесс: сырьё в колонку не попадает никогда, а новое значение словаря видно в логах. В raw_payload сырая фраза площадки остаётся как была. Миграция 284 получила тот же CASE lower(...) — иначе backfill залил бы задним числом ровно то, что код перестал писать. CASE без ELSE: незнакомое → NULL.
174 lines
7.2 KiB
Python
174 lines
7.2 KiB
Python
"""Дом-поля карточки Домклика доезжают до houses (#3253).
|
||
|
||
Парсер читал houseInfo.info (тип стен/перекрытий, число квартир) и складывал всё
|
||
в listings.raw_payload, а в houses не переносил: на проде 400 домов-domklik против
|
||
3061 объявления, а houses.total_units = 0 у ВСЕХ источников при том, что
|
||
quarters_count уже лежал в собранных payload'ах.
|
||
|
||
(а) Значение: карточка с houseInfo → второй оператор получает total_units /
|
||
material_walls / material_floors ЧИСЛАМИ И СТРОКАМИ из фикстуры (не None).
|
||
(б) Непустое значение дома не затирается пустым из карточки — COALESCE в обе
|
||
стороны: и в SET, и в WHERE-гейте.
|
||
(в) Словарь: в material_walls едет КАНОН ДОМ.РФ («монолит»), а не сырая фраза
|
||
площадки («Монолитно-кирпичный») — иначе колонка становится двухсловарной.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from types import SimpleNamespace
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from scraper_kit.providers.domclick.detail import (
|
||
DomClickDetailEnrichment,
|
||
canon_floor_type,
|
||
canon_wall_type,
|
||
parse_detail_html,
|
||
save_detail_enrichment,
|
||
)
|
||
|
||
_STATE = {
|
||
"productCard": {"id": "t3253-1", "address": {"guid": "bldg-guid-1"}},
|
||
"houseInfo": {
|
||
"info": {
|
||
"wallType": "Монолитно-кирпичный",
|
||
"floorType": "Железобетонный",
|
||
"quartersCount": 168,
|
||
"entranceCount": 4,
|
||
"buildingSeries": "II-68",
|
||
"energyEfficiency": "B",
|
||
}
|
||
},
|
||
}
|
||
_HTML = (
|
||
"<html><body><script>window.__SSR_STATE__ = "
|
||
f"{json.dumps(_STATE, ensure_ascii=False)};</script></body></html>"
|
||
)
|
||
|
||
|
||
class _FakeNested:
|
||
def __enter__(self) -> None:
|
||
return None
|
||
|
||
def __exit__(self, *exc: object) -> bool:
|
||
return False
|
||
|
||
|
||
class _FakeSession:
|
||
"""Пишет все execute(stmt, params); rowcount=1 у каждого оператора."""
|
||
|
||
def __init__(self) -> None:
|
||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||
self.calls.append((str(stmt), params or {}))
|
||
return SimpleNamespace(rowcount=1)
|
||
|
||
def begin_nested(self) -> _FakeNested:
|
||
return _FakeNested()
|
||
|
||
def commit(self) -> None:
|
||
return None
|
||
|
||
|
||
def _house_call(db: _FakeSession) -> dict[str, Any]:
|
||
for sql, params in db.calls:
|
||
if "UPDATE houses" in sql:
|
||
return params
|
||
raise AssertionError(f"UPDATE houses не исполнен; операторы: {[s[:40] for s, _ in db.calls]}")
|
||
|
||
|
||
def test_house_fields_reach_houses_upsert() -> None:
|
||
"""(а)+(в) Значения из фикстуры доезжают до апсерта дома — канонизированными."""
|
||
e = parse_detail_html(_HTML, "https://domclick.ru/card/sale__flat__t3253-1")
|
||
assert e.raw_extra["quarters_count"] == 168
|
||
# В raw_payload сырая фраза площадки остаётся как есть — канон только в колонках.
|
||
assert e.raw_extra["wall_type"] == "Монолитно-кирпичный"
|
||
|
||
db = _FakeSession()
|
||
save_detail_enrichment(db, 555, e) # type: ignore[arg-type]
|
||
|
||
params = _house_call(db)
|
||
assert params["quarters_count"] == 168
|
||
assert params["wall_type"] == "монолит"
|
||
assert params["floor_type"] == "Железобетонные"
|
||
assert params["lid"] == 555
|
||
|
||
|
||
# ── (в) канонизация словаря (#3253) ───────────────────────────────────────────
|
||
# houses.material_walls на проде уже заполнена словарём ДОМ.РФ: кирпич 2662,
|
||
# железобетонная панель 2107, иное 1850, монолит 754. Домклик пишет свой словарь.
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("raw", "expected"),
|
||
[
|
||
("Кирпичный", "кирпич"), # 2241 карточка на проде
|
||
("Панельный", "железобетонная панель"), # 1671
|
||
("Монолитный", "монолит"), # 2656
|
||
("Монолитно-кирпичный", "монолит"), # 773
|
||
("Блочный", "иное"),
|
||
("Деревянный", "иное"),
|
||
(" монолитный ", "монолит"), # регистр и пробелы не мешают
|
||
("Саманный", None), # незнакомое — сырьё в колонку НЕ пишем
|
||
("", None),
|
||
(None, None),
|
||
(42, None), # не-строка из JSON
|
||
],
|
||
)
|
||
def test_canon_wall_type_maps_to_domrf_dictionary(raw: object, expected: str | None) -> None:
|
||
assert canon_wall_type(raw) == expected
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("raw", "expected"),
|
||
[
|
||
("Железобетонный", "Железобетонные"), # форма Домклика
|
||
("Железобетонные", "Железобетонные"), # форма, уже лежащая в колонке (avito)
|
||
("Деревянные", None),
|
||
(None, None),
|
||
],
|
||
)
|
||
def test_canon_floor_type_maps_to_column_form(raw: object, expected: str | None) -> None:
|
||
assert canon_floor_type(raw) == expected
|
||
|
||
|
||
def test_unknown_wall_type_is_not_written_to_houses() -> None:
|
||
"""Незнакомый тип стен не доезжает до houses — колонка не портится сырьём."""
|
||
e = DomClickDetailEnrichment(
|
||
item_id="t3253-3",
|
||
source_url="u",
|
||
raw_extra={"wall_type": "Саманный", "quarters_count": 12},
|
||
)
|
||
db = _FakeSession()
|
||
save_detail_enrichment(db, 558, e) # type: ignore[arg-type]
|
||
|
||
params = _house_call(db)
|
||
assert params["wall_type"] is None
|
||
assert params["quarters_count"] == 12 # остальные поля едут как обычно
|
||
|
||
|
||
def test_empty_card_does_not_overwrite_house() -> None:
|
||
"""(б) Карточка без дом-полей вообще не трогает houses."""
|
||
db = _FakeSession()
|
||
e = DomClickDetailEnrichment(item_id="t3253-2", source_url="u", raw_extra={"egrn_area": 42.0})
|
||
save_detail_enrichment(db, 556, e) # type: ignore[arg-type]
|
||
|
||
assert not [s for s, _ in db.calls if "UPDATE houses" in s]
|
||
|
||
|
||
def test_coalesce_protects_filled_columns() -> None:
|
||
"""(б) SET и WHERE-гейт оба COALESCE-семантика: пустым непустое не затирается."""
|
||
e = parse_detail_html(_HTML, "https://domclick.ru/card/sale__flat__t3253-1")
|
||
db = _FakeSession()
|
||
save_detail_enrichment(db, 557, e) # type: ignore[arg-type]
|
||
|
||
sql = next(s for s, _ in db.calls if "UPDATE houses" in s)
|
||
for col in ("total_units", "material_walls", "material_floors"):
|
||
assert f"COALESCE(h.{col}," in sql
|
||
assert f"h.{col} IS NULL" in sql
|