Живой замер браузера 2026-08-21 разошёлся с тем, что шлёт прогретая сессия:
- impersonate="chrome120" был захардкожен в 9+ местах (avito/{serp,detail,imv,
houses}.py, pipeline.py x4, cian/valuation.py, yandex/valuation.py) вместо
единой DEFAULT_IMPERSONATE (providers/_base.py). Обновление до chrome146
(макс. доступный профиль curl_cffi 0.15.0; alias "chrome" НЕ используется -
едет сам при апгрейде библиотеки без ревью) теперь меняется в одном месте.
Guard-тест backend/tests/test_impersonate_single_source.py грепает всё
дерево scraper_kit на литерал "chrome120".
- DOCUMENT_HEADERS ставил Sec-Fetch-Site="none" на уровне сессии, а Referer
добавлялся per-request (curl_cffi мёржит per-request headers поверх
session-level) - живой Referer с "none" рядом не бывает у настоящего
Chrome. Добавлен referer_headers() (Sec-Fetch-Site="cross-site") для
caller'ов с чужедоменным Referer; avito warm-up (yandex/ya.ru -> avito)
теперь его использует. Внутренний avito-search -> avito-detail Referer
(fetch_detail, same-origin) НЕ тронут - отдельный явный комментарий почему.
- Referer прогрева заменён с yandex.ru на ya.ru (живой переход из выдачи
даёт короткий домен). ysclid сознательно не добавлен - значение выпускает
Яндекс, подделка хуже отсутствия.
- warm_up_session/research_in_session считали прогрев успешным по HTTP-
статусу и отсутствию firewall-маркеров, не проверяя антибот-cookies
(__zzatw-*/cfidsw-*, сняты с живого браузера) - detail-батч на такой
"прогретой" сессии сжигал прокси на обречённых 403. Теперь поднимают
AvitoWarmupCookiesMissingError (наследник AvitoBlockedError - существующие
except-блоки/ban_kind/proxy-ротация в pipeline и backfill ловят без
изменений).
Полный backend pytest suite зелёный (4672 passed), ruff check чист.
Refs #3034
224 lines
9.2 KiB
Python
224 lines
9.2 KiB
Python
"""Тесты 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,
|
||
referer_headers,
|
||
)
|
||
|
||
|
||
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()
|
||
|
||
|
||
# ── Sec-Fetch-Site (#3034) ───────────────────────────────────────────────────
|
||
|
||
|
||
def test_document_headers_default_sec_fetch_site_is_none() -> None:
|
||
"""Без Referer на конкретном запросе — "none" остаётся дефолтом session-level
|
||
заголовков (запрос без Referer = «адрес введён руками», это ЗАДУМАННОЕ значение
|
||
для caller'ов, которые никогда не добавляют Referer, например SERP-фетч)."""
|
||
assert DOCUMENT_HEADERS["Sec-Fetch-Site"] == "none"
|
||
|
||
|
||
def test_referer_headers_sets_cross_site() -> None:
|
||
"""referer_headers() — per-request override для caller'ов с Referer с ДРУГОГО
|
||
домена (#3034): должен явно менять Sec-Fetch-Site на "cross-site", а не оставлять
|
||
его наследоваться от session-level DOCUMENT_HEADERS ("none")."""
|
||
headers = referer_headers("https://ya.ru/")
|
||
assert headers == {"Referer": "https://ya.ru/", "Sec-Fetch-Site": "cross-site"}
|
||
|
||
|
||
async def test_referer_headers_overrides_session_default_when_merged() -> None:
|
||
"""Прямая проверка сценария #3034: сессия построена с DOCUMENT_HEADERS
|
||
(Sec-Fetch-Site="none"), per-request headers=referer_headers(...) должен
|
||
победить session-default при мёрже curl_cffi (per-request поверх session-level)."""
|
||
session = build_document_session(proxy_url=None)
|
||
try:
|
||
merged = dict(session.headers)
|
||
merged.update({k.lower(): v for k, v in referer_headers("https://ya.ru/").items()})
|
||
assert merged["sec-fetch-site"] == "cross-site"
|
||
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
|
||
|
||
|
||
# ── environment threading (#2616 шаг 1) ──────────────────────────────────────
|
||
|
||
|
||
def test_build_browser_fetcher_defaults_environment_dev_when_config_lacks_field() -> None:
|
||
"""_FakeConfig (минимальная заглушка) не имеет поля `environment` — getattr-дефолт
|
||
"dev" защищает от AttributeError и не меняет поведение старых/минимальных фейков.
|
||
"""
|
||
config = _FakeConfig(endpoint="http://browser:3000")
|
||
|
||
fetcher = build_browser_fetcher(config, "cian") # type: ignore[arg-type]
|
||
|
||
assert fetcher._environment == "dev"
|
||
|
||
|
||
def test_build_browser_fetcher_threads_environment_from_config() -> None:
|
||
"""config.environment (когда есть) прокидывается в BrowserFetcher как есть."""
|
||
config = _FakeConfig(endpoint="http://browser:3000", use_pool=True)
|
||
config.environment = "production" # type: ignore[attr-defined]
|
||
|
||
fetcher = build_browser_fetcher(config, "avito") # type: ignore[arg-type]
|
||
|
||
assert fetcher._environment == "production"
|