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(