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.
This commit is contained in:
bot-backend 2026-07-04 03:39:43 +03:00
parent 91c8365d91
commit 9b4cdf8ec6
3 changed files with 378 additions and 3 deletions

View file

@ -9,9 +9,13 @@
RealSessionFactory SessionFactory RealSessionFactory SessionFactory
RealEnrichmentJobs EnrichmentJobs RealEnrichmentJobs EnrichmentJobs
Пока НИКТО их не использует скрапперы всё ещё на прямых импортах. Это подготовка: Wired в прод с #2192: `app.scheduler_main._run_kit_scheduler` конструирует
переключение вызовов на инжекцию отдельный шаг (C). Держим сигнатуры в точном `SchedulerContext` из этих адаптеров (`RealScraperConfig`/`RealMatcherAdapter`/
соответствии с исходными функциями, чтобы шаг C состыковался без правок контрактов. `RealEnrichmentJobs`/`RealSessionFactory`/`RealProxyProvider`) и передаёт в
`scraper_kit.orchestration.scheduler.scheduler_loop` kit-native sweep-registry
крутится через эту инжекцию, а не через прямые импорты `app.*`. Держим сигнатуры
в точном соответствии с исходными функциями, чтобы Protocol'ы `scraper_kit.contracts`
не расходились с боевой реализацией.
""" """
from __future__ import annotations from __future__ import annotations

View 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

View file

@ -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",
]