"""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 = "
" + ("x" * 500) + ""
# Валидная пустая выборка: no-results маркер присутствует (_AVITO_NO_RESULTS_MARKERS).
_NO_RESULTS_HTML = (
"По вашему запросу ничего не найдено. Попробуйте изменить фильтры."
+ ("y" * 200)
+ ""
)
# Firewall/captcha-страница (переиспользуем существующий fixture-паттерн из #754) —
# используется только для проверки, что page>1 остаётся graceful независимо от
# содержимого (проверка применяется ТОЛЬКО к page==1).
_BLOCKPAGE_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 "page1" 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="probe-page-1",
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="probe-page-1",
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"}