fix(tradein/houses): camelCase-типы домов приводятся к канону у источника (#2678) #2719

Merged
bot-backend merged 1 commit from fix/2678-house-type-canon into main 2026-08-06 09:55:55 +00:00
4 changed files with 233 additions and 2 deletions

View file

@ -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;

View file

@ -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

View file

@ -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",

View file

@ -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: