Merge pull request 'fix(tradein): avito full-load probe resilience — browser 500/timeout splits/skips instead of crashing run' (#1757) from fix/avito-fullload-probe-resilience into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 7s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 40s
Deploy Trade-In / build-backend (push) Successful in 1m10s
Deploy Trade-In / deploy (push) Successful in 53s
All checks were successful
Deploy Trade-In / changes (push) Successful in 7s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 40s
Deploy Trade-In / build-backend (push) Successful in 1m10s
Deploy Trade-In / deploy (push) Successful in 53s
Reviewed-on: #1757
This commit is contained in:
commit
add002e8d7
2 changed files with 291 additions and 10 deletions
|
|
@ -33,6 +33,7 @@ from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode, urljoin, urlparse, urlunparse
|
from urllib.parse import urlencode, urljoin, urlparse, urlunparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
from curl_cffi.requests import AsyncSession
|
from curl_cffi.requests import AsyncSession
|
||||||
from selectolax.parser import HTMLParser
|
from selectolax.parser import HTMLParser
|
||||||
|
|
||||||
|
|
@ -637,6 +638,49 @@ class AvitoScraper(BaseScraper):
|
||||||
url = self._build_rooms_url(room_slug, page, min_price, max_price)
|
url = self._build_rooms_url(room_slug, page, min_price, max_price)
|
||||||
return await self._fetch_serp_html(url, page)
|
return await self._fetch_serp_html(url, page)
|
||||||
|
|
||||||
|
async def _probe_total(
|
||||||
|
self, room_slug: str, min_price: int | None, max_price: int
|
||||||
|
) -> tuple[str | None, int | None]:
|
||||||
|
"""Probe page=1 бакета и извлекает total, устойчиво к browser-ошибкам.
|
||||||
|
|
||||||
|
tradein-browser на тяжёлой выдаче (огромный диапазон, напр. студии
|
||||||
|
pmax=200M) может таймаутить на goto → HTTP 500 → ``httpx.HTTPStatusError``,
|
||||||
|
либо отдавать ``httpx.TimeoutException``/``ReadTimeout``. Раньше эти ошибки
|
||||||
|
пробрасывались из probe до самого верха и роняли весь full-load с 0 uniq.
|
||||||
|
Теперь они трактуются как «total недоступен» (``None``) — вызывающий
|
||||||
|
``_walk_price_range`` решит: split (бакет делим) или skip (узкий бакет).
|
||||||
|
|
||||||
|
AvitoBlockedError/AvitoRateLimitedError НЕ глушатся — это hard-block,
|
||||||
|
должен всплыть в pipeline (mark_banned). Возвращает (html, total); при
|
||||||
|
ошибке fetch'а html=None.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
html = await self._fetch_rooms_page_html(room_slug, 1, min_price, max_price)
|
||||||
|
except (AvitoBlockedError, AvitoRateLimitedError):
|
||||||
|
# Hard-block — пробрасываем наверх (pipeline пометит run banned).
|
||||||
|
raise
|
||||||
|
except (httpx.HTTPStatusError, httpx.TimeoutException, httpx.HTTPError) as exc:
|
||||||
|
logger.warning(
|
||||||
|
"avito: probe browser error %s for %s [%s, %d] — treating as total=None",
|
||||||
|
type(exc).__name__,
|
||||||
|
room_slug,
|
||||||
|
min_price,
|
||||||
|
max_price,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"avito: probe unexpected error for %s [%s, %d] — treating as total=None",
|
||||||
|
room_slug,
|
||||||
|
min_price,
|
||||||
|
max_price,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
total = self._extract_total_count(html) if html is not None else None
|
||||||
|
return html, total
|
||||||
|
|
||||||
async def fetch_all_secondary(
|
async def fetch_all_secondary(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -748,13 +792,12 @@ class AvitoScraper(BaseScraper):
|
||||||
_lo_param = lo if lo > 0 else None
|
_lo_param = lo if lo > 0 else None
|
||||||
|
|
||||||
# ── Шаг 1: probe page 1 ────────────────────────────────────────────────
|
# ── Шаг 1: probe page 1 ────────────────────────────────────────────────
|
||||||
html = await self._fetch_rooms_page_html(room_slug, 1, _lo_param, hi)
|
# _probe_total ловит browser-ошибки (HTTP 500 / timeout от tradein-browser
|
||||||
|
# на тяжёлой выдаче, напр. студии pmax=200M) → total=None вместо краха.
|
||||||
|
# AvitoBlockedError/AvitoRateLimitedError всплывают наверх (mark_banned).
|
||||||
|
html, total = await self._probe_total(room_slug, _lo_param, hi)
|
||||||
|
|
||||||
total: int | None = None
|
# Ретрай на captcha/missing-count/browser-ошибке: rotate IP + 1 retry.
|
||||||
if html is not None:
|
|
||||||
total = self._extract_total_count(html)
|
|
||||||
|
|
||||||
# Ретрай на captcha/missing-count: rotate IP + 1 retry.
|
|
||||||
if total is None:
|
if total is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"avito: total=None for %s [%d, %d] depth=%d — rotating IP + retry",
|
"avito: total=None for %s [%d, %d] depth=%d — rotating IP + retry",
|
||||||
|
|
@ -765,16 +808,61 @@ class AvitoScraper(BaseScraper):
|
||||||
)
|
)
|
||||||
rotated = await self._rotate_ip()
|
rotated = await self._rotate_ip()
|
||||||
if rotated:
|
if rotated:
|
||||||
html = await self._fetch_rooms_page_html(room_slug, 1, _lo_param, hi)
|
html, total = await self._probe_total(room_slug, _lo_param, hi)
|
||||||
if html is not None:
|
|
||||||
total = self._extract_total_count(html)
|
|
||||||
|
|
||||||
if total is None:
|
if total is None:
|
||||||
|
bracket_size = hi - lo
|
||||||
|
# Probe не удался даже после ретрая. Если бакет ещё делим — НЕ теряем
|
||||||
|
# его целиком: тяжёлая страница (огромная выдача) скорее всего станет
|
||||||
|
# загружаемой в более узком ценовом диапазоне → принудительный split.
|
||||||
|
if bracket_size >= _AVITO_MIN_BRACKET:
|
||||||
|
logger.warning(
|
||||||
|
"avito: probe failed for %s [%d, %d] depth=%d — splitting (assume heavy)",
|
||||||
|
room_label,
|
||||||
|
lo,
|
||||||
|
hi,
|
||||||
|
_depth,
|
||||||
|
)
|
||||||
|
mid = (lo + hi) // 2
|
||||||
|
await self._walk_price_range(
|
||||||
|
room_slug=room_slug,
|
||||||
|
room_label=room_label,
|
||||||
|
lo=lo,
|
||||||
|
hi=mid,
|
||||||
|
seen=seen,
|
||||||
|
price_cap_per_bucket=price_cap_per_bucket,
|
||||||
|
max_pages_per_bucket=max_pages_per_bucket,
|
||||||
|
concurrency=concurrency,
|
||||||
|
secondary_only=secondary_only,
|
||||||
|
on_bucket=on_bucket,
|
||||||
|
skip_buckets=skip_buckets,
|
||||||
|
_depth=_depth + 1,
|
||||||
|
)
|
||||||
|
await self._walk_price_range(
|
||||||
|
room_slug=room_slug,
|
||||||
|
room_label=room_label,
|
||||||
|
lo=mid + 1,
|
||||||
|
hi=hi,
|
||||||
|
seen=seen,
|
||||||
|
price_cap_per_bucket=price_cap_per_bucket,
|
||||||
|
max_pages_per_bucket=max_pages_per_bucket,
|
||||||
|
concurrency=concurrency,
|
||||||
|
secondary_only=secondary_only,
|
||||||
|
on_bucket=on_bucket,
|
||||||
|
skip_buckets=skip_buckets,
|
||||||
|
_depth=_depth + 1,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# Бакет уже узкий (< MIN_BRACKET) — split не поможет, хвост теряем
|
||||||
|
# осознанно (как cian) и логируем.
|
||||||
logger.error(
|
logger.error(
|
||||||
"avito: skipping bucket %s [%d, %d] — total unavailable after retry",
|
"avito: skipping bucket %s [%d, %d] — total unavailable after retry "
|
||||||
|
"(bracket=%d < MIN_BRACKET=%d, tail loss accepted)",
|
||||||
room_label,
|
room_label,
|
||||||
lo,
|
lo,
|
||||||
hi,
|
hi,
|
||||||
|
bracket_size,
|
||||||
|
_AVITO_MIN_BRACKET,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from unittest.mock import MagicMock, patch
|
||||||
# Settings requires DATABASE_URL at import time — set dummy DSN before any app import.
|
# Settings requires DATABASE_URL at import time — set dummy DSN before any app import.
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.scrapers.avito import (
|
from app.services.scrapers.avito import (
|
||||||
|
|
@ -24,6 +25,7 @@ from app.services.scrapers.avito import (
|
||||||
_AVITO_OFFERS_PER_PAGE,
|
_AVITO_OFFERS_PER_PAGE,
|
||||||
AvitoScraper,
|
AvitoScraper,
|
||||||
)
|
)
|
||||||
|
from app.services.scrapers.avito_exceptions import AvitoBlockedError
|
||||||
from app.services.scrapers.base import ScrapedLot
|
from app.services.scrapers.base import ScrapedLot
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -441,3 +443,194 @@ async def test_on_bucket_cancel_stops_run(scraper: AvitoScraper) -> None:
|
||||||
price_cap_per_bucket=1400,
|
price_cap_per_bucket=1400,
|
||||||
on_bucket=cancel_on_bucket,
|
on_bucket=cancel_on_bucket,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Probe-устойчивость к browser-ошибкам (HTTP 500 / timeout) ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _http_500_error() -> httpx.HTTPStatusError:
|
||||||
|
"""Сборка httpx.HTTPStatusError(500) как из browser_fetcher.raise_for_status()."""
|
||||||
|
request = httpx.Request("POST", "http://tradein-browser:3000/fetch")
|
||||||
|
response = httpx.Response(500, request=request)
|
||||||
|
return httpx.HTTPStatusError("Server error '500'", request=request, response=response)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_browser_500_on_wide_bucket_splits_not_crash(
|
||||||
|
scraper: AvitoScraper,
|
||||||
|
) -> None:
|
||||||
|
"""Probe бросает HTTP 500 на ШИРОКОМ bucket → НЕ крах, происходит split.
|
||||||
|
|
||||||
|
Воспроизводит прод-инцидент run 199: студии pmax=200M таймаутят на goto →
|
||||||
|
browser HTTP 500. Раньше это роняло весь full-load с 0 uniq. Теперь широкий
|
||||||
|
bucket принудительно делится пополам; под-бакеты пагинируются нормально.
|
||||||
|
"""
|
||||||
|
probe_calls: list[tuple[int | None, int]] = []
|
||||||
|
|
||||||
|
async def fake_fetch(
|
||||||
|
room_slug: str, page: int, min_price: int | None, max_price: int | None
|
||||||
|
) -> str:
|
||||||
|
if page == 1 and (min_price in (None, 0)) and max_price == _AVITO_MAX_PRICE:
|
||||||
|
probe_calls.append((min_price, max_price))
|
||||||
|
raise _http_500_error()
|
||||||
|
return f"<html>page={page} min={min_price} max={max_price}</html>"
|
||||||
|
|
||||||
|
def fake_total(html: str) -> int | None:
|
||||||
|
return 50
|
||||||
|
|
||||||
|
def fake_parse(html: str, source_url_base: str) -> list[ScrapedLot]:
|
||||||
|
import re
|
||||||
|
|
||||||
|
page_m = re.search(r"page=(\d+)", html)
|
||||||
|
page = int(page_m.group(1)) if page_m else 1
|
||||||
|
max_m = re.search(r"max=(\w+)", html)
|
||||||
|
max_p = max_m.group(1) if max_m else "MAX"
|
||||||
|
if page == 1:
|
||||||
|
return [_make_lot(f"lot_{max_p}_{i}") for i in range(3)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(scraper, "_fetch_rooms_page_html", side_effect=fake_fetch),
|
||||||
|
patch.object(scraper, "_extract_total_count", side_effect=fake_total),
|
||||||
|
patch.object(scraper, "_parse_html", side_effect=fake_parse),
|
||||||
|
patch.object(scraper, "_rotate_ip", return_value=False),
|
||||||
|
):
|
||||||
|
lots = await scraper.fetch_all_secondary(
|
||||||
|
rooms_buckets=[("студии", "studii-X")],
|
||||||
|
price_cap_per_bucket=1400,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert probe_calls, "Probe полного диапазона должен был вызваться и упасть"
|
||||||
|
assert len(lots) > 0, f"Ожидались лоты из под-бакетов после split, got {len(lots)}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_timeout_on_wide_bucket_splits_not_crash(
|
||||||
|
scraper: AvitoScraper,
|
||||||
|
) -> None:
|
||||||
|
"""httpx.TimeoutException на широком bucket обрабатывается как HTTP 500 → split."""
|
||||||
|
probe_failed = False
|
||||||
|
|
||||||
|
async def fake_fetch(
|
||||||
|
room_slug: str, page: int, min_price: int | None, max_price: int | None
|
||||||
|
) -> str:
|
||||||
|
nonlocal probe_failed
|
||||||
|
if page == 1 and (min_price in (None, 0)) and max_price == _AVITO_MAX_PRICE:
|
||||||
|
probe_failed = True
|
||||||
|
raise httpx.TimeoutException("Page.goto timeout")
|
||||||
|
return f"<html>page={page} max={max_price}</html>"
|
||||||
|
|
||||||
|
def fake_total(html: str) -> int | None:
|
||||||
|
return 40
|
||||||
|
|
||||||
|
def fake_parse(html: str, source_url_base: str) -> list[ScrapedLot]:
|
||||||
|
import re
|
||||||
|
|
||||||
|
page_m = re.search(r"page=(\d+)", html)
|
||||||
|
page = int(page_m.group(1)) if page_m else 1
|
||||||
|
max_m = re.search(r"max=(\w+)", html)
|
||||||
|
max_p = max_m.group(1) if max_m else "MAX"
|
||||||
|
return [_make_lot(f"lot_{max_p}_{i}") for i in range(2)] if page == 1 else []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(scraper, "_fetch_rooms_page_html", side_effect=fake_fetch),
|
||||||
|
patch.object(scraper, "_extract_total_count", side_effect=fake_total),
|
||||||
|
patch.object(scraper, "_parse_html", side_effect=fake_parse),
|
||||||
|
patch.object(scraper, "_rotate_ip", return_value=False),
|
||||||
|
):
|
||||||
|
lots = await scraper.fetch_all_secondary(
|
||||||
|
rooms_buckets=[("студии", "studii-X")],
|
||||||
|
price_cap_per_bucket=1400,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert probe_failed, "Probe должен был таймаутнуть"
|
||||||
|
assert len(lots) > 0, "После split под-бакеты должны собрать лоты"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_browser_500_on_narrow_bucket_skips_not_crash(
|
||||||
|
scraper: AvitoScraper,
|
||||||
|
) -> None:
|
||||||
|
"""Probe бросает HTTP 500 на УЗКОМ bucket (< MIN_BRACKET) → skip+return, без краха.
|
||||||
|
|
||||||
|
Узкий bucket делить бесполезно — хвост теряется осознанно. Прогон не падает,
|
||||||
|
остальные комнатности продолжаются.
|
||||||
|
"""
|
||||||
|
second_room_paginated = False
|
||||||
|
|
||||||
|
async def fake_fetch(
|
||||||
|
room_slug: str, page: int, min_price: int | None, max_price: int | None
|
||||||
|
) -> str:
|
||||||
|
nonlocal second_room_paginated
|
||||||
|
if room_slug == "narrow-X":
|
||||||
|
raise _http_500_error()
|
||||||
|
if room_slug == "ok-X" and page == 1:
|
||||||
|
second_room_paginated = True
|
||||||
|
return f"<html>page={page}</html>"
|
||||||
|
|
||||||
|
def fake_total(html: str) -> int | None:
|
||||||
|
return 5
|
||||||
|
|
||||||
|
def fake_parse(html: str, source_url_base: str) -> list[ScrapedLot]:
|
||||||
|
return [_make_lot("ok_lot")] if "page=1" in html else []
|
||||||
|
|
||||||
|
narrow_lo = 1_000_000
|
||||||
|
narrow_hi = narrow_lo + _AVITO_MIN_BRACKET - 1
|
||||||
|
seen: dict[str, ScrapedLot] = {}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(scraper, "_fetch_rooms_page_html", side_effect=fake_fetch),
|
||||||
|
patch.object(scraper, "_extract_total_count", side_effect=fake_total),
|
||||||
|
patch.object(scraper, "_parse_html", side_effect=fake_parse),
|
||||||
|
patch.object(scraper, "_rotate_ip", return_value=False),
|
||||||
|
):
|
||||||
|
await scraper._walk_price_range(
|
||||||
|
room_slug="narrow-X",
|
||||||
|
room_label="room_narrow",
|
||||||
|
lo=narrow_lo,
|
||||||
|
hi=narrow_hi,
|
||||||
|
seen=seen,
|
||||||
|
price_cap_per_bucket=1400,
|
||||||
|
max_pages_per_bucket=100,
|
||||||
|
)
|
||||||
|
assert seen == {}, "Узкий bucket с упавшим probe не должен дать лотов"
|
||||||
|
|
||||||
|
await scraper._walk_price_range(
|
||||||
|
room_slug="ok-X",
|
||||||
|
room_label="room_ok",
|
||||||
|
lo=0,
|
||||||
|
hi=_AVITO_MAX_PRICE,
|
||||||
|
seen=seen,
|
||||||
|
price_cap_per_bucket=1400,
|
||||||
|
max_pages_per_bucket=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert second_room_paginated, "Вторая комнатность должна пагинироваться после skip"
|
||||||
|
assert "ok_lot" in seen
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_avito_blocked_propagates_not_swallowed(
|
||||||
|
scraper: AvitoScraper,
|
||||||
|
) -> None:
|
||||||
|
"""AvitoBlockedError из probe ВСПЛЫВАЕТ (hard-block), а не глушится как total=None."""
|
||||||
|
|
||||||
|
async def fake_fetch(
|
||||||
|
room_slug: str, page: int, min_price: int | None, max_price: int | None
|
||||||
|
) -> str:
|
||||||
|
raise AvitoBlockedError("Avito SERP firewall (browser-mode) — IP banned")
|
||||||
|
|
||||||
|
def fake_total(html: str) -> int | None:
|
||||||
|
return 100
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(scraper, "_fetch_rooms_page_html", side_effect=fake_fetch),
|
||||||
|
patch.object(scraper, "_extract_total_count", side_effect=fake_total),
|
||||||
|
patch.object(scraper, "_parse_html", side_effect=lambda h, source_url_base: []),
|
||||||
|
patch.object(scraper, "_rotate_ip", return_value=False),
|
||||||
|
):
|
||||||
|
with pytest.raises(AvitoBlockedError):
|
||||||
|
await scraper.fetch_all_secondary(
|
||||||
|
rooms_buckets=[("1-комн.", "1-komnatnye-X")],
|
||||||
|
price_cap_per_bucket=1400,
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue