ceiling_height на detail-странице лежит в offer.building.ceilingHeight,
а не offer.ceilingHeight (всегда null) → было 0% покрытия. repairType
часто пустой, структурное значение приходит как offer.decoration
('preFine'/...) — читаем оба + добавлен preFine->needs_repair и
case-insensitive lookup в нормализаторе. kitchen/description пути уже
верны (offer.kitchenArea/offer.description) — низкое покрытие = мёртвый
CIAN_PROXY_URL, не маппинг. Регресс-тест на реальной карточке 330982715.
162 lines
8.4 KiB
Python
162 lines
8.4 KiB
Python
"""Нормализация repair_state → каноничный enum для listings.repair_state.
|
||
|
||
Целевой enum: needs_repair / standard / good / excellent.
|
||
Используется во всех инgest-путях: avito_detail, cian SERP, cian_detail.
|
||
|
||
Источники raw-значений:
|
||
- Avito HTML: «Ремонт: евро» → avito_detail._REPAIR_MAP → «euro» → good
|
||
- Cian SERP JSON: offer.decoration → «euro» / «cosmetic» / «without» / ...
|
||
- Cian Detail JSON: offer.repairType → «cosmetic» / «design» / «no» / «euro» / ...
|
||
|
||
Маппинг основан на _IMV_REPAIR_MAP из estimator.py (обратная форма):
|
||
needs_repair→«required», standard→«cosmetic», good→«euro», excellent→«designer».
|
||
|
||
Связь с estimator._REPAIR_COEF:
|
||
needs_repair=0.94 / standard=1.00 / good=1.05 / excellent=1.10
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Все известные raw-значения с источником в комментарии
|
||
_RAW_TO_ENUM: dict[str, str] = {
|
||
# ── needs_repair ────────────────────────────────────────────────────────────
|
||
"required": "needs_repair", # Avito: «требуется» / «без ремонта» (через _REPAIR_MAP)
|
||
"without": "needs_repair", # Cian: без ремонта
|
||
"no": "needs_repair", # Cian: нет ремонта (синоним without)
|
||
"rough": "needs_repair", # Cian/общий: черновая отделка
|
||
"prefine": "needs_repair", # Cian detail: предчистовая/whitebox (offer.decoration, #1791)
|
||
# ── standard ────────────────────────────────────────────────────────────────
|
||
"cosmetic": "standard", # Avito/Cian: косметический
|
||
# ── good ────────────────────────────────────────────────────────────────────
|
||
"euro": "good", # Avito/Cian: евро
|
||
"fine": "good", # Cian: хорошая отделка (предчистовая / whitebox)
|
||
# ── excellent ───────────────────────────────────────────────────────────────
|
||
"designer": "excellent", # Avito: дизайнерский
|
||
"design": "excellent", # Cian: дизайнерский (camelCase вариант)
|
||
}
|
||
|
||
# Допустимые значения целевого enum — для pass-through защиты
|
||
_VALID_ENUM: frozenset[str] = frozenset({"needs_repair", "standard", "good", "excellent"})
|
||
|
||
|
||
def normalize_repair_state(raw: str | None) -> str | None:
|
||
"""Преобразовать raw repair-значение в каноничный enum.
|
||
|
||
Если raw уже является корректным enum-значением — вернуть as-is (идемпотентно).
|
||
Если raw не распознан — вернуть None + залогировать WARNING один раз.
|
||
|
||
Args:
|
||
raw: сырое значение из парсера (e.g. «euro», «without», «design»)
|
||
или None.
|
||
|
||
Returns:
|
||
Одно из needs_repair / standard / good / excellent, либо None.
|
||
"""
|
||
if raw is None:
|
||
return None
|
||
# Pass-through: уже нормализованный enum (idempotency — нужно при ре-обработке)
|
||
if raw in _VALID_ENUM:
|
||
return raw
|
||
# Cian отдаёт camelCase ('preFine', 'whiteBox') — ключи карты в нижнем регистре,
|
||
# сравниваем case-insensitive, чтобы не плодить дубль-ключи на каждый регистр.
|
||
result = _RAW_TO_ENUM.get(raw) or _RAW_TO_ENUM.get(raw.lower())
|
||
if result is None:
|
||
logger.warning("repair_state_normalizer: unknown raw value %r — stored as NULL", raw)
|
||
return result
|
||
|
||
|
||
# ── Inference из текста описания (#622) ──────────────────────────────────────
|
||
# Когда структурное поле repair отсутствует (avito ~1% / cian ~5% / yandex 0%),
|
||
# извлекаем состояние из текста описания регэкспами по русским фразам.
|
||
#
|
||
# Порядок ВАЖЕН: проверяем от самого сильного сигнала к слабому
|
||
# excellent → good → needs_repair → standard,
|
||
# чтобы «после черновой сделали дизайнерский ремонт» дал excellent, а не
|
||
# needs_repair. Первое совпадение по порядку выигрывает.
|
||
#
|
||
# Целевой enum выровнен по normalize_repair_state() — те же 4 значения.
|
||
_TEXT_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||
# ── excellent: дизайнерский / премиум / эксклюзивный ремонт ──────────────
|
||
(
|
||
re.compile(
|
||
r"дизайнерск(?:ий|ого|ом|ая)\s+ремонт"
|
||
r"|дизайнерск(?:ая|ой)\s+отделк"
|
||
r"|премиальн\w*\s+ремонт"
|
||
r"|эксклюзивн\w*\s+ремонт"
|
||
r"|авторск\w*\s+ремонт",
|
||
re.IGNORECASE,
|
||
),
|
||
"excellent",
|
||
),
|
||
# ── good: евроремонт / отличный / свежий / современный ремонт ────────────
|
||
(
|
||
re.compile(
|
||
r"евроремонт"
|
||
r"|евро[\s-]?ремонт"
|
||
r"|отличн\w*\s+ремонт"
|
||
r"|свеж\w*\s+ремонт"
|
||
r"|современн\w*\s+ремонт"
|
||
r"|качественн\w*\s+ремонт"
|
||
r"|ремонт\s+в\s+отличн",
|
||
re.IGNORECASE,
|
||
),
|
||
"good",
|
||
),
|
||
# ── needs_repair: без отделки / черновая / требует ремонта / под ремонт ──
|
||
(
|
||
re.compile(
|
||
r"без\s+отделк"
|
||
r"|без\s+ремонт"
|
||
r"|черновая\s+отделк"
|
||
r"|черновой\s+ремонт"
|
||
r"|требует(?:ся)?\s+ремонт"
|
||
r"|требует(?:ся)?\s+космет"
|
||
r"|нужен\s+ремонт"
|
||
r"|под\s+ремонт"
|
||
r"|под\s+чистов\w+\s+отделк"
|
||
r"|предчистов",
|
||
re.IGNORECASE,
|
||
),
|
||
"needs_repair",
|
||
),
|
||
# ── standard: косметический / обычный / жилое состояние ─────────────────
|
||
(
|
||
re.compile(
|
||
r"косметическ\w*\s+ремонт"
|
||
r"|косметическ\w*\s+отделк"
|
||
r"|обычн\w*\s+ремонт"
|
||
r"|жил(?:ое|ом)\s+состоян"
|
||
r"|хорош\w*\s+состоян",
|
||
re.IGNORECASE,
|
||
),
|
||
"standard",
|
||
),
|
||
)
|
||
|
||
|
||
def infer_repair_state_from_text(description: str | None) -> str | None:
|
||
"""Извлечь repair_state из текста описания объявления (#622).
|
||
|
||
Fallback-путь для повышения покрытия listings.repair_state, когда
|
||
структурное поле источника отсутствует. Проверяет паттерны в порядке
|
||
убывания силы сигнала (excellent → good → needs_repair → standard);
|
||
первое совпадение выигрывает.
|
||
|
||
Args:
|
||
description: текст описания объявления (avito/cian/yandex) или None.
|
||
|
||
Returns:
|
||
Одно из needs_repair / standard / good / excellent, либо None если
|
||
ни один паттерн не сработал (НЕ фабрикуем — unknown остаётся NULL).
|
||
"""
|
||
if not description:
|
||
return None
|
||
for pattern, enum_value in _TEXT_PATTERNS:
|
||
if pattern.search(description):
|
||
return enum_value
|
||
return None
|