diff --git a/backend/app/services/scrapers/domrf_catalog.py b/backend/app/services/scrapers/domrf_catalog.py index 7dc417d2..28525496 100644 --- a/backend/app/services/scrapers/domrf_catalog.py +++ b/backend/app/services/scrapers/domrf_catalog.py @@ -77,8 +77,16 @@ STATUS_RESERVED = "reserved" # Паттерны для извлечения статуса (issue #1609). # Все морфоварианты: продан/продана/продано, забронирован[аоы]?, реализован[аоы]?. +# +# Группа `neg` (#2464): без неё отрицательные формы давали ОБРАТНЫЙ статус — +# «нереализована» содержит «реализована» и классифицировалась как sold, +# «не продана» → sold, «не забронирована» → reserved, «не в продаже» → free. +# `\s*` покрывает и слитную приставку («нереализована»), и раздельное «не +# продана»; `\b` перед «не» не даёт зацепиться за хвост другого слова («в цене +# продажи» — «не» внутри «цене» не на границе слова). _STATUS_KW_RE = re.compile( - r"(в\s*продаже|свободн[аоы]?|free" + r"(?P\bне\s*)?" + r"(?Pв\s*продаже|свободн[аоы]?|free" r"|продан[аоы]?|реализован[аоы]?|sold" r"|забронирован[аоы]?|бронь|reserved)", re.IGNORECASE | re.UNICODE, @@ -153,6 +161,26 @@ def _classify_status_kw(matched_text: str) -> str | None: return None +def _status_in_text(text_: str) -> str | None: + """Первый НЕотрицаемый статус-токен в тексте, иначе None. + + Отрицание не переворачивается в противоположный статус, а гасит токен: + «не забронирована» не означает ни sold, ни free, а «не продана» — + вывод, а не факт со страницы. Лучше отсутствие статуса, чем неверный. + + Перебираем ВСЕ вхождения, а не только первое: блок «Квартира не продана. + Статус: в продаже» на первом совпадении дал бы None и был бы пропущен + целиком, хотя настоящий статус в нём есть. + """ + for m in _STATUS_KW_RE.finditer(text_): + if m.group("neg"): + continue + classified = _classify_status_kw(m.group("kw")) + if classified: + return classified + return None + + # ── HTML fetching ───────────────────────────────────────────────────────────── @@ -542,11 +570,9 @@ def parse_catalog_flat(html: str) -> dict[str, Any]: 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 + status_from_badge = _status_in_text(block_text) + if status_from_badge: + break if status_from_badge: result["status"] = status_from_badge @@ -555,9 +581,7 @@ def parse_catalog_flat(html: str) -> dict[str, Any]: 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)) + status_from_label = _status_in_text(status_label_value) if status_from_label: result["status"] = status_from_label @@ -568,10 +592,9 @@ def parse_catalog_flat(html: str) -> dict[str, Any]: 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: + classified = _status_in_text(block_text) + if not classified: continue - classified = _classify_status_kw(m3.group(1)) if classified in (STATUS_SOLD, STATUS_RESERVED): sold_reserved_found = classified break # точнее nav-текстов, дальше не ищем diff --git a/backend/tests/services/scrapers/test_2464_status_negation.py b/backend/tests/services/scrapers/test_2464_status_negation.py new file mode 100644 index 00000000..c2a6fbcb --- /dev/null +++ b/backend/tests/services/scrapers/test_2464_status_negation.py @@ -0,0 +1,111 @@ +"""Отрицательные формы не дают ОБРАТНЫЙ статус продажи (#2464). + +`_STATUS_KW_RE` искал ключевые слова без защиты от отрицания, поэтому русские +отрицательные формы классифицировались наоборот: + + «нереализована» → содержит «реализована» → sold + «не продана» → содержит «продана» → sold + «не забронирована» → содержит «забронирована» → reserved + «не в продаже» → содержит «в продаже» → free + +То есть свободная квартира могла попасть в базу проданной. Отрицание теперь +гасит токен, а не переворачивает его: «не забронирована» не значит ни sold, ни +free, а «не продана → free» — вывод, а не факт со страницы. Лучше отсутствие +статуса, чем неверный. + +Тесты идут через `parse_catalog_flat` (полный путь разбора HTML), а не только +через хелпер: инвертированный статус — это то, что доехало бы до БД. + +Текущий эффект на проде — НУЛЕВОЙ, и это проверено, а не предположено: +`catalog_updated_at` пуст у всех 983 088 строк `domrf_kn_flats`, таска +`scrape_kn_catalog_flats` закомментирована в beat_schedule.py (WAF-cooldown). +Правка предупредительная: при включении пути дефект инвертировал бы статусы молча. +""" + +from __future__ import annotations + +import pytest + +from app.services.scrapers.domrf_catalog import ( + STATUS_FREE, + STATUS_RESERVED, + STATUS_SOLD, + parse_catalog_flat, +) + + +def _html_with_badge(badge_text: str, nav_text: str = "") -> str: + """Тот же каркас, что в test_domrf_catalog_parse.py: nav + статус-бейдж.""" + return ( + "" + f'' + f'
{badge_text}
' + "

Площадь 45 м²

" + "" + ) + + +@pytest.mark.parametrize( + "badge,было_бы", + [ + ("Нереализована", STATUS_SOLD), + ("Не реализована", STATUS_SOLD), + ("Не продана", STATUS_SOLD), + ("Квартира не продана", STATUS_SOLD), + ("Не забронирована", STATUS_RESERVED), + ("Не в продаже", STATUS_FREE), + ], +) +def test_negated_form_does_not_invert_status(badge: str, было_бы: str) -> None: + """Головной: отрицание не должно давать статус, тем более противоположный. + + На origin/main каждый случай возвращает `было_бы` — ровно обратное значение. + """ + result = parse_catalog_flat(_html_with_badge(badge)) + got = result.get("status") + assert got != было_бы, ( + f"«{badge}» классифицировано как {got!r} — отрицание перевёрнуто в свою " + "противоположность и уехало бы в domrf_kn_flats.status" + ) + assert got is None, f"«{badge}» дало статус {got!r}; ожидали отсутствие статуса" + + +@pytest.mark.parametrize( + "badge,ожидаем", + [ + ("Продана", STATUS_SOLD), + ("Продано", STATUS_SOLD), + ("Реализована", STATUS_SOLD), + ("Забронирована", STATUS_RESERVED), + ("Бронь", STATUS_RESERVED), + ("В продаже", STATUS_FREE), + ("Свободна", STATUS_FREE), + ], +) +def test_positive_forms_unchanged(badge: str, ожидаем: str) -> None: + """Контроль от переусердствования: обычные формы распознаются как раньше. + + Ловит «починку» через грубое гашение всего, что рядом со словом «не». + """ + result = parse_catalog_flat(_html_with_badge(badge)) + assert result.get("status") == ожидаем, f"«{badge}» перестало распознаваться" + + +def test_negation_does_not_swallow_a_real_status_in_same_block() -> None: + """Контроль: отрицание гасит СВОЙ токен, а не весь блок. + + «Квартира не продана. Статус: в продаже» — настоящий статус в блоке есть. + Разбор по первому совпадению вернул бы None и потерял бы его. + """ + result = parse_catalog_flat(_html_with_badge("Квартира не продана. Статус: в продаже")) + assert ( + result.get("status") == STATUS_FREE + ), f"настоящий статус в блоке потерян: {result.get('status')!r}" + + +def test_ne_inside_another_word_is_not_a_negation() -> None: + """Контроль границы слова: «не» внутри другого слова не считается отрицанием.""" + result = parse_catalog_flat(_html_with_badge("Цене снижена, квартира продана")) + assert ( + result.get("status") == STATUS_SOLD + ), f"«не» в хвосте слова «Цене» съело настоящий статус: {result.get('status')!r}"