Compare commits
No commits in common. "d31fabd102fa23373c2ee294da806f774fcba97b" and "91c8365d917bb7d71c0f21905a8a4781d2f94475" have entirely different histories.
d31fabd102
...
91c8365d91
3 changed files with 3 additions and 382 deletions
|
|
@ -9,13 +9,9 @@
|
||||||
RealSessionFactory → SessionFactory
|
RealSessionFactory → SessionFactory
|
||||||
RealEnrichmentJobs → EnrichmentJobs
|
RealEnrichmentJobs → EnrichmentJobs
|
||||||
|
|
||||||
Wired в прод с #2192: `app.scheduler_main._run_kit_scheduler` конструирует
|
Пока НИКТО их не использует — скрапперы всё ещё на прямых импортах. Это подготовка:
|
||||||
`SchedulerContext` из этих адаптеров (`RealScraperConfig`/`RealMatcherAdapter`/
|
переключение вызовов на инжекцию — отдельный шаг (C). Держим сигнатуры в точном
|
||||||
`RealEnrichmentJobs`/`RealSessionFactory`/`RealProxyProvider`) и передаёт в
|
соответствии с исходными функциями, чтобы шаг C состыковался без правок контрактов.
|
||||||
`scraper_kit.orchestration.scheduler.scheduler_loop` — kit-native sweep-registry
|
|
||||||
крутится через эту инжекцию, а не через прямые импорты `app.*`. Держим сигнатуры
|
|
||||||
в точном соответствии с исходными функциями, чтобы Protocol'ы `scraper_kit.contracts`
|
|
||||||
не расходились с боевой реализацией.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
"""Тесты 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
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
"""Общий фундамент для `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