gendesign/backend/tests/services/scrapers/test_2464_status_negation.py
bot-backend 91b0747b3e
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (pull_request) Successful in 17m22s
fix(ptica): «не продана» перестаёт означать «продана» (#2464)
Регекс статусов каталога ДОМ.РФ искал ключевые слова без защиты от
отрицания, поэтому русские отрицательные формы давали ОБРАТНЫЙ статус:

    «нереализована»    → содержит «реализована»   → sold
    «не продана»       → содержит «продана»       → sold
    «не забронирована» → содержит «забронирована» → reserved
    «не в продаже»     → содержит «в продаже»     → free

Свободная квартира попадала бы в domrf_kn_flats.status проданной.

Отрицание теперь гасит токен, а не переворачивает его. «Не забронирована»
не означает ни sold, ни free; «не продана → free» — это вывод, а не факт
со страницы. Лучше отсутствие статуса, чем неверный.

Заодно вылечен второй дефект того же места: разбор брал ПЕРВОЕ совпадение
в блоке, поэтому «Квартира не продана. Статус: в продаже» на main даёт
sold. Новый _status_in_text перебирает все вхождения и берёт первое
неотрицаемое — настоящий статус в блоке больше не теряется.

Текущий эффект на проде НУЛЕВОЙ, и это проверено, а не предположено:
catalog_updated_at пуст у всех 983 088 строк domrf_kn_flats (скрапер не
записал ни одной), таска scrape_kn_catalog_flats закомментирована в
beat_schedule.py из-за WAF-cooldown. Существующие значения status
(free 25 656 / sold 3 122 / booked 641) пришли из kn-API — среди них
'booked', которого нет в константах этого модуля. Правка
предупредительная: при включении пути дефект инвертировал бы статусы молча.

Двусторонне: против origin/main 7 тестов красные, каждый с конкретным
неверным значением («Нереализована» → 'sold'). Контроли (7 обычных форм
и «не» в хвосте слова «Цене») зелёные с обеих сторон. Старая сюита
парсера — 320 passed, регрессий нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 17:45:04 +05:00

111 lines
5.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Отрицательные формы не дают ОБРАТНЫЙ статус продажи (#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 (
"<!doctype html><html><body>"
f'<nav><a href="/catalog">Все квартиры</a><span>{nav_text}</span></nav>'
f'<div class="status-badge">{badge_text}</div>'
"<p>Площадь 45 м²</p>"
"</body></html>"
)
@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}"