feat(tradein): infer repair_state from listing description text (#622)

Structured repair coverage was too low for data-derived repair coefficients
(#7 v2): avito ~1% / cian ~5% / yandex 0%. When the structured field is absent
we now extract repair state from the listing description via Russian-phrase
regexes (евроремонт / без отделки / черновая / дизайнерский / косметический,
...), centralized in repair_state_normalizer.infer_repair_state_from_text().

Wired as a fallback into avito_detail, cian SERP + cian_detail, and both yandex
SERP and detail parsers — the structured field always wins; only NULLs fall back
to inference. Patterns are checked strongest-first (excellent > good >
needs_repair > standard) so mixed descriptions resolve to the latest state.
Migration 075 backfills existing rows with the same patterns; unknown stays NULL
(no fabrication).
This commit is contained in:
Light1YT 2026-05-28 20:33:06 +05:00
parent e72cf87e35
commit 96c09c30ee
10 changed files with 370 additions and 9 deletions

View file

@ -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

View file

@ -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"]

View file

@ -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:

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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;

View file

@ -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 = """
<html><body>
<div data-marker="item-view/item-id"> 3333333333</div>
<span itemprop="price" content="6000000">6 000 000 </span>
<div data-marker="item-view/item-params">
<ul><li>Ремонт: евро</li></ul>
</div>
<div data-marker="item-view/item-description">Квартира без отделки, под чистовую</div>
</body></html>
"""
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 = """
<html><body>
<div data-marker="item-view/item-id"> 4444444444</div>
<span itemprop="price" content="8000000">8 000 000 </span>
<div data-marker="item-view/item-params">
<ul><li>Количество комнат: 2</li></ul>
</div>
<div data-marker="item-view/item-description">Сделан свежий евроремонт, заезжай и живи</div>
</body></html>
"""
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 = """
<html><body>
<div data-marker="item-view/item-id"> 5555555555</div>
<span itemprop="price" content="9000000">9 000 000 </span>
<div data-marker="item-view/item-description">Светлая квартира рядом с метро</div>
</body></html>
"""
result = parse_detail_html(html, "https://www.avito.ru/test_5555555555")
assert result.repair_state is None

View file

@ -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 ────────────────────────────────────────────────────

View file

@ -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