gendesign/tradein-mvp/backend/app/services/scrapers/repair_state_normalizer.py
bot-backend 2d3ca78b0b
All checks were successful
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(scrapers): cian_detail — ceiling/repair актуальные пути state (#1791)
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.
2026-06-19 19:57:18 +03:00

162 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Нормализация 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