fix(tradein/domclick): дом-поля едут в словаре ДОМ.РФ, а не сырьём Домклика
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.
This commit is contained in:
bot-backend 2026-09-05 23:17:50 +05:00
parent 2532bcbe27
commit d518efbed1
3 changed files with 184 additions and 13 deletions

View file

@ -20,6 +20,29 @@
-- Серия дома, энергоэффективность и число подъездов — колонок под них в houses нет,
-- схему ради этого не расширяем: остаются в raw_payload.
--
-- СЛОВАРЬ СТЕН И ПЕРЕКРЫТИЙ — НЕ СЫРЬЁ ПЛОЩАДКИ
-- houses.material_walls уже заполнена словарём ДОМ.РФ (капремонт КР1.2, #2013):
-- кирпич 2662, железобетонная панель 2107, иное 1850, монолит 754. У Домклика в
-- raw_payload свой словарь. Лить его как есть — сделать колонку двухсловарной, и
-- фильтр по одному значению перестанет видеть половину строк (ровно то, что уже
-- случилось с sale_type до #2674). Тот же CASE стоит в коде-переносе
-- (canon_wall_type / canon_floor_type, providers/domclick/detail.py).
--
-- Домклик → houses.material_walls
-- Кирпичный → кирпич
-- Панельный → железобетонная панель
-- Монолитный → монолит
-- Монолитно-кирпичный → монолит (несущий каркас монолитный, кирпич —
-- заполнение; смешанного класса у ДОМ.РФ
-- нет, «иное» потеряло бы конструктив)
-- Блочный, Деревянный → иное
-- всё прочее → NULL (CASE без ELSE; сырьё не пишем)
--
-- Домклик → houses.material_floors
-- Железобетонный → Железобетонные (форма, уже лежащая в колонке — 26
-- Железобетонные → Железобетонные строк от avito)
-- всё прочее → NULL
--
-- ИДЕМПОТЕНТНОСТЬ
-- Каждый UPDATE пишет ТОЛЬКО туда, где колонка пуста (IS NULL в WHERE) — повторный
-- прогон видит уже заполненное и трогает 0 строк. Непустое значение дома не
@ -58,8 +81,18 @@ BEGIN
SELECT DISTINCT ON (l.house_id_fk)
l.house_id_fk AS house_id,
NULLIF(l.raw_payload ->> 'quarters_count', '') AS quarters_count,
NULLIF(l.raw_payload ->> 'wall_type', '') AS wall_type,
NULLIF(l.raw_payload ->> 'floor_type', '') AS floor_type
CASE lower(trim(NULLIF(l.raw_payload ->> 'wall_type', '')))
WHEN 'кирпичный' THEN 'кирпич'
WHEN 'панельный' THEN 'железобетонная панель'
WHEN 'монолитный' THEN 'монолит'
WHEN 'монолитно-кирпичный' THEN 'монолит'
WHEN 'блочный' THEN 'иное'
WHEN 'деревянный' THEN 'иное'
END AS wall_type,
CASE lower(trim(NULLIF(l.raw_payload ->> 'floor_type', '')))
WHEN 'железобетонный' THEN 'Железобетонные'
WHEN 'железобетонные' THEN 'Железобетонные'
END AS floor_type
FROM listings l
WHERE l.source = 'domklik'
AND l.house_id_fk IS NOT NULL

View file

@ -9,6 +9,8 @@ quarters_count уже лежал в собранных payload'ах.
material_walls / material_floors ЧИСЛАМИ И СТРОКАМИ из фикстуры (не None).
(б) Непустое значение дома не затирается пустым из карточки COALESCE в обе
стороны: и в SET, и в WHERE-гейте.
(в) Словарь: в material_walls едет КАНОН ДОМ.РФ («монолит»), а не сырая фраза
площадки («Монолитно-кирпичный») иначе колонка становится двухсловарной.
"""
from __future__ import annotations
@ -18,10 +20,14 @@ 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,
)
@ -30,7 +36,7 @@ _STATE = {
"productCard": {"id": "t3253-1", "address": {"guid": "bldg-guid-1"}},
"houseInfo": {
"info": {
"wallType": "Кирпично-монолитный",
"wallType": "Монолитно-кирпичный",
"floorType": "Железобетонный",
"quartersCount": 168,
"entranceCount": 4,
@ -78,21 +84,75 @@ def _house_call(db: _FakeSession) -> dict[str, Any]:
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
assert e.raw_extra["wall_type"] == "Кирпично-монолитный"
# В 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["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()

View file

@ -109,6 +109,36 @@ _SALE_TYPE_MAP: dict[str, str] = {
"альтернатива": "alternative",
}
# houses.material_walls УЖЕ заполнена словарём ДОМ.РФ (капремонт КР1.2, #2013): на
# проде кирпич 2662, железобетонная панель 2107, иное 1850, монолит 754. У Домклика
# словарь свой — Монолитный 2656, Кирпичный 2241, Панельный 1671, Монолитно-кирпичный
# 773. Написать его сырьём = сделать колонку двухсловарной, и `WHERE material_walls =
# 'монолит'` перестанет видеть весь Домклик — ровно те же грабли, что sale_type в
# #2674. Поэтому маппим в ТОТ словарь, который в колонке уже лежит.
# «Монолитно-кирпичный» → монолит: несущий каркас монолитный, кирпич — заполнение и
# фасад; отдельного класса под смешанный тип у ДОМ.РФ нет, а «иное» выбросило бы
# известный конструктив в корзину «неизвестно». Блочный/деревянный → иное: у ДОМ.РФ
# для них тоже отдельного значения нет, но и терять нечего — это и есть «иное».
_WALL_TYPE_MAP: dict[str, str] = {
"кирпичный": "кирпич",
"панельный": "железобетонная панель",
"монолитный": "монолит",
"монолитно-кирпичный": "монолит",
"блочный": "иное",
"деревянный": "иное",
}
# houses.material_floors: в колонке ровно одна форма — «Железобетонные» (26 строк,
# avito). Домклик пишет «Железобетонный». Приводим к форме, которая уже в колонке.
_FLOOR_TYPE_MAP: dict[str, str] = {
"железобетонный": "Железобетонные",
"железобетонные": "Железобетонные",
}
# Незнакомое значение словаря площадки логируем ОДИН раз на процесс: новый тип стен
# должно быть видно в логах, но не строкой на каждую карточку.
_WARNED_UNKNOWN_MATERIAL: set[str] = set()
# ── DomClickDetailEnrichment ──────────────────────────────────────────────────
@dataclass
@ -314,6 +344,47 @@ def canon_sale_type(sale_type: str | None) -> str | None:
return _SALE_TYPE_MAP.get(sale_type.strip().lower())
def _canon_material(value: Any, mapping: dict[str, str], field: str) -> str | None:
"""Общая механика canon_wall_type/canon_floor_type: словарь площадки → словарь колонки.
Незнакомое (и не-строка, и пустая строка) None: сырьё в колонку не попадает
никогда. Про каждое незнакомое значение warning раз на процесс.
"""
if not isinstance(value, str) or not value.strip():
return None
key = value.strip().lower()
canon = mapping.get(key)
if canon is None and key not in _WARNED_UNKNOWN_MATERIAL:
_WARNED_UNKNOWN_MATERIAL.add(key)
logger.warning(
"domclick_detail: неизвестный %s=%r — houses не пишем (сырьё в колонку "
"не кладём); добавь значение в карту, если оно валидное",
field,
value,
)
return canon
def canon_wall_type(wall_type: Any) -> str | None:
"""Тип стен Домклика → словарь houses.material_walls (ДОМ.РФ, #3253).
Кирпичныйкирпич, Панельныйжелезобетонная панель, Монолитныймонолит,
Монолитно-кирпичныймонолит, Блочный/Деревянныйиное. Незнакомое None.
Стоит на границе записи в houses (как canon_sale_type на границе записи в
listings, #2674): в raw_payload сырая фраза площадки остаётся как есть.
"""
return _canon_material(wall_type, _WALL_TYPE_MAP, "wall_type")
def canon_floor_type(floor_type: Any) -> str | None:
"""Тип перекрытий Домклика → форма, лежащая в houses.material_floors (#3253).
Железобетонный/Железобетонные «Железобетонные». Незнакомое None.
"""
return _canon_material(floor_type, _FLOOR_TYPE_MAP, "floor_type")
def _compact(d: dict[str, Any]) -> dict[str, Any]:
"""Выкидывает None-значения, чтобы raw_extra оставался компактным."""
return {k: v for k, v in d.items() if v is not None}
@ -610,14 +681,18 @@ def _fill_house_params_from_detail(
) -> int:
"""Заполнить пустые дом-поля дома этого листинга значениями с карточки (#3253).
Тип стен и перекрытий проходят через canon_wall_type/canon_floor_type: в колонках
houses лежит словарь ДОМ.РФ, а не словарь площадки. Незнакомое значение None,
т.е. дом-поле просто не пишется сырая фраза Домклика остаётся в raw_payload.
Возвращает число обновлённых домов (0 листинг без house_id_fk, у дома всё уже
заполнено, либо карточка про дом ничего не сказала). Ошибка оператора глушится
под SAVEPOINT с warning: обогащение листинга важнее дом-полей.
"""
raw = e.raw_extra or {}
quarters_count = _to_int(raw.get("quarters_count"))
wall_type = raw.get("wall_type")
floor_type = raw.get("floor_type")
wall_type = canon_wall_type(raw.get("wall_type"))
floor_type = canon_floor_type(raw.get("floor_type"))
if quarters_count is None and wall_type is None and floor_type is None:
return 0
try:
@ -654,10 +729,13 @@ def save_detail_enrichment(
словарь, а не сырая русская фраза Домклика (#2674, миграция 220).
2. Дом-поля карточки houses через listings.house_id_fk, fill-only (#3253):
quarters_count total_units, wall_type material_walls, floor_type
material_floors. COALESCE(houses.X, новое) непустое значение дома НЕ
затирается: у houses есть конкурирующие писатели (ДОМ.РФ капремонт,
Houses Catalog), карточка площадки не канон. Серия дома, энергоэффективность
и число подъездов остаются в raw_payload колонок под них в houses нет.
material_floors. Стены/перекрытия через canon_wall_type/canon_floor_type:
в колонках houses словарь ДОМ.РФ, сырую фразу площадки туда не кладём (та же
болезнь, что sale_type до #2674). COALESCE(houses.X, новое) — непустое
значение дома НЕ затирается: у houses есть конкурирующие писатели (ДОМ.РФ
капремонт, Houses Catalog), карточка площадки не канон. Серия дома,
энергоэффективность и число подъездов остаются в raw_payload колонок под
них в houses нет.
3. Для каждой записи priceHistory INSERT INTO offer_price_history
(ON CONFLICT DO NOTHING идемпотентно). source='domklik'.