fix(avito): считать HTTP 439 блокировкой, а не концом выдачи #3065
3 changed files with 104 additions and 4 deletions
|
|
@ -0,0 +1,84 @@
|
|||
"""#3044: HTTP 439 (QRATOR-челлендж Avito) считается блокировкой, а не концом выдачи.
|
||||
|
||||
439 — нестандартный код, которым QRATOR отвечает на странице-челлендже. Ни в одном
|
||||
RFC его нет, поэтому он не попал ни в один из списков «это блок» — и обе точки входа
|
||||
трактовали его как штатный исход:
|
||||
|
||||
* SERP — проваливался в `if sc != 200: return None`, то есть выглядел как «страница
|
||||
закончилась»: пагинация мирно останавливалась, ротации IP не было, `_report_ban`
|
||||
не звался, прогон не помечался banned.
|
||||
* detail — доходил до `if sc != 200: raise ValueError`, то есть считался ошибкой
|
||||
разбора страницы: без реконнекта на свежий exit-IP и без ban-сигнала.
|
||||
|
||||
Обе ветки молчали ровно там, где нас блокировали — отсюда прогоны с диагнозом
|
||||
«причина НЕ установлена» (#2700) при том, что у отказа был чёткий HTTP-код.
|
||||
|
||||
Фальсификация: до правки первый тест возвращает None вместо исключения, второй
|
||||
поднимает ValueError вместо AvitoBlockedError.
|
||||
"""
|
||||
|
||||
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 scraper_kit.avito_exceptions import AvitoBlockedError
|
||||
from scraper_kit.providers.avito import detail as detail_module
|
||||
from scraper_kit.providers.avito import serp as serp_module
|
||||
from scraper_kit.providers.avito.detail import fetch_detail
|
||||
from scraper_kit.providers.avito.serp import AvitoScraper
|
||||
|
||||
from app.services.scraper_adapters import RealScraperConfig
|
||||
|
||||
|
||||
def _resp(status: int, text: str = "<html>QRATOR challenge</html>") -> MagicMock:
|
||||
r = MagicMock()
|
||||
r.status_code = status
|
||||
r.text = text
|
||||
return r
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", [403, 439])
|
||||
async def test_serp_439_raises_block_like_403(status: int) -> None:
|
||||
"""SERP: 439 поднимает AvitoBlockedError ровно как 403, а не «конец выдачи».
|
||||
|
||||
Параметризация не для полноты, а для доказательства ПАРИТЕТА: 403 — заведомо
|
||||
рабочая ветка, и 439 обязан вести себя ровно так же. Если однажды кто-то
|
||||
перепишет блок-логику и уронит только 439 — упадёт лишь половина кейсов, и это
|
||||
сразу покажет, что ветки разъехались.
|
||||
"""
|
||||
scraper = AvitoScraper(RealScraperConfig())
|
||||
scraper._browser = None
|
||||
scraper._cffi = AsyncMock()
|
||||
scraper._cffi.get = AsyncMock(return_value=_resp(status))
|
||||
scraper._rotate_ip = AsyncMock(return_value=False) # ротация недоступна -> сразу raise
|
||||
|
||||
with patch.object(serp_module.asyncio, "sleep", AsyncMock()):
|
||||
with pytest.raises(AvitoBlockedError):
|
||||
await scraper._fetch_serp_html("https://www.avito.ru/ekaterinburg/kvartiry", page=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", [403, 439])
|
||||
async def test_detail_439_raises_block_like_403(status: int) -> None:
|
||||
"""detail: 439 поднимает AvitoBlockedError, а не ValueError «не смог распарсить».
|
||||
|
||||
config=None -> backconnect=False, поэтому reconnect-ветка не задействуется и
|
||||
исключение поднимается на первом же ответе — проверяем именно классификацию,
|
||||
не механику ретраев.
|
||||
"""
|
||||
session = AsyncMock()
|
||||
session.get = AsyncMock(return_value=_resp(status))
|
||||
|
||||
with patch.object(detail_module.asyncio, "sleep", AsyncMock()):
|
||||
with pytest.raises(AvitoBlockedError) as excinfo:
|
||||
await fetch_detail("/ekaterinburg/kvartiry/1_k_kvartira_1234567", cffi_session=session)
|
||||
|
||||
# Текст ошибки несёт фактический код: до правки он был жёстко зашит как "403"
|
||||
# и на 439 врал бы в логах ровно там, где по логу и ставят диагноз.
|
||||
assert str(status) in str(excinfo.value)
|
||||
|
|
@ -106,6 +106,10 @@ _AVITO_WARM_YANDEX_REFERER = "https://ya.ru/"
|
|||
# avito лочит этот IP, и short-retry той же сессии не помогает (run 217: 5 подряд 429,
|
||||
# abort на 19 enriched). Поэтому при исчерпании short-retry под backconnect делаем ещё
|
||||
# _AVITO_DETAIL_429_RECONNECT_RETRIES попыток через эфемерную сессию (свежий exit-IP).
|
||||
# HTTP 439 — нестандартный код QRATOR'а на странице-челлендже; обрабатывается по
|
||||
# 403-пути (реконнект = свежий exit-IP). До этого 439 не попадал ни в одну блок-
|
||||
# ветку и доходил до `if sc != 200: raise ValueError` — то есть считался обычной
|
||||
# ошибкой парсинга, без _report_ban и без реконнекта на свежий IP.
|
||||
_AVITO_DETAIL_403_MAX_RETRIES = 5
|
||||
_AVITO_DETAIL_403_BACKOFF_SEC = 2.0
|
||||
_AVITO_DETAIL_429_MAX_RETRIES = 8
|
||||
|
|
@ -583,7 +587,7 @@ async def fetch_detail(
|
|||
await asyncio.sleep(_AVITO_DETAIL_429_BACKOFF_SEC)
|
||||
continue
|
||||
|
||||
if sc == 403 or is_firewall:
|
||||
if sc in (403, 439) or is_firewall:
|
||||
if backconnect and r403 < _AVITO_DETAIL_403_MAX_RETRIES:
|
||||
r403 += 1
|
||||
# Эфемерная свежая сессия (новый CONNECT-туннель = свежий exit-IP).
|
||||
|
|
@ -604,7 +608,7 @@ async def fetch_detail(
|
|||
)
|
||||
await asyncio.sleep(_AVITO_DETAIL_403_BACKOFF_SEC)
|
||||
continue
|
||||
raise AvitoBlockedError(f"Avito detail HTTP 403 for {full_url}")
|
||||
raise AvitoBlockedError(f"Avito detail HTTP {sc} for {full_url}")
|
||||
|
||||
if sc == 429:
|
||||
# short-retry (та же сессия) исчерпан. Под backconnect — ещё несколько
|
||||
|
|
|
|||
|
|
@ -322,6 +322,14 @@ _AVITO_429_RECONNECT_RETRIES = 3
|
|||
_AVITO_403_MAX_RETRIES = 5
|
||||
_AVITO_403_BACKOFF_SEC = 2.0
|
||||
|
||||
# HTTP 439 — нестандартный код QRATOR'а на странице-челлендже Avito. Классифицируем
|
||||
# ровно как 403: тот же exit-IP признан подозрительным, помогает смена IP, а не
|
||||
# повтор на той же сессии. Держим отдельным комментарием, а не в списке "403/429",
|
||||
# потому что 439 нет ни в одном RFC — без пояснения он читается как опечатка.
|
||||
# ДО этого 439 не попадал ни в одну блок-ветку и проваливался в `if sc != 200:
|
||||
# return None` ниже, то есть тихо трактовался как штатный конец пагинации: без
|
||||
# ретрая, без ротации IP, без _report_ban и без пометки прогона banned.
|
||||
|
||||
# tradein-browser sidecar (POST /fetch) отдаёт HTTP 500 на ЛЮБУЮ внутреннюю
|
||||
# ошибку страницы (тело {"error": "ErrorType: message"}) и HTTP 503 когда браузер
|
||||
# не поднимается (прокси упал). Транзиентные сбои (Page.goto Timeout, Target
|
||||
|
|
@ -729,7 +737,7 @@ class AvitoScraper(BaseScraper):
|
|||
jitter = (r429 % 3) * 0.1
|
||||
await asyncio.sleep(_AVITO_429_BACKOFF_SEC + jitter)
|
||||
continue
|
||||
if sc in (403, 429) or is_firewall:
|
||||
if sc in (403, 429, 439) or is_firewall:
|
||||
if rot_done < max_rot and await self._rotate_ip():
|
||||
rot_done += 1
|
||||
logger.info(
|
||||
|
|
@ -743,7 +751,11 @@ class AvitoScraper(BaseScraper):
|
|||
# backconnect: 403/firewall = только текущий exit-IP залочен. Новый
|
||||
# CONNECT-туннель (пересоздание сессии) = свежий IP → reconnect-retry.
|
||||
# Только для 403/firewall (НЕ 429 — у него свой short-retry выше).
|
||||
if backconnect and (sc == 403 or is_firewall) and r403 < _AVITO_403_MAX_RETRIES:
|
||||
if (
|
||||
backconnect
|
||||
and (sc in (403, 439) or is_firewall)
|
||||
and r403 < _AVITO_403_MAX_RETRIES
|
||||
):
|
||||
r403 += 1
|
||||
logger.info(
|
||||
"avito page=%d blocked (HTTP %d, firewall=%s) — "
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue