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 1m26s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 48s
Soft-block (HTTP 200 generic витрина без item-view) теперь детектится как блок → существующая 403/firewall reconnect-машинерия → ротация exit-IP вместо silent failed (blocked=0, enriched→0 каскад). + snapshot ORDER BY (lat IS NULL) DESC для приоритизации coord-holes (#1967, in-scope 321). Tests: test_avito_detail_soft_block.py. Full tradein suite 2591 passed.
101 lines
4.1 KiB
Python
101 lines
4.1 KiB
Python
"""Soft-block (HTTP 200 generic-витрина) detection на detail-странице (#1967 / #1950).
|
||
|
||
Под нагрузкой Avito иногда отдаёт HTTP 200 с ОБЩЕЙ витриной
|
||
(<title>Авито — Объявления на сайте Авито</title>) вместо запрошенного объявления —
|
||
мягкий анти-бот deflect. У такой страницы нет ни firewall-маркеров, ни item-view →
|
||
parse_detail_html ронял ValueError → generic failed, IP НЕ ротировался, enriched
|
||
каскадно падал к 0. _is_detail_soft_block детектит её как блок → существующая
|
||
403/firewall reconnect-машинерия пересоздаёт эфемерную сессию (свежий exit-IP).
|
||
|
||
Зеркало fixture-стиля test_avito_detail_403_reconnect.py.
|
||
"""
|
||
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from app.core.config import settings
|
||
from app.services.scrapers import avito_detail as detail_mod
|
||
from app.services.scrapers.avito_detail import fetch_detail
|
||
from app.services.scrapers.avito_exceptions import AvitoBlockedError
|
||
|
||
_SENTINEL = object()
|
||
_SOFT_BLOCK_HTML = (
|
||
'<!DOCTYPE html><html lang="ru"><head>'
|
||
"<title>Авито — Объявления на сайте Авито</title>"
|
||
"</head><body><div>...</div></body></html>"
|
||
)
|
||
|
||
|
||
def _resp(status_code: int, text: str = "") -> MagicMock:
|
||
r = MagicMock()
|
||
r.status_code = status_code
|
||
r.text = text
|
||
return r
|
||
|
||
|
||
def _enable_backconnect(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(settings, "scraper_proxy_url_env", "http://u:p@mproxy.site:14619")
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _patch_common() -> object:
|
||
"""asyncio.sleep → no-op; parse_detail_html → sentinel (нет нужды в реальном HTML)."""
|
||
with (
|
||
patch("app.services.scrapers.avito_detail.asyncio.sleep", AsyncMock()),
|
||
patch(
|
||
"app.services.scrapers.avito_detail.parse_detail_html", return_value=_SENTINEL
|
||
) as parse,
|
||
):
|
||
yield parse
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_soft_block_200_reconnect_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""soft-block(HTTP 200) → 200: generic-витрина детектится как блок → reconnect."""
|
||
_enable_backconnect(monkeypatch)
|
||
|
||
caller = AsyncMock()
|
||
caller.get = AsyncMock(return_value=_resp(200, _SOFT_BLOCK_HTML))
|
||
retry1 = AsyncMock()
|
||
retry1.get = AsyncMock(return_value=_resp(200, "<html/>"))
|
||
build = MagicMock(side_effect=[retry1])
|
||
|
||
with patch.object(detail_mod, "_build_detail_session", build):
|
||
result = await fetch_detail("/x", cffi_session=caller)
|
||
|
||
assert result is _SENTINEL
|
||
assert build.call_count == 1
|
||
caller.close.assert_not_called()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_soft_block_exhausted_raises_blocked(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""soft-block на каждом attempt _AVITO_DETAIL_403_MAX_RETRIES+1 раз → AvitoBlockedError."""
|
||
_enable_backconnect(monkeypatch)
|
||
|
||
caller = AsyncMock()
|
||
caller.get = AsyncMock(return_value=_resp(200, _SOFT_BLOCK_HTML))
|
||
|
||
def _make_retry() -> AsyncMock:
|
||
s = AsyncMock()
|
||
s.get = AsyncMock(return_value=_resp(200, _SOFT_BLOCK_HTML))
|
||
return s
|
||
|
||
build = MagicMock(side_effect=lambda: _make_retry())
|
||
|
||
with patch.object(detail_mod, "_build_detail_session", build):
|
||
with pytest.raises(AvitoBlockedError):
|
||
await fetch_detail("/x", cffi_session=caller)
|
||
|
||
assert build.call_count == detail_mod._AVITO_DETAIL_403_MAX_RETRIES
|
||
caller.close.assert_not_called()
|
||
|
||
|
||
def test_is_detail_soft_block_unit() -> None:
|
||
"""Юнит _is_detail_soft_block: generic-витрина → True; item-view / пусто → False."""
|
||
assert detail_mod._is_detail_soft_block(_SOFT_BLOCK_HTML) is True
|
||
item_view = "<html><body><span data-marker='item-view/item-id'>№ 123</span></body></html>"
|
||
assert detail_mod._is_detail_soft_block(item_view) is False
|
||
assert detail_mod._is_detail_soft_block("<html/>") is False
|
||
assert detail_mod._is_detail_soft_block("") is False
|