gendesign/tradein-mvp/backend/tests/scrapers/test_avito_anti_bot.py
bot-backend cd91d5e279
All checks were successful
CI / changes (pull_request) Successful in 7s
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 pipeline — новостройки house graceful (без housePage), AvitoBlockedError per-item не крашит прогон (#1820)
2026-06-20 09:53:46 +03:00

581 lines
23 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.

"""Test anti-bot hardening: blocked exceptions + early abort."""
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from app.services.scrapers.avito_exceptions import (
AvitoBlockedError,
AvitoContentBlockedError,
AvitoRateLimitedError,
)
# ── fetch_detail tests ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_fetch_detail_raises_blocked_on_403() -> None:
"""fetch_detail должен raise AvitoBlockedError на HTTP 403."""
from app.services.scrapers.avito_detail import fetch_detail
mock_session = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 403
mock_session.get = AsyncMock(return_value=mock_response)
with pytest.raises(AvitoBlockedError, match="403"):
await fetch_detail("/ekaterinburg/kvartiry/test-123", cffi_session=mock_session)
@pytest.mark.asyncio
async def test_fetch_detail_raises_rate_limited_on_429() -> None:
"""fetch_detail должен raise AvitoRateLimitedError на HTTP 429."""
from app.services.scrapers.avito_detail import fetch_detail
mock_session = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 429
mock_session.get = AsyncMock(return_value=mock_response)
with pytest.raises(AvitoRateLimitedError, match="429"):
await fetch_detail("/ekaterinburg/kvartiry/test-123", cffi_session=mock_session)
@pytest.mark.asyncio
async def test_fetch_detail_raises_value_error_on_other_non_200() -> None:
"""fetch_detail должен raise ValueError на прочие non-200 (не 403/429)."""
from app.services.scrapers.avito_detail import fetch_detail
mock_session = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 503
mock_session.get = AsyncMock(return_value=mock_response)
with pytest.raises(ValueError, match="503"):
await fetch_detail("/ekaterinburg/kvartiry/test-123", cffi_session=mock_session)
# ── SERP (avito.py) tests ───────────────────────────────────────
@pytest.mark.asyncio
async def test_fetch_around_raises_content_blocked_on_page1_empty_html() -> None:
"""fetch_around должен raise AvitoContentBlockedError на HTTP 200 + 0 карточек на странице 1."""
from app.services.scrapers.avito import AvitoScraper
scraper = AvitoScraper()
mock_session = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 200
# Garbage HTML: нет data-marker="item" → _parse_html вернёт []
mock_response.text = "<html><body><p>captcha challenge</p></body></html>"
mock_session.get = AsyncMock(return_value=mock_response)
scraper._cffi = mock_session
with pytest.raises(AvitoContentBlockedError, match="content-block"):
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
@pytest.mark.asyncio
async def test_fetch_around_content_blocked_is_subtype_of_blocked() -> None:
"""AvitoContentBlockedError должен быть поймать как AvitoBlockedError (наследование)."""
from app.services.scrapers.avito import AvitoScraper
scraper = AvitoScraper()
mock_session = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = "<html><body></body></html>"
mock_session.get = AsyncMock(return_value=mock_response)
scraper._cffi = mock_session
with pytest.raises(AvitoBlockedError):
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
@pytest.mark.asyncio
async def test_fetch_around_page_gt1_empty_returns_gracefully() -> None:
"""fetch_around на page>1 с 0 карточек должен тихо завершить пагинацию (не raise)."""
from app.services.scrapers.avito import AvitoScraper
scraper = AvitoScraper()
mock_session = AsyncMock()
# Страница 1: нормальный HTML с одной карточкой
valid_html = (
"<html><body>"
'<div data-marker="item" data-item-id="123456789">'
'<a data-marker="item-title" href="/ekaterinburg/kvartiry/test-123456789">'
"2-к. квартира, 54,3 м², 5/9 эт."
"</a>"
'<meta itemprop="price" content="4500000"/>'
"</div>"
"</body></html>"
)
# Страница 2: пустой HTML → end of pagination
empty_html = "<html><body><p>no listings</p></body></html>"
page1_resp = MagicMock()
page1_resp.status_code = 200
page1_resp.text = valid_html
page2_resp = MagicMock()
page2_resp.status_code = 200
page2_resp.text = empty_html
mock_session.get = AsyncMock(side_effect=[page1_resp, page2_resp])
scraper._cffi = mock_session
with patch("app.services.scrapers.avito.AvitoScraper.sleep_between_requests", AsyncMock()):
result = await scraper.fetch_around(56.84, 60.60, 1000, pages=2)
# Должен вернуть лоты со страницы 1, не raise на странице 2
assert len(result) >= 1
@pytest.mark.asyncio
async def test_fetch_around_raises_blocked_on_403() -> None:
"""AvitoScraper.fetch_around должен raise AvitoBlockedError на HTTP 403."""
from app.services.scrapers.avito import AvitoScraper
scraper = AvitoScraper()
mock_session = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 403
mock_session.get = AsyncMock(return_value=mock_response)
scraper._cffi = mock_session
with pytest.raises(AvitoBlockedError):
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
@pytest.mark.asyncio
async def test_fetch_around_raises_rate_limited_on_429() -> None:
"""AvitoScraper.fetch_around должен raise AvitoRateLimitedError на persistent HTTP 429.
429 (backconnect conn-limit) сначала проходит короткие retry; при отсутствии
rotate-url после исчерпания retry сохраняется старый fallback — AvitoRateLimitedError.
"""
from app.services.scrapers.avito import AvitoScraper
scraper = AvitoScraper()
mock_session = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 429
mock_session.get = AsyncMock(return_value=mock_response)
scraper._cffi = mock_session
with patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()):
with pytest.raises(AvitoRateLimitedError):
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
# ── Pipeline abort tests ────────────────────────────────────────
@pytest.mark.asyncio
async def test_pipeline_aborts_after_3_consecutive_blocks() -> None:
"""#1820: 3 consecutive 403 в detail-фазе прерывают enrichment, но НЕ роняют прогон.
Раньше pipeline re-raise'ил AvitoBlockedError (один блок ронял весь прогон). Теперь
блок ловится пер-item, и лишь N consecutive блоков прерывают enrichment-фазу —
PipelineResult возвращается, detail_failed считается, лишние detail-фетчи не делаются.
"""
from app.services.scrape_pipeline import (
_AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT,
PipelineResult,
run_avito_pipeline,
)
mock_db = MagicMock()
mock_scraper = MagicMock()
mock_scraper.__aenter__ = AsyncMock(return_value=mock_scraper)
mock_scraper.__aexit__ = AsyncMock(return_value=None)
mock_scraper.fetch_around = AsyncMock(return_value=[])
mock_scraper._cffi = None
mock_row = MagicMock()
mock_row.__getitem__ = lambda self, key: (
"https://www.avito.ru/ekaterinburg/kvartiry/test-1" if key == "source_url" else None
)
mock_mappings = MagicMock()
mock_mappings.all.return_value = [mock_row, mock_row, mock_row, mock_row]
mock_execute = MagicMock()
mock_execute.mappings.return_value = mock_mappings
mock_db.execute.return_value = mock_execute
block_error = AvitoBlockedError("HTTP 403")
detail_mock = AsyncMock(side_effect=block_error)
with (
patch("app.services.scrape_pipeline.AvitoScraper", return_value=mock_scraper),
patch("app.services.scrape_pipeline.fetch_detail", detail_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(
mock_db,
56.8,
60.6,
1000,
enrich_houses=False,
enrich_detail_top_n=4,
)
assert isinstance(result, PipelineResult)
# enrichment прерван ровно после порога consecutive блоков (4-я строка не фетчилась)
assert detail_mock.await_count == _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
assert result.counters.detail_attempted == _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
assert result.counters.detail_failed == _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
# в errors зафиксирован abort
assert any("AVITO_BLOCKED" in e for e in result.counters.errors)
@pytest.mark.asyncio
async def test_pipeline_sleep_between_detail_requests() -> None:
"""Pipeline должен вызывать asyncio.sleep между каждым detail request."""
from app.services.scrape_pipeline import run_avito_pipeline
mock_db = MagicMock()
mock_scraper = MagicMock()
mock_scraper.__aenter__ = AsyncMock(return_value=mock_scraper)
mock_scraper.__aexit__ = AsyncMock(return_value=None)
mock_scraper.fetch_around = AsyncMock(return_value=[])
mock_scraper._cffi = None
mock_row = MagicMock()
mock_row.__getitem__ = lambda self, key: (
"https://www.avito.ru/ekaterinburg/kvartiry/test-1" if key == "source_url" else None
)
mock_mappings = MagicMock()
mock_mappings.all.return_value = [mock_row, mock_row, mock_row]
mock_execute = MagicMock()
mock_execute.mappings.return_value = mock_mappings
mock_db.execute.return_value = mock_execute
mock_enrichment = MagicMock()
sleep_mock = AsyncMock()
with (
patch("app.services.scrape_pipeline.AvitoScraper", return_value=mock_scraper),
patch(
"app.services.scrape_pipeline.fetch_detail",
AsyncMock(return_value=mock_enrichment),
),
patch("app.services.scrape_pipeline.save_detail_enrichment", return_value=True),
patch("app.services.scrape_pipeline.asyncio.sleep", sleep_mock),
patch(
"curl_cffi.requests.AsyncSession",
return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()),
),
patch("app.services.scrape_pipeline.random.uniform", return_value=1.0),
):
await run_avito_pipeline(
mock_db,
56.8,
60.6,
1000,
enrich_houses=False,
enrich_detail_top_n=3,
request_delay_sec=7.0,
)
assert sleep_mock.call_count == 2
for c in sleep_mock.call_args_list:
assert c == call(7.0)
@pytest.mark.asyncio
async def test_city_sweep_marks_banned_on_block() -> None:
"""City sweep mark'ит run как banned при AvitoBlockedError.
Migration 015 задокументировал 'banned' как 'Avito вернул 403/captcha' — именно этот сценарий.
Патчим AvitoScraper.fetch_around (не run_avito_pipeline — он больше не вызывается
из run_avito_city_sweep; sweep использует внутренние _avito_anchor_phases).
"""
from app.services.scrape_pipeline import run_avito_city_sweep
from app.services.scrapers.avito import AvitoScraper
mock_db = MagicMock()
mock_runs = MagicMock()
mock_runs.is_cancelled.return_value = False
mock_runs.update_heartbeat = MagicMock()
mock_runs.mark_banned = MagicMock()
mock_runs.mark_done = MagicMock()
block_error = AvitoBlockedError("IP blocked at page=1")
with (
patch("app.services.scrape_pipeline.scrape_runs", mock_runs),
patch.object(AvitoScraper, "fetch_around", AsyncMock(side_effect=block_error)),
patch(
"curl_cffi.requests.AsyncSession",
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=AsyncMock()),
__aexit__=AsyncMock(return_value=None),
),
),
):
result = await run_avito_city_sweep(
mock_db,
run_id=42,
anchors=[(56.84, 60.60, "Центр"), (56.79, 60.53, "ЮЗ")],
)
assert mock_runs.mark_banned.called
assert not mock_runs.mark_done.called
assert result.anchors_done == 1
# ── #623 firewall-детект + ротация IP ────────────────────────────
_CARD_HTML = (
"<html><body>"
'<div data-marker="item" data-item-id="123456789">'
'<a data-marker="item-title" href="/ekaterinburg/kvartiry/test-123456789">'
"2-к. квартира, 54,3 м², 5/9 эт."
"</a>"
'<meta itemprop="price" content="4500000"/>'
"</div>"
"</body></html>"
)
# Firewall-интерстициал Avito (HTTP 200, не 403) — IP-блок datacenter.
_FIREWALL_HTML = (
"<html><head><title>Доступ ограничен: проблема с IP</title></head>"
'<body><div class="firewall-container">...</div></body></html>'
)
@pytest.mark.asyncio
async def test_fetch_around_raises_blocked_on_firewall_html_200() -> None:
"""HTTP 200 + firewall-страница «Доступ ограничен» → AvitoBlockedError (#623).
Avito банит datacenter-IP отдачей firewall-интерстициала с кодом 200 (не 403) —
раньше это молча проходило как «0 cards / content-block», теперь явный IP-блок.
"""
from app.services.scrapers.avito import AvitoScraper
scraper = AvitoScraper()
mock_session = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = _FIREWALL_HTML
mock_session.get = AsyncMock(return_value=mock_response)
scraper._cffi = mock_session
with pytest.raises(AvitoBlockedError, match="firewall"):
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
@pytest.mark.asyncio
async def test_fetch_around_rotates_ip_then_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
"""При firewall + заданной changeip-ссылке: ротация IP и повтор страницы (#623)."""
from app.services.scrapers import avito as avito_mod
from app.services.scrapers.avito import AvitoScraper
monkeypatch.setattr(
avito_mod.settings, "avito_proxy_rotate_url", "https://changeip.example/?proxy_key=x"
)
monkeypatch.setattr(avito_mod.settings, "avito_proxy_max_rotations", 1)
scraper = AvitoScraper()
fw = MagicMock()
fw.status_code = 200
fw.text = _FIREWALL_HTML
ok = MagicMock()
ok.status_code = 200
ok.text = _CARD_HTML
mock_session = AsyncMock()
mock_session.get = AsyncMock(side_effect=[fw, ok])
scraper._cffi = mock_session
with (
patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)) as rot,
patch.object(AvitoScraper, "sleep_between_requests", AsyncMock()),
):
result = await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
rot.assert_awaited_once()
assert len(result) == 1
assert mock_session.get.await_count == 2 # firewall → rotate → retry OK
@pytest.mark.asyncio
async def test_fetch_around_blocked_when_rotation_exhausted(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Если ротация не сняла бан (max_rotations исчерпан) → AvitoBlockedError (#623)."""
from app.services.scrapers import avito as avito_mod
from app.services.scrapers.avito import AvitoScraper
monkeypatch.setattr(
avito_mod.settings, "avito_proxy_rotate_url", "https://changeip.example/?proxy_key=x"
)
monkeypatch.setattr(avito_mod.settings, "avito_proxy_max_rotations", 1)
scraper = AvitoScraper()
fw = MagicMock()
fw.status_code = 200
fw.text = _FIREWALL_HTML
mock_session = AsyncMock()
mock_session.get = AsyncMock(return_value=fw) # всегда firewall
scraper._cffi = mock_session
with patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)) as rot:
with pytest.raises(AvitoBlockedError, match="firewall"):
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
assert rot.await_count == 1 # одна попытка ротации, потом сдаёмся
# ── #623 sweep-pipeline proxy (SERP+detail через mobile proxy) ───
def test_avito_proxies_helper(monkeypatch: pytest.MonkeyPatch) -> None:
"""_avito_proxies: None без env, dict {http,https} при заданном AVITO_PROXY_URL.
Регрессия: sweep создаёт shared-сессию, через неё идут и SERP, и detail —
без прокси detail летит с datacenter-IP → 429 → run banned (#623 followup).
"""
from app.services import scrape_pipeline as sp
monkeypatch.setattr(sp.settings, "avito_proxy_url", None)
assert sp._avito_proxies() is None
url = "http://u:p@mproxy.site:16928"
monkeypatch.setattr(sp.settings, "avito_proxy_url", url)
assert sp._avito_proxies() == {"http": url, "https": url}
# ── curl_cffi 429 short-retry (backconnect conn-limit transient) ──────────────
@pytest.mark.asyncio
async def test_fetch_around_429_short_retry_then_succeeds() -> None:
"""429 дважды → 200: короткий retry снимает transient conn-limit БЕЗ ротации IP.
backconnect-прокси (mproxy.site, лимит 5 соединений) отдаёт 429 при параллельной
пагинации — это transient, проходит на коротком retry. _rotate_ip НЕ должен дёргаться.
"""
from app.services.scrapers.avito import AvitoScraper
scraper = AvitoScraper()
r429 = MagicMock()
r429.status_code = 429
ok = MagicMock()
ok.status_code = 200
ok.text = _CARD_HTML
mock_session = AsyncMock()
mock_session.get = AsyncMock(side_effect=[r429, r429, ok])
scraper._cffi = mock_session
with (
patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()) as slp,
patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)) as rot,
patch.object(AvitoScraper, "sleep_between_requests", AsyncMock()),
):
result = await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
assert len(result) == 1
assert mock_session.get.await_count == 3 # 429, 429, 200
rot.assert_not_awaited() # короткий retry, не ротация
# короткий backoff (_AVITO_429_BACKOFF_SEC ~1.5s), не 9с-settle ротации
assert slp.await_count >= 2
for c in slp.await_args_list:
assert c.args[0] < 5.0
@pytest.mark.asyncio
async def test_fetch_around_429_exhausted_no_rotate_url_raises() -> None:
"""429 постоянно + НЕТ rotate-url → после исчерпания коротких retry: старый fallback."""
from app.services.scrapers import avito as avito_mod
from app.services.scrapers.avito import AvitoScraper
scraper = AvitoScraper()
r429 = MagicMock()
r429.status_code = 429
mock_session = AsyncMock()
mock_session.get = AsyncMock(return_value=r429)
scraper._cffi = mock_session
with (
patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()) as slp,
patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)) as rot,
):
with pytest.raises(AvitoRateLimitedError):
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
rot.assert_not_awaited() # нет rotate-url → ротация не дёргается
# ровно _AVITO_429_MAX_RETRIES коротких retry, потом fallback
assert slp.await_count == avito_mod._AVITO_429_MAX_RETRIES
@pytest.mark.asyncio
async def test_fetch_around_429_exhausted_with_rotate_url_rotates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""429 постоянно + ЕСТЬ rotate-url → короткие retry, затем ротация (старый путь)."""
from app.services.scrapers import avito as avito_mod
from app.services.scrapers.avito import AvitoScraper
monkeypatch.setattr(
avito_mod.settings, "avito_proxy_rotate_url", "https://changeip.example/?proxy_key=x"
)
monkeypatch.setattr(avito_mod.settings, "avito_proxy_max_rotations", 1)
scraper = AvitoScraper()
r429 = MagicMock()
r429.status_code = 429
ok = MagicMock()
ok.status_code = 200
ok.text = _CARD_HTML
mock_session = AsyncMock()
# 429 на всех коротких retry (1 первичный + _AVITO_429_MAX_RETRIES), после
# исчерпания short-retry — ротация → 200. backconnect-reconnect здесь НЕ
# срабатывает (scraper_proxy_url не задан), поэтому сразу rotate-путь.
n_429 = 1 + avito_mod._AVITO_429_MAX_RETRIES
mock_session.get = AsyncMock(side_effect=[r429] * n_429 + [ok])
scraper._cffi = mock_session
with (
patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()),
patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)) as rot,
patch.object(AvitoScraper, "sleep_between_requests", AsyncMock()),
):
result = await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
rot.assert_awaited_once() # после исчерпания коротких retry — ротация
assert len(result) == 1
@pytest.mark.asyncio
async def test_fetch_around_403_no_429_retry_path(monkeypatch: pytest.MonkeyPatch) -> None:
"""403 (реальный IP-ban) НЕ проходит через 429-retry — поведение без изменений."""
from app.services.scrapers import avito as avito_mod
from app.services.scrapers.avito import AvitoScraper
monkeypatch.setattr(avito_mod.settings, "avito_proxy_rotate_url", None)
scraper = AvitoScraper()
r403 = MagicMock()
r403.status_code = 403
mock_session = AsyncMock()
mock_session.get = AsyncMock(return_value=r403)
scraper._cffi = mock_session
with (
patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()) as slp,
patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)),
):
with pytest.raises(AvitoBlockedError):
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
# 403 без rotate-url → немедленный raise, ни одного 429-backoff sleep
slp.assert_not_awaited()
assert mock_session.get.await_count == 1