All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m40s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m50s
Deploy Trade-In / deploy (push) Successful in 7m48s
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
322 lines
13 KiB
Python
322 lines
13 KiB
Python
"""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
|