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
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:
parent
18da92ccc7
commit
4f0cff92ec
2 changed files with 337 additions and 22 deletions
|
|
@ -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")):
|
||||
# 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
|
||||
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
|
||||
|
||||
# Finishing type: "Предчистовая", "Чистовая", "Без отделки", "Под ключ"
|
||||
finishing_match = re.search(
|
||||
|
|
|
|||
236
backend/tests/services/scrapers/test_domrf_catalog_parse.py
Normal file
236
backend/tests/services/scrapers/test_domrf_catalog_parse.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue