Общая тема: скрапер тихо возвращал пустоту вместо сигнала об ошибке, продукт считал оценку по остаткам данных, не зная что источник отвалился. 1. avito serp.py (medium): citywide/byrooms/exhaustive sweep больше не путает DOM-drift с "объявлений нет". _is_unexpected_empty_page() перепроверяет независимый сигнал (_extract_total_count — счётчик page-title/count либо no-results маркер): page=1 c 0 карточками и БЕЗ no-results маркера → AvitoContentBlockedError (переиспользует существующий механизм #754/#779). Exhaustive-бакеты (_paginate_leaf_bucket) получают expected_total из probe — total>0 но 0 карточек после пагинации всех страниц тоже поднимает ошибку. page>1 c 0 карточками остаётся graceful end-of-pagination (не regressed). 2. cian serp.py (low): _parse_serp_html сравнивает totalOffers vs results.offers из ОДНОГО state-блоба. totalOffers>0 при пустом offers — report как schema regression (elevated до ERROR + Glitchtip через новый _report_schema_regression(), общий с существующей offer-level охраной). Порог 0-vs->0 выбран т.к. это единственный позиционно-независимый сигнал: функция не знает номер страницы, дробный порог ложно сработал бы на легитимной последней частичной странице exhaustive-пагинации. 3. avito detail.py (low): publish_date больше не завышает свежесть лота на границе года. Если "текущий год + месяц/день" даёт дату в будущем относительно момента парсинга (декабрьское объявление читается в январе) — откатываем на год назад. 4. cian_session.py (low): verify_session больше не конфлирует expired/ source-down/markup-changed. Новые VERIFY_SOURCE_UNAVAILABLE_SENTINEL (5xx, network/timeout exception) и VERIFY_MARKUP_CHANGED_SENTINEL (HTTP 200 но header-frontend auth-state не распарсился) отделены от None (единственный сигнал реального логаута: 401 или isAuthenticated=false). Callers (product_handlers.py::_cian_pre_claim, admin.py) НЕ обновлены — вне разрешённого scope этого PR (только cian_session.py + scraper-kit), отдельный follow-up. Тесты: 19 новых (8 avito sweep drift, 6 cian totalOffers mismatch, 5 date rollover) + обновлены 2 существующих (cian_session markup-changed вместо None) под новый, более точный контракт. Полный backend suite: 2649 passed, 8 skipped, 1 deselected (без регрессий).
106 lines
5.3 KiB
Python
106 lines
5.3 KiB
Python
"""Audit-scrapers finding 2: Cian totalOffers vs results.offers length mismatch.
|
||
|
||
`_parse_serp_html` извлекает `totalOffers` и `results.offers` из ОДНОГО Redux
|
||
state-блоба (одна SSR-выдача). Раньше `results.offers` пустой при `totalOffers>0`
|
||
логировался WARNING'ом и тихо возвращался `[]` — не считался schema-regression, не
|
||
попадал в мониторинг (`_report_schema_regression`/Glitchtip).
|
||
|
||
Порог: 0 vs >0 — единственный позиционно-независимый сигнал, который можно
|
||
проверить без номера страницы внутри `_parse_serp_html` (эта функция не знает,
|
||
какая это страница пагинации — дробный порог типа "< 50% от totalOffers" ложно
|
||
сработал бы на легитимной последней частичной странице exhaustive-пагинации,
|
||
которую эта функция не различает). totalOffers=0 (реально пустой поиск) НЕ
|
||
считается регрессией.
|
||
|
||
Refs: audit-scrapers 2026-07-26, finding 2 (low).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from scraper_kit.providers.cian.serp import CianScraper
|
||
|
||
from app.services.scraper_adapters import RealScraperConfig
|
||
|
||
|
||
def _scraper() -> CianScraper:
|
||
return CianScraper(RealScraperConfig())
|
||
|
||
|
||
def test_total_offers_positive_but_offers_empty_reports_regression() -> None:
|
||
"""totalOffers=5, results.offers=[] — internal contradiction, must report."""
|
||
s = _scraper()
|
||
state = {"results": {"totalOffers": 5, "offers": []}}
|
||
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
|
||
with patch.object(s, "_report_schema_regression") as mock_report:
|
||
lots = s._parse_serp_html("<html>irrelevant</html>")
|
||
|
||
assert lots == []
|
||
mock_report.assert_called_once()
|
||
(msg,), _ = mock_report.call_args
|
||
assert "totalOffers=5" in msg
|
||
|
||
|
||
def test_total_offers_zero_and_offers_empty_is_valid_empty_search() -> None:
|
||
"""totalOffers=0, offers=[] — легитимная пустая выборка, НЕ регрессия."""
|
||
s = _scraper()
|
||
state = {"results": {"totalOffers": 0, "offers": []}}
|
||
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
|
||
with patch.object(s, "_report_schema_regression") as mock_report:
|
||
lots = s._parse_serp_html("<html>irrelevant</html>")
|
||
|
||
assert lots == []
|
||
mock_report.assert_not_called()
|
||
|
||
|
||
def test_total_offers_none_and_offers_empty_is_not_reported_as_regression() -> None:
|
||
"""totalOffers отсутствует/None в state — недостаточно сигнала для regression-репорта
|
||
(могла быть частично битая state-структура без явного totalOffers>0 контр-сигнала)."""
|
||
s = _scraper()
|
||
state = {"results": {"offers": []}}
|
||
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
|
||
with patch.object(s, "_report_schema_regression") as mock_report:
|
||
lots = s._parse_serp_html("<html>irrelevant</html>")
|
||
|
||
assert lots == []
|
||
mock_report.assert_not_called()
|
||
|
||
|
||
def test_offers_present_normal_path_unaffected() -> None:
|
||
"""totalOffers=1, offers содержит 1 запись без cianId/id — не проходит
|
||
_offer_to_lot, но это уже существующая (0/N offer-level) охрана, не finding 2."""
|
||
s = _scraper()
|
||
state = {"results": {"totalOffers": 1, "offers": [{"noId": True}]}}
|
||
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
|
||
with patch.object(s, "_report_schema_regression") as mock_report:
|
||
lots = s._parse_serp_html("<html>irrelevant</html>")
|
||
|
||
# offers_data непустой → finding 2 guard не участвует; существующая offer-level
|
||
# охрана (raw_count>0 and saved_count==0) должна отработать вместо неё.
|
||
assert lots == []
|
||
mock_report.assert_called_once()
|
||
(msg,), _ = mock_report.call_args
|
||
assert "_offer_to_lot" in msg
|
||
|
||
|
||
def test_state_none_extraction_failed_no_regression_report() -> None:
|
||
"""extract_state вернул None (captcha/структура целиком не найдена) — уже
|
||
существующая ветка, НЕ должна триггерить finding-2 regression report."""
|
||
s = _scraper()
|
||
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=None):
|
||
with patch.object(s, "_report_schema_regression") as mock_report:
|
||
lots = s._parse_serp_html("<html>irrelevant</html>")
|
||
|
||
assert lots == []
|
||
mock_report.assert_not_called()
|
||
|
||
|
||
def test_report_schema_regression_swallows_missing_glitchtip_dsn() -> None:
|
||
"""_report_schema_regression не должен падать, если glitchtip_dsn не настроен."""
|
||
s = _scraper()
|
||
s._config = MagicMock(glitchtip_dsn=None)
|
||
s._report_schema_regression("test message") # не должно бросить исключение
|