From 312df8536a205939f54d3a28259728a7a6ed781f Mon Sep 17 00:00:00 2001 From: Light1YT Date: Thu, 28 May 2026 15:37:34 +0000 Subject: [PATCH] feat(tradein): infer repair_state from listing description text (#622) (#632) --- .../app/services/scrapers/avito_detail.py | 5 + .../backend/app/services/scrapers/cian.py | 9 +- .../app/services/scrapers/cian_detail.py | 16 ++- .../scrapers/repair_state_normalizer.py | 93 ++++++++++++++ .../app/services/scrapers/yandex_detail.py | 9 ++ .../app/services/scrapers/yandex_realty.py | 6 + ...backfill_repair_state_from_description.sql | 44 +++++++ .../backend/tests/test_avito_detail_parse.py | 48 +++++++ tradein-mvp/backend/tests/test_cian_detail.py | 29 ++++- .../tests/test_repair_state_normalizer.py | 120 +++++++++++++++++- 10 files changed, 370 insertions(+), 9 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/075_backfill_repair_state_from_description.sql diff --git a/tradein-mvp/backend/app/services/scrapers/avito_detail.py b/tradein-mvp/backend/app/services/scrapers/avito_detail.py index 92ea9cdb..c6373df6 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito_detail.py +++ b/tradein-mvp/backend/app/services/scrapers/avito_detail.py @@ -34,6 +34,7 @@ from sqlalchemy.orm import Session from app.services.scrapers.avito import _clean_address from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError +from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text logger = logging.getLogger(__name__) @@ -403,6 +404,10 @@ def parse_detail_html(html: str, source_url: str) -> DetailEnrichment: if "закрытая территория" in val.lower(): closed_yard = True + # ── Fallback: repair_state из описания, если структурное поле пустое (#622) ── + if repair_state is None and description: + repair_state = infer_repair_state_from_text(description) + # ── Domoteka ───────────────────────────────────────────────────────────── owners_count: int | None = None owners_at_least: bool | None = None diff --git a/tradein-mvp/backend/app/services/scrapers/cian.py b/tradein-mvp/backend/app/services/scrapers/cian.py index fc833607..817fbeb1 100644 --- a/tradein-mvp/backend/app/services/scrapers/cian.py +++ b/tradein-mvp/backend/app/services/scrapers/cian.py @@ -26,7 +26,10 @@ 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 +from app.services.scrapers.repair_state_normalizer import ( + infer_repair_state_from_text, + normalize_repair_state, +) logger = logging.getLogger(__name__) @@ -372,6 +375,10 @@ class CianScraper(BaseScraper): description.lower().encode("utf-8", errors="replace") ).hexdigest()[:32] + # Fallback: repair_state из описания, если decoration отсутствует (#622) + if repair_state is None and description: + repair_state = infer_repair_state_from_text(description) + # ── Фото ───────────────────────────────────────────────────────── photo_urls: list[str] = [ p["fullUrl"] diff --git a/tradein-mvp/backend/app/services/scrapers/cian_detail.py b/tradein-mvp/backend/app/services/scrapers/cian_detail.py index f870113b..9054193f 100644 --- a/tradein-mvp/backend/app/services/scrapers/cian_detail.py +++ b/tradein-mvp/backend/app/services/scrapers/cian_detail.py @@ -21,7 +21,10 @@ 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.repair_state_normalizer import ( + infer_repair_state_from_text, + normalize_repair_state, +) from app.services.scrapers.snapshot_writer import upsert_listing_snapshot logger = logging.getLogger(__name__) @@ -162,8 +165,15 @@ def _extract_repair_type(offer: dict[str, Any]) -> str | None: 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")) + """Нормализованный enum из offer.repairType → needs_repair/standard/good/excellent. + + Если структурное поле repairType пустое — fallback на инференс из описания (#622). + Структурное значение всегда в приоритете. + """ + state = normalize_repair_state(offer.get("repairType")) + if state is None: + state = infer_repair_state_from_text(offer.get("description")) + return state def _parse_float(value: Any) -> float | None: diff --git a/tradein-mvp/backend/app/services/scrapers/repair_state_normalizer.py b/tradein-mvp/backend/app/services/scrapers/repair_state_normalizer.py index 6717b069..8693347f 100644 --- a/tradein-mvp/backend/app/services/scrapers/repair_state_normalizer.py +++ b/tradein-mvp/backend/app/services/scrapers/repair_state_normalizer.py @@ -17,6 +17,7 @@ from __future__ import annotations import logging +import re logger = logging.getLogger(__name__) @@ -66,3 +67,95 @@ def normalize_repair_state(raw: str | None) -> str | None: 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 diff --git a/tradein-mvp/backend/app/services/scrapers/yandex_detail.py b/tradein-mvp/backend/app/services/scrapers/yandex_detail.py index 0d4c6101..6ad3313f 100644 --- a/tradein-mvp/backend/app/services/scrapers/yandex_detail.py +++ b/tradein-mvp/backend/app/services/scrapers/yandex_detail.py @@ -17,6 +17,7 @@ from selectolax.parser import HTMLParser, Node from app.services.scraper_settings import get_scraper_delay from app.services.scrapers.base import BaseScraper +from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text from app.services.scrapers.yandex_helpers import ( RE_AGENCY_FOUNDED, RE_AGENCY_OBJECTS, @@ -66,6 +67,10 @@ class DetailEnrichment(BaseModel): # Sale type — raw RU phrase ('свободная продажа' / 'альтернативная') sale_type_text: str | None = None + # Repair state — enum, inferred from description text (#622). + # Yandex не отдаёт структурного поля ремонта, поэтому только инференс. + repair_state: str | None = None + # Stats views_total: int | None = None publish_date: date | None = None @@ -210,6 +215,9 @@ class YandexDetailScraper(BaseScraper): # --- Description section (after H2 "Описание") --- description = _find_section_text(tree, "Описание") + # --- Repair state: инференс из описания (#622), Yandex без структурного поля --- + repair_state = infer_repair_state_from_text(description or summary_text) + # --- Address --- address = _extract_address(summary_text) @@ -253,6 +261,7 @@ class YandexDetailScraper(BaseScraper): address=address, description=description, sale_type_text=sale_type_text, + repair_state=repair_state, views_total=views_total, publish_date=publish_date, publish_date_relative=publish_date_relative, diff --git a/tradein-mvp/backend/app/services/scrapers/yandex_realty.py b/tradein-mvp/backend/app/services/scrapers/yandex_realty.py index 203e2ce2..c4e76dbd 100644 --- a/tradein-mvp/backend/app/services/scrapers/yandex_realty.py +++ b/tradein-mvp/backend/app/services/scrapers/yandex_realty.py @@ -21,6 +21,7 @@ from selectolax.parser import HTMLParser, Node from app.services.scraper_settings import get_scraper_delay from app.services.scrapers.base import BaseScraper, ScrapedLot +from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text from app.services.scrapers.yandex_helpers import ( RE_FLOOR, RE_JK_ID, @@ -164,6 +165,10 @@ class YandexRealtyScraper(BaseScraper): bargain = "торг" in text.lower() listing_date = parse_listing_date(text) + # Repair state — инференс из текста карточки (#622). Yandex SERP не + # отдаёт структурного поля ремонта; берём сигнал из текста сниппета. + repair_state = infer_repair_state_from_text(text) + # Address — prefer the full address container (street + house number); # fall back to the street aggregator link (street name only) if absent. # See _extract_address for details. @@ -199,6 +204,7 @@ class YandexRealtyScraper(BaseScraper): area_m2=area_m2, floor=floor, total_floors=total_floors, + repair_state=repair_state, price_rub=price_rub, price_per_m2=price_per_m2, bargain_allowed=bargain, diff --git a/tradein-mvp/backend/data/sql/075_backfill_repair_state_from_description.sql b/tradein-mvp/backend/data/sql/075_backfill_repair_state_from_description.sql new file mode 100644 index 00000000..0aba443c --- /dev/null +++ b/tradein-mvp/backend/data/sql/075_backfill_repair_state_from_description.sql @@ -0,0 +1,44 @@ +-- Migration 075: backfill listings.repair_state из текста description (#622) +-- +-- Покрытие структурного repair_state низкое (avito ~1% / cian ~5% / yandex 0%), +-- что недостаточно для data-derived repair-коэффициентов (#7 v2). Эта миграция +-- извлекает состояние ремонта из текста описания регэкспами по русским фразам — +-- тот же набор паттернов, что и repair_state_normalizer.infer_repair_state_from_text(). +-- +-- Порядок UPDATE'ов ВАЖЕН (от сильного сигнала к слабому): +-- excellent → good → needs_repair → standard. +-- Каждый шаг трогает только оставшиеся NULL-строки (WHERE repair_state IS NULL), +-- поэтому «дизайнерский ремонт после черновой» получит excellent, а не needs_repair. +-- +-- Idempotent: повторный прогон ничего не меняет — все совпавшие строки уже +-- получили не-NULL значение и отсекаются предикатом repair_state IS NULL. +-- Не фабрикуем: строки без совпадения паттерна остаются NULL. +-- Issue: #622 + +BEGIN; + +-- ── excellent: дизайнерский / премиальный / эксклюзивный / авторский ремонт ── +UPDATE listings +SET repair_state = 'excellent' +WHERE repair_state IS NULL + AND description ~* '(дизайнерск(ий|ого|ом|ая)\s+ремонт|дизайнерск(ая|ой)\s+отделк|премиальн\w*\s+ремонт|эксклюзивн\w*\s+ремонт|авторск\w*\s+ремонт)'; + +-- ── good: евроремонт / отличный / свежий / современный / качественный ремонт ─ +UPDATE listings +SET repair_state = 'good' +WHERE repair_state IS NULL + AND description ~* '(евроремонт|евро[\s-]?ремонт|отличн\w*\s+ремонт|свеж\w*\s+ремонт|современн\w*\s+ремонт|качественн\w*\s+ремонт|ремонт\s+в\s+отличн)'; + +-- ── needs_repair: без отделки / черновая / требует ремонта / под ремонт ────── +UPDATE listings +SET repair_state = 'needs_repair' +WHERE repair_state IS NULL + AND description ~* '(без\s+отделк|без\s+ремонт|черновая\s+отделк|черновой\s+ремонт|требует(ся)?\s+ремонт|требует(ся)?\s+космет|нужен\s+ремонт|под\s+ремонт|под\s+чистов\w+\s+отделк|предчистов)'; + +-- ── standard: косметический / обычный / жилое (хорошее) состояние ──────────── +UPDATE listings +SET repair_state = 'standard' +WHERE repair_state IS NULL + AND description ~* '(косметическ\w*\s+ремонт|косметическ\w*\s+отделк|обычн\w*\s+ремонт|жил(ое|ом)\s+состоян|хорош\w*\s+состоян)'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_avito_detail_parse.py b/tradein-mvp/backend/tests/test_avito_detail_parse.py index dbec6cbe..a2808e39 100644 --- a/tradein-mvp/backend/tests/test_avito_detail_parse.py +++ b/tradein-mvp/backend/tests/test_avito_detail_parse.py @@ -173,3 +173,51 @@ def test_photo_urls_extracted() -> None: def test_source_url_preserved() -> None: result = parse_detail_html(MINIMAL_HTML, SOURCE_URL) assert result.source_url == SOURCE_URL + + +# ── repair_state fallback из описания (#622) ────────────────────────────────── + + +def test_repair_state_structured_wins_over_description() -> None: + """Структурное «Ремонт: евро» (good) в приоритете даже при сигнале в тексте.""" + html = """ + +
№ 3333333333
+ 6 000 000 ₽ +
+ +
+
Квартира без отделки, под чистовую
+ + """ + result = parse_detail_html(html, "https://www.avito.ru/test_3333333333") + assert result.repair_state == "good" # структурное поле, не needs_repair из текста + + +def test_repair_state_inferred_from_description_when_structured_absent() -> None: + """Нет блока «Ремонт» → инференс из описания (#622).""" + html = """ + +
№ 4444444444
+ 8 000 000 ₽ +
+ +
+
Сделан свежий евроремонт, заезжай и живи
+ + """ + result = parse_detail_html(html, "https://www.avito.ru/test_4444444444") + assert result.repair_state == "good" + + +def test_repair_state_none_when_no_signal() -> None: + """Нет структурного поля и нет сигнала в тексте → None (не фабрикуем).""" + html = """ + +
№ 5555555555
+ 9 000 000 ₽ +
Светлая квартира рядом с метро
+ + """ + result = parse_detail_html(html, "https://www.avito.ru/test_5555555555") + assert result.repair_state is None diff --git a/tradein-mvp/backend/tests/test_cian_detail.py b/tradein-mvp/backend/tests/test_cian_detail.py index 22fc8dc2..01cdabc6 100644 --- a/tradein-mvp/backend/tests/test_cian_detail.py +++ b/tradein-mvp/backend/tests/test_cian_detail.py @@ -9,11 +9,11 @@ from app.services.scrapers.cian_detail import ( DetailEnrichment, _extract_agent_profile, _extract_price_changes, + _extract_repair_state, _parse_views, fetch_detail, ) - # ── _parse_views ────────────────────────────────────────────────────────────── @@ -37,6 +37,33 @@ def test_parse_views_comma_formatted(): assert _parse_views("1,234") == 1234 +# ── _extract_repair_state — структурное поле в приоритете, fallback на текст (#622) ── + + +def test_repair_state_structured_wins_over_description(): + """repairType присутствует → используется он, описание игнорируется.""" + offer = { + "repairType": "cosmetic", # → standard + "description": "Шикарный дизайнерский ремонт", # инференс дал бы excellent + } + assert _extract_repair_state(offer) == "standard" + + +def test_repair_state_falls_back_to_description_when_structured_missing(): + """repairType отсутствует → инференс из описания.""" + offer = { + "repairType": None, + "description": "Сделан свежий евроремонт, заезжай и живи", + } + assert _extract_repair_state(offer) == "good" + + +def test_repair_state_none_when_both_missing(): + """Нет structured и нет сигнала в тексте → None (не фабрикуем).""" + offer = {"description": "Светлая квартира рядом с парком"} + assert _extract_repair_state(offer) is None + + # ── _extract_price_changes ──────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_repair_state_normalizer.py b/tradein-mvp/backend/tests/test_repair_state_normalizer.py index 300dc741..67363ad3 100644 --- a/tradein-mvp/backend/tests/test_repair_state_normalizer.py +++ b/tradein-mvp/backend/tests/test_repair_state_normalizer.py @@ -1,14 +1,19 @@ -"""Unit-тесты для repair_state_normalizer.normalize_repair_state() (#621). +"""Unit-тесты для repair_state_normalizer (#621, #622). -Покрывают все известные raw-значения → enum mapping, -idempotency для уже нормализованных значений и None-guard. +Покрывают: +- normalize_repair_state(): все known raw → enum, idempotency, None-guard (#621). +- infer_repair_state_from_text(): русские фразы → enum, приоритет сигналов, + unknown → None, None-guard (#622). """ import logging import pytest -from app.services.scrapers.repair_state_normalizer import normalize_repair_state +from app.services.scrapers.repair_state_normalizer import ( + infer_repair_state_from_text, + normalize_repair_state, +) # ── needs_repair ───────────────────────────────────────────────────────────── @@ -69,3 +74,110 @@ def test_unknown_raw_returns_none(caplog: pytest.LogCaptureFixture) -> None: result = normalize_repair_state("whitebox") assert result is None assert "whitebox" in caplog.text + + +# ═══════════════════════════════════════════════════════════════════════════════ +# infer_repair_state_from_text() — извлечение из описания (#622) +# ═══════════════════════════════════════════════════════════════════════════════ + + +# ── excellent ──────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "text", + [ + "Сделан дизайнерский ремонт с авторской мебелью", + "Квартира с дизайнерской отделкой премиум-класса", + "Премиальный ремонт от застройщика", + "Эксклюзивный ремонт по индивидуальному проекту", + "Авторский ремонт, всё новое", + ], +) +def test_infer_excellent(text: str) -> None: + assert infer_repair_state_from_text(text) == "excellent" + + +# ── good ───────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "text", + [ + "Свежий евроремонт, заезжай и живи", + "Сделан евро-ремонт в 2024 году", + "Современный ремонт, всё в отличном состоянии — ремонт в отличном", + "Отличный ремонт, продаётся с мебелью", + "Качественный ремонт от собственника", + ], +) +def test_infer_good(text: str) -> None: + assert infer_repair_state_from_text(text) == "good" + + +# ── needs_repair ───────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "text", + [ + "Квартира без отделки, под чистовую отделку", + "Сдаётся без ремонта, черновая отделка", + "Черновой ремонт, нужен ремонт", + "Требует ремонта, торг уместен", + "Требуется косметический ремонт", + "Квартира под ремонт, цена снижена", + "Предчистовая отделка от застройщика", + ], +) +def test_infer_needs_repair(text: str) -> None: + assert infer_repair_state_from_text(text) == "needs_repair" + + +# ── standard ───────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "text", + [ + "Косметический ремонт, чисто и уютно", + "Сделана косметическая отделка", + "Обычный ремонт, всё работает", + "Квартира в жилом состоянии", + "Хорошее состояние, заезжай и живи", + ], +) +def test_infer_standard(text: str) -> None: + assert infer_repair_state_from_text(text) == "standard" + + +# ── приоритет сигналов (порядок excellent > good > needs_repair > standard) ─── + + +def test_infer_excellent_beats_needs_repair() -> None: + """Дизайнерский ремонт после черновой → excellent, не needs_repair.""" + text = "Была черновая отделка, теперь сделан дизайнерский ремонт" + assert infer_repair_state_from_text(text) == "excellent" + + +def test_infer_good_beats_standard() -> None: + """Евроремонт + косметический → good (сильнее).""" + text = "Косметический ремонт обновлён до евроремонта" + assert infer_repair_state_from_text(text) == "good" + + +# ── None / unknown guard ───────────────────────────────────────────────────── + + +def test_infer_none_returns_none() -> None: + assert infer_repair_state_from_text(None) is None + + +def test_infer_empty_returns_none() -> None: + assert infer_repair_state_from_text("") is None + + +def test_infer_unknown_text_returns_none() -> None: + """Текст без repair-сигналов → None (не фабрикуем).""" + text = "Светлая квартира рядом с метро, развитая инфраструктура, школа во дворе" + assert infer_repair_state_from_text(text) is None