Compare commits
No commits in common. "0fd1d63ab337f3f5c321a8a535510ac5fba44b5a" and "b7d43db6dab0c894b3cddb73ca956efe9e31d2e7" have entirely different histories.
0fd1d63ab3
...
b7d43db6da
3 changed files with 33 additions and 342 deletions
|
|
@ -250,16 +250,10 @@ _AVITO_OFFERS_PER_PAGE = 50
|
||||||
|
|
||||||
# HTTP 429 в curl_cffi-режиме через backconnect-прокси (mproxy.site) — НЕ IP-ban, а
|
# HTTP 429 в curl_cffi-режиме через backconnect-прокси (mproxy.site) — НЕ IP-ban, а
|
||||||
# transient «слишком много одновременных соединений» (лимит 5). Проходит на коротком
|
# transient «слишком много одновременных соединений» (лимит 5). Проходит на коротком
|
||||||
# retry без ротации IP. Делаем до _AVITO_429_MAX_RETRIES коротких пауз; если они
|
# retry без ротации IP. Делаем до _AVITO_429_MAX_RETRIES коротких пауз перед тем как
|
||||||
# исчерпаны под backconnect — ещё _AVITO_429_RECONNECT_RETRIES попыток через
|
# свалиться в старое поведение (rotation / AvitoRateLimitedError).
|
||||||
# пересоздание сессии (_reset_cffi): свежее соединение освобождает удерживаемый
|
_AVITO_429_MAX_RETRIES = 4
|
||||||
# conn-slot и может попасть на менее загруженный путь. Только потом —
|
|
||||||
# AvitoRateLimitedError (run 216: deep-pagination 429 на page 36 ронял весь sweep).
|
|
||||||
_AVITO_429_MAX_RETRIES = 8
|
|
||||||
_AVITO_429_BACKOFF_SEC = 1.5
|
_AVITO_429_BACKOFF_SEC = 1.5
|
||||||
# Reconnect-fallback на исчерпанном 429 short-retry (только backconnect): дольше спим
|
|
||||||
# (_AVITO_403_BACKOFF_SEC), чтобы дать conn-slot'ам разгрузиться.
|
|
||||||
_AVITO_429_RECONNECT_RETRIES = 3
|
|
||||||
|
|
||||||
# HTTP 403 / firewall-интерстициал в curl_cffi-режиме через backconnect-прокси
|
# HTTP 403 / firewall-интерстициал в curl_cffi-режиме через backconnect-прокси
|
||||||
# (mproxy.site, нет changeip-URL): провайдер выдаёт СВЕЖИЙ exit-IP на каждое НОВОЕ
|
# (mproxy.site, нет changeip-URL): провайдер выдаёт СВЕЖИЙ exit-IP на каждое НОВОЕ
|
||||||
|
|
@ -272,12 +266,6 @@ _AVITO_429_RECONNECT_RETRIES = 3
|
||||||
_AVITO_403_MAX_RETRIES = 5
|
_AVITO_403_MAX_RETRIES = 5
|
||||||
_AVITO_403_BACKOFF_SEC = 2.0
|
_AVITO_403_BACKOFF_SEC = 2.0
|
||||||
|
|
||||||
# Per-bucket block tolerance в fetch_all_secondary: одиночный 429/блок на ОДНОМ
|
|
||||||
# (комнатность × ценовой брекет) бакете (напр. deep-pagination dense-бакета) НЕ должен
|
|
||||||
# ронять весь sweep — бакет скипается, обход продолжается. Только N ПОДРЯД заблокированных
|
|
||||||
# бакетов = реальный hard IP-ban → re-raise (run_avito_full_load → mark_banned).
|
|
||||||
_AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED = 4
|
|
||||||
|
|
||||||
# Счётчик результатов в шапке SERP: <span data-marker="page-title/count">1 178</span>.
|
# Счётчик результатов в шапке SERP: <span data-marker="page-title/count">1 178</span>.
|
||||||
# Текст может содержать NBSP (\xa0), -entity или обычные пробелы как разделители
|
# Текст может содержать NBSP (\xa0), -entity или обычные пробелы как разделители
|
||||||
# разрядов — чистятся в _extract_total_count. [^<]* нежадно ловит весь текст до </span>.
|
# разрядов — чистятся в _extract_total_count. [^<]* нежадно ловит весь текст до </span>.
|
||||||
|
|
@ -457,7 +445,6 @@ class AvitoScraper(BaseScraper):
|
||||||
rot_done = 0
|
rot_done = 0
|
||||||
transient_left = 2
|
transient_left = 2
|
||||||
r429 = 0
|
r429 = 0
|
||||||
r429_reconnect = 0
|
|
||||||
r403 = 0
|
r403 = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|
@ -520,22 +507,6 @@ class AvitoScraper(BaseScraper):
|
||||||
await asyncio.sleep(_AVITO_403_BACKOFF_SEC)
|
await asyncio.sleep(_AVITO_403_BACKOFF_SEC)
|
||||||
continue
|
continue
|
||||||
if sc == 429:
|
if sc == 429:
|
||||||
# short-retry (тот же session) исчерпан. Под backconnect одна
|
|
||||||
# фаза reconnect-fallback: пересоздание сессии освобождает
|
|
||||||
# удерживаемый conn-slot и может лечь на менее загруженный путь
|
|
||||||
# (deep-pagination 429 = conn-limit, не IP-ban). Только потом raise.
|
|
||||||
if backconnect and r429_reconnect < _AVITO_429_RECONNECT_RETRIES:
|
|
||||||
r429_reconnect += 1
|
|
||||||
logger.info(
|
|
||||||
"avito page=%d HTTP 429 short-retry exhausted — "
|
|
||||||
"backconnect reconnect retry %d/%d",
|
|
||||||
page,
|
|
||||||
r429_reconnect,
|
|
||||||
_AVITO_429_RECONNECT_RETRIES,
|
|
||||||
)
|
|
||||||
await self._reset_cffi()
|
|
||||||
await asyncio.sleep(_AVITO_403_BACKOFF_SEC)
|
|
||||||
continue
|
|
||||||
logger.error("avito SERP HTTP 429 (rate limited) page=%d url=%s", page, url)
|
logger.error("avito SERP HTTP 429 (rate limited) page=%d url=%s", page, url)
|
||||||
raise AvitoRateLimitedError(
|
raise AvitoRateLimitedError(
|
||||||
f"Avito SERP returned 429 — rate limited at page={page}"
|
f"Avito SERP returned 429 — rate limited at page={page}"
|
||||||
|
|
@ -865,10 +836,6 @@ class AvitoScraper(BaseScraper):
|
||||||
"""
|
"""
|
||||||
_buckets = rooms_buckets if rooms_buckets is not None else _AVITO_DEFAULT_ROOMS
|
_buckets = rooms_buckets if rooms_buckets is not None else _AVITO_DEFAULT_ROOMS
|
||||||
seen: dict[str, ScrapedLot] = {}
|
seen: dict[str, ScrapedLot] = {}
|
||||||
# Tolerance к одиночным per-bucket блокам (deep-pagination 429 dense-бакета):
|
|
||||||
# скипаем заблокированный бакет и продолжаем; N ПОДРЯД блоков = hard ban → re-raise.
|
|
||||||
consecutive_blocked = 0
|
|
||||||
skipped_blocked = 0
|
|
||||||
|
|
||||||
for name, slug in _buckets:
|
for name, slug in _buckets:
|
||||||
room_label = _avito_room_label(name)
|
room_label = _avito_room_label(name)
|
||||||
|
|
@ -886,61 +853,34 @@ class AvitoScraper(BaseScraper):
|
||||||
# в общем `seen` страхует на стыках.
|
# в общем `seen` страхует на стыках.
|
||||||
for br_lo, br_hi in _AVITO_PRICE_SEED_BRACKETS:
|
for br_lo, br_hi in _AVITO_PRICE_SEED_BRACKETS:
|
||||||
walk_hi = br_hi - 1 if br_hi is not None else None
|
walk_hi = br_hi - 1 if br_hi is not None else None
|
||||||
try:
|
if since is not None:
|
||||||
if since is not None:
|
# INCREMENTAL: последовательная newest-first пагинация с ранней
|
||||||
# INCREMENTAL: последовательная newest-first пагинация с ранней
|
# остановкой по watermark `since`; без бисекции по цене.
|
||||||
# остановкой по watermark `since`; без бисекции по цене.
|
await self._paginate_incremental_bracket(
|
||||||
await self._paginate_incremental_bracket(
|
room_slug=slug,
|
||||||
room_slug=slug,
|
room_label=room_label,
|
||||||
room_label=room_label,
|
lo=br_lo,
|
||||||
lo=br_lo,
|
hi=walk_hi,
|
||||||
hi=walk_hi,
|
since=since,
|
||||||
since=since,
|
seen=seen,
|
||||||
seen=seen,
|
max_pages_per_bucket=max_pages_per_bucket,
|
||||||
max_pages_per_bucket=max_pages_per_bucket,
|
secondary_only=secondary_only,
|
||||||
secondary_only=secondary_only,
|
on_bucket=on_bucket,
|
||||||
on_bucket=on_bucket,
|
)
|
||||||
)
|
else:
|
||||||
else:
|
await self._walk_price_range(
|
||||||
await self._walk_price_range(
|
room_slug=slug,
|
||||||
room_slug=slug,
|
room_label=room_label,
|
||||||
room_label=room_label,
|
lo=br_lo,
|
||||||
lo=br_lo,
|
hi=walk_hi,
|
||||||
hi=walk_hi,
|
seen=seen,
|
||||||
seen=seen,
|
price_cap_per_bucket=price_cap_per_bucket,
|
||||||
price_cap_per_bucket=price_cap_per_bucket,
|
max_pages_per_bucket=max_pages_per_bucket,
|
||||||
max_pages_per_bucket=max_pages_per_bucket,
|
concurrency=concurrency,
|
||||||
concurrency=concurrency,
|
secondary_only=secondary_only,
|
||||||
secondary_only=secondary_only,
|
on_bucket=on_bucket,
|
||||||
on_bucket=on_bucket,
|
skip_buckets=skip_buckets,
|
||||||
skip_buckets=skip_buckets,
|
|
||||||
)
|
|
||||||
except (AvitoRateLimitedError, AvitoBlockedError) as exc:
|
|
||||||
# Одиночный блок на бакете (deep-pagination 429 dense-бакета) НЕ
|
|
||||||
# роняет sweep: скипаем бакет, продолжаем. N ПОДРЯД блоков = реальный
|
|
||||||
# hard IP-ban → re-raise (run_avito_full_load → mark_banned).
|
|
||||||
consecutive_blocked += 1
|
|
||||||
skipped_blocked += 1
|
|
||||||
logger.warning(
|
|
||||||
"avito exhaustive: %s bracket [%s..%s] skipped — blocked (%s); "
|
|
||||||
"consecutive_blocked=%d/%d",
|
|
||||||
room_label,
|
|
||||||
br_lo,
|
|
||||||
walk_hi,
|
|
||||||
type(exc).__name__,
|
|
||||||
consecutive_blocked,
|
|
||||||
_AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED,
|
|
||||||
)
|
)
|
||||||
if consecutive_blocked >= _AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED:
|
|
||||||
logger.error(
|
|
||||||
"avito exhaustive: %d consecutive blocked buckets — "
|
|
||||||
"treating as hard IP-ban, aborting sweep",
|
|
||||||
consecutive_blocked,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
continue
|
|
||||||
# Успешный бакет — сбрасываем счётчик подряд-блоков.
|
|
||||||
consecutive_blocked = 0
|
|
||||||
room_collected = len(seen) - before
|
room_collected = len(seen) - before
|
||||||
logger.info(
|
logger.info(
|
||||||
"avito exhaustive: %s done — collected %d (total unique=%d)",
|
"avito exhaustive: %s done — collected %d (total unique=%d)",
|
||||||
|
|
@ -951,11 +891,7 @@ class AvitoScraper(BaseScraper):
|
||||||
if on_progress is not None:
|
if on_progress is not None:
|
||||||
on_progress(len(seen))
|
on_progress(len(seen))
|
||||||
|
|
||||||
logger.info(
|
logger.info("avito exhaustive: DONE — total unique=%d lots", len(seen))
|
||||||
"avito exhaustive: DONE — total unique=%d lots skipped_blocked=%d",
|
|
||||||
len(seen),
|
|
||||||
skipped_blocked,
|
|
||||||
)
|
|
||||||
return list(seen.values())
|
return list(seen.values())
|
||||||
|
|
||||||
async def _walk_price_range(
|
async def _walk_price_range(
|
||||||
|
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
"""429-hardening: widened short-retry + reconnect-fallback в _fetch_serp_html и
|
|
||||||
per-bucket skip в fetch_all_secondary.
|
|
||||||
|
|
||||||
Run 216 (прод): merged 403-reconnect-retry восстановил два 403, но прогон всё равно
|
|
||||||
забанился на HTTP 429 на page 36 (deep-pagination dense-бакета). 429 под backconnect =
|
|
||||||
transient conn-limit (лимит 5 соединений), не IP-ban.
|
|
||||||
|
|
||||||
Две защиты:
|
|
||||||
A. _fetch_serp_html: short-retry того же session расширен до _AVITO_429_MAX_RETRIES=8;
|
|
||||||
на исчерпании под backconnect — ещё _AVITO_429_RECONNECT_RETRIES попыток через
|
|
||||||
_reset_cffi (освобождает conn-slot) перед AvitoRateLimitedError.
|
|
||||||
B. fetch_all_secondary: одиночный заблокированный бакет скипается (continue), sweep
|
|
||||||
продолжается; только _AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED ПОДРЯД блоков = hard ban
|
|
||||||
→ re-raise (сохраняет real-ban detection).
|
|
||||||
"""
|
|
||||||
|
|
||||||
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, AvitoRateLimitedError
|
|
||||||
|
|
||||||
# Валидная карточка SERP (одна) — successful page.
|
|
||||||
_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>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _resp(status: int, text: str = "") -> MagicMock:
|
|
||||||
r = MagicMock()
|
|
||||||
r.status_code = status
|
|
||||||
r.text = text
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
# --- A. _fetch_serp_html 429 short-retry + reconnect-fallback -------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_429_short_retries_then_200_within_widened_budget(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
"""429×6 (< _AVITO_429_MAX_RETRIES=8) → 200: short-retry того же session снимает,
|
|
||||||
_reset_cffi НЕ дёргается (reconnect-fallback не нужен)."""
|
|
||||||
_enable_backconnect(monkeypatch)
|
|
||||||
n_429 = 6
|
|
||||||
assert n_429 < avito_mod._AVITO_429_MAX_RETRIES
|
|
||||||
|
|
||||||
scraper = AvitoScraper()
|
|
||||||
seq = [_resp(429) for _ in range(n_429)] + [_resp(200, _CARD_HTML)]
|
|
||||||
mock_session = AsyncMock()
|
|
||||||
mock_session.get = AsyncMock(side_effect=seq)
|
|
||||||
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=36)
|
|
||||||
|
|
||||||
assert html == _CARD_HTML
|
|
||||||
reset.assert_not_awaited() # short-retry хватило, reconnect не понадобился
|
|
||||||
assert mock_session.get.await_count == n_429 + 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_429_exhausted_then_reconnect_then_200(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
"""short-retry исчерпан (429×_AVITO_429_MAX_RETRIES) → reconnect-fallback (_reset_cffi)
|
|
||||||
→ 200 успех. Подтверждает: одна reconnect-фаза освобождает conn-slot."""
|
|
||||||
_enable_backconnect(monkeypatch)
|
|
||||||
n_short = avito_mod._AVITO_429_MAX_RETRIES
|
|
||||||
|
|
||||||
scraper = AvitoScraper()
|
|
||||||
# _AVITO_429_MAX_RETRIES коротких retry (все 429), ещё один 429 проваливается в
|
|
||||||
# reconnect-block, после _reset_cffi следующий ответ 200.
|
|
||||||
seq = [_resp(429) for _ in range(n_short + 1)] + [_resp(200, _CARD_HTML)]
|
|
||||||
mock_session = AsyncMock()
|
|
||||||
mock_session.get = AsyncMock(side_effect=seq)
|
|
||||||
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=36)
|
|
||||||
|
|
||||||
assert html == _CARD_HTML
|
|
||||||
reset.assert_awaited() # reconnect-fallback сработал
|
|
||||||
assert reset.await_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_429_fully_exhausted_raises_rate_limited(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
"""429 на каждый запрос: short-retry + все reconnect-fallback исчерпаны →
|
|
||||||
AvitoRateLimitedError. _reset_cffi дёрнут ровно _AVITO_429_RECONNECT_RETRIES раз."""
|
|
||||||
_enable_backconnect(monkeypatch)
|
|
||||||
|
|
||||||
scraper = AvitoScraper()
|
|
||||||
mock_session = AsyncMock()
|
|
||||||
mock_session.get = AsyncMock(return_value=_resp(429))
|
|
||||||
scraper._cffi = mock_session
|
|
||||||
|
|
||||||
reset = AsyncMock()
|
|
||||||
with (
|
|
||||||
patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()),
|
|
||||||
patch.object(AvitoScraper, "_reset_cffi", reset),
|
|
||||||
):
|
|
||||||
with pytest.raises(AvitoRateLimitedError, match="rate limited"):
|
|
||||||
await scraper._fetch_serp_html("https://www.avito.ru/x", page=36)
|
|
||||||
|
|
||||||
assert reset.await_count == avito_mod._AVITO_429_RECONNECT_RETRIES
|
|
||||||
# get: 1 первичный + _AVITO_429_MAX_RETRIES short-retry + _AVITO_429_RECONNECT_RETRIES
|
|
||||||
assert (
|
|
||||||
mock_session.get.await_count
|
|
||||||
== 1 + avito_mod._AVITO_429_MAX_RETRIES + avito_mod._AVITO_429_RECONNECT_RETRIES
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- B. fetch_all_secondary per-bucket skip -----------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _single_room() -> list[tuple[str, str]]:
|
|
||||||
"""Один room-bucket → число брекетов = len(_AVITO_PRICE_SEED_BRACKETS)."""
|
|
||||||
return [("2-комнатные", "2-komnatnye")]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_one_blocked_bucket_skipped_sweep_completes(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
"""_walk_price_range кидает AvitoRateLimitedError на 1-м брекете, успех на остальных →
|
|
||||||
sweep завершается без raise, заблокированный бакет посчитан."""
|
|
||||||
n_brackets = len(avito_mod._AVITO_PRICE_SEED_BRACKETS)
|
|
||||||
assert n_brackets >= 2
|
|
||||||
|
|
||||||
scraper = AvitoScraper()
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
async def fake_walk(self: object, **kwargs: object) -> None:
|
|
||||||
calls["n"] += 1
|
|
||||||
if calls["n"] == 1:
|
|
||||||
raise AvitoRateLimitedError("429 deep pagination")
|
|
||||||
# успех: ничего не добавляем в seen (не нужно для теста контракта)
|
|
||||||
|
|
||||||
with patch.object(AvitoScraper, "_walk_price_range", fake_walk):
|
|
||||||
result = await scraper.fetch_all_secondary(rooms_buckets=_single_room())
|
|
||||||
|
|
||||||
assert result == [] # seen пуст, но без raise
|
|
||||||
assert calls["n"] == n_brackets # все брекеты пройдены (1 скипнут, остальные ок)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_consecutive_blocked_threshold_reraises(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
"""_walk_price_range кидает _AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED раз ПОДРЯД →
|
|
||||||
последнее исключение пробрасывается (real hard-ban detection сохранена)."""
|
|
||||||
thr = avito_mod._AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED
|
|
||||||
assert len(avito_mod._AVITO_PRICE_SEED_BRACKETS) >= thr
|
|
||||||
|
|
||||||
scraper = AvitoScraper()
|
|
||||||
|
|
||||||
async def always_block(self: object, **kwargs: object) -> None:
|
|
||||||
raise AvitoBlockedError("IP banned")
|
|
||||||
|
|
||||||
with patch.object(AvitoScraper, "_walk_price_range", always_block):
|
|
||||||
with pytest.raises(AvitoBlockedError, match="IP banned"):
|
|
||||||
await scraper.fetch_all_secondary(rooms_buckets=_single_room())
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_success_between_blocks_resets_counter(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
"""Чередование block/success НЕ срабатывает порог: успех между блоками сбрасывает
|
|
||||||
consecutive_blocked. Паттерн block,success,block,success,... → sweep завершается."""
|
|
||||||
thr = avito_mod._AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED
|
|
||||||
n_brackets = len(avito_mod._AVITO_PRICE_SEED_BRACKETS)
|
|
||||||
# Нужно достаточно брекетов чтобы суммарно было > thr блоков, но не подряд.
|
|
||||||
assert n_brackets > thr
|
|
||||||
|
|
||||||
scraper = AvitoScraper()
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
async def alternate(self: object, **kwargs: object) -> None:
|
|
||||||
calls["n"] += 1
|
|
||||||
# нечётные вызовы блокируются, чётные — успех → никогда не thr подряд
|
|
||||||
if calls["n"] % 2 == 1:
|
|
||||||
raise AvitoRateLimitedError("429 transient")
|
|
||||||
|
|
||||||
with patch.object(AvitoScraper, "_walk_price_range", alternate):
|
|
||||||
result = await scraper.fetch_all_secondary(rooms_buckets=_single_room())
|
|
||||||
|
|
||||||
assert result == [] # без raise — порог не пробит
|
|
||||||
assert calls["n"] == n_brackets
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_incremental_path_also_skips_blocked_bucket(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
"""Per-bucket skip применяется и к incremental-пути (_paginate_incremental_bracket)."""
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
n_brackets = len(avito_mod._AVITO_PRICE_SEED_BRACKETS)
|
|
||||||
scraper = AvitoScraper()
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
async def fake_incr(self: object, **kwargs: object) -> None:
|
|
||||||
calls["n"] += 1
|
|
||||||
if calls["n"] == 1:
|
|
||||||
raise AvitoRateLimitedError("429")
|
|
||||||
|
|
||||||
with patch.object(AvitoScraper, "_paginate_incremental_bracket", fake_incr):
|
|
||||||
result = await scraper.fetch_all_secondary(
|
|
||||||
rooms_buckets=_single_room(), since=date(2026, 1, 1)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == []
|
|
||||||
assert calls["n"] == n_brackets
|
|
||||||
|
|
@ -521,11 +521,8 @@ async def test_fetch_around_429_exhausted_with_rotate_url_rotates(
|
||||||
ok.status_code = 200
|
ok.status_code = 200
|
||||||
ok.text = _CARD_HTML
|
ok.text = _CARD_HTML
|
||||||
mock_session = AsyncMock()
|
mock_session = AsyncMock()
|
||||||
# 429 на всех коротких retry (1 первичный + _AVITO_429_MAX_RETRIES), после
|
# 429 на коротких retry, после ротации — 200
|
||||||
# исчерпания short-retry — ротация → 200. backconnect-reconnect здесь НЕ
|
mock_session.get = AsyncMock(side_effect=[r429, r429, r429, r429, r429, ok])
|
||||||
# срабатывает (scraper_proxy_url не задан), поэтому сразу rotate-путь.
|
|
||||||
n_429 = 1 + avito_mod._AVITO_429_MAX_RETRIES
|
|
||||||
mock_session.get = AsyncMock(side_effect=[r429] * n_429 + [ok])
|
|
||||||
scraper._cffi = mock_session
|
scraper._cffi = mock_session
|
||||||
|
|
||||||
with (
|
with (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue