Общая тема: скрапер тихо возвращал пустоту вместо сигнала об ошибке, продукт считал оценку по остаткам данных, не зная что источник отвалился. 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 (без регрессий).
206 lines
9.3 KiB
Python
206 lines
9.3 KiB
Python
"""Audit-scrapers finding 1: Avito citywide/byrooms/exhaustive sweep DOM-drift detection.
|
||
|
||
Раньше 0 карточек на page=1 (обход всего города / категории комнатности / ценового
|
||
бакета exhaustive-сбора) молча трактовалось как «объявлений действительно нет» —
|
||
неотличимо от content-block/captcha или дрейфа DOM-маркера карточки (`data-marker=
|
||
"item-*"`). Фикс переиспользует существующий механизм `AvitoContentBlockedError`
|
||
(см. `fetch_around`, #754/#779) + новый `_is_unexpected_empty_page()` — независимый
|
||
сигнал `_extract_total_count` (счётчик `page-title/count` либо no-results маркер):
|
||
|
||
- page=1, 0 карточек, НЕТ no-results маркера/счётчика → аномалия → raise.
|
||
- page=1, 0 карточек, ЕСТЬ no-results маркер (total=0) → валидная пустая выборка.
|
||
- page>1, 0 карточек → всегда graceful end-of-pagination (не regressed).
|
||
- exhaustive leaf-бакет: probe независимо утверждал total>0, но после пагинации
|
||
всех страниц собрано 0 карточек → аномалия → raise (даже без per-page проверки
|
||
внутри _paginate_leaf_bucket, т.к. там нет break-on-empty цикла).
|
||
|
||
Refs: audit-scrapers 2026-07-26, finding 1 (medium).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from scraper_kit.avito_exceptions import AvitoContentBlockedError
|
||
from scraper_kit.base import ScrapedLot
|
||
from scraper_kit.providers.avito.serp import ROOM_SLUGS, AvitoScraper
|
||
|
||
from app.services.scraper_adapters import RealScraperConfig
|
||
|
||
# HTML "успешно получен, разумного размера", но БЕЗ data-marker="item-*" карточек
|
||
# И без no-results маркера/счётчика — неотличимо от content-block/DOM-drift.
|
||
_NO_MARKER_HTML = "<html><body>" + ("x" * 500) + "</body></html>"
|
||
|
||
# Валидная пустая выборка: no-results маркер присутствует (_AVITO_NO_RESULTS_MARKERS).
|
||
_NO_RESULTS_HTML = (
|
||
"<html><body>По вашему запросу ничего не найдено. Попробуйте изменить фильтры."
|
||
+ ("y" * 200)
|
||
+ "</body></html>"
|
||
)
|
||
|
||
# Firewall/captcha-страница (переиспользуем существующий fixture-паттерн из #754) —
|
||
# используется только для проверки, что page>1 остаётся graceful независимо от
|
||
# содержимого (проверка применяется ТОЛЬКО к page==1).
|
||
_BLOCKPAGE_HTML = "<html><body><h1>Доступ ограничен</h1></body></html>"
|
||
|
||
|
||
def _make_lot(source_id: str) -> ScrapedLot:
|
||
return ScrapedLot(
|
||
source="avito",
|
||
source_url=f"https://www.avito.ru/ekaterinburg/kvartiry/{source_id}",
|
||
source_id=source_id,
|
||
price_rub=6_000_000,
|
||
)
|
||
|
||
|
||
# ── fetch_city_wide (_paginate_sweep) ────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_citywide_page1_zero_cards_no_marker_raises() -> None:
|
||
s = AvitoScraper(RealScraperConfig())
|
||
with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_MARKER_HTML)):
|
||
with pytest.raises(AvitoContentBlockedError):
|
||
await s.fetch_city_wide(pages=5, delay_override_sec=0)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_citywide_page1_zero_cards_with_no_results_marker_is_valid_empty() -> None:
|
||
s = AvitoScraper(RealScraperConfig())
|
||
with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_RESULTS_HTML)):
|
||
result = await s.fetch_city_wide(pages=5, delay_override_sec=0)
|
||
assert result == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_citywide_page_gt1_zero_cards_stays_graceful() -> None:
|
||
"""page=1 реально возвращает карточки (mock _parse_html) — page=2 пустой
|
||
firewall-текст без карточек НЕ должен поднимать исключение (только page==1)."""
|
||
s = AvitoScraper(RealScraperConfig())
|
||
call_n = 0
|
||
|
||
async def _fetch(url: str, page: int) -> str:
|
||
return "<html>page1</html>" if page == 1 else _BLOCKPAGE_HTML
|
||
|
||
def _parse(html: str, source_url_base: str) -> list[ScrapedLot]:
|
||
nonlocal call_n
|
||
call_n += 1
|
||
return [_make_lot("A"), _make_lot("B")] if call_n == 1 else []
|
||
|
||
with patch.object(s, "_fetch_serp_html", AsyncMock(side_effect=_fetch)):
|
||
with patch.object(s, "_parse_html", side_effect=_parse):
|
||
with patch.object(s, "sleep_between_requests", AsyncMock(return_value=None)):
|
||
result = await s.fetch_city_wide(pages=5, delay_override_sec=0)
|
||
|
||
assert len(result) == 2
|
||
assert call_n == 2 # page1(2 lots) + page2(0 lots) → stop, no raise
|
||
|
||
|
||
# ── fetch_by_rooms ────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_byrooms_category_page1_zero_cards_no_marker_raises() -> None:
|
||
s = AvitoScraper(RealScraperConfig())
|
||
with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_MARKER_HTML)):
|
||
with pytest.raises(AvitoContentBlockedError):
|
||
await s.fetch_by_rooms(pages=5, delay_override_sec=0, room_slugs=ROOM_SLUGS[:1])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_byrooms_category_page1_zero_cards_with_marker_is_valid_empty() -> None:
|
||
s = AvitoScraper(RealScraperConfig())
|
||
with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_RESULTS_HTML)):
|
||
result = await s.fetch_by_rooms(pages=5, delay_override_sec=0, room_slugs=ROOM_SLUGS[:1])
|
||
assert result == []
|
||
|
||
|
||
# ── _paginate_leaf_bucket (exhaustive/fetch_all_secondary) ───────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_leaf_bucket_expected_total_positive_but_zero_parsed_raises() -> None:
|
||
"""Probe независимо утверждал total=5 (bucket не может быть легитимно пустым),
|
||
но парсинг всех страниц дал 0 карточек — DOM-drift, не пустой бакет."""
|
||
s = AvitoScraper(RealScraperConfig())
|
||
seen: dict[str, ScrapedLot] = {}
|
||
|
||
with patch.object(s, "_parse_html", return_value=[]):
|
||
with pytest.raises(AvitoContentBlockedError):
|
||
await s._paginate_leaf_bucket(
|
||
room_slug="studii-ASgBAgICAUSSA8YQ",
|
||
room_label="studio",
|
||
lo=0,
|
||
hi=3_000_000,
|
||
html="<html>probe-page-1</html>",
|
||
max_pages=1,
|
||
seen=seen,
|
||
price_cap_per_bucket=1400,
|
||
max_pages_per_bucket=100,
|
||
concurrency=5,
|
||
secondary_only=True,
|
||
on_bucket=None,
|
||
skip_buckets=None,
|
||
expected_total=5,
|
||
)
|
||
assert seen == {}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_leaf_bucket_expected_total_none_zero_parsed_no_raise() -> None:
|
||
"""Probe провалился (expected_total=None, best-effort пагинация) — 0 карточек
|
||
здесь НЕ аномалия (мы не знаем, есть ли реально данные в бакете)."""
|
||
s = AvitoScraper(RealScraperConfig())
|
||
seen: dict[str, ScrapedLot] = {}
|
||
|
||
with patch.object(s, "_parse_html", return_value=[]):
|
||
# Не должно поднимать исключение.
|
||
await s._paginate_leaf_bucket(
|
||
room_slug="studii-ASgBAgICAUSSA8YQ",
|
||
room_label="studio",
|
||
lo=0,
|
||
hi=3_000_000,
|
||
html=None,
|
||
max_pages=1,
|
||
seen=seen,
|
||
price_cap_per_bucket=1400,
|
||
max_pages_per_bucket=100,
|
||
concurrency=5,
|
||
secondary_only=True,
|
||
on_bucket=None,
|
||
skip_buckets=None,
|
||
expected_total=None,
|
||
)
|
||
assert seen == {}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_leaf_bucket_expected_total_matches_collected_no_raise() -> None:
|
||
"""Нормальный путь: probe total=2, парсинг реально даёт 2 карточки — не аномалия."""
|
||
s = AvitoScraper(RealScraperConfig())
|
||
seen: dict[str, ScrapedLot] = {}
|
||
lots = [_make_lot("L1"), _make_lot("L2")]
|
||
|
||
with patch.object(s, "_parse_html", return_value=lots):
|
||
await s._paginate_leaf_bucket(
|
||
room_slug="studii-ASgBAgICAUSSA8YQ",
|
||
room_label="studio",
|
||
lo=0,
|
||
hi=3_000_000,
|
||
html="<html>probe-page-1</html>",
|
||
max_pages=1,
|
||
seen=seen,
|
||
price_cap_per_bucket=1400,
|
||
max_pages_per_bucket=100,
|
||
concurrency=5,
|
||
secondary_only=True,
|
||
on_bucket=None,
|
||
skip_buckets=None,
|
||
expected_total=2,
|
||
)
|
||
assert set(seen.keys()) == {"L1", "L2"}
|