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
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:
parent
58dcd76d2e
commit
9195f2aef4
2 changed files with 104 additions and 12 deletions
70
tradein-mvp/backend/tests/test_cian_views_parse.py
Normal file
70
tradein-mvp/backend/tests/test_cian_views_parse.py
Normal 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
|
||||
("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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue