refactor(tradein): нормализовать repair_state → enum на ингесте (#621)
listings.repair_state хранил сырые значения парсеров (cosmetic/euro/ designer/required/without/…), не совместимые с ожидаемым estimator-ом enum needs_repair/standard/good/excellent. Покрытие ~2%, но значения не совпадали с enum — repair_coef всегда возвращал 1.0 (no-op). Решение: централизованный helper repair_state_normalizer.py со всеми 10 известными raw-значениями → 4 enum-buckets. Все ingest-пути нормализуют на входе; миграция 073 бэкфилит существующие строки.
This commit is contained in:
parent
1731704ddc
commit
dfd608d6f2
8 changed files with 208 additions and 14 deletions
|
|
@ -132,12 +132,11 @@ _IMV_REPAIR_MAP: dict[str | None, str | None] = {
|
||||||
# влияет на цену).
|
# влияет на цену).
|
||||||
#
|
#
|
||||||
# WARNING: tunable МАРКЕТ-ЭВРИСТИКА, НЕ data-derived (issue #7). Вывести из данных пока
|
# WARNING: tunable МАРКЕТ-ЭВРИСТИКА, НЕ data-derived (issue #7). Вывести из данных пока
|
||||||
# нельзя: listings.repair_state покрыт только ~2%, хранит un-normalized исходные
|
# нельзя: listings.repair_state покрыт только ~2% (coverage вырастет после #621 backfill),
|
||||||
# значения (cosmetic/euro/fine/without/designer/rough — НЕ целевой enum
|
# а медианы по нему confounded by area (немонотонны). Baseline = standard = 1.00 (no-op:
|
||||||
# needs_repair/standard/good/excellent), а медианы по нему confounded by area
|
# было 0.98, срезало каждую «стандартную» оценку на 2% — пофикшено). Пересмотреть при
|
||||||
# (немонотонны). Baseline = standard = 1.00 (no-op: было 0.98, срезало каждую
|
# coverage > 20% и наборе достаточной выборки по каждому bucket-у (#7).
|
||||||
# «стандартную» оценку на 2% — пофикшено). Пересмотреть, когда покрытие
|
# После #621: repair_state нормализован → needs_repair/standard/good/excellent на инgesте.
|
||||||
# repair_state вырастет и появится нормализация.
|
|
||||||
_REPAIR_COEF: dict[str, float] = {
|
_REPAIR_COEF: dict[str, float] = {
|
||||||
"needs_repair": 0.94, # требует ремонта — ниже рынка
|
"needs_repair": 0.94, # требует ремонта — ниже рынка
|
||||||
"standard": 1.00, # baseline
|
"standard": 1.00, # baseline
|
||||||
|
|
|
||||||
|
|
@ -134,11 +134,13 @@ _WINDOWS_MAP: dict[str, str] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
_REPAIR_MAP: dict[str, str] = {
|
_REPAIR_MAP: dict[str, str] = {
|
||||||
"косметический": "cosmetic",
|
# Русские значения из Avito HTML → каноничный enum (needs_repair/standard/good/excellent)
|
||||||
"евро": "euro",
|
# Маппинг выровнен по repair_state_normalizer.normalize_repair_state().
|
||||||
"дизайнерский": "designer",
|
"косметический": "standard",
|
||||||
"требуется": "required",
|
"евро": "good",
|
||||||
"без ремонта": "required",
|
"дизайнерский": "excellent",
|
||||||
|
"требуется": "needs_repair",
|
||||||
|
"без ремонта": "needs_repair",
|
||||||
}
|
}
|
||||||
|
|
||||||
_SALE_TYPE_MAP: dict[str, str] = {
|
_SALE_TYPE_MAP: dict[str, str] = {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ from curl_cffi.requests import AsyncSession
|
||||||
from app.services.scraper_settings import get_scraper_delay
|
from app.services.scraper_settings import get_scraper_delay
|
||||||
from app.services.scrapers.base import BaseScraper, ScrapedLot
|
from app.services.scrapers.base import BaseScraper, ScrapedLot
|
||||||
from app.services.scrapers.cian_state_parser import extract_state
|
from app.services.scrapers.cian_state_parser import extract_state
|
||||||
|
from app.services.scrapers.repair_state_normalizer import normalize_repair_state
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -348,7 +349,9 @@ class CianScraper(BaseScraper):
|
||||||
# Мебель и repair_state
|
# Мебель и repair_state
|
||||||
has_furniture: bool | None = offer.get("hasFurniture") or offer.get("isSoldFurnished")
|
has_furniture: bool | None = offer.get("hasFurniture") or offer.get("isSoldFurnished")
|
||||||
# decoration — для новостроек (отделка); repair_state — для вторички
|
# decoration — для новостроек (отделка); repair_state — для вторички
|
||||||
repair_state: str | None = offer.get("decoration")
|
# Cian SERP: offer.decoration содержит raw значения (without/cosmetic/euro/design/...)
|
||||||
|
# Нормализуем до enum needs_repair/standard/good/excellent при инgesте.
|
||||||
|
repair_state: str | None = normalize_repair_state(offer.get("decoration"))
|
||||||
|
|
||||||
# ── Продавец ─────────────────────────────────────────────────────
|
# ── Продавец ─────────────────────────────────────────────────────
|
||||||
phones: list[dict[str, Any]] = offer.get("phones") or []
|
phones: list[dict[str, Any]] = offer.get("phones") or []
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services.scrapers.cian_state_parser import extract_all_states, extract_state
|
from app.services.scrapers.cian_state_parser import extract_all_states, extract_state
|
||||||
|
from app.services.scrapers.repair_state_normalizer import normalize_repair_state
|
||||||
from app.services.scrapers.snapshot_writer import upsert_listing_snapshot
|
from app.services.scrapers.snapshot_writer import upsert_listing_snapshot
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -36,7 +37,8 @@ class DetailEnrichment:
|
||||||
separate_wcs_count: int | None = None
|
separate_wcs_count: int | None = None
|
||||||
combined_wcs_count: int | None = None
|
combined_wcs_count: int | None = None
|
||||||
ceiling_height: float | None = None # meters
|
ceiling_height: float | None = None # meters
|
||||||
repair_type: str | None = None # 'cosmetic' / 'design' / 'no'
|
repair_type: str | None = None # 'cosmetic' / 'design' / 'no' — raw Cian value
|
||||||
|
repair_state: str | None = None # enum: needs_repair/standard/good/excellent
|
||||||
views_total: int | None = None # Cian stats.totalViewsFormattedString → int
|
views_total: int | None = None # Cian stats.totalViewsFormattedString → int
|
||||||
views_today: int | None = None
|
views_today: int | None = None
|
||||||
|
|
||||||
|
|
@ -106,6 +108,7 @@ async def fetch_detail(
|
||||||
combined_wcs_count=offer.get("combinedWcsCount"),
|
combined_wcs_count=offer.get("combinedWcsCount"),
|
||||||
ceiling_height=_parse_float(offer.get("ceilingHeight")),
|
ceiling_height=_parse_float(offer.get("ceilingHeight")),
|
||||||
repair_type=_extract_repair_type(offer),
|
repair_type=_extract_repair_type(offer),
|
||||||
|
repair_state=_extract_repair_state(offer),
|
||||||
raw_offer=offer,
|
raw_offer=offer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -158,6 +161,11 @@ def _extract_repair_type(offer: dict[str, Any]) -> str | None:
|
||||||
return offer.get("repairType")
|
return offer.get("repairType")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_repair_state(offer: dict[str, Any]) -> str | None:
|
||||||
|
"""Нормализованный enum из offer.repairType → needs_repair/standard/good/excellent."""
|
||||||
|
return normalize_repair_state(offer.get("repairType"))
|
||||||
|
|
||||||
|
|
||||||
def _parse_float(value: Any) -> float | None:
|
def _parse_float(value: Any) -> float | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -233,6 +241,7 @@ def save_detail_enrichment(db: Session, listing_id: int, enrichment: DetailEnric
|
||||||
combined_wcs_count = COALESCE(:cwc, combined_wcs_count),
|
combined_wcs_count = COALESCE(:cwc, combined_wcs_count),
|
||||||
ceiling_height = COALESCE(:ch, ceiling_height),
|
ceiling_height = COALESCE(:ch, ceiling_height),
|
||||||
repair_type = COALESCE(:rt, repair_type),
|
repair_type = COALESCE(:rt, repair_type),
|
||||||
|
repair_state = COALESCE(:rs, repair_state),
|
||||||
views_total = COALESCE(:vt, views_total),
|
views_total = COALESCE(:vt, views_total),
|
||||||
views_today = COALESCE(:vd, views_today)
|
views_today = COALESCE(:vd, views_today)
|
||||||
WHERE id = CAST(:lid AS bigint)
|
WHERE id = CAST(:lid AS bigint)
|
||||||
|
|
@ -244,6 +253,7 @@ def save_detail_enrichment(db: Session, listing_id: int, enrichment: DetailEnric
|
||||||
"cwc": enrichment.combined_wcs_count,
|
"cwc": enrichment.combined_wcs_count,
|
||||||
"ch": enrichment.ceiling_height,
|
"ch": enrichment.ceiling_height,
|
||||||
"rt": enrichment.repair_type,
|
"rt": enrichment.repair_type,
|
||||||
|
"rs": enrichment.repair_state,
|
||||||
"vt": enrichment.views_total,
|
"vt": enrichment.views_total,
|
||||||
"vd": enrichment.views_today,
|
"vd": enrichment.views_today,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Нормализация 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
|
||||||
|
|
||||||
|
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/общий: черновая отделка
|
||||||
|
|
||||||
|
# ── 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
|
||||||
|
result = _RAW_TO_ENUM.get(raw)
|
||||||
|
if result is None:
|
||||||
|
logger.warning("repair_state_normalizer: unknown raw value %r — stored as NULL", raw)
|
||||||
|
return result
|
||||||
41
tradein-mvp/backend/data/sql/073_normalize_repair_state.sql
Normal file
41
tradein-mvp/backend/data/sql/073_normalize_repair_state.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
-- Migration 073: нормализовать listings.repair_state → enum needs_repair/standard/good/excellent
|
||||||
|
--
|
||||||
|
-- До этой миграции repair_state хранил сырые значения парсеров:
|
||||||
|
-- avito_detail: cosmetic / euro / designer / required
|
||||||
|
-- cian SERP (decoration): without / no / cosmetic / euro / design / fine / rough
|
||||||
|
--
|
||||||
|
-- После: только needs_repair / standard / good / excellent.
|
||||||
|
-- Строки с нераспознанными значениями сбрасываются в NULL (безопаснее, чем хранить мусор).
|
||||||
|
--
|
||||||
|
-- Idempotent: CASE охватывает уже нормализованные значения (pass-through).
|
||||||
|
-- Issue: #621
|
||||||
|
|
||||||
|
UPDATE listings
|
||||||
|
SET repair_state = CASE repair_state
|
||||||
|
-- ── уже нормализованные (pass-through) ───────────────────────────────────
|
||||||
|
WHEN 'needs_repair' THEN 'needs_repair'
|
||||||
|
WHEN 'standard' THEN 'standard'
|
||||||
|
WHEN 'good' THEN 'good'
|
||||||
|
WHEN 'excellent' THEN 'excellent'
|
||||||
|
|
||||||
|
-- ── needs_repair ─────────────────────────────────────────────────────────
|
||||||
|
WHEN 'required' THEN 'needs_repair' -- avito: «требуется» / «без ремонта»
|
||||||
|
WHEN 'without' THEN 'needs_repair' -- cian: без ремонта
|
||||||
|
WHEN 'no' THEN 'needs_repair' -- cian: нет ремонта
|
||||||
|
WHEN 'rough' THEN 'needs_repair' -- cian: черновая
|
||||||
|
|
||||||
|
-- ── standard ─────────────────────────────────────────────────────────────
|
||||||
|
WHEN 'cosmetic' THEN 'standard' -- avito/cian: косметический
|
||||||
|
|
||||||
|
-- ── good ─────────────────────────────────────────────────────────────────
|
||||||
|
WHEN 'euro' THEN 'good' -- avito/cian: евро
|
||||||
|
WHEN 'fine' THEN 'good' -- cian: хорошая отделка
|
||||||
|
|
||||||
|
-- ── excellent ────────────────────────────────────────────────────────────
|
||||||
|
WHEN 'designer' THEN 'excellent' -- avito: дизайнерский
|
||||||
|
WHEN 'design' THEN 'excellent' -- cian: дизайнерский
|
||||||
|
|
||||||
|
-- ── нераспознанные → NULL ────────────────────────────────────────────────
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
WHERE repair_state IS NOT NULL;
|
||||||
|
|
@ -54,7 +54,7 @@ def test_minimal_parse() -> None:
|
||||||
assert result.floor == 20
|
assert result.floor == 20
|
||||||
assert result.total_floors == 26
|
assert result.total_floors == 26
|
||||||
assert result.balcony_loggia == "loggia"
|
assert result.balcony_loggia == "loggia"
|
||||||
assert result.repair_state == "euro"
|
assert result.repair_state == "good" # «евро» → good (normalized enum, #621)
|
||||||
assert result.sale_type == "free"
|
assert result.sale_type == "free"
|
||||||
assert result.mortgage_available is True
|
assert result.mortgage_available is True
|
||||||
assert result.house_type == "monolith"
|
assert result.house_type == "monolith"
|
||||||
|
|
|
||||||
71
tradein-mvp/backend/tests/test_repair_state_normalizer.py
Normal file
71
tradein-mvp/backend/tests/test_repair_state_normalizer.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""Unit-тесты для repair_state_normalizer.normalize_repair_state() (#621).
|
||||||
|
|
||||||
|
Покрывают все известные raw-значения → enum mapping,
|
||||||
|
idempotency для уже нормализованных значений и None-guard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.scrapers.repair_state_normalizer import normalize_repair_state
|
||||||
|
|
||||||
|
# ── needs_repair ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["required", "without", "no", "rough"])
|
||||||
|
def test_needs_repair_values(raw: str) -> None:
|
||||||
|
assert normalize_repair_state(raw) == "needs_repair"
|
||||||
|
|
||||||
|
|
||||||
|
# ── standard ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_cosmetic_maps_to_standard() -> None:
|
||||||
|
assert normalize_repair_state("cosmetic") == "standard"
|
||||||
|
|
||||||
|
|
||||||
|
# ── good ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["euro", "fine"])
|
||||||
|
def test_good_values(raw: str) -> None:
|
||||||
|
assert normalize_repair_state(raw) == "good"
|
||||||
|
|
||||||
|
|
||||||
|
# ── excellent ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["designer", "design"])
|
||||||
|
def test_excellent_values(raw: str) -> None:
|
||||||
|
assert normalize_repair_state(raw) == "excellent"
|
||||||
|
|
||||||
|
|
||||||
|
# ── None guard ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_returns_none() -> None:
|
||||||
|
assert normalize_repair_state(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── idempotency ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("enum_val", ["needs_repair", "standard", "good", "excellent"])
|
||||||
|
def test_already_normalized_is_passthrough(enum_val: str) -> None:
|
||||||
|
"""Уже нормализованное значение возвращается as-is (idempotent при ре-обработке)."""
|
||||||
|
assert normalize_repair_state(enum_val) == enum_val
|
||||||
|
|
||||||
|
|
||||||
|
# ── unknown value ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_raw_returns_none(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Нераспознанное значение → None + WARNING в лог."""
|
||||||
|
with caplog.at_level(
|
||||||
|
logging.WARNING,
|
||||||
|
logger="app.services.scrapers.repair_state_normalizer",
|
||||||
|
):
|
||||||
|
result = normalize_repair_state("whitebox")
|
||||||
|
assert result is None
|
||||||
|
assert "whitebox" in caplog.text
|
||||||
Loading…
Add table
Reference in a new issue