diff --git a/tradein-mvp/backend/tests/test_cian_views_parse.py b/tradein-mvp/backend/tests/test_cian_views_parse.py index 55db5237..44d0488d 100644 --- a/tradein-mvp/backend/tests/test_cian_views_parse.py +++ b/tradein-mvp/backend/tests/test_cian_views_parse.py @@ -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() diff --git a/tradein-mvp/backend/tests/test_yandex_detail.py b/tradein-mvp/backend/tests/test_yandex_detail.py index c3431aa5..f481529d 100644 --- a/tradein-mvp/backend/tests/test_yandex_detail.py +++ b/tradein-mvp/backend/tests/test_yandex_detail.py @@ -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): +#
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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py index 494a04de..9f27f311 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py @@ -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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py index cbf171a6..a48d8407 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py @@ -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) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/yandex_helpers.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/yandex_helpers.py index a33d4501..64389642 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/yandex_helpers.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/yandex_helpers.py @@ -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(