"""#2669: Cian отдаёт просмотры фразой — разбор обязан доставать из неё числа. Замер на проде до правки: `listings.views_total` пуст у ВСЕХ 21 799 cian-строк (0 из 1 571 detail-обогащённых), при том что у avito 12 350/12 352, у domklik 6 296/6 296, у yandex 1 146/1 210 — потеря ровно одна и только у Cian. Корень: `_parse_views` звала `int()` по всей фразе `"146 просмотров, 8 за сегодня"` → ValueError → None (except в самой функции). Тесты гоняют настоящий сохранённый ответ (fixtures/cian_flat_330982715.html) через настоящий `fetch_detail` + перебирают формы фразы. """ from __future__ import annotations import logging import os from unittest.mock import AsyncMock, MagicMock import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from scraper_kit.providers.cian.detail import _parse_views, fetch_detail _FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "cian_flat_330982715.html") # Дословно из фикстуры: "stats":{"totalViewsFormattedString":"146 просмотров, 8 за сегодня"} _FIXTURE_PHRASE = "146 просмотров, 8 за сегодня" def _fixture_html() -> str: with open(_FIXTURE, encoding="utf-8") as fh: return fh.read() def test_fixture_still_carries_the_phrase() -> None: """Страховка: тесты ниже бессмысленны, если фикстура перестала содержать фразу.""" assert f'"totalViewsFormattedString":"{_FIXTURE_PHRASE}"' in _fixture_html() @pytest.mark.parametrize( ("phrase", "expected"), [ (_FIXTURE_PHRASE, (146, 8)), # форма из сохранённого ответа ("1 просмотр", (1, None)), # единственное число, хвоста нет ("2 просмотра", (2, None)), ("0 просмотров", (0, None)), # ноль — это 0, а не «нет данных» ("1 234 просмотра", (1234, None)), # разделитель тысяч — обычный пробел ("1\xa0234 просмотра", (1234, None)), # ... и NBSP ("12 345 просмотров, 1\xa0234 за сегодня", (12345, 1234)), # narrow NBSP ("1 234", (1234, 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() fetcher.fetch = AsyncMock(return_value=_fixture_html()) result = await fetch_detail("https://ekb.cian.ru/sale/flat/330982715/", browser_fetcher=fetcher) assert result is not None assert result.views_total == 146, "views_total снова теряется — колонка останется пустой" assert result.views_today == 8