From d518efbed1f6c6e3ff86a90835c39ca2c33f0139 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 5 Sep 2026 23:17:50 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/domclick):=20=D0=B4=D0=BE=D0=BC-?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8F=20=D0=B5=D0=B4=D1=83=D1=82=20=D0=B2=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D0=B5=20=D0=94=D0=9E=D0=9C?= =?UTF-8?q?.=D0=A0=D0=A4,=20=D0=B0=20=D0=BD=D0=B5=20=D1=81=D1=8B=D1=80?= =?UTF-8?q?=D1=8C=D1=91=D0=BC=20=D0=94=D0=BE=D0=BC=D0=BA=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../sql/284_houses_total_units_from_raw.sql | 37 +++++++- .../tests/test_3253_domclick_house_fields.py | 70 +++++++++++++-- .../scraper_kit/providers/domclick/detail.py | 90 +++++++++++++++++-- 3 files changed, 184 insertions(+), 13 deletions(-) diff --git a/tradein-mvp/backend/data/sql/284_houses_total_units_from_raw.sql b/tradein-mvp/backend/data/sql/284_houses_total_units_from_raw.sql index a7d23a0e..b544a422 100644 --- a/tradein-mvp/backend/data/sql/284_houses_total_units_from_raw.sql +++ b/tradein-mvp/backend/data/sql/284_houses_total_units_from_raw.sql @@ -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 diff --git a/tradein-mvp/backend/tests/test_3253_domclick_house_fields.py b/tradein-mvp/backend/tests/test_3253_domclick_house_fields.py index ba7f657b..7043937a 100644 --- a/tradein-mvp/backend/tests/test_3253_domclick_house_fields.py +++ b/tradein-mvp/backend/tests/test_3253_domclick_house_fields.py @@ -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() diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py index 978f062e..481b2a1f 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py @@ -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'.