fix(tradein/houses): camelCase-типы домов приводятся к канону у источника (#2678) (#2719)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m58s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 2m5s
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m58s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 2m5s
This commit is contained in:
parent
663a831775
commit
0144092856
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",
|
"panel": "panel",
|
||||||
"block": "block",
|
"block": "block",
|
||||||
"wood": "wood",
|
"wood": "wood",
|
||||||
|
# #2678 п.6: канон целиком, включая monolith_brick. Pass-through по _CANON
|
||||||
|
# регистрозависим, и без этого ключа 'MONOLITH_BRICK'/'Monolith_Brick' (форма,
|
||||||
|
# в которой канон может прийти от нового источника) уезжали бы в None —
|
||||||
|
# единственный канонический токен без такой страховки.
|
||||||
|
"monolith_brick": "monolith_brick",
|
||||||
"monolithBrick": "monolith_brick",
|
"monolithBrick": "monolith_brick",
|
||||||
"gasSilicateBlock": "block",
|
"gasSilicateBlock": "block",
|
||||||
"aerocreteBlock": "block",
|
"aerocreteBlock": "block",
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from scraper_kit.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
|
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.serp import _is_firewall_page
|
||||||
from scraper_kit.providers.avito.shared import RUS_MONTHS, _unix_to_date
|
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:
|
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:
|
if not raw:
|
||||||
return None
|
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:
|
def _normalize_house_class(raw: str | None) -> str | None:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue