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 new file mode 100644 index 00000000..b544a422 --- /dev/null +++ b/tradein-mvp/backend/data/sql/284_houses_total_units_from_raw.sql @@ -0,0 +1,131 @@ +-- 284_houses_total_units_from_raw.sql +-- Перелить дом-поля карточек Домклика из listings.raw_payload в колонки houses (#3253). +-- +-- ЧТО БЫЛО НЕ ТАК +-- Парсер карточки Домклика читает блок дома (houseInfo.info) и кладёт его ЦЕЛИКОМ +-- в listings.raw_payload: wall_type, floor_type, quarters_count, плюс серию дома, +-- энергоэффективность и число подъездов. В колонки houses не переносил ничего. +-- Замер 29.08.2026: houses.total_units = 0 у ВСЕХ источников без исключения, хотя +-- у Домклика число квартир уже лежало в собранных payload'ах. Код перенос чинит +-- для будущих карточек (providers/domclick/detail.py, тот же PR); эта миграция +-- отрабатывает задним числом по уже собранному — ни одного запроса к площадке. +-- +-- СВЯЗЬ ОБЪЯВЛЕНИЕ → ДОМ +-- listings.house_id_fk — та же связь, которой пользуется код-перенос и соседний +-- avito-путь (#3036). Другой связи нет: у SERP Домклика house_source/house_ext_id +-- намеренно не заполняются (#3064), а domclick_building_guid из карточки лежит в +-- raw_payload и ключом дома пока не является. +-- +-- ЧТО НЕ ДЕЛАЕМ +-- Серия дома, энергоэффективность и число подъездов — колонок под них в 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 строк. Непустое значение дома не +-- затирается никогда: у houses есть конкурирующие писатели (ДОМ.РФ капремонт, +-- Houses Catalog), карточка площадки не канон. +-- +-- ВЫБОР ЗНАЧЕНИЯ ПРИ НЕСКОЛЬКИХ ОБЪЯВЛЕНИЯХ В ОДНОМ ДОМЕ +-- DISTINCT ON (house_id_fk) ... ORDER BY house_id_fk, detail_enriched_at DESC NULLS LAST, +-- id DESC — берём самую свежую обогащённую карточку. NULLS LAST обязателен: в +-- Postgres DESC по умолчанию ставит NULL ПЕРВЫМИ, и без него победителем стала бы +-- карточка вообще без отметки обогащения. +BEGIN; +-- Конвенция проекта (#2752): массовый UPDATE берёт блокировки на строках houses и без +-- lock_timeout встанет в очередь за чужой сессией, утащив за собой запросы приложения. +SET LOCAL lock_timeout = '5s'; + +DO $$ +DECLARE + before_units bigint; + before_walls bigint; + before_floors bigint; + after_units bigint; + after_walls bigint; + after_floors bigint; +BEGIN + SELECT count(*) FILTER (WHERE total_units IS NOT NULL), + count(*) FILTER (WHERE material_walls IS NOT NULL), + count(*) FILTER (WHERE material_floors IS NOT NULL) + INTO before_units, before_walls, before_floors + FROM houses; + + RAISE NOTICE 'houses ДО: total_units=%, material_walls=%, material_floors=%', + before_units, before_walls, before_floors; + + WITH src AS ( + SELECT DISTINCT ON (l.house_id_fk) + l.house_id_fk AS house_id, + NULLIF(l.raw_payload ->> 'quarters_count', '') AS quarters_count, + 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 + AND l.raw_payload IS NOT NULL + AND (l.raw_payload ? 'quarters_count' + OR l.raw_payload ? 'wall_type' + OR l.raw_payload ? 'floor_type') + ORDER BY l.house_id_fk, l.detail_enriched_at DESC NULLS LAST, l.id DESC + ) + UPDATE houses h SET + total_units = COALESCE( + h.total_units, + CASE WHEN s.quarters_count ~ '^[0-9]+$' + THEN CAST(s.quarters_count AS integer) END + ), + material_walls = COALESCE(h.material_walls, s.wall_type), + material_floors = COALESCE(h.material_floors, s.floor_type) + FROM src s + WHERE h.id = s.house_id + AND (h.total_units IS NULL AND s.quarters_count ~ '^[0-9]+$' + OR h.material_walls IS NULL AND s.wall_type IS NOT NULL + OR h.material_floors IS NULL AND s.floor_type IS NOT NULL); + + SELECT count(*) FILTER (WHERE total_units IS NOT NULL), + count(*) FILTER (WHERE material_walls IS NOT NULL), + count(*) FILTER (WHERE material_floors IS NOT NULL) + INTO after_units, after_walls, after_floors + FROM houses; + + RAISE NOTICE 'houses ПОСЛЕ: total_units=% (+%), material_walls=% (+%), material_floors=% (+%)', + after_units, after_units - before_units, + after_walls, after_walls - before_walls, + after_floors, after_floors - before_floors; +END $$; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_3253_domclick_house_fields.py b/tradein-mvp/backend/tests/test_3253_domclick_house_fields.py new file mode 100644 index 00000000..7043937a --- /dev/null +++ b/tradein-mvp/backend/tests/test_3253_domclick_house_fields.py @@ -0,0 +1,174 @@ +"""Дом-поля карточки Домклика доезжают до 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 = ( + "" +) + + +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 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 27a7484f..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} @@ -587,6 +658,62 @@ async def fetch_detail( # ── save_detail_enrichment ────────────────────────────────────────────────────── +# #3253: дом-поля карточки → houses (fill-only), тот же второй оператор, что у avito +# (_HOUSE_PARAMS_SQL в providers/avito/detail.py, #3036). Связь — listings.house_id_fk. +# Колонок под building_series / energy_efficiency / entrance_count в houses НЕТ — +# они намеренно остаются только в listings.raw_payload, схему тут не расширяем. +_HOUSE_PARAMS_SQL = text(""" + UPDATE houses h SET + total_units = COALESCE(h.total_units, CAST(:quarters_count AS integer)), + material_walls = COALESCE(h.material_walls, CAST(:wall_type AS text)), + material_floors = COALESCE(h.material_floors, CAST(:floor_type AS text)) + FROM listings l + WHERE l.id = CAST(:lid AS bigint) + AND l.house_id_fk = h.id + AND (h.total_units IS NULL AND CAST(:quarters_count AS integer) IS NOT NULL + OR h.material_walls IS NULL AND CAST(:wall_type AS text) IS NOT NULL + OR h.material_floors IS NULL AND CAST(:floor_type AS text) IS NOT NULL) +""") + + +def _fill_house_params_from_detail( + db: Session, listing_id: int, e: DomClickDetailEnrichment +) -> 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 = 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: + with db.begin_nested(): + r = db.execute( + _HOUSE_PARAMS_SQL, + { + "lid": listing_id, + "quarters_count": quarters_count, + "wall_type": wall_type, + "floor_type": floor_type, + }, + ) + except Exception: + logger.warning( + "domclick_detail: house params not saved for listing_id=%s", + listing_id, + exc_info=True, + ) + return 0 + return int(r.rowcount or 0) def save_detail_enrichment( @@ -600,7 +727,16 @@ def save_detail_enrichment( raw_payload || raw_extra, detail_enriched_at=NOW() WHERE id=listing_id. sale_type проходит через canon_sale_type — в колонке общий с cian/avito словарь, а не сырая русская фраза Домклика (#2674, миграция 220). - 2. Для каждой записи priceHistory — INSERT INTO offer_price_history + 2. Дом-поля карточки → houses через listings.house_id_fk, fill-only (#3253): + quarters_count → total_units, wall_type → material_walls, floor_type → + 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'. Snapshot (upsert_listing_snapshot) НЕ пишем: enrichment не несёт текущую цену, @@ -684,6 +820,10 @@ def save_detail_enrichment( exc, ) + # Дом-поля — только если листинг нашёлся: без строки в listings нет и house_id_fk. + if result.rowcount > 0: + _fill_house_params_from_detail(db, listing_id, e) + db.commit() found = result.rowcount > 0 if not found: