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
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:
parent
2532bcbe27
commit
d518efbed1
3 changed files with 184 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue