- Добавлен `_rotate_proxy_ip()` в scrape_pipeline.py: вызывает changeip-URL (settings.avito_proxy_rotate_url) + settle-sleep (settings.avito_proxy_rotate_settle_s, default 9s), логирует old→new IP. - В `run_avito_pipeline` (Steps 4/5/5b) и `run_avito_city_sweep` (Phase 4/5/5b): при достижении consecutive-block порога — сначала ротация IP, сброс счётчика, продолжение; только при исчерпании бюджета (enrich_rotations_done >= avito_proxy_max_rotations) — abort (bounded). #1820 graceful сохранён: один блок ниже порога ротацию не вызывает. - Добавлено `avito_proxy_rotate_settle_s: float = 9.0` в config.py (ENV: AVITO_PROXY_ROTATE_SETTLE_S). avito_proxy_max_rotations был 4 (с #1731). - Тесты: rotation_called / bounded_abort / single_block_no_rotation.
216 lines
9.6 KiB
Python
216 lines
9.6 KiB
Python
"""#1790 — ban-triggered IP rotation в enrichment-фазах scrape_pipeline.
|
||
|
||
Тест 1: AvitoBlockedError при доступных ротациях → вызывается _rotate_proxy_ip
|
||
(changeip) + consecutive счётчик сбрасывается → enrichment продолжается.
|
||
|
||
Тест 2: Ротаций больше нет (бюджет исчерпан) → abort enrichment (bounded N ротаций).
|
||
|
||
Тест 3: Graceful per-item (#1820) сохранён — один блок без порога ротации НЕ вызывает.
|
||
|
||
Без сети, без БД.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||
|
||
from app.services.scrape_pipeline import (
|
||
_AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT,
|
||
PipelineResult,
|
||
run_avito_pipeline,
|
||
)
|
||
from app.services.scrapers.avito_exceptions import AvitoBlockedError
|
||
|
||
|
||
def _make_lot(
|
||
item_id: str,
|
||
house_url: str | None = None,
|
||
) -> object:
|
||
from app.services.scrapers.base import ScrapedLot
|
||
|
||
return ScrapedLot(
|
||
source="avito",
|
||
source_url=f"https://www.avito.ru/ekaterinburg/kvartiry/test_{item_id}",
|
||
source_id=item_id,
|
||
price_rub=5_000_000,
|
||
address="Test address",
|
||
house_source="avito" if house_url else None,
|
||
house_ext_id=house_url.rstrip("/").split("/")[-1] if house_url else None,
|
||
house_url=house_url,
|
||
listing_segment="vtorichka",
|
||
)
|
||
|
||
|
||
def _block_scraper(lots: list) -> MagicMock: # type: ignore[type-arg]
|
||
scraper = MagicMock()
|
||
scraper.__aenter__ = AsyncMock(return_value=scraper)
|
||
scraper.__aexit__ = AsyncMock(return_value=None)
|
||
scraper.fetch_around = AsyncMock(return_value=lots)
|
||
scraper._cffi = None
|
||
return scraper
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Тест 1: ротация срабатывает при consecutive blocks == порог
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_rotation_called_on_consecutive_blocks() -> None:
|
||
"""При N подряд-блоках (порог) вызывается _rotate_proxy_ip — IP меняется.
|
||
|
||
После успешной ротации consecutive счётчик сбрасывается и enrichment продолжается:
|
||
следующий (N+1)-й дом фетчится успешно.
|
||
"""
|
||
n = _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
|
||
lots = [
|
||
_make_lot(str(i), f"https://www.avito.ru/catalog/houses/ekb/h{i}/{100 + i}")
|
||
for i in range(n + 2) # n блокируются, потом ротация, потом успех
|
||
]
|
||
scraper = _block_scraper(lots)
|
||
|
||
# Первые n обращений — блок, (n+1)-й — успех
|
||
fetch_house = AsyncMock(side_effect=[AvitoBlockedError("banned")] * n + [MagicMock()])
|
||
rotate_mock = AsyncMock(return_value=True) # ротация успешна
|
||
|
||
with (
|
||
patch("app.services.scrape_pipeline.AvitoScraper", return_value=scraper),
|
||
patch("app.services.scrape_pipeline.save_listings", return_value=(len(lots), 0)),
|
||
patch("app.services.scrape_pipeline.fetch_house_catalog", fetch_house),
|
||
patch(
|
||
"app.services.scrape_pipeline.save_house_catalog_enrichment",
|
||
return_value={"house_id": 99},
|
||
),
|
||
patch("app.services.scrape_pipeline._rotate_proxy_ip", rotate_mock),
|
||
patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()),
|
||
patch(
|
||
"curl_cffi.requests.AsyncSession",
|
||
return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()),
|
||
),
|
||
):
|
||
result = await run_avito_pipeline(
|
||
MagicMock(), 56.8, 60.6, 1000, enrich_houses=True, enrich_detail_top_n=0
|
||
)
|
||
|
||
assert isinstance(result, PipelineResult)
|
||
# _rotate_proxy_ip вызвана хотя бы один раз
|
||
assert rotate_mock.await_count >= 1
|
||
# Enrichment продолжился после ротации — (n+1)-й дом обогащён
|
||
assert result.counters.houses_enriched >= 1
|
||
# Listings сохранены
|
||
assert result.counters.lots_inserted == len(lots)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Тест 2: бюджет ротаций исчерпан → abort
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_rotation_bounded_abort_when_budget_exhausted() -> None:
|
||
"""После max_rotations попыток ротации enrichment abort'ится (bounded).
|
||
|
||
Используем avito_proxy_max_rotations=1 → только одна попытка ротации.
|
||
После неё блоки продолжаются → abort.
|
||
"""
|
||
n = _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
|
||
# Достаточно домов чтобы вызвать и ротацию и повторные блоки
|
||
lots = [
|
||
_make_lot(str(i), f"https://www.avito.ru/catalog/houses/ekb/h{i}/{200 + i}")
|
||
for i in range(n * 3)
|
||
]
|
||
scraper = _block_scraper(lots)
|
||
|
||
# Все дома блокируются (не будет успешного фетча)
|
||
fetch_house = AsyncMock(side_effect=AvitoBlockedError("banned"))
|
||
# Ротация успешна, но дом после неё тоже блокируется
|
||
rotate_mock = AsyncMock(return_value=True)
|
||
|
||
with (
|
||
patch("app.services.scrape_pipeline.AvitoScraper", return_value=scraper),
|
||
patch("app.services.scrape_pipeline.save_listings", return_value=(len(lots), 0)),
|
||
patch("app.services.scrape_pipeline.fetch_house_catalog", fetch_house),
|
||
patch("app.services.scrape_pipeline.save_house_catalog_enrichment", return_value={}),
|
||
patch("app.services.scrape_pipeline._rotate_proxy_ip", rotate_mock),
|
||
patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()),
|
||
patch("app.services.scrape_pipeline.settings") as mock_settings,
|
||
patch(
|
||
"curl_cffi.requests.AsyncSession",
|
||
return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()),
|
||
),
|
||
):
|
||
# Лимит 1 ротация: после неё бюджет исчерпан → abort на следующей серии блоков
|
||
mock_settings.avito_proxy_max_rotations = 1
|
||
mock_settings.avito_proxy_rotate_url = "http://rotate.test/"
|
||
mock_settings.avito_proxy_rotate_settle_s = 0.0
|
||
mock_settings.scraper_fetch_mode = "curl_cffi"
|
||
mock_settings.scraper_proxy_url = None
|
||
mock_settings.scraper_skip_seen_today = False
|
||
|
||
result = await run_avito_pipeline(
|
||
MagicMock(), 56.8, 60.6, 1000, enrich_houses=True, enrich_detail_top_n=0
|
||
)
|
||
|
||
assert isinstance(result, PipelineResult)
|
||
# Ровно 1 ротация (бюджет = 1)
|
||
assert rotate_mock.await_count == 1
|
||
# Enrichment abort'нут (есть AVITO_BLOCKED в ошибках)
|
||
assert any("AVITO_BLOCKED" in e for e in result.counters.errors)
|
||
# Listings всё равно сохранены
|
||
assert result.counters.lots_inserted == len(lots)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Тест 3: один блок (ниже порога) — ротация НЕ вызывается (#1820 graceful)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_single_block_below_threshold_no_rotation() -> None:
|
||
"""Один блок (< порога) — _rotate_proxy_ip НЕ вызывается.
|
||
|
||
Graceful per-item #1820: единственный блок пишется в errors, enrichment
|
||
продолжается, ротация не тратится.
|
||
"""
|
||
n = _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
|
||
# Только 2 дома: первый блокируется, второй успешен
|
||
lots = [
|
||
_make_lot("1", "https://www.avito.ru/catalog/houses/ekb/a/100"),
|
||
_make_lot("2", "https://www.avito.ru/catalog/houses/ekb/b/200"),
|
||
]
|
||
assert n >= 2, "Порог должен быть >= 2 чтобы тест был корректен"
|
||
scraper = _block_scraper(lots)
|
||
|
||
fetch_house = AsyncMock(side_effect=[AvitoBlockedError("HTTP 403"), MagicMock()])
|
||
rotate_mock = AsyncMock(return_value=True)
|
||
|
||
with (
|
||
patch("app.services.scrape_pipeline.AvitoScraper", return_value=scraper),
|
||
patch("app.services.scrape_pipeline.save_listings", return_value=(2, 0)),
|
||
patch("app.services.scrape_pipeline.fetch_house_catalog", fetch_house),
|
||
patch(
|
||
"app.services.scrape_pipeline.save_house_catalog_enrichment",
|
||
return_value={"house_id": 55},
|
||
),
|
||
patch("app.services.scrape_pipeline._rotate_proxy_ip", rotate_mock),
|
||
patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()),
|
||
patch(
|
||
"curl_cffi.requests.AsyncSession",
|
||
return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()),
|
||
),
|
||
):
|
||
result = await run_avito_pipeline(
|
||
MagicMock(), 56.8, 60.6, 1000, enrich_houses=True, enrich_detail_top_n=0
|
||
)
|
||
|
||
# Ротация НЕ вызвана — один блок ниже порога
|
||
assert rotate_mock.await_count == 0
|
||
# Первый дом упал, второй обогащён
|
||
assert result.counters.houses_failed == 1
|
||
assert result.counters.houses_enriched == 1
|
||
assert any("BLOCKED_HOUSE" in e for e in result.counters.errors)
|