diff --git a/backend/app/services/scrapers/domrf_catalog.py b/backend/app/services/scrapers/domrf_catalog.py index 76d3a04c..e1306a61 100644 --- a/backend/app/services/scrapers/domrf_catalog.py +++ b/backend/app/services/scrapers/domrf_catalog.py @@ -71,6 +71,33 @@ STATUS_FREE = "free" STATUS_SOLD = "sold" STATUS_RESERVED = "reserved" +# Паттерны для извлечения статуса (issue #1609). +# Все морфоварианты: продан/продана/продано, забронирован[аоы]?, реализован[аоы]?. +_STATUS_KW_RE = re.compile( + r"(в\s*продаже|свободн[аоы]?|free" + r"|продан[аоы]?|реализован[аоы]?|sold" + r"|забронирован[аоы]?|бронь|reserved)", + re.IGNORECASE | re.UNICODE, +) +# CSS-классы, характерные для статус-бейджей в Next.js/Tailwind. +_STATUS_BADGE_CLS_RE = re.compile(r"status|badge|tag|chip|label", re.IGNORECASE) + + +def _classify_status_kw(matched_text: str) -> str | None: + """Классифицировать совпавшее ключевое слово в STATUS_*. + + sold/reserved проверяются ПЕРВЫМИ — они специфичнее "в продаже" и никогда + не появляются в навигационных блоках страницы, в отличие от «в продаже». + """ + s = matched_text.lower() + if any(kw in s for kw in ("продан", "реализован", "sold")): + return STATUS_SOLD + if any(kw in s for kw in ("бронь", "забронирован", "reserved")): + return STATUS_RESERVED + if any(kw in s for kw in ("продаже", "свободн", "free")): + return STATUS_FREE + return None + # ── HTML fetching ───────────────────────────────────────────────────────────── @@ -162,8 +189,20 @@ class _TextCollector(HTMLParser): # рассинхрон стека, текст блоков теряется (issue #1608). _VOID_TAGS = frozenset( { - "area", "base", "br", "col", "embed", "hr", "img", "input", - "link", "meta", "param", "source", "track", "wbr", + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", } ) @@ -284,26 +323,66 @@ def parse_catalog_flat(html: str) -> dict[str, Any]: except ValueError: pass - # Status: ищем характерные слова рядом с "статус" или в badge. - # Морфоварианты продан[ао]?/забронирован[ао]? — типовая "Квартира продана" - # (ж.р.) раньше не матчилась, status уходил во free (issue #1609). - # NOTE: классификация по первому совпадению во ВСЁМ HTML остаётся хрупкой — - # 'в продаже' из блока 'другие квартиры в продаже' может дать ложный free. - # Полный фикс (якорь к статус-бейджу / исключение секций "похожие") требует - # знания DOM-структуры страницы каталога — см. отчёт (needs-leha). - status_match = re.search( - r"(в\s*продаже|свободн[ао]|free|продан[ао]?|sold|забронирован[ао]?|бронь|reserved)", - html, - re.IGNORECASE | re.UNICODE, - ) - if status_match: - s = status_match.group(1).lower() - if any(kw in s for kw in ("продаже", "свободн", "free")): - result["status"] = STATUS_FREE - elif any(kw in s for kw in ("продан", "sold")): - result["status"] = STATUS_SOLD - elif any(kw in s for kw in ("бронь", "забронирован", "reserved")): - result["status"] = STATUS_RESERVED + # Status extraction (issue #1609 fix). + # + # Проблема: re.search по всему HTML находит "в продаже" из навигации + # ("другие квартиры в продаже") РАНЬШЕ, чем реальный статус-бейдж → + # проданная/забронированная квартира ошибочно маркируется как free. + # + # Стратегия (3 уровня, от точного к широкому): + # + # 1. Блоки с CSS-классом, содержащим "status"/"badge"/"tag"/"chip"/"label" — + # типовые имена для статус-бейджей в Next.js/Tailwind. + # 2. Ближайший блок ПОСЛЕ лейбла "Статус" (proximity-поиск в blocks). + # 3. Полный перебор всех blocks: sold/reserved побеждают free + # (они никогда не появляются в навигации, в отличие от "в продаже"). + # + # Морфоварианты: продан / продана / продано / продан → продан[аоы]? + # Аналогично: забронирован[аоы]? / реализован[аоы]? (синоним для sold). + + # Уровень 1: блок с CSS-классом типа badge/status/tag + status_from_badge: str | None = None + for cls, block_text in blocks: + if _STATUS_BADGE_CLS_RE.search(cls): + m = _STATUS_KW_RE.search(block_text) + if m: + status_from_badge = _classify_status_kw(m.group(1)) + if status_from_badge: + break + + if status_from_badge: + result["status"] = status_from_badge + else: + # Уровень 2: блок сразу после лейбла "Статус" + status_label_value = _find_text_near(blocks, r"^статус$") + status_from_label: str | None = None + if status_label_value: + m2 = _STATUS_KW_RE.search(status_label_value) + if m2: + status_from_label = _classify_status_kw(m2.group(1)) + + if status_from_label: + result["status"] = status_from_label + else: + # Уровень 3: перебор всех blocks — sold/reserved побеждают free. + # Если встретили sold/reserved — сразу break (не появляются в nav). + # Если только free — запоминаем как кандидата (может быть перебит). + free_candidate: bool = False + sold_reserved_found: str | None = None + for _cls, block_text in blocks: + m3 = _STATUS_KW_RE.search(block_text) + if not m3: + continue + classified = _classify_status_kw(m3.group(1)) + if classified in (STATUS_SOLD, STATUS_RESERVED): + sold_reserved_found = classified + break # точнее nav-текстов, дальше не ищем + if classified == STATUS_FREE: + free_candidate = True # продолжаем — вдруг sold встретится позже + if sold_reserved_found: + result["status"] = sold_reserved_found + elif free_candidate: + result["status"] = STATUS_FREE # Finishing type: "Предчистовая", "Чистовая", "Без отделки", "Под ключ" finishing_match = re.search( diff --git a/backend/tests/services/scrapers/test_domrf_catalog_parse.py b/backend/tests/services/scrapers/test_domrf_catalog_parse.py new file mode 100644 index 00000000..c75f3fdc --- /dev/null +++ b/backend/tests/services/scrapers/test_domrf_catalog_parse.py @@ -0,0 +1,236 @@ +"""Тесты для parse_catalog_flat() из domrf_catalog.py (issue #1609). + +Покрывает: + - _classify_status_kw — классификация ключевых слов + - Статус: sold (все морфоварианты: продан/продана/продано/реализована) + - Статус: reserved (забронирована/забронировано/бронь) + - Статус: free (в продаже/свободна) + - Уровень 1: badge-класс CSS → sold берётся из badge, а не из nav "в продаже" + - Уровень 2: proximity "Статус" → следующий блок + - Уровень 3: sold/reserved побеждают free, даже если free встретился раньше в blocks + - Нет статуса → result не содержит ключ "status" +""" + +from __future__ import annotations + +import pytest + +from app.services.scrapers.domrf_catalog import ( + STATUS_FREE, + STATUS_RESERVED, + STATUS_SOLD, + _classify_status_kw, + parse_catalog_flat, +) + +# ── _classify_status_kw ─────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "text,expected", + [ + # sold — все морфоварианты + ("продан", STATUS_SOLD), + ("продана", STATUS_SOLD), + ("продано", STATUS_SOLD), + ("Квартира продана", STATUS_SOLD), + ("ПРОДАНО", STATUS_SOLD), + ("реализована", STATUS_SOLD), + ("реализовано", STATUS_SOLD), + ("sold", STATUS_SOLD), + # reserved + ("забронирована", STATUS_RESERVED), + ("забронировано", STATUS_RESERVED), + ("бронь", STATUS_RESERVED), + ("reserved", STATUS_RESERVED), + # free + ("в продаже", STATUS_FREE), + ("свободна", STATUS_FREE), + ("свободно", STATUS_FREE), + ("free", STATUS_FREE), + # нераспознанные + ("какой-то текст", None), + ("", None), + ], +) +def test_classify_status_kw(text: str, expected: str | None) -> None: + assert _classify_status_kw(text) == expected + + +# ── Вспомогательные конструкторы HTML-фрагментов ────────────────────────────── + + +def _html_with_badge(badge_text: str, nav_text: str = "другие квартиры в продаже") -> str: + """HTML с типичной структурой DOM.РФ: nav-блок + статус-бейдж.""" + return ( + "
" + # Навигационный блок — появляется раньше статуса в документе + f'' + # Статус-бейдж с CSS-классом "status-badge" + f'Площадь 45 м²
" + "" + ) + + +def _html_with_label(label: str, value: str, nav_text: str = "") -> str: + """HTML с лейблом «Статус» и значением в следующем блоке.""" + nav = f"{nav_text}" if nav_text else "" + return ( + "" + f"{nav}" + f"{label}" + f"{value}" + "" + ) + + +def _html_blocks_only(blocks: list[tuple[str, str]]) -> str: + """Генерирует HTML из списка (css_class, text) без badge-классов.""" + parts = [""] + for cls, text in blocks: + parts.append(f'Площадь 45 м²
" + result = parse_catalog_flat(html) + assert "status" not in result + + +# ── Регрессия: продана (ж.р.) не сбивает free-баг ──────────────────────────── + + +def test_prodana_feminine_classified_as_sold() -> None: + """'Квартира продана' (ж.р., feminine) → sold, а не free (issue #1609).""" + # Симулируем реальную страницу: nav содержит "в продаже" раньше + html = ( + "" + "