s-librarykit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- librarykit/__init__.py +238 -0
- librarykit/antibot.py +467 -0
- librarykit/auth.py +423 -0
- librarykit/browser.py +722 -0
- librarykit/checkpoint.py +187 -0
- librarykit/config_util.py +281 -0
- librarykit/contract.py +376 -0
- librarykit/entities.py +151 -0
- librarykit/enums.py +42 -0
- librarykit/errmap.py +352 -0
- librarykit/errors.py +261 -0
- librarykit/orchestration/__init__.py +43 -0
- librarykit/orchestration/health.py +308 -0
- librarykit/orchestration/onboarding.py +428 -0
- librarykit/orchestration/session_loader.py +121 -0
- librarykit/pagination.py +368 -0
- librarykit/ports.py +95 -0
- librarykit/protocols.py +183 -0
- librarykit/retry.py +287 -0
- librarykit/rpc.py +191 -0
- librarykit/secret_store.py +184 -0
- librarykit/sessions.py +811 -0
- librarykit/stream.py +166 -0
- librarykit/transport.py +738 -0
- s_librarykit-0.1.0.dist-info/METADATA +84 -0
- s_librarykit-0.1.0.dist-info/RECORD +28 -0
- s_librarykit-0.1.0.dist-info/WHEEL +4 -0
- s_librarykit-0.1.0.dist-info/licenses/LICENSE +21 -0
librarykit/antibot.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""Антибот-стратегия (КОРЕНЬ китов, W4) как ВЫБОР ТРАНСПОРТА, а не переписывание.
|
|
2
|
+
|
|
3
|
+
Перенесено из `adapterkit.antibot` в жирный КОРЕНЬ `librarykit` (граф
|
|
4
|
+
`librarykit <- adapterkit <- clikit`); `adapterkit.antibot` — тонкий реэкспорт.
|
|
5
|
+
curl-cffi — ОПЦИОНАЛЬНЫЙ extra `[antibot]` (ленивый импорт внутри транспорта),
|
|
6
|
+
librarykit-without-extras не падает на import пакета.
|
|
7
|
+
|
|
8
|
+
Идея (spec §2, recipes/antibot reverse-factory): антибот — это не «headless vs
|
|
9
|
+
residential proxy», а пять уровней архитектуры (Tier 0-4). Сервис «опт-инится» в
|
|
10
|
+
нужный уровень, просто выбрав транспорт у эндпоинта (`Endpoint.transport`):
|
|
11
|
+
|
|
12
|
+
- `TransportKind.HTTP` → `HttpxTransport` (Tier 0, обычный httpx) — другой модуль;
|
|
13
|
+
- `TransportKind.CURL_CFFI` → `CurlCffiTransport` (Tier 1-3, JA3-имитация Chrome);
|
|
14
|
+
- `TransportKind.BROWSER` → `CdpTransport` (Tier 0-1, реальный системный Edge/Chrome).
|
|
15
|
+
|
|
16
|
+
Каждый из них реализует ОДИН И ТОТ ЖЕ контракт `librarykit.contract.Transport`
|
|
17
|
+
(`async request(method, url, *, ...) -> httpx.Response`), поэтому вышестоящий код
|
|
18
|
+
(`HttpClient`/`ErrorMapper`/адаптер) не различает транспорты — меняется только
|
|
19
|
+
TLS-fingerprint и источник заголовков, но НЕ форма вызова. Маппинг кодов в
|
|
20
|
+
доменные ошибки — НЕ здесь (это `HttpClient`/`ErrorMapper`); транспорт бросает
|
|
21
|
+
`TransportError` только на сетевых сбоях и возвращает сырой `httpx.Response`.
|
|
22
|
+
|
|
23
|
+
Опциональные зависимости (`curl_cffi`, `playwright`/`patchright`) импортируются
|
|
24
|
+
ЛЕНИВО — модуль импортируется без них (graceful), а понятная ошибка о
|
|
25
|
+
недостающем пакете возникает только при реальной попытке использовать транспорт.
|
|
26
|
+
|
|
27
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
Tier 0-4 — какой транспорт выбрать под нагрузку и характер защиты
|
|
29
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
Антибот эскалируется уровнями; не «беги за прокси первым», а подбирай по симптому.
|
|
31
|
+
|
|
32
|
+
- **Tier 0** — видимый браузер (headed) / обычный httpx. RAM ~800 МБ. dev / debug /
|
|
33
|
+
первый bootstrap-логин. Здесь: `CdpTransport(headless=False)` / `HttpxTransport`.
|
|
34
|
+
- **Tier 1** — headless реальный браузер (CDP→Edge `--headless=new`), ~700 мс,
|
|
35
|
+
500-800 МБ; prod 1-100 calls/day, антибот средний. Здесь: `CdpTransport(headless=True)`.
|
|
36
|
+
Вариант того же уровня — curl-cffi (JA3-имитация Chrome), ~10 МБ: JA3/TLS-детект
|
|
37
|
+
(Akamai, Google) БЕЗ signing. Здесь: `CurlCffiTransport`.
|
|
38
|
+
- **Tier 2** — jsdom one-shot subprocess, 3-5 с, 30-50 МБ; init-флоу известен,
|
|
39
|
+
sign-only без рендера. Вне этого модуля.
|
|
40
|
+
- **Tier 3** — jsdom persistent daemon (RPC), ~50 мс, ~80 МБ; prod sweet-spot
|
|
41
|
+
100-10K calls/day. Вне этого модуля.
|
|
42
|
+
- **Tier 4** — pure native (Python/Go/Rust), ~1 мс, ~10 МБ; bundle pinned forever
|
|
43
|
+
+ есть команда сопровождения. Вне этого модуля.
|
|
44
|
+
|
|
45
|
+
Дерево решений:
|
|
46
|
+
- read-only без подписи запроса → Tier 0/1 (или официальный REST, если есть);
|
|
47
|
+
- мутирующий вызов с подписью (X-Sign/X-Bogus/_signature/msToken):
|
|
48
|
+
* bootstrap-логин → `CdpTransport` (реальный Edge ставит нативные
|
|
49
|
+
`sec-fetch-*` / Client-Hints / TLS — patchright их НЕ шлёт);
|
|
50
|
+
* массовая prod-нагрузка → Tier 3 jsdom-daemon (вне adapterkit), а Python-сторона
|
|
51
|
+
шлёт уже подписанный URL через `CurlCffiTransport` (JA3 совпадает).
|
|
52
|
+
|
|
53
|
+
Симптом → направление (три ОРТОГОНАЛЬНЫХ оси, выбирай по симптому, не «по простоте»):
|
|
54
|
+
- `403`/`Access denied` на любом IP, RU-регион заблокирован → IP/proxy (дорого, last resort);
|
|
55
|
+
- видимый captcha-виджет (hCaptcha/Turnstile) → внешний solver (reCAPTCHA v3 — score-based,
|
|
56
|
+
solver НЕ работает, только реальный браузер `CdpTransport`);
|
|
57
|
+
- `signatures don't match` / `isTTwidDecryptedFail` / «unusual activity» / нет
|
|
58
|
+
`sec-fetch-*` → архитектура: `CdpTransport` (реальный Edge);
|
|
59
|
+
- `ECONNRESET` / другой контент на JA3-detection endpoint → архитектура: `CurlCffiTransport`.
|
|
60
|
+
|
|
61
|
+
Железные правила (анти-паттерны):
|
|
62
|
+
- версию Chrome НЕ пинить: захардкоженный `chrome131` через 4+ версии — сигнал «бот»
|
|
63
|
+
(`CurlCffiTransport` подбирает свежайший доступный профиль автоматически);
|
|
64
|
+
- UA major == sec-ch-ua major == curl-cffi `impersonate` major — рассинхрон палится;
|
|
65
|
+
- `CdpTransport` берёт версию браузера сам — держи системный Edge/Chrome обновлённым.
|
|
66
|
+
"""
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import asyncio
|
|
70
|
+
from collections.abc import Mapping
|
|
71
|
+
from typing import Any, Protocol, runtime_checkable
|
|
72
|
+
|
|
73
|
+
import httpx
|
|
74
|
+
|
|
75
|
+
from librarykit.contract import Transport
|
|
76
|
+
from librarykit.errors import TransportError
|
|
77
|
+
|
|
78
|
+
# =========================================================================== #
|
|
79
|
+
# Подбор impersonate-профиля curl-cffi (версию Chrome не пиним) #
|
|
80
|
+
# =========================================================================== #
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pick_chrome_impersonate(target_major: int | None = None) -> str:
|
|
84
|
+
"""Вернуть лучший доступный `impersonate` для Chrome из установленного curl-cffi.
|
|
85
|
+
|
|
86
|
+
- `target_major` — желаемый major (актуальная stable). `None` → самый свежий
|
|
87
|
+
`chromeNNN`, который знает текущий curl-cffi.
|
|
88
|
+
|
|
89
|
+
Если точного профиля нет — спускаемся к ближайшему МЕНЬШЕМУ; если target свежее
|
|
90
|
+
всего, что знает пакет, — берём максимум доступный (намёк: обновить curl-cffi).
|
|
91
|
+
Бросает `TransportError`, если curl-cffi не установлен или не знает ни одного
|
|
92
|
+
chrome-профиля (нечем имитировать JA3).
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
from curl_cffi.requests.impersonate import BrowserType
|
|
96
|
+
except ImportError as exc: # curl-cffi — опциональная зависимость (extra "antibot")
|
|
97
|
+
raise TransportError(
|
|
98
|
+
"curl_cffi не установлен — CurlCffiTransport недоступен. "
|
|
99
|
+
"Установите: uv sync --extra antibot (или uv add curl-cffi)."
|
|
100
|
+
) from exc
|
|
101
|
+
|
|
102
|
+
chrome_versions: list[tuple[int, str]] = []
|
|
103
|
+
for b in BrowserType:
|
|
104
|
+
name = str(b.value) # напр. "chrome148", "chrome120", "edge99", ...
|
|
105
|
+
suffix = name.removeprefix("chrome")
|
|
106
|
+
if name.startswith("chrome") and suffix.isdigit():
|
|
107
|
+
chrome_versions.append((int(suffix), name))
|
|
108
|
+
if not chrome_versions:
|
|
109
|
+
raise TransportError(
|
|
110
|
+
"curl_cffi не знает ни одного chrome-профиля — обновите пакет "
|
|
111
|
+
"(uv pip install -U curl-cffi)."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
chrome_versions.sort() # по возрастанию major
|
|
115
|
+
if target_major is None:
|
|
116
|
+
return chrome_versions[-1][1] # самый свежий доступный
|
|
117
|
+
for major, name in chrome_versions:
|
|
118
|
+
if major == target_major:
|
|
119
|
+
return name
|
|
120
|
+
lower = [c for c in chrome_versions if c[0] < target_major]
|
|
121
|
+
if lower:
|
|
122
|
+
return lower[-1][1] # ближайший меньший ≤ target
|
|
123
|
+
return chrome_versions[-1][1] # target свежее пакета → максимум доступный
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# =========================================================================== #
|
|
127
|
+
# Tier 1-3: curl-cffi транспорт (JA3-имитация реального Chrome) #
|
|
128
|
+
# =========================================================================== #
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@runtime_checkable
|
|
132
|
+
class _CurlSessionLike(Protocol):
|
|
133
|
+
"""Структурный минимум curl-cffi `AsyncSession` (для DI-фейков в тестах).
|
|
134
|
+
|
|
135
|
+
Реальный `curl_cffi.requests.AsyncSession` совпадает по форме; в тестах
|
|
136
|
+
подсовываем фейк той же формы, не таща сам curl-cffi.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
async def request(self, method: str, url: str, **kwargs: Any) -> Any:
|
|
140
|
+
"""Выполнить запрос; вернуть объект-ответ curl-cffi (см. `_to_httpx_response`)."""
|
|
141
|
+
...
|
|
142
|
+
|
|
143
|
+
async def close(self) -> None:
|
|
144
|
+
"""Закрыть нижележащую сессию/соединения."""
|
|
145
|
+
...
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _to_httpx_response(
|
|
149
|
+
raw: Any, *, request: httpx.Request, default_encoding: str = "utf-8"
|
|
150
|
+
) -> httpx.Response:
|
|
151
|
+
"""Сконвертировать ответ стороннего backend (curl-cffi) в `httpx.Response`.
|
|
152
|
+
|
|
153
|
+
Нормализует разнотипный ответ к единому контракту `Transport`: вышестоящий код
|
|
154
|
+
(`ErrorMapper`/адаптер) работает только с `httpx.Response`. Тянем status/headers/
|
|
155
|
+
тело и привязываем реконструированный `httpx.Request` (нужен для url в ошибках).
|
|
156
|
+
"""
|
|
157
|
+
status = int(getattr(raw, "status_code", 0) or 0)
|
|
158
|
+
raw_headers = getattr(raw, "headers", None) or {}
|
|
159
|
+
headers = dict(raw_headers.items()) if hasattr(raw_headers, "items") else dict(raw_headers)
|
|
160
|
+
content = getattr(raw, "content", None)
|
|
161
|
+
if content is None:
|
|
162
|
+
text = getattr(raw, "text", "") or ""
|
|
163
|
+
content = text.encode(default_encoding)
|
|
164
|
+
elif isinstance(content, str):
|
|
165
|
+
content = content.encode(default_encoding)
|
|
166
|
+
return httpx.Response(
|
|
167
|
+
status,
|
|
168
|
+
headers=headers,
|
|
169
|
+
content=content,
|
|
170
|
+
request=request,
|
|
171
|
+
default_encoding=default_encoding,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class CurlCffiTransport(Transport):
|
|
176
|
+
"""Транспорт на `curl_cffi.AsyncSession` с JA3-имитацией Chrome (antibot Tier 1-3).
|
|
177
|
+
|
|
178
|
+
Обходит TLS/JA3-fingerprint-детект (Akamai, Google JA3-endpoint), где обычный
|
|
179
|
+
`httpx`/`requests` отбивается (`ECONNRESET` / подменённый контент): curl-cffi на
|
|
180
|
+
BoringSSL имитирует TLS-handshake и заголовки конкретной версии Chrome
|
|
181
|
+
(`impersonate=`). Реализует `librarykit.contract.Transport` — взаимозаменяем с
|
|
182
|
+
`HttpxTransport` для вышестоящего кода.
|
|
183
|
+
|
|
184
|
+
Параметры:
|
|
185
|
+
- `impersonate` — явный профиль (``"chrome148"``); `None` → подбор по `target_major`;
|
|
186
|
+
- `target_major` — желаемый major Chrome (если `impersonate=None`);
|
|
187
|
+
- `headers` — базовые заголовки (UA/sec-ch-ua держать СИНХРОННО с `impersonate`);
|
|
188
|
+
- `cookies` — стартовые cookie (подставляет вызывающий код из своего storage);
|
|
189
|
+
- `timeout` — таймаут запроса (сек);
|
|
190
|
+
- `proxy` — явный прокси (``http(s)://``/``socks5://``);
|
|
191
|
+
- `session` — внедрить готовую сессию (DI для тестов; минует импорт curl-cffi).
|
|
192
|
+
|
|
193
|
+
Версию Chrome НЕ пиним: при `impersonate=None` берётся свежайший доступный
|
|
194
|
+
профиль (`pick_chrome_impersonate`). UA/sec-ch-ua, переданные в `headers`,
|
|
195
|
+
обязаны совпадать major-в-major с `impersonate` (иначе рассинхрон палит бота).
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(
|
|
199
|
+
self,
|
|
200
|
+
*,
|
|
201
|
+
impersonate: str | None = None,
|
|
202
|
+
target_major: int | None = None,
|
|
203
|
+
headers: Mapping[str, str] | None = None,
|
|
204
|
+
cookies: Mapping[str, str] | None = None,
|
|
205
|
+
timeout: float = 30.0,
|
|
206
|
+
proxy: str | None = None,
|
|
207
|
+
session: _CurlSessionLike | None = None,
|
|
208
|
+
) -> None:
|
|
209
|
+
self._headers: dict[str, str] = dict(headers) if headers else {}
|
|
210
|
+
self._cookies: dict[str, str] = dict(cookies) if cookies else {}
|
|
211
|
+
self._timeout = timeout
|
|
212
|
+
self._proxy = proxy
|
|
213
|
+
# Профиль резолвим лениво (в _ensure_session), чтобы импорт модуля и
|
|
214
|
+
# конструктор работали БЕЗ установленного curl-cffi (graceful).
|
|
215
|
+
self._impersonate = impersonate
|
|
216
|
+
self._target_major = target_major
|
|
217
|
+
self._session: _CurlSessionLike | None = session
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def impersonate(self) -> str | None:
|
|
221
|
+
"""Текущий выбранный impersonate-профиль (`None`, пока сессия не создана)."""
|
|
222
|
+
return self._impersonate
|
|
223
|
+
|
|
224
|
+
def _ensure_session(self) -> _CurlSessionLike:
|
|
225
|
+
"""Лениво создать `AsyncSession` curl-cffi (или вернуть внедрённую DI-сессию).
|
|
226
|
+
|
|
227
|
+
Здесь происходит фактический импорт curl-cffi и резолв профиля — поэтому
|
|
228
|
+
отсутствие пакета бьёт понятным `TransportError` только при первом запросе.
|
|
229
|
+
"""
|
|
230
|
+
if self._session is not None:
|
|
231
|
+
return self._session
|
|
232
|
+
try:
|
|
233
|
+
from curl_cffi.requests import AsyncSession
|
|
234
|
+
except ImportError as exc:
|
|
235
|
+
raise TransportError(
|
|
236
|
+
"curl_cffi не установлен — CurlCffiTransport недоступен. "
|
|
237
|
+
"Установите: uv sync --extra antibot (или uv add curl-cffi)."
|
|
238
|
+
) from exc
|
|
239
|
+
if self._impersonate is None:
|
|
240
|
+
self._impersonate = pick_chrome_impersonate(self._target_major)
|
|
241
|
+
self._session = AsyncSession( # type: ignore[assignment]
|
|
242
|
+
impersonate=self._impersonate,
|
|
243
|
+
timeout=self._timeout,
|
|
244
|
+
proxies={"http": self._proxy, "https": self._proxy} if self._proxy else None,
|
|
245
|
+
)
|
|
246
|
+
return self._session
|
|
247
|
+
|
|
248
|
+
async def request(
|
|
249
|
+
self,
|
|
250
|
+
method: str,
|
|
251
|
+
url: str,
|
|
252
|
+
*,
|
|
253
|
+
params: Mapping[str, Any] | None = None,
|
|
254
|
+
json: Any | None = None,
|
|
255
|
+
data: Any | None = None,
|
|
256
|
+
headers: Mapping[str, str] | None = None,
|
|
257
|
+
files: Any | None = None,
|
|
258
|
+
cookies: Mapping[str, str] | None = None,
|
|
259
|
+
) -> httpx.Response:
|
|
260
|
+
"""Выполнить запрос через curl-cffi и вернуть `httpx.Response`.
|
|
261
|
+
|
|
262
|
+
Заголовки/cookies запроса = базовые (конструктор) + переданные в вызов
|
|
263
|
+
(вызов перебивает). Сетевой сбой curl-cffi → `TransportError`. Маппинг
|
|
264
|
+
статусов в доменные ошибки делает вышестоящий `HttpClient`/`ErrorMapper`.
|
|
265
|
+
"""
|
|
266
|
+
session = self._ensure_session()
|
|
267
|
+
merged_headers = {**self._headers, **(dict(headers) if headers else {})}
|
|
268
|
+
merged_cookies = {**self._cookies, **(dict(cookies) if cookies else {})}
|
|
269
|
+
# Реконструируем httpx.Request — нужен ответу (url в ошибках/логах ErrorMapper).
|
|
270
|
+
req = httpx.Request(
|
|
271
|
+
method,
|
|
272
|
+
url,
|
|
273
|
+
params=params,
|
|
274
|
+
headers=merged_headers or None,
|
|
275
|
+
cookies=merged_cookies or None,
|
|
276
|
+
json=json,
|
|
277
|
+
data=data,
|
|
278
|
+
files=files,
|
|
279
|
+
)
|
|
280
|
+
try:
|
|
281
|
+
raw = await session.request(
|
|
282
|
+
method,
|
|
283
|
+
url,
|
|
284
|
+
params=dict(params) if params else None,
|
|
285
|
+
json=json,
|
|
286
|
+
data=data,
|
|
287
|
+
headers=merged_headers or None,
|
|
288
|
+
cookies=merged_cookies or None,
|
|
289
|
+
)
|
|
290
|
+
except Exception as exc:
|
|
291
|
+
if isinstance(exc, TransportError):
|
|
292
|
+
raise
|
|
293
|
+
raise TransportError(f"curl_cffi: {type(exc).__name__}: {exc}") from exc
|
|
294
|
+
return _to_httpx_response(raw, request=req)
|
|
295
|
+
|
|
296
|
+
async def aclose(self) -> None:
|
|
297
|
+
"""Закрыть нижележащую сессию curl-cffi (вызывать при завершении адаптера)."""
|
|
298
|
+
if self._session is not None:
|
|
299
|
+
close = getattr(self._session, "close", None)
|
|
300
|
+
if close is not None:
|
|
301
|
+
result = close()
|
|
302
|
+
if asyncio.iscoroutine(result):
|
|
303
|
+
await result
|
|
304
|
+
self._session = None
|
|
305
|
+
|
|
306
|
+
async def __aenter__(self) -> CurlCffiTransport:
|
|
307
|
+
return self
|
|
308
|
+
|
|
309
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
310
|
+
await self.aclose()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# =========================================================================== #
|
|
314
|
+
# Tier 0-1: CDP-транспорт (подключение к реальному системному Edge/Chrome) #
|
|
315
|
+
# =========================================================================== #
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@runtime_checkable
|
|
319
|
+
class CdpBrowserBackend(Protocol):
|
|
320
|
+
"""Контракт «движка реального браузера» под `CdpTransport` (точка DI).
|
|
321
|
+
|
|
322
|
+
Скрывает за собой запуск системного Edge/Chrome с `--remote-debugging-port`
|
|
323
|
+
и `connect_over_cdp` (см. recipes/antibot `cdp_real_browser.py`). Реализация —
|
|
324
|
+
в `librarykit.browser` (Playwright/patchright); здесь — только контракт, чтобы
|
|
325
|
+
`antibot` не тащил тяжёлый browser-стек и тестировался фейком той же формы.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
async def fetch(
|
|
329
|
+
self,
|
|
330
|
+
method: str,
|
|
331
|
+
url: str,
|
|
332
|
+
*,
|
|
333
|
+
params: Mapping[str, Any] | None = None,
|
|
334
|
+
json: Any | None = None,
|
|
335
|
+
data: Any | None = None,
|
|
336
|
+
headers: Mapping[str, str] | None = None,
|
|
337
|
+
cookies: Mapping[str, str] | None = None,
|
|
338
|
+
) -> Any:
|
|
339
|
+
"""Выполнить запрос внутри реального браузера; вернуть ответоподобный объект.
|
|
340
|
+
|
|
341
|
+
Объект нормализуется `_to_httpx_response` (нужны `status_code`/`headers`/
|
|
342
|
+
`content`|`text`). Браузер сам ставит нативные `sec-fetch-*` / Client-Hints
|
|
343
|
+
/ TLS-fingerprint — ради чего CDP-транспорт и существует.
|
|
344
|
+
"""
|
|
345
|
+
...
|
|
346
|
+
|
|
347
|
+
async def aclose(self) -> None:
|
|
348
|
+
"""Отключить CDP и завершить процесс браузера (idempotent)."""
|
|
349
|
+
...
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class CdpTransport(Transport):
|
|
353
|
+
"""Транспорт через РЕАЛЬНЫЙ системный Edge/Chrome по CDP (antibot Tier 0-1).
|
|
354
|
+
|
|
355
|
+
PRIMARY-путь для signing-heavy сервисов (TikTok / WB seller / Bitrix24-auth):
|
|
356
|
+
реальный браузер сам ставит нативный триплет `sec-fetch-{dest,mode,site}`,
|
|
357
|
+
корректные Client-Hints и TLS-fingerprint, которых нет у patchright (симптомы
|
|
358
|
+
`isTTwidDecryptedFail`, `signatures don't match`, «unusual activity»). `headless`
|
|
359
|
+
переключает Tier 0 (видимый, debug/bootstrap) ↔ Tier 1 (`--headless=new`, prod
|
|
360
|
+
1-100 calls/day).
|
|
361
|
+
|
|
362
|
+
Сам тяжёлый browser-движок инкапсулирован в `CdpBrowserBackend` (реализация —
|
|
363
|
+
`librarykit.browser`), который внедряется в конструктор. Так `antibot`:
|
|
364
|
+
1) не импортирует Playwright/patchright (опциональны → graceful);
|
|
365
|
+
2) реализует ровно `librarykit.contract.Transport` (взаимозаменяем с остальными);
|
|
366
|
+
3) детерминированно тестируется фейк-backend без реального браузера.
|
|
367
|
+
|
|
368
|
+
Параметры:
|
|
369
|
+
- `backend` — реализация `CdpBrowserBackend` (composition root внедряет реальную);
|
|
370
|
+
- `headless` — Tier 1 (`True`) против Tier 0 (`False`); пробрасывается в реализацию;
|
|
371
|
+
- `prefer` — предпочесть ``"edge"`` или ``"chrome"`` (Edge стабильнее на Windows);
|
|
372
|
+
- `headers` — базовые заголовки, доливаемые к каждому запросу.
|
|
373
|
+
|
|
374
|
+
Если `backend=None` и реальный движок не сконфигурирован — первый `request`
|
|
375
|
+
бьёт понятным `TransportError` (а не падает при импорте модуля).
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
def __init__(
|
|
379
|
+
self,
|
|
380
|
+
*,
|
|
381
|
+
backend: CdpBrowserBackend | None = None,
|
|
382
|
+
headless: bool = True,
|
|
383
|
+
prefer: str = "edge",
|
|
384
|
+
headers: Mapping[str, str] | None = None,
|
|
385
|
+
) -> None:
|
|
386
|
+
self._backend = backend
|
|
387
|
+
self.headless = headless
|
|
388
|
+
self.prefer = prefer
|
|
389
|
+
self._headers: dict[str, str] = dict(headers) if headers else {}
|
|
390
|
+
|
|
391
|
+
def _ensure_backend(self) -> CdpBrowserBackend:
|
|
392
|
+
"""Вернуть browser-backend или объяснить, что реальный движок не подключён.
|
|
393
|
+
|
|
394
|
+
В spine `antibot` НЕ создаёт Playwright-backend сам (он живёт в
|
|
395
|
+
`librarykit.browser`); composition root внедряет его через конструктор.
|
|
396
|
+
"""
|
|
397
|
+
if self._backend is None:
|
|
398
|
+
raise TransportError(
|
|
399
|
+
"CdpTransport требует CdpBrowserBackend (реальный Edge/Chrome через CDP). "
|
|
400
|
+
"Передайте backend=... из librarykit.browser или установите extra 'browser' "
|
|
401
|
+
"(uv sync --extra browser)."
|
|
402
|
+
)
|
|
403
|
+
return self._backend
|
|
404
|
+
|
|
405
|
+
async def request(
|
|
406
|
+
self,
|
|
407
|
+
method: str,
|
|
408
|
+
url: str,
|
|
409
|
+
*,
|
|
410
|
+
params: Mapping[str, Any] | None = None,
|
|
411
|
+
json: Any | None = None,
|
|
412
|
+
data: Any | None = None,
|
|
413
|
+
headers: Mapping[str, str] | None = None,
|
|
414
|
+
files: Any | None = None,
|
|
415
|
+
cookies: Mapping[str, str] | None = None,
|
|
416
|
+
) -> httpx.Response:
|
|
417
|
+
"""Выполнить запрос внутри реального браузера и вернуть `httpx.Response`.
|
|
418
|
+
|
|
419
|
+
`files` контрактом принимается, но в браузерном fetch не применяется
|
|
420
|
+
(multipart внутри страницы — отдельный сценарий); передаётся `None`. Сбой
|
|
421
|
+
браузера/CDP → `TransportError`. Маппинг статусов — выше по стеку.
|
|
422
|
+
"""
|
|
423
|
+
backend = self._ensure_backend()
|
|
424
|
+
merged_headers = {**self._headers, **(dict(headers) if headers else {})}
|
|
425
|
+
req = httpx.Request(
|
|
426
|
+
method,
|
|
427
|
+
url,
|
|
428
|
+
params=params,
|
|
429
|
+
headers=merged_headers or None,
|
|
430
|
+
cookies=cookies,
|
|
431
|
+
json=json,
|
|
432
|
+
data=data,
|
|
433
|
+
)
|
|
434
|
+
try:
|
|
435
|
+
raw = await backend.fetch(
|
|
436
|
+
method,
|
|
437
|
+
url,
|
|
438
|
+
params=dict(params) if params else None,
|
|
439
|
+
json=json,
|
|
440
|
+
data=data,
|
|
441
|
+
headers=merged_headers or None,
|
|
442
|
+
cookies=dict(cookies) if cookies else None,
|
|
443
|
+
)
|
|
444
|
+
except Exception as exc:
|
|
445
|
+
if isinstance(exc, TransportError):
|
|
446
|
+
raise
|
|
447
|
+
raise TransportError(f"CDP: {type(exc).__name__}: {exc}") from exc
|
|
448
|
+
return _to_httpx_response(raw, request=req)
|
|
449
|
+
|
|
450
|
+
async def aclose(self) -> None:
|
|
451
|
+
"""Закрыть browser-backend (отключить CDP, завершить процесс), если он есть."""
|
|
452
|
+
if self._backend is not None:
|
|
453
|
+
await self._backend.aclose()
|
|
454
|
+
|
|
455
|
+
async def __aenter__(self) -> CdpTransport:
|
|
456
|
+
return self
|
|
457
|
+
|
|
458
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
459
|
+
await self.aclose()
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
__all__ = [
|
|
463
|
+
"CurlCffiTransport",
|
|
464
|
+
"CdpTransport",
|
|
465
|
+
"CdpBrowserBackend",
|
|
466
|
+
"pick_chrome_impersonate",
|
|
467
|
+
]
|