fix(avito): считать HTTP 439 блокировкой, а не концом выдачи #3065

Merged
lekss361 merged 1 commit from fix/avito-439-and-context into main 2026-08-23 21:49:36 +00:00
3 changed files with 104 additions and 4 deletions

View file

@ -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)

View file

@ -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 — ещё несколько

View file

@ -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) — "