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:
parent
e72cf87e35
commit
96c09c30ee
10 changed files with 370 additions and 9 deletions
|
|
@ -34,6 +34,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services.scrapers.avito import _clean_address
|
from app.services.scrapers.avito import _clean_address
|
||||||
from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -403,6 +404,10 @@ def parse_detail_html(html: str, source_url: str) -> DetailEnrichment:
|
||||||
if "закрытая территория" in val.lower():
|
if "закрытая территория" in val.lower():
|
||||||
closed_yard = True
|
closed_yard = True
|
||||||
|
|
||||||
|
# ── Fallback: repair_state из описания, если структурное поле пустое (#622) ──
|
||||||
|
if repair_state is None and description:
|
||||||
|
repair_state = infer_repair_state_from_text(description)
|
||||||
|
|
||||||
# ── Domoteka ─────────────────────────────────────────────────────────────
|
# ── Domoteka ─────────────────────────────────────────────────────────────
|
||||||
owners_count: int | None = None
|
owners_count: int | None = None
|
||||||
owners_at_least: bool | None = None
|
owners_at_least: bool | None = None
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,10 @@ 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
|
from app.services.scrapers.repair_state_normalizer import (
|
||||||
|
infer_repair_state_from_text,
|
||||||
|
normalize_repair_state,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -372,6 +375,10 @@ class CianScraper(BaseScraper):
|
||||||
description.lower().encode("utf-8", errors="replace")
|
description.lower().encode("utf-8", errors="replace")
|
||||||
).hexdigest()[:32]
|
).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] = [
|
photo_urls: list[str] = [
|
||||||
p["fullUrl"]
|
p["fullUrl"]
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ 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.repair_state_normalizer import (
|
||||||
|
infer_repair_state_from_text,
|
||||||
|
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__)
|
||||||
|
|
@ -162,8 +165,15 @@ def _extract_repair_type(offer: dict[str, Any]) -> str | None:
|
||||||
|
|
||||||
|
|
||||||
def _extract_repair_state(offer: dict[str, Any]) -> str | None:
|
def _extract_repair_state(offer: dict[str, Any]) -> str | None:
|
||||||
"""Нормализованный enum из offer.repairType → needs_repair/standard/good/excellent."""
|
"""Нормализованный enum из offer.repairType → needs_repair/standard/good/excellent.
|
||||||
return normalize_repair_state(offer.get("repairType"))
|
|
||||||
|
Если структурное поле 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:
|
def _parse_float(value: Any) -> float | None:
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -66,3 +67,95 @@ def normalize_repair_state(raw: str | None) -> str | None:
|
||||||
if result is None:
|
if result is None:
|
||||||
logger.warning("repair_state_normalizer: unknown raw value %r — stored as NULL", raw)
|
logger.warning("repair_state_normalizer: unknown raw value %r — stored as NULL", raw)
|
||||||
return result
|
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
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from selectolax.parser import HTMLParser, Node
|
||||||
|
|
||||||
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
|
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 (
|
from app.services.scrapers.yandex_helpers import (
|
||||||
RE_AGENCY_FOUNDED,
|
RE_AGENCY_FOUNDED,
|
||||||
RE_AGENCY_OBJECTS,
|
RE_AGENCY_OBJECTS,
|
||||||
|
|
@ -66,6 +67,10 @@ class DetailEnrichment(BaseModel):
|
||||||
# Sale type — raw RU phrase ('свободная продажа' / 'альтернативная')
|
# Sale type — raw RU phrase ('свободная продажа' / 'альтернативная')
|
||||||
sale_type_text: str | None = None
|
sale_type_text: str | None = None
|
||||||
|
|
||||||
|
# Repair state — enum, inferred from description text (#622).
|
||||||
|
# Yandex не отдаёт структурного поля ремонта, поэтому только инференс.
|
||||||
|
repair_state: str | None = None
|
||||||
|
|
||||||
# Stats
|
# Stats
|
||||||
views_total: int | None = None
|
views_total: int | None = None
|
||||||
publish_date: date | None = None
|
publish_date: date | None = None
|
||||||
|
|
@ -210,6 +215,9 @@ class YandexDetailScraper(BaseScraper):
|
||||||
# --- Description section (after H2 "Описание") ---
|
# --- Description section (after H2 "Описание") ---
|
||||||
description = _find_section_text(tree, "Описание")
|
description = _find_section_text(tree, "Описание")
|
||||||
|
|
||||||
|
# --- Repair state: инференс из описания (#622), Yandex без структурного поля ---
|
||||||
|
repair_state = infer_repair_state_from_text(description or summary_text)
|
||||||
|
|
||||||
# --- Address ---
|
# --- Address ---
|
||||||
address = _extract_address(summary_text)
|
address = _extract_address(summary_text)
|
||||||
|
|
||||||
|
|
@ -253,6 +261,7 @@ class YandexDetailScraper(BaseScraper):
|
||||||
address=address,
|
address=address,
|
||||||
description=description,
|
description=description,
|
||||||
sale_type_text=sale_type_text,
|
sale_type_text=sale_type_text,
|
||||||
|
repair_state=repair_state,
|
||||||
views_total=views_total,
|
views_total=views_total,
|
||||||
publish_date=publish_date,
|
publish_date=publish_date,
|
||||||
publish_date_relative=publish_date_relative,
|
publish_date_relative=publish_date_relative,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from selectolax.parser import HTMLParser, Node
|
||||||
|
|
||||||
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.repair_state_normalizer import infer_repair_state_from_text
|
||||||
from app.services.scrapers.yandex_helpers import (
|
from app.services.scrapers.yandex_helpers import (
|
||||||
RE_FLOOR,
|
RE_FLOOR,
|
||||||
RE_JK_ID,
|
RE_JK_ID,
|
||||||
|
|
@ -164,6 +165,10 @@ class YandexRealtyScraper(BaseScraper):
|
||||||
bargain = "торг" in text.lower()
|
bargain = "торг" in text.lower()
|
||||||
listing_date = parse_listing_date(text)
|
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);
|
# Address — prefer the full address container (street + house number);
|
||||||
# fall back to the street aggregator link (street name only) if absent.
|
# fall back to the street aggregator link (street name only) if absent.
|
||||||
# See _extract_address for details.
|
# See _extract_address for details.
|
||||||
|
|
@ -199,6 +204,7 @@ class YandexRealtyScraper(BaseScraper):
|
||||||
area_m2=area_m2,
|
area_m2=area_m2,
|
||||||
floor=floor,
|
floor=floor,
|
||||||
total_floors=total_floors,
|
total_floors=total_floors,
|
||||||
|
repair_state=repair_state,
|
||||||
price_rub=price_rub,
|
price_rub=price_rub,
|
||||||
price_per_m2=price_per_m2,
|
price_per_m2=price_per_m2,
|
||||||
bargain_allowed=bargain,
|
bargain_allowed=bargain,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -173,3 +173,51 @@ def test_photo_urls_extracted() -> None:
|
||||||
def test_source_url_preserved() -> None:
|
def test_source_url_preserved() -> None:
|
||||||
result = parse_detail_html(MINIMAL_HTML, SOURCE_URL)
|
result = parse_detail_html(MINIMAL_HTML, SOURCE_URL)
|
||||||
assert result.source_url == 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
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ from app.services.scrapers.cian_detail import (
|
||||||
DetailEnrichment,
|
DetailEnrichment,
|
||||||
_extract_agent_profile,
|
_extract_agent_profile,
|
||||||
_extract_price_changes,
|
_extract_price_changes,
|
||||||
|
_extract_repair_state,
|
||||||
_parse_views,
|
_parse_views,
|
||||||
fetch_detail,
|
fetch_detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── _parse_views ──────────────────────────────────────────────────────────────
|
# ── _parse_views ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -37,6 +37,33 @@ def test_parse_views_comma_formatted():
|
||||||
assert _parse_views("1,234") == 1234
|
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 ────────────────────────────────────────────────────
|
# ── _extract_price_changes ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 logging
|
||||||
|
|
||||||
import pytest
|
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 ─────────────────────────────────────────────────────────────
|
# ── needs_repair ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -69,3 +74,110 @@ def test_unknown_raw_returns_none(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
result = normalize_repair_state("whitebox")
|
result = normalize_repair_state("whitebox")
|
||||||
assert result is None
|
assert result is None
|
||||||
assert "whitebox" in caplog.text
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue