fix(tradein/yandex): просмотры режутся по разделителю тысяч + Циан молчит о непонятой фразе (#2669) #2773

Merged
bot-backend merged 1 commit from fix/2669-views-total into main 2026-08-07 08:44:49 +00:00
5 changed files with 96 additions and 3 deletions

View file

@ -13,6 +13,7 @@
from __future__ import annotations
import logging
import os
from unittest.mock import AsyncMock, MagicMock
@ -52,12 +53,42 @@ def test_fixture_still_carries_the_phrase() -> None:
("", (None, None)),
(None, (None, None)),
("просмотров нет", (None, None)), # без цифр — пусто, а не 0
("нет данных", (None, None)), # ответ площадки «нечего показать»
],
)
def test_parse_views_forms(phrase: str | None, expected: tuple[int | None, int | None]) -> None:
assert _parse_views(phrase) == expected
def test_unparsed_phrase_is_logged_with_itself(caplog: pytest.LogCaptureFixture) -> None:
"""Провал разбора обязан быть отличим от «Циан не прислал» (эпик #2674).
Прошлая пропажа держалась месяцами ровно потому, что в журнале не было ни
строки: пустая колонка одинаково выглядела и при сломанном разборе, и при
молчании площадки. Поэтому в warning уходит САМА фраза.
"""
with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.detail"):
assert _parse_views("нет данных") == (None, None)
assert "нет данных" in caplog.text, "неразобранная фраза не видна в журнале"
def test_absent_phrase_is_not_logged(caplog: pytest.LogCaptureFixture) -> None:
"""Циан не прислал ключ вовсе — это не поломка разбора, шуметь не о чем."""
with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.detail"):
assert _parse_views(None) == (None, None)
assert caplog.text == ""
def test_no_warning_when_today_tail_absent(caplog: pytest.LogCaptureFixture) -> None:
"""«0 просмотров» без хвоста «за сегодня» — штатная форма, не повод для warning."""
with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.detail"):
assert _parse_views("0 просмотров") == (0, None)
assert caplog.text == ""
async def test_fetch_detail_fills_views_from_real_saved_page() -> None:
"""Настоящая сохранённая страница → views_total/views_today непусты (#2669)."""
fetcher = MagicMock()

View file

@ -385,3 +385,42 @@ class TestHelpers:
result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL)
assert result is not None
assert result.metro_walk_min == 7
# ---------------------------------------------------------------------------
# #2669 — просмотры с разделителем тысяч
# ---------------------------------------------------------------------------
# Форма взята дословно из сохранённого ответа Яндекса
# tests/fixtures/yandex_offer_3402418396407468801.html (сверено 2026-08-07):
# <div data-test="OfferCardSummary">16 июня, 8 просмотров35,5 м², ...
# 3 850 000 ₽108 451 ₽ за м²...
# — тот же блок, из которого читаются просмотры, отдаёт числа с NBSP-разделителем.
# Прежняя RE_VIEWS `(\d+)\s+просмотр` обрывалась на разделителе и писала в БД
# ПОСЛЕДНИЕ ТРИ ЦИФРЫ (1 234 → 234): не пусто, а тихо неверно. Косвенное
# подтверждение с прода на 2026-08-07: из 1620 непустых views_total_yandex
# ровно НОЛЬ значений ≥ 1000, при 2512 таких у avito.
@pytest.mark.parametrize(
("views_fragment", "expected"),
[
("8\xa0просмотров", 8), # дословно из сохранённого ответа
("1\xa0234 просмотра", 1234), # NBSP-разделитель — до правки давало 234
("1 234 просмотра", 1234), # обычный пробел
("12\xa0345 просмотров", 12345),
("342 просмотра", 342), # без разделителя — прежнее поведение цело
],
)
def test_views_total_survives_thousands_separator(views_fragment: str, expected: int) -> None:
html = _make_html(summary_text=f"Екатеринбург, улица Малышева, д. 5 • {views_fragment}")
result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL)
assert result is not None
assert result.views_total == expected, "просмотры Яндекса снова режутся по разделителю тысяч"
def test_views_total_absent_stays_none() -> None:
"""Нет блока просмотров → None, а не 0: «не пришло» ≠ «ноль просмотров»."""
html = _make_html(summary_text="Екатеринбург, улица Малышева, д. 5 • нет данных")
result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL)
assert result is not None
assert result.views_total is None

View file

@ -250,6 +250,10 @@ def _parse_float(value: Any) -> float | None:
# Цифры до слова «просмотр» (или до конца строки, если фраза — голое число) и
# цифры перед «за сегодня». `\s` покрывает и обычный пробел, и NBSP/narrow-NBSP —
# Cian отдаёт разделитель тысяч по-разному.
# Формат «N просмотров, M за сегодня» — сверено с сохранённым ответом Циана
# tests/fixtures/cian_flat_330982715.html (снят 2026-06-19), пересверено 2026-08-07.
# Разметка чужая и стареет сама: если ниже пошли warning'и «не разобрал» —
# формат сменился, обнови образец и дату, а не только регулярку.
_VIEWS_TOTAL_RE = re.compile(r"^\s*(\d[\d\s]*?)\s*(?:просмотр|$)")
_VIEWS_TODAY_RE = re.compile(r"(\d[\d\s]*?)\s*за\s+сегодня")
@ -267,6 +271,12 @@ def _parse_views(formatted_str: str | None) -> tuple[int | None, int | None]:
"1 234 просмотра" (пробел / NBSP как разделитель тысяч) · "0 просмотров"
(без хвоста «за сегодня») · голое "1 234". Нераспознанное (None, None):
пусто честнее выдуманного числа.
Непустая, но неразобранная фраза уходит в warning ВМЕСТЕ С СОБОЙ (обрезанной):
прошлый раз поле молчало неизвестно сколько месяцев именно потому, что провал
разбора ничем не отличался от «Циан не прислал» (эпик #2674). «Нет данных» и
прочие бесцифровые ответы площадки тоже warning: это её ответ, а не наш баг,
но увидеть смену формулировки можно только если её видно.
"""
if not formatted_str:
return None, None
@ -275,6 +285,12 @@ def _parse_views(formatted_str: str | None) -> tuple[int | None, int | None]:
today_m = _VIEWS_TODAY_RE.search(phrase)
total = int(re.sub(r"\D", "", total_m.group(1))) if total_m else None
today = int(re.sub(r"\D", "", today_m.group(1))) if today_m else None
if total is None:
# НЕ логируем отсутствие «за сегодня»: у "0 просмотров" хвоста нет штатно.
logger.warning(
"cian views: не разобрал totalViewsFormattedString=%r — views_total останется пуст",
phrase[:80],
)
return total, today

View file

@ -209,9 +209,10 @@ class YandexDetailScraper(BaseScraper):
summary_node = tree.css_first('[data-test="OfferCardSummary"]')
summary_text = summary_node.text(strip=True) if summary_node else ""
# Views + relative publish date from summary text
# Views + relative publish date from summary text.
# group(1) несёт NBSP-разделитель тысяч — выкидываем нецифры (#2669).
views_match = RE_VIEWS.search(summary_text)
views_total = int(views_match.group(1)) if views_match else None
views_total = int(re.sub(r"\D", "", views_match.group(1))) if views_match else None
publish_date = parse_ru_date(summary_text)
publish_date_relative = _extract_relative_date(summary_text)

View file

@ -93,7 +93,13 @@ RE_TITLE_ROOMS = re.compile(r"(\d+)\s*-?\s*комнатн|(студи[яюй])",
RE_FLOOR = re.compile(r"(\d+)\s+этаж\s+из\s+(\d+)", re.IGNORECASE)
RE_PRICE = re.compile(r"(\d[\d\s]+\d)\s*₽")
RE_PPM2 = re.compile(r"(\d[\d\s]+)\s*₽\s*за\s*м²", re.IGNORECASE)
RE_VIEWS = re.compile(r"(\d+)\s+просмотр", re.IGNORECASE)
# Разделитель тысяч — как у RE_PRICE/RE_PPM2 выше: тот же блок OfferCardSummary
# отдаёт «3 850 000 ₽» и «108 451 ₽ за м²» через NBSP. Прежнее `(\d+)` обрывалось
# на разделителе и «1 234 просмотра» превращались в 234 — не пусто, а НЕВЕРНОЕ
# число (#2669). Формат «N просмотров» с NBSP-разделителем — сверено с сохранённым
# ответом tests/fixtures/yandex_offer_3402418396407468801.html, 2026-08-07.
# group(1) может содержать пробелы — вызывающий обязан выкинуть нецифры.
RE_VIEWS = re.compile(r"(\d[\d\s]*?)\s*просмотр", re.IGNORECASE)
# Detail / NLP extractors
RE_METRO_WALK = re.compile(