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_SOLD = "sold"
|
||||||
STATUS_RESERVED = "reserved"
|
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 ─────────────────────────────────────────────────────────────
|
# ── HTML fetching ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -162,8 +189,20 @@ class _TextCollector(HTMLParser):
|
||||||
# рассинхрон стека, текст блоков теряется (issue #1608).
|
# рассинхрон стека, текст блоков теряется (issue #1608).
|
||||||
_VOID_TAGS = frozenset(
|
_VOID_TAGS = frozenset(
|
||||||
{
|
{
|
||||||
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
"area",
|
||||||
"link", "meta", "param", "source", "track", "wbr",
|
"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:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Status: ищем характерные слова рядом с "статус" или в badge.
|
# Status extraction (issue #1609 fix).
|
||||||
# Морфоварианты продан[ао]?/забронирован[ао]? — типовая "Квартира продана"
|
#
|
||||||
# (ж.р.) раньше не матчилась, status уходил во free (issue #1609).
|
# Проблема: re.search по всему HTML находит "в продаже" из навигации
|
||||||
# NOTE: классификация по первому совпадению во ВСЁМ HTML остаётся хрупкой —
|
# ("другие квартиры в продаже") РАНЬШЕ, чем реальный статус-бейдж →
|
||||||
# 'в продаже' из блока 'другие квартиры в продаже' может дать ложный free.
|
# проданная/забронированная квартира ошибочно маркируется как free.
|
||||||
# Полный фикс (якорь к статус-бейджу / исключение секций "похожие") требует
|
#
|
||||||
# знания DOM-структуры страницы каталога — см. отчёт (needs-leha).
|
# Стратегия (3 уровня, от точного к широкому):
|
||||||
status_match = re.search(
|
#
|
||||||
r"(в\s*продаже|свободн[ао]|free|продан[ао]?|sold|забронирован[ао]?|бронь|reserved)",
|
# 1. Блоки с CSS-классом, содержащим "status"/"badge"/"tag"/"chip"/"label" —
|
||||||
html,
|
# типовые имена для статус-бейджей в Next.js/Tailwind.
|
||||||
re.IGNORECASE | re.UNICODE,
|
# 2. Ближайший блок ПОСЛЕ лейбла "Статус" (proximity-поиск в blocks).
|
||||||
)
|
# 3. Полный перебор всех blocks: sold/reserved побеждают free
|
||||||
if status_match:
|
# (они никогда не появляются в навигации, в отличие от "в продаже").
|
||||||
s = status_match.group(1).lower()
|
#
|
||||||
if any(kw in s for kw in ("продаже", "свободн", "free")):
|
# Морфоварианты: продан / продана / продано / продан → продан[аоы]?
|
||||||
result["status"] = STATUS_FREE
|
# Аналогично: забронирован[аоы]? / реализован[аоы]? (синоним для sold).
|
||||||
elif any(kw in s for kw in ("продан", "sold")):
|
|
||||||
result["status"] = STATUS_SOLD
|
# Уровень 1: блок с CSS-классом типа badge/status/tag
|
||||||
elif any(kw in s for kw in ("бронь", "забронирован", "reserved")):
|
status_from_badge: str | None = None
|
||||||
result["status"] = STATUS_RESERVED
|
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 type: "Предчистовая", "Чистовая", "Без отделки", "Под ключ"
|
||||||
finishing_match = re.search(
|
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