gendesign/tradein-mvp/backend/tests/scrapers/test_avito_detail_soft_block.py
bot-backend 81fc5e4014
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(tradein/avito): detect HTTP-200 soft-block as block + coords-priority backfill ordering (#1950, #1967)
Под нагрузкой Avito отдаёт HTTP 200 с общей витриной ("Авито — Объявления
на сайте Авито", без item-view) вместо detail-страницы — мягкий анти-бот
deflect. _is_firewall_page (3 IP-block маркера) её не ловил → parse падал
ValueError("Cannot extract item_id") → generic failed, blocked=0, IP не
ротировался, enriched каскадно падал к 0 (прод runs 431→437: 115→0).

FIX 2: _is_detail_soft_block() (нет item-view + generic <title> в первых 16KB)
OR-ится в is_firewall в обоих путях fetch_detail (curl + browser) → soft-block
проходит существующую 403/firewall reconnect-машинерию (свежий backconnect
exit-IP) либо после MAX_RETRIES → AvitoBlockedError → loop-ротация.

FIX 1: snapshot ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST —
сперва листинги без координат (detail-страница даёт координаты здания, #1967).

Tests: test_avito_detail_soft_block.py (reconnect-escape, exhausted->blocked,
unit-detector) + lat-priority assert в snapshot-тесте.
2026-06-28 10:51:18 +03:00

101 lines
4.1 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.

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