This commit is contained in:
parent
4e4762f234
commit
d842811390
8 changed files with 212 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] = {
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
45
tradein-mvp/backend/data/sql/073_normalize_repair_state.sql
Normal file
45
tradein-mvp/backend/data/sql/073_normalize_repair_state.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
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