gendesign/tradein-mvp/backend/tests/test_ban_rotation_1790.py
bot-backend 7f036a96af
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(scrapers): avito ban-triggered IP rotation (#1790) — changeip on block, bounded
- Добавлен `_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.
2026-06-20 21:24:12 +03:00

216 lines
9.6 KiB
Python
Raw 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.

"""#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)