gendesign/tradein-mvp/backend/tests/test_avito_sweep_dom_drift.py
bot-backend d362b16d7c
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m39s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 1m40s
fix(tradein/proxy): доводить сигнал бана площадки до пула (#2600 п.1) (#2653)
2026-08-05 11:37:35 +00:00

236 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 types import SimpleNamespace
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_reports_ban_when_browser_lease_active() -> None:
"""#2600 п.1: AvitoContentBlockedError → self._report_ban → browser.report_ban,
ПОКА self._browser (lease) ещё жив (`__aexit__` не вызывался, self._browser
установлен напрямую — тот же паттерн, что `orchestration/pipeline.py::
run_avito_pipeline` own_browser-путь, минующий AvitoScraper.__aenter__)."""
s = AvitoScraper(RealScraperConfig())
banned: list[str] = []
s._browser = SimpleNamespace(report_ban=lambda reason: banned.append(reason)) # type: ignore[assignment]
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)
assert banned # report_ban вызван на живом lease, ДО того как исключение всплыло
@pytest.mark.asyncio
async def test_citywide_page1_zero_cards_no_browser_does_not_crash() -> None:
"""cffi-only режим (self._browser=None, дефолт в этих тестах) — _report_ban
no-op, исключение по-прежнему поднимается штатно (parity с уже существующим
test_citywide_page1_zero_cards_no_marker_raises)."""
s = AvitoScraper(RealScraperConfig())
assert s._browser is None
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"}