fix(tradein/cian): просмотры перестают теряться на int() по фразе (#2669)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m57s

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
This commit is contained in:
bot-backend 2026-08-06 11:39:47 +05:00
parent 58dcd76d2e
commit 9195f2aef4
2 changed files with 104 additions and 12 deletions

View file

@ -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
("12345 просмотров, 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

View file

@ -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(