fix(avito): reconnect-retry on SERP 403/firewall under backconnect proxy
Under a backconnect rotating proxy (no changeip URL) a single HTTP 403 or firewall interstitial aborted the entire avito_full_load sweep via AvitoBlockedError -> mark_banned. Each new connection through backconnect gets a fresh exit IP, so retry by rebuilding the curl_cffi session up to _AVITO_403_MAX_RETRIES times before giving up. Legacy single-IP changeip rotation and browser-mode paths unchanged. Mirrors existing 429 short-retry.
This commit is contained in:
parent
8d516d0781
commit
9dc3be56e3
2 changed files with 223 additions and 2 deletions
|
|
@ -255,6 +255,17 @@ _AVITO_OFFERS_PER_PAGE = 50
|
|||
_AVITO_429_MAX_RETRIES = 4
|
||||
_AVITO_429_BACKOFF_SEC = 1.5
|
||||
|
||||
# HTTP 403 / firewall-интерстициал в curl_cffi-режиме через backconnect-прокси
|
||||
# (mproxy.site, нет changeip-URL): провайдер выдаёт СВЕЖИЙ exit-IP на каждое НОВОЕ
|
||||
# соединение (5 последовательных запросов → 5 разных IP). 403 значит залочен лишь
|
||||
# ТЕКУЩИЙ exit-IP — открытие нового CONNECT-туннеля escape'ит бан. Keep-alive curl_cffi
|
||||
# сессия переиспользует тот же туннель = тот же IP, поэтому retry на том же self._cffi
|
||||
# не помогает: нужно пересоздать сессию (_reset_cffi). Делаем до _AVITO_403_MAX_RETRIES
|
||||
# reconnect-попыток прежде чем свалиться в AvitoBlockedError (mark_banned). Старый
|
||||
# single-IP changeip-путь и browser-режим не затрагиваются.
|
||||
_AVITO_403_MAX_RETRIES = 5
|
||||
_AVITO_403_BACKOFF_SEC = 2.0
|
||||
|
||||
# Счётчик результатов в шапке SERP: <span data-marker="page-title/count">1 178</span>.
|
||||
# Текст может содержать NBSP (\xa0), -entity или обычные пробелы как разделители
|
||||
# разрядов — чистятся в _extract_total_count. [^<]* нежадно ловит весь текст до </span>.
|
||||
|
|
@ -322,11 +333,20 @@ class AvitoScraper(BaseScraper):
|
|||
logger.info("avito: SERP fetch via BrowserFetcher (camoufox) — #901")
|
||||
return self
|
||||
await super().__aenter__()
|
||||
self._cffi = self._build_cffi_session()
|
||||
return self
|
||||
|
||||
def _build_cffi_session(self) -> AsyncSession:
|
||||
"""Создать curl_cffi-сессию (impersonate chrome120, через прокси если задан).
|
||||
|
||||
Вынесено из __aenter__ чтобы _reset_cffi мог пересоздать сессию (новый
|
||||
CONNECT-туннель) для escape 403 на залоченном backconnect exit-IP.
|
||||
"""
|
||||
proxies = None
|
||||
if settings.scraper_proxy_url:
|
||||
proxies = {"http": settings.scraper_proxy_url, "https": settings.scraper_proxy_url}
|
||||
logger.info("avito: routing through mobile proxy egress (#623)")
|
||||
self._cffi = AsyncSession(
|
||||
return AsyncSession(
|
||||
impersonate="chrome120",
|
||||
timeout=25,
|
||||
proxies=proxies,
|
||||
|
|
@ -341,7 +361,16 @@ class AvitoScraper(BaseScraper):
|
|||
"Upgrade-Insecure-Requests": "1",
|
||||
},
|
||||
)
|
||||
return self
|
||||
|
||||
async def _reset_cffi(self) -> None:
|
||||
"""Закрыть и пересоздать curl_cffi-сессию — новый CONNECT-туннель через
|
||||
backconnect-прокси = свежий exit-IP (escape 403 на залоченном IP)."""
|
||||
if self._cffi is not None:
|
||||
try:
|
||||
await self._cffi.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._cffi = self._build_cffi_session()
|
||||
|
||||
async def __aexit__(self, *args: Any) -> None:
|
||||
if self._browser is not None:
|
||||
|
|
@ -408,9 +437,13 @@ class AvitoScraper(BaseScraper):
|
|||
return html
|
||||
assert self._cffi is not None
|
||||
max_rot = settings.avito_proxy_max_rotations if settings.avito_proxy_rotate_url else 0
|
||||
# backconnect-режим: прокси задан, но changeip-URL нет → ротация exit-IP
|
||||
# делается пересозданием соединения, а не changeip-вызовом.
|
||||
backconnect = bool(settings.scraper_proxy_url) and not settings.avito_proxy_rotate_url
|
||||
rot_done = 0
|
||||
transient_left = 2
|
||||
r429 = 0
|
||||
r403 = 0
|
||||
while True:
|
||||
try:
|
||||
response = await self._cffi.get(url)
|
||||
|
|
@ -454,6 +487,23 @@ class AvitoScraper(BaseScraper):
|
|||
rot_done,
|
||||
)
|
||||
continue
|
||||
# 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:
|
||||
r403 += 1
|
||||
logger.info(
|
||||
"avito page=%d blocked (HTTP %d, firewall=%s) — "
|
||||
"backconnect reconnect retry %d/%d",
|
||||
page,
|
||||
sc,
|
||||
is_firewall,
|
||||
r403,
|
||||
_AVITO_403_MAX_RETRIES,
|
||||
)
|
||||
await self._reset_cffi()
|
||||
await asyncio.sleep(_AVITO_403_BACKOFF_SEC)
|
||||
continue
|
||||
if sc == 429:
|
||||
logger.error("avito SERP HTTP 429 (rate limited) page=%d url=%s", page, url)
|
||||
raise AvitoRateLimitedError(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
"""curl_cffi 403/firewall reconnect-retry под backconnect-прокси.
|
||||
|
||||
Под backconnect rotating-прокси (mproxy.site, нет changeip-URL) одиночный HTTP 403
|
||||
или firewall-интерстициал ронял весь avito_full_load sweep через AvitoBlockedError →
|
||||
mark_banned. Каждое новое соединение через backconnect даёт свежий exit-IP, поэтому
|
||||
ретраим пересозданием curl_cffi-сессии до _AVITO_403_MAX_RETRIES раз.
|
||||
|
||||
backconnect = scraper_proxy_url задан И avito_proxy_rotate_url НЕ задан. Включаем через
|
||||
scraper_proxy_url_env (scraper_proxy_url — read-only property, его не monkeypatch'ишь).
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.scrapers import avito as avito_mod
|
||||
from app.services.scrapers.avito import AvitoScraper
|
||||
from app.services.scrapers.avito_exceptions import AvitoBlockedError
|
||||
|
||||
# Валидная карточка SERP (одна) — successful page после reconnect.
|
||||
_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-интерстициал (HTTP 200, не 403).
|
||||
_FIREWALL_HTML = (
|
||||
"<html><head><title>Доступ ограничен: проблема с IP</title></head>"
|
||||
'<body><div class="firewall-container">...</div></body></html>'
|
||||
)
|
||||
|
||||
|
||||
def _enable_backconnect(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""scraper_proxy_url задан (через env-поле), changeip-URL — нет → backconnect."""
|
||||
monkeypatch.setattr(avito_mod.settings, "scraper_proxy_url_env", "http://u:p@mproxy.site:14619")
|
||||
monkeypatch.setattr(avito_mod.settings, "avito_proxy_url", None)
|
||||
monkeypatch.setattr(avito_mod.settings, "avito_proxy_rotate_url", None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backconnect_403_403_200_reconnect_succeeds(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""backconnect: 403 → 403 → 200 → reconnect-retry возвращает HTML, _reset_cffi дёрнут 2×."""
|
||||
_enable_backconnect(monkeypatch)
|
||||
|
||||
scraper = AvitoScraper()
|
||||
r403 = MagicMock()
|
||||
r403.status_code = 403
|
||||
r403.text = ""
|
||||
ok = MagicMock()
|
||||
ok.status_code = 200
|
||||
ok.text = _CARD_HTML
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = AsyncMock(side_effect=[r403, r403, ok])
|
||||
scraper._cffi = mock_session
|
||||
|
||||
# _reset_cffi не должен реально пересоздавать сессию (иначе .get-side_effect
|
||||
# потеряется) — патчим на no-op счётчик, проверяем что вызвался.
|
||||
reset = AsyncMock()
|
||||
with (
|
||||
patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()),
|
||||
patch.object(AvitoScraper, "_reset_cffi", reset),
|
||||
patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)) as rot,
|
||||
):
|
||||
html = await scraper._fetch_serp_html("https://www.avito.ru/x", page=1)
|
||||
|
||||
assert html == _CARD_HTML
|
||||
assert reset.await_count == 2 # 403, 403 → два reconnect перед 200
|
||||
rot.assert_not_awaited() # backconnect не дёргает changeip-ротацию
|
||||
assert mock_session.get.await_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backconnect_firewall_reconnect_succeeds(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""backconnect: firewall-200 → 200 → reconnect-retry (firewall тоже триггерит)."""
|
||||
_enable_backconnect(monkeypatch)
|
||||
|
||||
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
|
||||
|
||||
reset = AsyncMock()
|
||||
with (
|
||||
patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()),
|
||||
patch.object(AvitoScraper, "_reset_cffi", reset),
|
||||
):
|
||||
html = await scraper._fetch_serp_html("https://www.avito.ru/x", page=1)
|
||||
|
||||
assert html == _CARD_HTML
|
||||
assert reset.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backconnect_403_exhausted_raises_blocked(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""backconnect: 403 _AVITO_403_MAX_RETRIES+1 раз → всё равно AvitoBlockedError."""
|
||||
_enable_backconnect(monkeypatch)
|
||||
|
||||
scraper = AvitoScraper()
|
||||
r403 = MagicMock()
|
||||
r403.status_code = 403
|
||||
r403.text = ""
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = AsyncMock(return_value=r403)
|
||||
scraper._cffi = mock_session
|
||||
|
||||
reset = AsyncMock()
|
||||
with (
|
||||
patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()),
|
||||
patch.object(AvitoScraper, "_reset_cffi", reset),
|
||||
):
|
||||
with pytest.raises(AvitoBlockedError, match="IP banned"):
|
||||
await scraper._fetch_serp_html("https://www.avito.ru/x", page=1)
|
||||
|
||||
# ровно _AVITO_403_MAX_RETRIES reconnect, потом raise
|
||||
assert reset.await_count == avito_mod._AVITO_403_MAX_RETRIES
|
||||
# get: 1 первичный + _AVITO_403_MAX_RETRIES повторов
|
||||
assert mock_session.get.await_count == avito_mod._AVITO_403_MAX_RETRIES + 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_backconnect_with_rotate_url_no_reconnect_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""rotate_url задан → backconnect=False → одиночный 403 (max_rotations=0) raise сразу.
|
||||
|
||||
Новая reconnect-ветка должна быть выключена когда есть changeip-URL: legacy
|
||||
single-IP mobile-proxy путь без изменений.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
avito_mod.settings, "avito_proxy_rotate_url", "https://changeip.example/?proxy_key=x"
|
||||
)
|
||||
monkeypatch.setattr(avito_mod.settings, "avito_proxy_max_rotations", 0)
|
||||
monkeypatch.setattr(avito_mod.settings, "scraper_proxy_url_env", "http://u:p@mproxy.site:14619")
|
||||
|
||||
scraper = AvitoScraper()
|
||||
r403 = MagicMock()
|
||||
r403.status_code = 403
|
||||
r403.text = ""
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = AsyncMock(return_value=r403)
|
||||
scraper._cffi = mock_session
|
||||
|
||||
reset = AsyncMock()
|
||||
with (
|
||||
patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()),
|
||||
patch.object(AvitoScraper, "_reset_cffi", reset),
|
||||
patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)),
|
||||
):
|
||||
with pytest.raises(AvitoBlockedError):
|
||||
await scraper._fetch_serp_html("https://www.avito.ru/x", page=1)
|
||||
|
||||
reset.assert_not_awaited() # reconnect-ветка выключена при наличии rotate_url
|
||||
assert mock_session.get.await_count == 1 # немедленный raise
|
||||
Loading…
Add table
Reference in a new issue