fix(tradein/yandex): просмотры режутся по разделителю тысяч + Циан молчит о непонятой фразе (#2669) #2773
5 changed files with 96 additions and 3 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
# <div data-test="OfferCardSummary">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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue