gendesign/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py
bot-backend 9b4cdf8ec6 feat(tradein/scraper-kit): shared base/utils для providers/* (#2358, Foundation)
Аддитивный Foundation-модуль (эпик #2277 Group F4): scraper_kit/providers/_base.py
извлекает 3 реально повторяющихся паттерна, найденных чтением avito/cian/yandex/
domclick providers (~13k строк):

- build_browser_fetcher(config: ScraperConfig, source, ...) — config MANDATORY,
  устраняет footgun-класс #2322/#2330 (BrowserFetcher конструировался вручную в
  6+ местах, endpoint/proxy-pool wiring легко забыть/потерять).
- build_curl_cffi_session / build_document_session + http_proxies() — общий
  curl_cffi AsyncSession(impersonate=chrome120, proxies=..., headers=...)
  boilerplate, дублированный в avito/serp.py, avito/detail.py, avito/imv.py,
  cian/detail.py, cian/newbuilding.py (x2), cian/session.py.
- DOCUMENT_HEADERS — идентичный "document" headers dict, дублированный трижды.

Retry/backoff-циклы (yandex tarpit-retry, avito page-retry, tenacity в
BaseScraper) сознательно НЕ обобщены — разная семантика ошибок/действий между
попытками, обобщение дало бы leaky abstraction (см. docstring модуля).

Existing providers/{avito,cian,yandex,domclick}/*.py НЕ тронуты — миграция
call-site'ов на новый модуль это отдельные future issues (F4a-d).

Заодно: актуализирован устаревший докстринг app/services/scraper_adapters.py
("Пока НИКТО их не использует") — адаптеры wired в прод с #2192 через
app.scheduler_main._run_kit_scheduler.
2026-07-04 03:50:55 +03:00

168 lines
6.3 KiB
Python
Raw Permalink 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.

"""Тесты shared-фундамента `scraper_kit.providers._base` (#2358, Foundation).
Покрывает:
- `http_proxies()` — url → dict / None passthrough.
- `build_curl_cffi_session()` / `build_document_session()` — корректные kwargs
в curl_cffi `AsyncSession` (impersonate/timeout/proxies/headers/cookies).
- `build_browser_fetcher()` — `config` MANDATORY (нет default в сигнатуре);
endpoint/use_pool/proxy_provider корректно прокинуты; `fetch_timeout_s`
опционален (дефолт `BrowserFetcher`, если не передан явно).
Изолированные — никакой интеграции с existing providers/avito|cian|yandex|domclick.
"""
from __future__ import annotations
import inspect
from typing import Any
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.providers._base import (
DEFAULT_IMPERSONATE,
DOCUMENT_HEADERS,
build_browser_fetcher,
build_curl_cffi_session,
build_document_session,
http_proxies,
)
class _FakeConfig:
"""Минимальная ScraperConfig-заглушка — только поля, читаемые build_browser_fetcher."""
def __init__(self, endpoint: str, use_pool: bool = False) -> None:
self.browser_http_endpoint = endpoint
self.use_proxy_pool_browser = use_pool
class _FakeProxyProvider:
"""ProxyProvider-заглушка — сюда достаточно структурного соответствия, вызовы не нужны."""
def acquire(self, provider: str) -> Any:
raise NotImplementedError
def release(self, lease: Any) -> None:
raise NotImplementedError
def mark_health(self, lease: Any, ok: bool, **_: Any) -> None:
raise NotImplementedError
# ── http_proxies ──────────────────────────────────────────────────────────────
def test_http_proxies_none_passthrough() -> None:
assert http_proxies(None) is None
def test_http_proxies_empty_string_is_none() -> None:
assert http_proxies("") is None
def test_http_proxies_builds_dict_for_both_schemes() -> None:
result = http_proxies("http://user:pass@proxy:8080")
assert result == {
"http": "http://user:pass@proxy:8080",
"https": "http://user:pass@proxy:8080",
}
# ── build_curl_cffi_session / build_document_session ──────────────────────────
async def test_build_curl_cffi_session_wires_impersonate_timeout_proxies() -> None:
session = build_curl_cffi_session(proxy_url="http://proxy:8080", timeout=15.0)
try:
assert session.impersonate == DEFAULT_IMPERSONATE
assert session.timeout == 15.0
assert session.proxies == {"http": "http://proxy:8080", "https": "http://proxy:8080"}
finally:
await session.close()
async def test_build_curl_cffi_session_no_proxy_direct_connection() -> None:
session = build_curl_cffi_session(proxy_url=None)
try:
# curl_cffi нормализует proxies=None в {} (falsy) — прямое подключение.
assert not session.proxies
finally:
await session.close()
async def test_build_curl_cffi_session_passes_headers_and_cookies() -> None:
session = build_curl_cffi_session(
proxy_url=None, headers={"X-Test": "1"}, cookies={"sid": "abc"}
)
try:
assert dict(session.headers).get("x-test") == "1"
assert session.cookies.get("sid") == "abc"
finally:
await session.close()
async def test_build_document_session_uses_document_headers() -> None:
session = build_document_session(proxy_url=None)
try:
headers = dict(session.headers)
for key, value in DOCUMENT_HEADERS.items():
assert headers.get(key.lower()) == value
finally:
await session.close()
async def test_build_document_session_custom_timeout_and_proxy() -> None:
session = build_document_session(proxy_url="http://proxy:8080", timeout=10.0)
try:
assert session.timeout == 10.0
assert session.proxies == {"http": "http://proxy:8080", "https": "http://proxy:8080"}
finally:
await session.close()
# ── build_browser_fetcher ──────────────────────────────────────────────────────
def test_build_browser_fetcher_config_is_mandatory_in_signature() -> None:
"""config — параметр БЕЗ default в сигнатуре: пропустить его невозможно (footgun-guard)."""
sig = inspect.signature(build_browser_fetcher)
config_param = sig.parameters["config"]
assert config_param.default is inspect.Parameter.empty
def test_build_browser_fetcher_wires_endpoint_and_use_pool() -> None:
config = _FakeConfig(endpoint="http://browser:3000", use_pool=True)
provider = _FakeProxyProvider()
fetcher = build_browser_fetcher(config, "avito", proxy_provider=provider) # type: ignore[arg-type]
assert isinstance(fetcher, BrowserFetcher)
assert fetcher._endpoint == "http://browser:3000"
assert fetcher._use_pool is True
assert fetcher._proxy_provider is provider
assert fetcher._source == "avito"
def test_build_browser_fetcher_use_pool_false_by_default_config() -> None:
config = _FakeConfig(endpoint="http://browser:3000", use_pool=False)
fetcher = build_browser_fetcher(config, "cian") # type: ignore[arg-type]
assert fetcher._use_pool is False
assert fetcher._proxy_provider is None
def test_build_browser_fetcher_default_timeout_when_not_specified() -> None:
config = _FakeConfig(endpoint="http://browser:3000")
fetcher = build_browser_fetcher(config, "yandex") # type: ignore[arg-type]
# Дефолт BrowserFetcher (_HTTP_TIMEOUT_S = 120.0) — не переопределён.
assert fetcher._fetch_timeout_s == 120.0
def test_build_browser_fetcher_explicit_timeout_override() -> None:
config = _FakeConfig(endpoint="http://browser:3000")
fetcher = build_browser_fetcher(config, "yandex", fetch_timeout_s=30.0) # type: ignore[arg-type]
assert fetcher._fetch_timeout_s == 30.0