Merge pull request 'feat(tradein/scraper-kit): shared base/utils для providers/* (#2358, Foundation)' (#2360) from feat/tradein-scraper-kit-base-2358 into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m32s
Deploy Trade-In / build-backend (push) Successful in 1m16s
Deploy Trade-In / deploy (push) Successful in 1m11s
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m32s
Deploy Trade-In / build-backend (push) Successful in 1m16s
Deploy Trade-In / deploy (push) Successful in 1m11s
This commit is contained in:
commit
d31fabd102
3 changed files with 382 additions and 3 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
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,207 @@
|
||||||
|
"""Общий фундамент для `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-совместимый `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.
|
||||||
|
|
||||||
|
ВАЖНО: не для httpx — начиная с httpx 0.28 `Client`/`AsyncClient` больше не
|
||||||
|
принимают dict в `proxies=` (только одиночный `proxy=` строкой). Не передавай
|
||||||
|
этот dict в httpx-клиент — `TypeError`.
|
||||||
|
"""
|
||||||
|
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