From d842811390426c45fe52a684b31f9aa815da4527 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Thu, 28 May 2026 15:18:11 +0000 Subject: [PATCH] =?UTF-8?q?refactor(tradein):=20normalize=20repair=5Fstate?= =?UTF-8?q?=20raw=E2=86=92enum=20at=20ingest=20(#621)=20(#629)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/estimator.py | 11 ++- .../app/services/scrapers/avito_detail.py | 12 ++-- .../backend/app/services/scrapers/cian.py | 5 +- .../app/services/scrapers/cian_detail.py | 12 +++- .../scrapers/repair_state_normalizer.py | 68 ++++++++++++++++++ .../data/sql/073_normalize_repair_state.sql | 45 ++++++++++++ .../backend/tests/test_avito_detail_parse.py | 2 +- .../tests/test_repair_state_normalizer.py | 71 +++++++++++++++++++ 8 files changed, 212 insertions(+), 14 deletions(-) create mode 100644 tradein-mvp/backend/app/services/scrapers/repair_state_normalizer.py create mode 100644 tradein-mvp/backend/data/sql/073_normalize_repair_state.sql create mode 100644 tradein-mvp/backend/tests/test_repair_state_normalizer.py diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index ed710dfb..0bd631bf 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -132,12 +132,11 @@ _IMV_REPAIR_MAP: dict[str | None, str | None] = { # влияет на цену). # # WARNING: tunable МАРКЕТ-ЭВРИСТИКА, НЕ data-derived (issue #7). Вывести из данных пока -# нельзя: listings.repair_state покрыт только ~2%, хранит un-normalized исходные -# значения (cosmetic/euro/fine/without/designer/rough — НЕ целевой enum -# needs_repair/standard/good/excellent), а медианы по нему confounded by area -# (немонотонны). Baseline = standard = 1.00 (no-op: было 0.98, срезало каждую -# «стандартную» оценку на 2% — пофикшено). Пересмотреть, когда покрытие -# repair_state вырастет и появится нормализация. +# нельзя: listings.repair_state покрыт только ~2% (coverage вырастет после #621 backfill), +# а медианы по нему confounded by area (немонотонны). Baseline = standard = 1.00 (no-op: +# было 0.98, срезало каждую «стандартную» оценку на 2% — пофикшено). Пересмотреть при +# coverage > 20% и наборе достаточной выборки по каждому bucket-у (#7). +# После #621: repair_state нормализован → needs_repair/standard/good/excellent на инgesте. _REPAIR_COEF: dict[str, float] = { "needs_repair": 0.94, # требует ремонта — ниже рынка "standard": 1.00, # baseline diff --git a/tradein-mvp/backend/app/services/scrapers/avito_detail.py b/tradein-mvp/backend/app/services/scrapers/avito_detail.py index 49d7d614..92ea9cdb 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito_detail.py +++ b/tradein-mvp/backend/app/services/scrapers/avito_detail.py @@ -134,11 +134,13 @@ _WINDOWS_MAP: dict[str, str] = { } _REPAIR_MAP: dict[str, str] = { - "косметический": "cosmetic", - "евро": "euro", - "дизайнерский": "designer", - "требуется": "required", - "без ремонта": "required", + # Русские значения из Avito HTML → каноничный enum (needs_repair/standard/good/excellent) + # Маппинг выровнен по repair_state_normalizer.normalize_repair_state(). + "косметический": "standard", + "евро": "good", + "дизайнерский": "excellent", + "требуется": "needs_repair", + "без ремонта": "needs_repair", } _SALE_TYPE_MAP: dict[str, str] = { diff --git a/tradein-mvp/backend/app/services/scrapers/cian.py b/tradein-mvp/backend/app/services/scrapers/cian.py index 3dbd4a38..fc833607 100644 --- a/tradein-mvp/backend/app/services/scrapers/cian.py +++ b/tradein-mvp/backend/app/services/scrapers/cian.py @@ -26,6 +26,7 @@ from curl_cffi.requests import AsyncSession from app.services.scraper_settings import get_scraper_delay from app.services.scrapers.base import BaseScraper, ScrapedLot 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__) @@ -348,7 +349,9 @@ class CianScraper(BaseScraper): # Мебель и repair_state has_furniture: bool | None = offer.get("hasFurniture") or offer.get("isSoldFurnished") # 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 [] diff --git a/tradein-mvp/backend/app/services/scrapers/cian_detail.py b/tradein-mvp/backend/app/services/scrapers/cian_detail.py index d931e9be..f870113b 100644 --- a/tradein-mvp/backend/app/services/scrapers/cian_detail.py +++ b/tradein-mvp/backend/app/services/scrapers/cian_detail.py @@ -21,6 +21,7 @@ from sqlalchemy import text from sqlalchemy.orm import Session 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 logger = logging.getLogger(__name__) @@ -36,7 +37,8 @@ class DetailEnrichment: separate_wcs_count: int | None = None combined_wcs_count: int | None = None 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_today: int | None = None @@ -106,6 +108,7 @@ async def fetch_detail( combined_wcs_count=offer.get("combinedWcsCount"), ceiling_height=_parse_float(offer.get("ceilingHeight")), repair_type=_extract_repair_type(offer), + repair_state=_extract_repair_state(offer), raw_offer=offer, ) @@ -158,6 +161,11 @@ def _extract_repair_type(offer: dict[str, Any]) -> str | None: 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: if value is 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), ceiling_height = COALESCE(:ch, ceiling_height), repair_type = COALESCE(:rt, repair_type), + repair_state = COALESCE(:rs, repair_state), views_total = COALESCE(:vt, views_total), views_today = COALESCE(:vd, views_today) 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, "ch": enrichment.ceiling_height, "rt": enrichment.repair_type, + "rs": enrichment.repair_state, "vt": enrichment.views_total, "vd": enrichment.views_today, }, diff --git a/tradein-mvp/backend/app/services/scrapers/repair_state_normalizer.py b/tradein-mvp/backend/app/services/scrapers/repair_state_normalizer.py new file mode 100644 index 00000000..6717b069 --- /dev/null +++ b/tradein-mvp/backend/app/services/scrapers/repair_state_normalizer.py @@ -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 diff --git a/tradein-mvp/backend/data/sql/073_normalize_repair_state.sql b/tradein-mvp/backend/data/sql/073_normalize_repair_state.sql new file mode 100644 index 00000000..cf3d77ec --- /dev/null +++ b/tradein-mvp/backend/data/sql/073_normalize_repair_state.sql @@ -0,0 +1,45 @@ +-- 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 + +BEGIN; + +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; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_avito_detail_parse.py b/tradein-mvp/backend/tests/test_avito_detail_parse.py index ffd1dd34..dbec6cbe 100644 --- a/tradein-mvp/backend/tests/test_avito_detail_parse.py +++ b/tradein-mvp/backend/tests/test_avito_detail_parse.py @@ -54,7 +54,7 @@ def test_minimal_parse() -> None: assert result.floor == 20 assert result.total_floors == 26 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.mortgage_available is True assert result.house_type == "monolith" diff --git a/tradein-mvp/backend/tests/test_repair_state_normalizer.py b/tradein-mvp/backend/tests/test_repair_state_normalizer.py new file mode 100644 index 00000000..300dc741 --- /dev/null +++ b/tradein-mvp/backend/tests/test_repair_state_normalizer.py @@ -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