gendesign/tradein-mvp/backend/tests/test_pipeline_browser_routing.py
bot-backend 6573edba51
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m54s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein/avito): route sidecar timeout/transport errors as transient, not crash
avito_full_load has been failing since 2026-07-02 (8 runs status=failed, some
with error=''). Root cause: _fetch_serp_html_browser only caught
httpx.HTTPStatusError from BrowserFetcher.fetch. When the sidecar fetch fails
with httpx.TimeoutException/httpx.TransportError (ReadTimeout/ConnectError/
RemoteProtocolError) after BrowserFetcher's own internal retry, the exception
was unhandled and propagated through _paginate_incremental_bracket into
run_avito_full_load's generic `except Exception` -> mark_failed(str(exc)).
str(ReadTimeout())=="" produced an empty error, and mark_failed does not
persist done_buckets (unlike mark_banned), losing the resume checkpoint.

Widen the except clause to also catch httpx.TimeoutException/TransportError,
mirroring the existing pattern in _probe_total. Non-HTTPStatusError exceptions
have no HTTP status, so they're classified status=0 with a descriptive
error_text and routed through the existing transient-backoff branch (not
soft-ban — status=0/exception text never matches the soft-ban keyword checks).
Once the bounded retry budget is exhausted, they raise AvitoRateLimitedError,
same as a sidecar HTTPStatusError -> run_avito_full_load's dedicated
except (AvitoBlockedError, AvitoRateLimitedError) -> mark_banned, which DOES
persist done_buckets, giving graceful partial-save instead of a crash.

Does NOT fix the underlying cause (mobile proxy instability) -- that's
infra/devops, out of scope. This only makes the failure mode graceful.
2026-07-13 23:52:48 +03:00

322 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Stage 1 #915 — route avito pipeline SERP+detail+houses through BrowserFetcher.
Тесты без сети и без БД. BrowserFetcher мокируется объектом с async fetch().
Покрытие:
1. fetch_detail(browser_fetcher=mock) → DetailEnrichment (browser branch taken)
2. fetch_detail(browser_fetcher=mock) → AvitoBlockedError при firewall HTML
3. fetch_house_catalog(browser_fetcher=mock) → browser branch taken
4. Curl mode (default) — browser_fetcher=None, curl path используется
Legacy `run_avito_pipeline` browser/curl-mode integration tests (было ранее секцией 4)
удалены вместе с `app.services.scrape_pipeline` (#2397 Part E1) — browser-routing
покрытие на уровне `fetch_detail`/`fetch_house_catalog`/`AvitoScraper._fetch_serp_html`
ниже остаётся нетронутым.
`fetch_house_catalog` ретаргетирован на `scraper_kit.providers.avito.houses` (#2397
Part E2) — legacy `app.services.scrapers.avito_houses` удалён (0 runtime-импортёров,
kit — единственный живой путь).
`fetch_detail`/`AvitoScraper`/`AvitoBlockedError` ретаргетированы на
`scraper_kit.providers.avito.detail`/`serp`/`avito_exceptions` (#2397 финальный шаг
E) — legacy `app.services.scrapers.avito*` удалён (0 runtime-импортёров).
"""
from __future__ import annotations
import json
import os
import urllib.parse
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
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.detail import fetch_detail
from scraper_kit.providers.avito.houses import fetch_house_catalog
FIXTURES = Path(__file__).parent / "fixtures"
# Минимальный HTML для fetch_detail — достаточен для parse_detail_html
DETAIL_HTML = """
<html><body>
<div data-marker="item-view/item-id">№ 99887766 · 18 мая в 10:00 · 100 просмотров</div>
<span itemprop="price" content="5000000">5 000 000 ₽</span>
<div data-marker="item-map-wrapper"
data-map-lat="56.8" data-map-lon="60.6" data-location-id="1"></div>
<div data-marker="item-view/item-params">
<ul>
<li>Количество комнат: 2</li>
<li>Общая площадь: 50 м²</li>
<li>Этаж: 5 из 10</li>
</ul>
</div>
</body></html>
"""
# Firewall HTML — содержит маркер который _is_firewall_page ловит
FIREWALL_HTML = """
<html><head><title>Доступ ограничен</title></head>
<body><div class="firewall-container">Доступ ограничен</div></body></html>
"""
_MINIMAL_STATE = {
"data": {
"data": {
"page": {
"placeholders": [
{
"type": "housePage",
"props": {
"developmentData": {
"avitoId": 12345,
"id": "abc123",
"title": "ЖК Тест",
"address": "ул. Тестовая, 1",
"fullAddress": "Екатеринбург, ул. Тестовая, 1",
"coords": {"lat": 56.8, "lng": 60.6},
"aboutDevelopment": {"expandParams": {"items": []}},
"developer": {},
"mapPreview": {},
}
},
}
]
}
}
}
}
_encoded_state = urllib.parse.quote(json.dumps(_MINIMAL_STATE, ensure_ascii=False))
HOUSES_HTML = f"""
<html><body>
<script>window.__preloadedState__ = "{_encoded_state}";</script>
</body></html>
"""
# ── Helpers ──────────────────────────────────────────────────────────────────
def make_mock_fetcher(html: str) -> MagicMock:
"""Создаёт mock BrowserFetcher с async fetch(), возвращающим заданный HTML."""
mock = MagicMock()
mock.fetch = AsyncMock(return_value=html)
return mock
# ── 1. fetch_detail browser branch → DetailEnrichment ────────────────────────
@pytest.mark.asyncio
async def test_fetch_detail_browser_returns_enrichment() -> None:
"""browser_fetcher.fetch() вызывается, curl AsyncSession не трогается."""
mock_fetcher = make_mock_fetcher(DETAIL_HTML)
item_url = "/ekaterinburg/kvartiry/test_99887766-1234"
result = await fetch_detail(item_url, browser_fetcher=mock_fetcher)
# browser path взят
mock_fetcher.fetch.assert_awaited_once()
called_url = mock_fetcher.fetch.call_args[0][0]
assert called_url.startswith("https://www.avito.ru"), f"unexpected url: {called_url}"
assert called_url.endswith(item_url)
assert result.item_id == "99887766"
assert result.price_rub == 5_000_000
assert result.rooms == 2
@pytest.mark.asyncio
async def test_fetch_detail_browser_full_url_passthrough() -> None:
"""Если item_url уже абсолютный — передаётся без urljoin."""
mock_fetcher = make_mock_fetcher(DETAIL_HTML)
full_url = "https://www.avito.ru/ekaterinburg/kvartiry/test_99887766-1234"
result = await fetch_detail(full_url, browser_fetcher=mock_fetcher)
called_url = mock_fetcher.fetch.call_args[0][0]
assert called_url == full_url
assert result.item_id == "99887766"
@pytest.mark.asyncio
async def test_fetch_detail_browser_no_curl_session_used() -> None:
"""cffi_session не используется когда browser_fetcher передан."""
mock_fetcher = make_mock_fetcher(DETAIL_HTML)
mock_session = MagicMock()
mock_session.get = AsyncMock() # НЕ должен быть вызван
result = await fetch_detail(
"/ekaterinburg/kvartiry/test_99887766-1234",
cffi_session=mock_session,
browser_fetcher=mock_fetcher,
)
mock_session.get.assert_not_awaited()
assert result.item_id == "99887766"
# ── 2. fetch_detail browser firewall → AvitoBlockedError ─────────────────────
@pytest.mark.asyncio
async def test_fetch_detail_browser_firewall_raises() -> None:
"""Firewall HTML через browser path → AvitoBlockedError."""
mock_fetcher = make_mock_fetcher(FIREWALL_HTML)
with pytest.raises(AvitoBlockedError, match="browser-mode"):
await fetch_detail(
"/ekaterinburg/kvartiry/blocked_item-9999",
browser_fetcher=mock_fetcher,
)
# ── 3. fetch_house_catalog browser branch ────────────────────────────────────
@pytest.mark.asyncio
async def test_fetch_house_catalog_browser_branch_taken() -> None:
"""browser_fetcher.fetch() вызывается, curl path не трогается."""
mock_fetcher = make_mock_fetcher(HOUSES_HTML)
house_path = "/catalog/houses/ekaterinburg/ul_test/3171365"
result = await fetch_house_catalog(house_path, browser_fetcher=mock_fetcher)
mock_fetcher.fetch.assert_awaited_once()
called_url = mock_fetcher.fetch.call_args[0][0]
assert "avito.ru" in called_url
assert house_path in called_url
assert result.house.ext_id == 12345
assert result.house.title == "ЖК Тест"
@pytest.mark.asyncio
async def test_fetch_house_catalog_browser_no_curl_used() -> None:
"""cffi_session.get не вызывается в browser mode."""
mock_fetcher = make_mock_fetcher(HOUSES_HTML)
mock_session = MagicMock()
mock_session.get = AsyncMock()
await fetch_house_catalog(
"/catalog/houses/ekaterinburg/ul_test/3171365",
cffi_session=mock_session,
browser_fetcher=mock_fetcher,
)
mock_session.get.assert_not_awaited()
@pytest.mark.asyncio
async def test_fetch_house_catalog_browser_firewall_raises() -> None:
"""Firewall HTML через browser path → AvitoBlockedError (kit)."""
mock_fetcher = make_mock_fetcher(FIREWALL_HTML)
with pytest.raises(AvitoBlockedError, match="browser-mode"):
await fetch_house_catalog(
"/catalog/houses/ekaterinburg/ul_test/9999",
browser_fetcher=mock_fetcher,
)
# ── 4. Curl mode (default) — browser_fetcher stays None ──────────────────────
@pytest.mark.asyncio
async def test_fetch_detail_curl_mode_no_browser_fetcher() -> None:
"""Если browser_fetcher=None, curl path используется (собственная сессия)."""
from unittest.mock import patch as _patch
# Мокаем создание собственной сессии чтобы не нужен реальный curl_cffi
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = DETAIL_HTML
mock_session = AsyncMock()
mock_session.get = AsyncMock(return_value=mock_resp)
mock_session.close = AsyncMock()
# Kit _build_detail_session delegates to scraper_kit.providers._base.build_document_session
# (shared helper), which constructs AsyncSession there — not in the detail module itself.
with _patch(
"scraper_kit.providers._base.AsyncSession",
return_value=mock_session,
):
result = await fetch_detail("/ekaterinburg/kvartiry/test_99887766-1234")
# curl path взят — session.get был вызван
mock_session.get.assert_awaited_once()
assert result.item_id == "99887766"
# ── 5. SERP routing: real _fetch_serp_html routes through browser ────────────
@pytest.mark.asyncio
async def test_fetch_serp_html_routes_through_browser() -> None:
"""With _browser set and _cffi=None, the REAL _fetch_serp_html returns the
browser HTML and never dereferences _cffi (no AssertionError). Proves #915
SERP routing on top of #901's browser branch."""
from unittest.mock import AsyncMock
from scraper_kit.providers.avito.serp import AvitoScraper
from app.services.scraper_adapters import RealScraperConfig
scraper = AvitoScraper(RealScraperConfig())
assert scraper._cffi is None
scraper._browser = AsyncMock()
scraper._browser.fetch = AsyncMock(return_value="<html><body>real serp listing</body></html>")
html = await scraper._fetch_serp_html(
"https://www.avito.ru/ekaterinburg/kvartiry/prodam", page=1
)
assert html == "<html><body>real serp listing</body></html>"
scraper._browser.fetch.assert_awaited_once()
# ── 6. SERP browser sidecar timeout → transient retry, not bare exception ────
# #2472: BrowserFetcher.fetch (browser_fetcher.py) retries once internally on
# httpx.HTTPError/TransportError, then re-raises. Before this fix, non-
# HTTPStatusError exceptions (httpx.TimeoutException/TransportError — e.g.
# ReadTimeout/ConnectError/RemoteProtocolError) propagated unhandled through
# _fetch_serp_html_browser straight into pipeline.run_avito_full_load's generic
# `except Exception` → mark_failed(str(exc)) (str(ReadTimeout())=="" → empty
# error, and mark_failed does NOT persist done_buckets, unlike mark_banned).
@pytest.mark.asyncio
async def test_fetch_serp_html_browser_timeout_routes_transient_not_bare_exception() -> None:
"""httpx.ReadTimeout from browser.fetch() must NOT propagate as an unhandled
exception. It's classified transient (status=0, no soft-ban keyword match)
and, once the bounded retry budget is exhausted, surfaces as
AvitoRateLimitedError — the same graceful banned-path a sidecar
HTTPStatusError takes — never as a bare/generic exception."""
from unittest.mock import patch
import httpx
from scraper_kit.avito_exceptions import AvitoRateLimitedError
from scraper_kit.providers.avito import serp as serp_module
from scraper_kit.providers.avito.serp import AvitoScraper
from app.services.scraper_adapters import RealScraperConfig
scraper = AvitoScraper(RealScraperConfig())
assert scraper._cffi is None
scraper._browser = AsyncMock()
scraper._browser.fetch = AsyncMock(side_effect=httpx.ReadTimeout(""))
with patch.object(serp_module.asyncio, "sleep", AsyncMock()):
with pytest.raises(AvitoRateLimitedError):
await scraper._fetch_serp_html(
"https://www.avito.ru/ekaterinburg/kvartiry/prodam", page=1
)
# 1 initial attempt + 2 transient retries (_AVITO_SIDECAR_TRANSIENT_RETRIES) = 3,
# no IP rotation involved (RealScraperConfig has no rotate_url configured in tests).
assert scraper._browser.fetch.await_count == 3