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 нет,
|
-- Серия дома, энергоэффективность и число подъездов — колонок под них в houses нет,
|
||||||
-- схему ради этого не расширяем: остаются в raw_payload.
|
-- схему ради этого не расширяем: остаются в 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) — повторный
|
-- Каждый UPDATE пишет ТОЛЬКО туда, где колонка пуста (IS NULL в WHERE) — повторный
|
||||||
-- прогон видит уже заполненное и трогает 0 строк. Непустое значение дома не
|
-- прогон видит уже заполненное и трогает 0 строк. Непустое значение дома не
|
||||||
|
|
@ -58,8 +81,18 @@ BEGIN
|
||||||
SELECT DISTINCT ON (l.house_id_fk)
|
SELECT DISTINCT ON (l.house_id_fk)
|
||||||
l.house_id_fk AS house_id,
|
l.house_id_fk AS house_id,
|
||||||
NULLIF(l.raw_payload ->> 'quarters_count', '') AS quarters_count,
|
NULLIF(l.raw_payload ->> 'quarters_count', '') AS quarters_count,
|
||||||
NULLIF(l.raw_payload ->> 'wall_type', '') AS wall_type,
|
CASE lower(trim(NULLIF(l.raw_payload ->> 'wall_type', '')))
|
||||||
NULLIF(l.raw_payload ->> 'floor_type', '') AS floor_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
|
FROM listings l
|
||||||
WHERE l.source = 'domklik'
|
WHERE l.source = 'domklik'
|
||||||
AND l.house_id_fk IS NOT NULL
|
AND l.house_id_fk IS NOT NULL
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ quarters_count уже лежал в собранных payload'ах.
|
||||||
material_walls / material_floors ЧИСЛАМИ И СТРОКАМИ из фикстуры (не None).
|
material_walls / material_floors ЧИСЛАМИ И СТРОКАМИ из фикстуры (не None).
|
||||||
(б) Непустое значение дома не затирается пустым из карточки — COALESCE в обе
|
(б) Непустое значение дома не затирается пустым из карточки — COALESCE в обе
|
||||||
стороны: и в SET, и в WHERE-гейте.
|
стороны: и в SET, и в WHERE-гейте.
|
||||||
|
(в) Словарь: в material_walls едет КАНОН ДОМ.РФ («монолит»), а не сырая фраза
|
||||||
|
площадки («Монолитно-кирпичный») — иначе колонка становится двухсловарной.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -18,10 +20,14 @@ import os
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
from scraper_kit.providers.domclick.detail import (
|
from scraper_kit.providers.domclick.detail import (
|
||||||
DomClickDetailEnrichment,
|
DomClickDetailEnrichment,
|
||||||
|
canon_floor_type,
|
||||||
|
canon_wall_type,
|
||||||
parse_detail_html,
|
parse_detail_html,
|
||||||
save_detail_enrichment,
|
save_detail_enrichment,
|
||||||
)
|
)
|
||||||
|
|
@ -30,7 +36,7 @@ _STATE = {
|
||||||
"productCard": {"id": "t3253-1", "address": {"guid": "bldg-guid-1"}},
|
"productCard": {"id": "t3253-1", "address": {"guid": "bldg-guid-1"}},
|
||||||
"houseInfo": {
|
"houseInfo": {
|
||||||
"info": {
|
"info": {
|
||||||
"wallType": "Кирпично-монолитный",
|
"wallType": "Монолитно-кирпичный",
|
||||||
"floorType": "Железобетонный",
|
"floorType": "Железобетонный",
|
||||||
"quartersCount": 168,
|
"quartersCount": 168,
|
||||||
"entranceCount": 4,
|
"entranceCount": 4,
|
||||||
|
|
@ -78,21 +84,75 @@ def _house_call(db: _FakeSession) -> dict[str, Any]:
|
||||||
|
|
||||||
|
|
||||||
def test_house_fields_reach_houses_upsert() -> None:
|
def test_house_fields_reach_houses_upsert() -> None:
|
||||||
"""(а) Значения из фикстуры доезжают до апсерта дома."""
|
"""(а)+(в) Значения из фикстуры доезжают до апсерта дома — канонизированными."""
|
||||||
e = parse_detail_html(_HTML, "https://domclick.ru/card/sale__flat__t3253-1")
|
e = parse_detail_html(_HTML, "https://domclick.ru/card/sale__flat__t3253-1")
|
||||||
assert e.raw_extra["quarters_count"] == 168
|
assert e.raw_extra["quarters_count"] == 168
|
||||||
assert e.raw_extra["wall_type"] == "Кирпично-монолитный"
|
# В raw_payload сырая фраза площадки остаётся как есть — канон только в колонках.
|
||||||
|
assert e.raw_extra["wall_type"] == "Монолитно-кирпичный"
|
||||||
|
|
||||||
db = _FakeSession()
|
db = _FakeSession()
|
||||||
save_detail_enrichment(db, 555, e) # type: ignore[arg-type]
|
save_detail_enrichment(db, 555, e) # type: ignore[arg-type]
|
||||||
|
|
||||||
params = _house_call(db)
|
params = _house_call(db)
|
||||||
assert params["quarters_count"] == 168
|
assert params["quarters_count"] == 168
|
||||||
assert params["wall_type"] == "Кирпично-монолитный"
|
assert params["wall_type"] == "монолит"
|
||||||
assert params["floor_type"] == "Железобетонный"
|
assert params["floor_type"] == "Железобетонные"
|
||||||
assert params["lid"] == 555
|
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:
|
def test_empty_card_does_not_overwrite_house() -> None:
|
||||||
"""(б) Карточка без дом-полей вообще не трогает houses."""
|
"""(б) Карточка без дом-полей вообще не трогает houses."""
|
||||||
db = _FakeSession()
|
db = _FakeSession()
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,36 @@ _SALE_TYPE_MAP: dict[str, str] = {
|
||||||
"альтернатива": "alternative",
|
"альтернатива": "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 ──────────────────────────────────────────────────
|
# ── DomClickDetailEnrichment ──────────────────────────────────────────────────
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -314,6 +344,47 @@ def canon_sale_type(sale_type: str | None) -> str | None:
|
||||||
return _SALE_TYPE_MAP.get(sale_type.strip().lower())
|
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]:
|
def _compact(d: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Выкидывает None-значения, чтобы raw_extra оставался компактным."""
|
"""Выкидывает None-значения, чтобы raw_extra оставался компактным."""
|
||||||
return {k: v for k, v in d.items() if v is not None}
|
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:
|
) -> int:
|
||||||
"""Заполнить пустые дом-поля дома этого листинга значениями с карточки (#3253).
|
"""Заполнить пустые дом-поля дома этого листинга значениями с карточки (#3253).
|
||||||
|
|
||||||
|
Тип стен и перекрытий проходят через canon_wall_type/canon_floor_type: в колонках
|
||||||
|
houses лежит словарь ДОМ.РФ, а не словарь площадки. Незнакомое значение → None,
|
||||||
|
т.е. дом-поле просто не пишется — сырая фраза Домклика остаётся в raw_payload.
|
||||||
|
|
||||||
Возвращает число обновлённых домов (0 — листинг без house_id_fk, у дома всё уже
|
Возвращает число обновлённых домов (0 — листинг без house_id_fk, у дома всё уже
|
||||||
заполнено, либо карточка про дом ничего не сказала). Ошибка оператора глушится
|
заполнено, либо карточка про дом ничего не сказала). Ошибка оператора глушится
|
||||||
под SAVEPOINT с warning: обогащение листинга важнее дом-полей.
|
под SAVEPOINT с warning: обогащение листинга важнее дом-полей.
|
||||||
"""
|
"""
|
||||||
raw = e.raw_extra or {}
|
raw = e.raw_extra or {}
|
||||||
quarters_count = _to_int(raw.get("quarters_count"))
|
quarters_count = _to_int(raw.get("quarters_count"))
|
||||||
wall_type = raw.get("wall_type")
|
wall_type = canon_wall_type(raw.get("wall_type"))
|
||||||
floor_type = raw.get("floor_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:
|
if quarters_count is None and wall_type is None and floor_type is None:
|
||||||
return 0
|
return 0
|
||||||
try:
|
try:
|
||||||
|
|
@ -654,10 +729,13 @@ def save_detail_enrichment(
|
||||||
словарь, а не сырая русская фраза Домклика (#2674, миграция 220).
|
словарь, а не сырая русская фраза Домклика (#2674, миграция 220).
|
||||||
2. Дом-поля карточки → houses через listings.house_id_fk, fill-only (#3253):
|
2. Дом-поля карточки → houses через listings.house_id_fk, fill-only (#3253):
|
||||||
quarters_count → total_units, wall_type → material_walls, floor_type →
|
quarters_count → total_units, wall_type → material_walls, floor_type →
|
||||||
material_floors. COALESCE(houses.X, новое) — непустое значение дома НЕ
|
material_floors. Стены/перекрытия — через canon_wall_type/canon_floor_type:
|
||||||
затирается: у houses есть конкурирующие писатели (ДОМ.РФ капремонт,
|
в колонках houses словарь ДОМ.РФ, сырую фразу площадки туда не кладём (та же
|
||||||
Houses Catalog), карточка площадки не канон. Серия дома, энергоэффективность
|
болезнь, что sale_type до #2674). COALESCE(houses.X, новое) — непустое
|
||||||
и число подъездов остаются в raw_payload — колонок под них в houses нет.
|
значение дома НЕ затирается: у houses есть конкурирующие писатели (ДОМ.РФ
|
||||||
|
капремонт, Houses Catalog), карточка площадки не канон. Серия дома,
|
||||||
|
энергоэффективность и число подъездов остаются в raw_payload — колонок под
|
||||||
|
них в houses нет.
|
||||||
3. Для каждой записи priceHistory — INSERT INTO offer_price_history
|
3. Для каждой записи priceHistory — INSERT INTO offer_price_history
|
||||||
(ON CONFLICT DO NOTHING → идемпотентно). source='domklik'.
|
(ON CONFLICT DO NOTHING → идемпотентно). source='domklik'.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue