From 9195f2aef42efd59788f89bc3d433cfd813e5b43 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 11:39:47 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/cian):=20=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D0=BC=D0=BE=D1=82=D1=80=D1=8B=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=8E=D1=82=20=D1=82=D0=B5=D1=80=D1=8F=D1=82=D1=8C?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BD=D0=B0=20int()=20=D0=BF=D0=BE=20=D1=84?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B5=20(#2669)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cian отдаёт просмотры не числом, а фразой: "stats":{"totalViewsFormattedString":"146 просмотров, 8 за сегодня"} (дословно из tests/fixtures/cian_flat_330982715.html). `_parse_views` звала на ней int() → ValueError → свой же except → None. Прод: views_total пуст у ВСЕХ 21 799 cian-листингов, включая 1 571 detail-обогащённый. У соседей потери нет (avito 12 350/12 352, domklik 6 296/6 296, yandex 1 146/1 210) — дефект ровно один и только у Cian. Разбор теперь достаёт из фразы оба числа: «за сегодня» живёт в её хвосте, отдельного ключа todayViewsFormattedString в ответе нет (в фикстуре его нет вовсе — прежний вызов по нему всегда возвращал None). Колонка listings.views_today существует с 019_listings_alter_cian.sql и уже пишется тем же UPDATE — новых полей не заводим. Формы, покрытые тестами: фраза из фикстуры дословно, "1 просмотр", "0 просмотров", "1 234 просмотра" с обычным пробелом и с NBSP, narrow-NBSP, голое "1 234", пусто/None/строка без цифр → (None, None). Бэкфилл невозможен: исходной фразы в БД нет — 0 вхождений ViewsFormattedString в listings.raw_payload (21 799 строк, ключа stats нет ни у одной) и 0 в listing_sources.raw_payload по всем пяти источникам. Миграция не заводится. Эффект на проде появится только после возобновления cian-detail: парсер стоит с 22 июля (max(detail_enriched_at)=2026-07-22, HTTP 403 из-за протухших кук, #2700). До этого проверка правки — только на сохранённом ответе. Refs #2669 --- .../backend/tests/test_cian_views_parse.py | 70 +++++++++++++++++++ .../src/scraper_kit/providers/cian/detail.py | 46 ++++++++---- 2 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_cian_views_parse.py diff --git a/tradein-mvp/backend/tests/test_cian_views_parse.py b/tradein-mvp/backend/tests/test_cian_views_parse.py new file mode 100644 index 00000000..55db5237 --- /dev/null +++ b/tradein-mvp/backend/tests/test_cian_views_parse.py @@ -0,0 +1,70 @@ +"""#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 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 + ], +) +def test_parse_views_forms(phrase: str | None, expected: tuple[int | None, int | None]) -> None: + assert _parse_views(phrase) == expected + + +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 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 65edcdf4..494a04de 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 @@ -15,6 +15,7 @@ from __future__ import annotations import json import logging +import re from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any @@ -54,8 +55,8 @@ class DetailEnrichment: repair_state: str | None = None # enum: needs_repair/standard/good/excellent kitchen_area_m2: float | None = None # offer.kitchenArea (м²) description: str | None = None # offer.description (текст объявления) - views_total: int | None = None # Cian stats.totalViewsFormattedString → int - views_today: int | None = None + views_total: int | None = None # число из stats.totalViewsFormattedString (#2669) + views_today: int | None = None # хвост той же фразы («, 8 за сегодня») — своего ключа нет # BTI (вторичка only — primary buildings don't have БТИ) bti_data: dict[str, Any] | None = None # raw bti.houseData snapshot @@ -161,10 +162,10 @@ async def fetch_detail( raw_offer=offer, ) - # Stats (views) — from stats key in offerData or top-level state + # Stats (views) — from stats key in offerData or top-level state. + # Оба числа приходят ОДНОЙ фразой; отдельного ключа под «за сегодня» у Cian нет (#2669). stats = offer_data.get("stats") or offer_state.get("stats") or {} - result.views_total = _parse_views(stats.get("totalViewsFormattedString")) - result.views_today = _parse_views(stats.get("todayViewsFormattedString")) + result.views_total, result.views_today = _parse_views(stats.get("totalViewsFormattedString")) # Price changes — browser HTML: offerData.priceChanges (priceData.price format) # curl_cffi HTML: offer.priceChanges or state.priceChanges (flat price field) @@ -246,14 +247,35 @@ def _parse_float(value: Any) -> float | None: return None -def _parse_views(formatted_str: str | None) -> int | None: - """Cian's stats.totalViewsFormattedString — '1 234' → 1234.""" +# Цифры до слова «просмотр» (или до конца строки, если фраза — голое число) и +# цифры перед «за сегодня». `\s` покрывает и обычный пробел, и NBSP/narrow-NBSP — +# Cian отдаёт разделитель тысяч по-разному. +_VIEWS_TOTAL_RE = re.compile(r"^\s*(\d[\d\s]*?)\s*(?:просмотр|$)") +_VIEWS_TODAY_RE = re.compile(r"(\d[\d\s]*?)\s*за\s+сегодня") + + +def _parse_views(formatted_str: str | None) -> tuple[int | None, int | None]: + """`stats.totalViewsFormattedString` → (всего, за сегодня). + + Cian отдаёт просмотры ФРАЗОЙ, а не числом: сохранённый ответ + `tests/fixtures/cian_flat_330982715.html` содержит + `"stats":{"totalViewsFormattedString":"146 просмотров, 8 за сегодня"}`. + Прежний разбор звал `int()` по всей фразе → ValueError → None у всех + 21 799 cian-листингов на проде (#2669). + + Разбираемые формы: "146 просмотров, 8 за сегодня" · "1 просмотр" · + "1 234 просмотра" (пробел / NBSP как разделитель тысяч) · "0 просмотров" + (без хвоста «за сегодня») · голое "1 234". Нераспознанное → (None, None): + пусто честнее выдуманного числа. + """ if not formatted_str: - return None - try: - return int(str(formatted_str).replace(" ", "").replace(",", "")) - except (ValueError, TypeError): - return None + return None, None + phrase = str(formatted_str) + total_m = _VIEWS_TOTAL_RE.match(phrase) + 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 + return total, today def _extract_price_changes(