fix(ptica): «не продана» перестаёт означать «продана» (#2464)
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
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
Регекс статусов каталога ДОМ.РФ искал ключевые слова без защиты от
отрицания, поэтому русские отрицательные формы давали ОБРАТНЫЙ статус:
«нереализована» → содержит «реализована» → 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>
This commit is contained in:
parent
610ed20395
commit
91b0747b3e
2 changed files with 146 additions and 12 deletions
|
|
@ -77,8 +77,16 @@ STATUS_RESERVED = "reserved"
|
||||||
|
|
||||||
# Паттерны для извлечения статуса (issue #1609).
|
# Паттерны для извлечения статуса (issue #1609).
|
||||||
# Все морфоварианты: продан/продана/продано, забронирован[аоы]?, реализован[аоы]?.
|
# Все морфоварианты: продан/продана/продано, забронирован[аоы]?, реализован[аоы]?.
|
||||||
|
#
|
||||||
|
# Группа `neg` (#2464): без неё отрицательные формы давали ОБРАТНЫЙ статус —
|
||||||
|
# «нереализована» содержит «реализована» и классифицировалась как sold,
|
||||||
|
# «не продана» → sold, «не забронирована» → reserved, «не в продаже» → free.
|
||||||
|
# `\s*` покрывает и слитную приставку («нереализована»), и раздельное «не
|
||||||
|
# продана»; `\b` перед «не» не даёт зацепиться за хвост другого слова («в цене
|
||||||
|
# продажи» — «не» внутри «цене» не на границе слова).
|
||||||
_STATUS_KW_RE = re.compile(
|
_STATUS_KW_RE = re.compile(
|
||||||
r"(в\s*продаже|свободн[аоы]?|free"
|
r"(?P<neg>\bне\s*)?"
|
||||||
|
r"(?P<kw>в\s*продаже|свободн[аоы]?|free"
|
||||||
r"|продан[аоы]?|реализован[аоы]?|sold"
|
r"|продан[аоы]?|реализован[аоы]?|sold"
|
||||||
r"|забронирован[аоы]?|бронь|reserved)",
|
r"|забронирован[аоы]?|бронь|reserved)",
|
||||||
re.IGNORECASE | re.UNICODE,
|
re.IGNORECASE | re.UNICODE,
|
||||||
|
|
@ -153,6 +161,26 @@ def _classify_status_kw(matched_text: str) -> str | None:
|
||||||
return 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 ─────────────────────────────────────────────────────────────
|
# ── HTML fetching ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -542,11 +570,9 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
||||||
status_from_badge: str | None = None
|
status_from_badge: str | None = None
|
||||||
for cls, block_text in blocks:
|
for cls, block_text in blocks:
|
||||||
if _STATUS_BADGE_CLS_RE.search(cls):
|
if _STATUS_BADGE_CLS_RE.search(cls):
|
||||||
m = _STATUS_KW_RE.search(block_text)
|
status_from_badge = _status_in_text(block_text)
|
||||||
if m:
|
if status_from_badge:
|
||||||
status_from_badge = _classify_status_kw(m.group(1))
|
break
|
||||||
if status_from_badge:
|
|
||||||
break
|
|
||||||
|
|
||||||
if status_from_badge:
|
if status_from_badge:
|
||||||
result["status"] = 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_label_value = _find_text_near(blocks, r"^статус$")
|
||||||
status_from_label: str | None = None
|
status_from_label: str | None = None
|
||||||
if status_label_value:
|
if status_label_value:
|
||||||
m2 = _STATUS_KW_RE.search(status_label_value)
|
status_from_label = _status_in_text(status_label_value)
|
||||||
if m2:
|
|
||||||
status_from_label = _classify_status_kw(m2.group(1))
|
|
||||||
|
|
||||||
if status_from_label:
|
if status_from_label:
|
||||||
result["status"] = 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
|
free_candidate: bool = False
|
||||||
sold_reserved_found: str | None = None
|
sold_reserved_found: str | None = None
|
||||||
for _cls, block_text in blocks:
|
for _cls, block_text in blocks:
|
||||||
m3 = _STATUS_KW_RE.search(block_text)
|
classified = _status_in_text(block_text)
|
||||||
if not m3:
|
if not classified:
|
||||||
continue
|
continue
|
||||||
classified = _classify_status_kw(m3.group(1))
|
|
||||||
if classified in (STATUS_SOLD, STATUS_RESERVED):
|
if classified in (STATUS_SOLD, STATUS_RESERVED):
|
||||||
sold_reserved_found = classified
|
sold_reserved_found = classified
|
||||||
break # точнее nav-текстов, дальше не ищем
|
break # точнее nav-текстов, дальше не ищем
|
||||||
|
|
|
||||||
111
backend/tests/services/scrapers/test_2464_status_negation.py
Normal file
111
backend/tests/services/scrapers/test_2464_status_negation.py
Normal file
|
|
@ -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 (
|
||||||
|
"<!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}"
|
||||||
Loading…
Add table
Reference in a new issue