fix(tradein/houses): camelCase-типы домов приводятся к канону у источника (#2678) #2719
4 changed files with 233 additions and 2 deletions
83
tradein-mvp/backend/data/sql/224_houses_house_type_canon.sql
Normal file
83
tradein-mvp/backend/data/sql/224_houses_house_type_canon.sql
Normal 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;
|
||||
128
tradein-mvp/backend/tests/test_2678_house_type_canon.py
Normal file
128
tradein-mvp/backend/tests/test_2678_house_type_canon.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue