feat(tradein/scraper-kit): shared base/utils для providers/* (#2358, Foundation) #2360
3 changed files with 378 additions and 3 deletions
|
|
@ -9,9 +9,13 @@
|
|||
RealSessionFactory → SessionFactory
|
||||
RealEnrichmentJobs → EnrichmentJobs
|
||||
|
||||
Пока НИКТО их не использует — скрапперы всё ещё на прямых импортах. Это подготовка:
|
||||
переключение вызовов на инжекцию — отдельный шаг (C). Держим сигнатуры в точном
|
||||
соответствии с исходными функциями, чтобы шаг C состыковался без правок контрактов.
|
||||
Wired в прод с #2192: `app.scheduler_main._run_kit_scheduler` конструирует
|
||||
`SchedulerContext` из этих адаптеров (`RealScraperConfig`/`RealMatcherAdapter`/
|
||||
`RealEnrichmentJobs`/`RealSessionFactory`/`RealProxyProvider`) и передаёт в
|
||||
`scraper_kit.orchestration.scheduler.scheduler_loop` — kit-native sweep-registry
|
||||
крутится через эту инжекцию, а не через прямые импорты `app.*`. Держим сигнатуры
|
||||
в точном соответствии с исходными функциями, чтобы Protocol'ы `scraper_kit.contracts`
|
||||
не расходились с боевой реализацией.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
|
|||
168
tradein-mvp/backend/tests/test_scraper_kit_providers_base.py
Normal file
168
tradein-mvp/backend/tests/test_scraper_kit_providers_base.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""Тесты 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
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
"""Общий фундамент для `providers/{avito,cian,yandex,domclick}/*` (#2358, Foundation).
|
||||
|
||||
Извлечено по факту чтения ~13k строк providers/* (issue #2358, эпик #2277 F4).
|
||||
Три РЕАЛЬНО повторяющихся паттерна:
|
||||
|
||||
1. **`BrowserFetcher(...)` конструировался независимо в 6+ местах**
|
||||
(`avito/serp.py:389`, `cian/serp.py:142`, `cian/newbuilding.py:147`,
|
||||
`yandex/serp.py:510`, `yandex/newbuilding.py:189,354`, `domclick/serp.py:319`),
|
||||
каждый раз вручную собирая `endpoint=config.browser_http_endpoint` +
|
||||
`proxy_provider=`/`use_pool=config.use_proxy_pool_browser`. Это ИМЕННО источник
|
||||
class-бага #2322/#2330: `endpoint=`/`config=` — либо optional и молча теряет
|
||||
поведение (proxy pool не подключается), либо забыт целиком (`TypeError` на
|
||||
вызове, как было в `yandex/cian.newbuilding` до #2322). `build_browser_fetcher()`
|
||||
принимает `config: ScraperConfig` **mandatory** (без default) — пропустить
|
||||
endpoint/use_pool технически невозможно, баг-класс закрыт структурно, а не
|
||||
точечным патчем.
|
||||
|
||||
2. **curl_cffi `AsyncSession(impersonate="chrome120", proxies=..., headers=...)`
|
||||
дублировался почти дословно** в `avito/serp.py::_build_cffi_session` (404-428),
|
||||
`avito/detail.py::_build_detail_session` (256-283, ИДЕНТИЧНЫЙ headers-dict),
|
||||
`avito/imv.py` (headers=_DOC_HEADERS, тот же dict третий раз), `cian/detail.py`
|
||||
(115-127), `cian/newbuilding.py` (дважды: 651-663 и 757-769), `cian/session.py`
|
||||
(117-128). Общий кусок вынесен в `build_curl_cffi_session` /
|
||||
`build_document_session`; идиома `{"http": url, "https": url} if url else None`
|
||||
(тот же 6+ раз) — в `http_proxies()`.
|
||||
|
||||
3. **"Document" browser-headers** (`Accept`/`Accept-Language`/`Cache-Control`/
|
||||
`Sec-Fetch-*`/`Upgrade-Insecure-Requests`) — идентичный dict в `avito/serp.py`,
|
||||
`avito/detail.py`, `avito/imv.py` (там уже был назван `_DOC_HEADERS`). Вынесен
|
||||
в `DOCUMENT_HEADERS` — единая точка правды.
|
||||
|
||||
**Осознанно НЕ извлечено (см. issue #2358 отчёт):** retry/backoff-циклы
|
||||
(`yandex/serp.py::fetch_around` tarpit-retry — rotate_ip+sleep(2) на status==0/
|
||||
JSON-error; `avito/serp.py::_fetch_serp_html_with_retry` — page-level backoff
|
||||
на generic Exception, кроме `AvitoBlockedError`/`AvitoRateLimitedError`;
|
||||
`BaseScraper._http_get` — tenacity-декоратор). Все три семантически РАЗНЫЕ:
|
||||
разные множества исключений, разное действие между попытками (IP-ротация vs
|
||||
голый sleep vs tenacity), разная политика "что не ретраить". Общий helper
|
||||
проглотил бы эти различия (leaky abstraction) — риск больше выгоды при текущей
|
||||
похожести. Кандидат на пересмотр, если появится 4-й похожий цикл.
|
||||
|
||||
**Scope:** этот модуль — НОВЫЙ, аддитивный. Existing `providers/{avito,cian,
|
||||
yandex,domclick}/*.py` НЕ переведены на него (миграция call-site'ов — отдельные
|
||||
future issues F4a-d). Здесь только новый shared-слой + тесты, 0 изменений в
|
||||
провайдерах.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from curl_cffi.requests import AsyncSession
|
||||
|
||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from scraper_kit.contracts import ProxyProvider, ScraperConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# curl_cffi impersonate-профиль, используемый ВСЕМИ providers без исключения
|
||||
# (TLS ClientHello настоящего Chrome — обход fingerprint-детекта).
|
||||
DEFAULT_IMPERSONATE = "chrome120"
|
||||
|
||||
# "Document"-style browser headers для curl_cffi GET верхнеуровневых HTML-страниц
|
||||
# (SERP/detail — НЕ XHR/JSON API-запросы, для них headers не нужны/другие).
|
||||
# Идентичен ранее продублированному `_DOC_HEADERS` (avito/imv.py) и inline-dict
|
||||
# в avito/serp.py::_build_cffi_session / avito/detail.py::_build_detail_session.
|
||||
DOCUMENT_HEADERS: dict[str, str] = {
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||
"Cache-Control": "max-age=0",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
}
|
||||
|
||||
|
||||
def http_proxies(proxy_url: str | None) -> dict[str, str] | None:
|
||||
"""curl_cffi/httpx-совместимый `proxies=` dict из одного url.
|
||||
|
||||
`None`/пустая строка → `None` (прямое подключение — без прокси).
|
||||
|
||||
Извлечено из идентичной идиомы `{"http": url, "https": url} if url else None`,
|
||||
продублированной в avito/serp.py, avito/detail.py, avito/imv.py, cian/detail.py,
|
||||
cian/newbuilding.py (дважды), cian/session.py.
|
||||
"""
|
||||
return {"http": proxy_url, "https": proxy_url} if proxy_url else None
|
||||
|
||||
|
||||
def build_curl_cffi_session(
|
||||
*,
|
||||
proxy_url: str | None,
|
||||
timeout: float = 25.0,
|
||||
headers: dict[str, str] | None = None,
|
||||
cookies: dict[str, str] | None = None,
|
||||
impersonate: str = DEFAULT_IMPERSONATE,
|
||||
) -> AsyncSession:
|
||||
"""Собрать curl_cffi `AsyncSession` с TLS-fingerprint impersonation.
|
||||
|
||||
`proxy_url` — уже РЕЗОЛВЛЕННЫЙ url, не `ScraperConfig`. Разные providers
|
||||
достают proxy-url по-разному (`config.scraper_proxy_url` для avito,
|
||||
`config.cian_proxy_url` для cian, pool-aware `providers._proxy.curl_proxy_url()`
|
||||
контекст-менеджер для yandex/#2163) — эта функция намеренно НЕ решает, откуда
|
||||
взять url, только собирает сессию из уже готового значения. Заставлять её
|
||||
принимать `ScraperConfig` было бы overengineering: пришлось бы либо гадать имя
|
||||
атрибута по provider-строке (магия), либо всё равно требовать явный proxy_url
|
||||
параметр — тогда мандатори-`config` ничего не даёт (в отличие от
|
||||
`build_browser_fetcher`, где `ScraperConfig` содержит ОДНОЗНАЧНЫЙ единый
|
||||
`browser_http_endpoint`/`use_proxy_pool_browser` независимо от provider).
|
||||
|
||||
`headers=None` → curl_cffi дефолтные заголовки (caller решает нужны ли
|
||||
document-style headers; см. `build_document_session` для явного варианта).
|
||||
"""
|
||||
return AsyncSession(
|
||||
impersonate=impersonate,
|
||||
timeout=timeout,
|
||||
proxies=http_proxies(proxy_url),
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
)
|
||||
|
||||
|
||||
def build_document_session(
|
||||
*,
|
||||
proxy_url: str | None,
|
||||
timeout: float = 25.0,
|
||||
impersonate: str = DEFAULT_IMPERSONATE,
|
||||
) -> AsyncSession:
|
||||
"""`build_curl_cffi_session` с `DOCUMENT_HEADERS` — document-like GET (SERP/detail HTML).
|
||||
|
||||
Это ровно та форма, что дублирована в `avito/serp.py::_build_cffi_session` и
|
||||
`avito/detail.py::_build_detail_session` (identical headers, timeout=25,
|
||||
impersonate=chrome120) — единственная разница между сайтами вызова была
|
||||
источник `proxy_url` (`config.scraper_proxy_url` в обоих случаях, но
|
||||
прокидывалось разными путями).
|
||||
"""
|
||||
return build_curl_cffi_session(
|
||||
proxy_url=proxy_url,
|
||||
timeout=timeout,
|
||||
headers=DOCUMENT_HEADERS,
|
||||
impersonate=impersonate,
|
||||
)
|
||||
|
||||
|
||||
def build_browser_fetcher(
|
||||
config: ScraperConfig,
|
||||
source: str,
|
||||
*,
|
||||
proxy_provider: ProxyProvider | None = None,
|
||||
fetch_timeout_s: float | None = None,
|
||||
) -> BrowserFetcher:
|
||||
"""Собрать `BrowserFetcher` с `config: ScraperConfig` **mandatory**.
|
||||
|
||||
Устраняет footgun-класс #2322/#2330: до этой фабрики каждый provider
|
||||
конструировал `BrowserFetcher(...)` вручную и был обязан не забыть
|
||||
`endpoint=config.browser_http_endpoint` — если забывал (или `config` был
|
||||
optional где-то выше по стеку), браузерный fetch либо падал `TypeError`
|
||||
(endpoint — required kwarg без default в `BrowserFetcher.__init__`), либо
|
||||
молча не подключал proxy-pool (`use_pool` не проставлен → всегда False).
|
||||
|
||||
Здесь `config` не имеет default — вызвать эту функцию без реального
|
||||
`ScraperConfig` невозможно на уровне сигнатуры (не полагаемся на
|
||||
code-review чтобы поймать пропущенный параметр).
|
||||
|
||||
`proxy_provider=None` (дефолт) — валидно: часть providers (avito legacy
|
||||
curl-only путь, domclick, cian/yandex newbuilding) пока не подключены к
|
||||
browser-proxy-пулу (#2160 P4 wiring gap, отдельный issue) — `use_pool` в
|
||||
этом случае эффективно игнорируется `BrowserFetcher` (`proxy_provider is
|
||||
None` → env-fallback, см. `browser_fetcher.py::_pool_proxy`).
|
||||
|
||||
`fetch_timeout_s=None` (дефолт) → используется дефолт `BrowserFetcher`
|
||||
(120s). Yandex-провайдер передаёt здесь 30s явно (см. вызовы в
|
||||
`yandex/serp.py`/`yandex/newbuilding.py`) — единственный provider с
|
||||
отличным от дефолта таймаутом.
|
||||
"""
|
||||
if fetch_timeout_s is None:
|
||||
return BrowserFetcher(
|
||||
source=source,
|
||||
endpoint=config.browser_http_endpoint,
|
||||
proxy_provider=proxy_provider,
|
||||
use_pool=config.use_proxy_pool_browser,
|
||||
)
|
||||
return BrowserFetcher(
|
||||
source=source,
|
||||
fetch_timeout_s=fetch_timeout_s,
|
||||
endpoint=config.browser_http_endpoint,
|
||||
proxy_provider=proxy_provider,
|
||||
use_pool=config.use_proxy_pool_browser,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_IMPERSONATE",
|
||||
"DOCUMENT_HEADERS",
|
||||
"build_browser_fetcher",
|
||||
"build_curl_cffi_session",
|
||||
"build_document_session",
|
||||
"http_proxies",
|
||||
]
|
||||
Loading…
Add table
Reference in a new issue