diff --git a/tradein-mvp/backend/data/sql/224_houses_house_type_canon.sql b/tradein-mvp/backend/data/sql/224_houses_house_type_canon.sql new file mode 100644 index 00000000..b8b58859 --- /dev/null +++ b/tradein-mvp/backend/data/sql/224_houses_house_type_canon.sql @@ -0,0 +1,83 @@ +-- 224_houses_house_type_canon.sql +-- Issue #2678 (хвост #2675/#2674): twin миграции 141 для таблицы ДОМОВ. +-- +-- Миграция 141 привела camelCase-вокабуляр Циана к канону только в listings. +-- В houses он остался — и каждый читатель типа дома чинил его у себя (#2675 +-- починил домовую оценку; поштучный путь и подбор аналогов продолжали сравнивать +-- 'monolithBrick' с 'monolith_brick' и не совпадать). +-- +-- ЗАМЕР ПРОДА 2026-08-06 (SELECT source, house_type, count(*) FROM houses GROUP BY 1,2): +-- канон: brick 388+5+4+1 · panel 325+3+2+1 · monolith 118+37+6+1 · +-- block 90+1+1 · monolith_brick 2+1 · wood 2 +-- camelCase: monolithBrick 48 (derived) + 8 (cian_newbuilding) = 56 · +-- gasSilicateBlock 1 · aerocreteBlock 1 +-- прочее: stalin 3 · other 18 · wireframe 1 +-- NULL: 8440 из 8880 строк (тип дома вообще неизвестен — не наш случай) +-- +-- ЖИВОГО ПИСАТЕЛЯ camelCase В houses НЕТ: у всех 80 неканоничных строк +-- last_scraped_at = 2026-05-24 14:04:20.012209 — одна и та же метка, т.е. +-- единственный прогон backfill'а 063 (промоут типов из listings ДО миграции 141). +-- Единственный живой писатель houses.house_type — avito-каталог домов +-- (providers/avito/houses.py), он пишет русские подписи через свою карту; в том +-- же PR он переведён на общий normalize_house_type, чтобы неизвестное значение +-- шло как NULL, а не как 'other' (его единственный источник неканона). +-- +-- ПРОВЕРКА СМЫСЛА ПЕРЕД СКЛЕЙКОЙ (требование #2674 — не слепить разное): +-- контрольная группа в своих же данных. Для каждой неканоничной строки взяты +-- типы её ЖЕ объявлений (listings.house_id_fk), уже нормализованных 141: +-- monolithBrick 56 домов — monolith_brick присутствует у ВСЕХ 56 → одно и то же +-- stalin 3 дома — brick (совпадает с решением 141: «сталинка» = кирпич) +-- aerocreteBlock 1 дом — block +-- gasSilicateBlock 1 дом — block +-- other 18 домов — разброс monolith/monolith_brick/brick/block, т.е. +-- 'other' = «неизвестно», а не отдельный материал +-- wireframe 1 дом — wireframe и у объявлений (само-согласовано) +-- Вывод: склейка безопасна ТОЛЬКО для четырёх camelCase-токенов + stalin. +-- +-- ЧТО НАМЕРЕННО НЕ ТРОГАЕМ: +-- 'other' (18) и 'wireframe' (1) — честного соответствия в каноне нет +-- (см. #2675: normalize_house_type схлопывает их в None на чтении, и это +-- правильный ответ — NULL нейтрален для soft-penalty эстиматора, а выдуманный +-- материал был бы враньём). Стирать их здесь тоже не будем: это единственный +-- след того, что источник что-то про дом сказал. +-- +-- BACKFILL (счётчики сняты на проде ДО применения, 2026-08-06): +-- monolithBrick -> monolith_brick : 56 строк +-- stalin -> brick : 3 строки +-- aerocreteBlock -> block : 1 строка +-- gasSilicateBlock -> block : 1 строка +-- foamConcreteBlock-> block : 0 строк (в houses не встречается, +-- оставлен для паритета с картой 141) +-- ИТОГО ожидаемо тронуто: 61 строка. +-- +-- Idempotent: WHERE перечисляет только мапимые токены → повторный прогон 0 строк. +-- Маппинг тождественен house_type_normalizer._RAW_TO_CANON и миграции 141 — +-- третьего словаря не заводим. + +BEGIN; + +UPDATE houses + SET house_type = CASE house_type + WHEN 'monolithBrick' THEN 'monolith_brick' + WHEN 'gasSilicateBlock' THEN 'block' + WHEN 'aerocreteBlock' THEN 'block' + WHEN 'foamConcreteBlock' THEN 'block' + WHEN 'stalin' THEN 'brick' + ELSE house_type + END + WHERE house_type IN ( + 'monolithBrick', 'gasSilicateBlock', 'aerocreteBlock', + 'foamConcreteBlock', 'stalin' + ); + +COMMENT ON COLUMN houses.house_type IS + 'Материал/тип дома, канон: panel/brick/monolith/monolith_brick/block/wood ' + '(тот же enum, что listings.house_type и scraper_kit.house_type_normalizer). ' + 'Писать сюда только через normalize_house_type — сырые вокабуляры источников ' + '(cian camelCase monolithBrick/gasSilicateBlock/stalin, yandex SCREAMING ' + 'MONOLIT_BRICK, русские подписи Авито) приводятся ДО записи, миграция 224 ' + 'вычистила исторические. Вне канона осталось намеренно: other (источник сказал ' + '«другое») и wireframe (каркас — материала в каноне нет). Неизвестный тип = ' + 'NULL, а не панель и не other: NULL нейтрален для soft-penalty эстиматора.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_2678_house_type_canon.py b/tradein-mvp/backend/tests/test_2678_house_type_canon.py new file mode 100644 index 00000000..c349a222 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2678_house_type_canon.py @@ -0,0 +1,128 @@ +"""#2678: тип дома приводится к канону У ИСТОЧНИКА, и словарь ровно один. + +Кейсы взяты не из головы, а из фактического замера прода 2026-08-06 +(`SELECT source, house_type, count(*) FROM houses GROUP BY 1,2`): + + monolithBrick 56 · other 18 · stalin 3 · aerocreteBlock 1 · + gasSilicateBlock 1 · wireframe 1 · плюс канон (brick/panel/monolith/ + monolith_brick/block/wood) и 8440 NULL. + +Проверяется три вещи: + 1. каждый фактический вариант → канон (или честный None); + 2. живой писатель houses.house_type (avito-каталог) больше не изобретает + 'other' и ходит через общий нормализатор; + 3. миграция 224 не заводит третий словарь — её CASE совпадает с картой кода. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from scraper_kit.house_type_normalizer import _RAW_TO_CANON, normalize_house_type +from scraper_kit.providers.avito.houses import _normalize_house_type as avito_house_type + +_MIGRATION_224 = ( + Path(__file__).resolve().parents[1] / "data" / "sql" / "224_houses_house_type_canon.sql" +) + +# Фактический словарь houses.house_type на проде 2026-08-06 → чем он обязан стать. +# None = «честно неизвестно» (NULL нейтрален для soft-penalty эстиматора, в отличие +# от выдуманного материала). +_PROD_VALUES: list[tuple[str, str | None]] = [ + ("monolithBrick", "monolith_brick"), # 56 строк + ("other", None), # 18 строк — источник сказал «другое», материала нет + ("stalin", "brick"), # 3 строки — «сталинка» = кирпич (решение миграции 141) + ("aerocreteBlock", "block"), # 1 строка + ("gasSilicateBlock", "block"), # 1 строка + ("wireframe", None), # 1 строка — каркас, в каноне такого материала нет + ("brick", "brick"), + ("panel", "panel"), + ("monolith", "monolith"), + ("monolith_brick", "monolith_brick"), + ("block", "block"), + ("wood", "wood"), +] + + +@pytest.mark.parametrize(("raw", "expected"), _PROD_VALUES) +def test_prod_value_maps_to_canon(raw: str, expected: str | None) -> None: + assert normalize_house_type(raw) == expected + + +def test_canon_survives_uppercase_including_monolith_brick() -> None: + """#2678 п.6: сквозной проброс канона был регистрозависим — кроме monolith_brick. + + Значений в верхнем регистре в базе сегодня ноль; это страховка на новый источник, + который отдаст канон «как в документации». + """ + assert normalize_house_type("MONOLITH_BRICK") == "monolith_brick" + assert normalize_house_type("Monolith_Brick") == "monolith_brick" + # Остальной канон и раньше переживал регистр — фиксируем, что не сломали. + for token in ("BRICK", "Panel", "MONOLITH", "Block", "WOOD"): + assert normalize_house_type(token) == token.lower() + + +# ── живой писатель houses.house_type: avito-каталог домов ──────────────────────── + + +@pytest.mark.parametrize( + ("label", "expected"), + [ + ("Монолитно-кирпичный", "monolith_brick"), + ("Панельный", "panel"), + ("КИРПИЧНЫЙ", "brick"), + (" Блочный ", "block"), + ("Деревянный", "wood"), + ], +) +def test_avito_house_label_maps_to_canon(label: str, expected: str) -> None: + assert avito_house_type(label) == expected + + +def test_avito_unknown_label_is_null_not_other() -> None: + """Незнакомая подпись → NULL. До #2678 здесь появлялось 'other'. + + 'other' всегда != канону, т.е. читатель получал не «неизвестно», а гарантированное + несовпадение: ложный штраф при подборе аналогов и пропуск оценки. + """ + assert avito_house_type("Саманный") is None + assert avito_house_type("") is None + assert avito_house_type(None) is None + + +def test_avito_writer_handles_foreign_vocabulary() -> None: + """Писатель ходит через общий нормализатор, а не только через свою карту.""" + assert avito_house_type("monolithBrick") == "monolith_brick" + assert avito_house_type("MONOLIT_BRICK") == "monolith_brick" + + +# ── миграция 224: тот же словарь, что в коде ───────────────────────────────────── + + +def _migration_case_pairs() -> dict[str, str]: + """WHEN 'x' THEN 'y' из исполняемой части миграции (без `--`-комментариев).""" + code = "\n".join( + line.split("--", 1)[0] for line in _MIGRATION_224.read_text(encoding="utf-8").splitlines() + ) + return dict(re.findall(r"WHEN\s+'([^']+)'\s+THEN\s+'([^']+)'", code)) + + +def test_migration_224_mapping_matches_code() -> None: + """Миграция не заводит третий словарь — каждая пара есть в _RAW_TO_CANON.""" + pairs = _migration_case_pairs() + assert pairs, "в миграции 224 не нашлось ни одного WHEN ... THEN" + for raw, canon in pairs.items(): + assert _RAW_TO_CANON.get(raw) == canon, f"{raw!r} расходится с house_type_normalizer" + + +def test_migration_224_touches_only_mapped_tokens() -> None: + """WHERE ограничен теми же токенами → 'other'/'wireframe'/канон не трогаются.""" + code = "\n".join( + line.split("--", 1)[0] for line in _MIGRATION_224.read_text(encoding="utf-8").splitlines() + ) + where_tokens = set(re.findall(r"'([A-Za-z]+)'", code.split("WHERE", 1)[1].split(";", 1)[0])) + assert where_tokens == set(_migration_case_pairs()) + assert "other" not in where_tokens + assert "wireframe" not in where_tokens diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/house_type_normalizer.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/house_type_normalizer.py index f9ad280d..aacb99ba 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/house_type_normalizer.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/house_type_normalizer.py @@ -47,6 +47,11 @@ _RAW_TO_CANON: dict[str, str] = { "panel": "panel", "block": "block", "wood": "wood", + # #2678 п.6: канон целиком, включая monolith_brick. Pass-through по _CANON + # регистрозависим, и без этого ключа 'MONOLITH_BRICK'/'Monolith_Brick' (форма, + # в которой канон может прийти от нового источника) уезжали бы в None — + # единственный канонический токен без такой страховки. + "monolith_brick": "monolith_brick", "monolithBrick": "monolith_brick", "gasSilicateBlock": "block", "aerocreteBlock": "block", diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py index a62f1755..c91b256b 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py @@ -39,6 +39,7 @@ from sqlalchemy import text from sqlalchemy.orm import Session from scraper_kit.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError +from scraper_kit.house_type_normalizer import normalize_house_type from scraper_kit.providers.avito.serp import _is_firewall_page from scraper_kit.providers.avito.shared import RUS_MONTHS, _unix_to_date @@ -258,10 +259,24 @@ def _strip_price(price_str: str | None) -> int | None: def _normalize_house_type(raw: str | None) -> str | None: - """Нормализует тип дома: "Монолитный" → "monolith".""" + """Нормализует тип дома: "Монолитный" → "monolith". Незнакомое → None. + + Единственный живой писатель houses.house_type (см. save-функцию ниже), поэтому + канон обязан приводиться ЗДЕСЬ, а не у каждого читателя (#2678). + + Русская подпись Авито снимается локальной HOUSE_TYPE_MAP, результат прогоняется + через общий scraper_kit.house_type_normalizer: он знает и канон, и чужие + вокабуляры (cian camelCase, yandex SCREAMING) — на случай, если карточка дома + однажды придёт с чужим токеном. + + #2678: раньше незнакомое значение становилось 'other' — единственный источник + неканоничных значений в houses среди живых писателей. 'other' всегда != канон, + т.е. для soft-penalty эстиматора это ложный штраф, а NULL нейтрален (тот же + довод, что в docstring house_type_normalizer). + """ if not raw: return None - return HOUSE_TYPE_MAP.get(raw.lower(), "other") + return normalize_house_type(HOUSE_TYPE_MAP.get(raw.strip().lower(), raw)) def _normalize_house_class(raw: str | None) -> str | None: