fix(scrapers): scope+stem domrf status classification, fix продано/продана mismatch (#1609)
Some checks failed
CI / changes (push) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m39s
CI / backend-tests (pull_request) Failing after 8m48s

Replace whole-HTML re.search for status with a 3-level block-scoped strategy:
1. CSS badge classes (status/badge/tag/chip/label) — highest precision.
2. Proximity to block labelled «Статус» via _find_text_near.
3. Full blocks scan where sold/reserved keywords always beat free —
   preventing «в продаже» from nav/similar-flats sections from
   misclassifying sold flats as free.

Add _STATUS_KW_RE and _classify_status_kw at module level with full
morphological coverage: продан/продана/продано, реализован[аоы]?,
забронирован[аоы]?, свободн[аоы]?.

Add 31 tests in test_domrf_catalog_parse.py covering all three
extraction levels plus regression for fem. word-form «Квартира продана».
This commit is contained in:
bot-backend 2026-06-17 20:40:49 +03:00
parent 18da92ccc7
commit 4f0cff92ec
2 changed files with 337 additions and 22 deletions

View file

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

View file

@ -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 (
"<!doctype html><html><body>"
# Навигационный блок — появляется раньше статуса в документе
f'<nav><a href="/catalog">Все квартиры</a><span>{nav_text}</span></nav>'
# Статус-бейдж с CSS-классом "status-badge"
f'<div class="status-badge">{badge_text}</div>'
"<p>Площадь 45 м²</p>"
"</body></html>"
)
def _html_with_label(label: str, value: str, nav_text: str = "") -> str:
"""HTML с лейблом «Статус» и значением в следующем блоке."""
nav = f"<span>{nav_text}</span>" if nav_text else ""
return (
"<!doctype html><html><body>"
f"{nav}"
f"<span>{label}</span>"
f"<span>{value}</span>"
"</body></html>"
)
def _html_blocks_only(blocks: list[tuple[str, str]]) -> str:
"""Генерирует HTML из списка (css_class, text) без badge-классов."""
parts = ["<!doctype html><html><body>"]
for cls, text in blocks:
parts.append(f'<div class="{cls}">{text}</div>')
parts.append("</body></html>")
return "".join(parts)
# ── Уровень 1: badge CSS-класс ────────────────────────────────────────────────
def test_status_badge_sold_wins_over_nav_free() -> None:
"""Проданная квартира: badge=продана, nav=в продаже → status=sold (#1609 core bug)."""
html = _html_with_badge("Продана", nav_text="другие квартиры в продаже")
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_SOLD
def test_status_badge_prodano() -> None:
"""Морфовариант 'продано' в badge → sold."""
html = _html_with_badge("Продано")
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_SOLD
def test_status_badge_reserved() -> None:
"""Забронирована в badge → reserved."""
html = _html_with_badge("Забронирована")
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_RESERVED
def test_status_badge_free() -> None:
"""В продаже в badge (нет nav-коллизии) → free."""
html = _html_with_badge("В продаже", nav_text="")
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_FREE
def test_status_badge_realizovana() -> None:
"""'Реализована' (синоним sold) в badge → sold."""
html = _html_with_badge("Реализована")
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_SOLD
# ── Уровень 2: proximity "Статус" ────────────────────────────────────────────
def test_status_label_proximity_sold() -> None:
"""Блок 'Статус' → следующий блок 'Продана' → sold."""
html = _html_with_label("Статус", "Продана")
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_SOLD
def test_status_label_proximity_free() -> None:
"""Блок 'Статус' → следующий блок 'В продаже' → free."""
html = _html_with_label("Статус", "В продаже")
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_FREE
# ── Уровень 3: blocks scan, sold/reserved > free ─────────────────────────────
def test_status_level3_sold_beats_earlier_free() -> None:
"""Без badge-класса и без label: free-блок раньше, sold-блок позже → sold побеждает."""
# Нет badge-класса: используем нейтральные классы
html = _html_blocks_only(
[
("nav-link", "другие квартиры в продаже"), # free — но это нав
("info-card", "Квартира продана"), # sold — специфичнее
]
)
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_SOLD
def test_status_level3_reserved_beats_earlier_free() -> None:
"""Без badge: free раньше, reserved позже → reserved побеждает."""
html = _html_blocks_only(
[
("nav", "в продаже"),
("info", "Забронировано"),
]
)
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_RESERVED
def test_status_level3_free_only() -> None:
"""Без badge: только free-блок (без sold/reserved) → free."""
html = _html_blocks_only(
[
("info", "В продаже"),
]
)
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_FREE
def test_status_no_keywords() -> None:
"""HTML без статусных слов → ключ 'status' отсутствует в результате."""
html = "<!doctype html><html><body><p>Площадь 45 м²</p></body></html>"
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 = (
"<!doctype html><html><body>"
"<header><a>Все квартиры в продаже</a></header>"
'<div class="flat-status-tag">Квартира продана</div>'
"</body></html>"
)
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_SOLD
def test_full_page_sold_flat_no_false_free() -> None:
"""Реалистичный HTML проданной квартиры: навигация 'в продаже', бейдж 'продано'."""
html = (
"<!doctype html><html><head><title>Квартира</title></head><body>"
"<nav>"
' <a href="/catalog">Каталог квартир в продаже</a>'
' <span class="breadcrumb">Квартиры в продаже</span>'
"</nav>"
'<main class="flat-page">'
' <h1 class="flat-title">Квартира 2-комнатная</h1>'
' <div class="price-block">4 500 000 ₽</div>'
' <div class="status-label">Продано</div>'
' <div class="flat-info">Площадь 55 м² Подъезд 2</div>'
"</main>"
'<section class="similar">'
" <h2>Похожие квартиры в продаже</h2>"
"</section>"
"</body></html>"
)
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_SOLD
# Цена тоже должна распознаться
assert result.get("price_rub") == 4_500_000