s-librarykit 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (97) hide show
  1. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/PKG-INFO +1 -1
  2. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/__init__.py +9 -1
  3. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/antibot/__init__.py +4 -0
  4. s_librarykit-0.3.0/librarykit/antibot/warmup_minter.py +255 -0
  5. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/pyproject.toml +1 -1
  6. s_librarykit-0.3.0/tests/test_warmup_minter.py +319 -0
  7. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/.gitignore +0 -0
  8. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/.gitlab-ci.yml +0 -0
  9. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/AGENTS.md +0 -0
  10. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/CHANGELOG.md +0 -0
  11. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/LICENSE +0 -0
  12. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/README.md +0 -0
  13. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/docs/superpowers/specs/2026-06-24-unified-sessions-design.md +0 -0
  14. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/antibot/_helpers.py +0 -0
  15. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/antibot/cdp.py +0 -0
  16. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/antibot/curl_cffi.py +0 -0
  17. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/antibot/page_evaluate.py +0 -0
  18. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/auth.py +0 -0
  19. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/blocks.py +0 -0
  20. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/browser/__init__.py +0 -0
  21. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/browser/_engine.py +0 -0
  22. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/browser/camoufox.py +0 -0
  23. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/browser/geometry.py +0 -0
  24. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/browser/launchers.py +0 -0
  25. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/browser/state.py +0 -0
  26. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/bulk.py +0 -0
  27. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/checkpoint.py +0 -0
  28. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/config_util.py +0 -0
  29. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/contract.py +0 -0
  30. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/entities.py +0 -0
  31. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/enums.py +0 -0
  32. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/errmap.py +0 -0
  33. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/errors.py +0 -0
  34. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/factory.py +0 -0
  35. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/forms.py +0 -0
  36. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/graphql.py +0 -0
  37. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/limit.py +0 -0
  38. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/orchestration/__init__.py +0 -0
  39. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/orchestration/health.py +0 -0
  40. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/orchestration/onboarding.py +0 -0
  41. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/orchestration/session_loader.py +0 -0
  42. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/pagination.py +0 -0
  43. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/pool.py +0 -0
  44. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/ports.py +0 -0
  45. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/protocols.py +0 -0
  46. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/redaction.py +0 -0
  47. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/refresh.py +0 -0
  48. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/retry.py +0 -0
  49. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/rpc.py +0 -0
  50. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/secret_store.py +0 -0
  51. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/session_bridge.py +0 -0
  52. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/session_paths.py +0 -0
  53. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/sessions/__init__.py +0 -0
  54. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/sessions/_const.py +0 -0
  55. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/sessions/_fsutil.py +0 -0
  56. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/stream.py +0 -0
  57. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/transport/__init__.py +0 -0
  58. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/transport/_helpers.py +0 -0
  59. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/transport/http_client.py +0 -0
  60. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/transport/permissive.py +0 -0
  61. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/transport/rest_client.py +0 -0
  62. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/transport/transport_http.py +0 -0
  63. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/upload.py +0 -0
  64. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/windows.py +0 -0
  65. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/conftest.py +0 -0
  66. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_antibot.py +0 -0
  67. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_auth.py +0 -0
  68. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_blocks.py +0 -0
  69. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_browser.py +0 -0
  70. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_bulk.py +0 -0
  71. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_checkpoint.py +0 -0
  72. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_config_util.py +0 -0
  73. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_errmap.py +0 -0
  74. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_errors.py +0 -0
  75. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_factory.py +0 -0
  76. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_forms.py +0 -0
  77. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_graphql.py +0 -0
  78. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_limit.py +0 -0
  79. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_limit_contract.py +0 -0
  80. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_orchestration.py +0 -0
  81. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_pagination.py +0 -0
  82. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_permissive_http.py +0 -0
  83. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_pool.py +0 -0
  84. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_redaction.py +0 -0
  85. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_refresh.py +0 -0
  86. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_retry.py +0 -0
  87. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_rpc.py +0 -0
  88. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_session_bridge.py +0 -0
  89. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_session_paths.py +0 -0
  90. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_sessions.py +0 -0
  91. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_stream.py +0 -0
  92. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_transport_client.py +0 -0
  93. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_transport_rest.py +0 -0
  94. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_transport_sync.py +0 -0
  95. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_upload.py +0 -0
  96. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_windows.py +0 -0
  97. {s_librarykit-0.2.0 → s_librarykit-0.3.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: s-librarykit
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Жирный КОРЕНЬ китов: единая error-иерархия, config/retry/paths-утили, checkpoint/resume, транспорт/auth/sessions — общий код для clikit, adapterkit и доменных пакетов.
5
5
  Author: Dmitry
6
6
  License: MIT
@@ -10,6 +10,11 @@ from __future__ import annotations
10
10
 
11
11
  # blocks (W8): GENERIC draft.js richtext block-хелперы (текст/заголовок/список).
12
12
  from librarykit import blocks
13
+
14
+ # antibot warmup-минт (W4): генерик-«монетный двор» одноразовых токенов тёплой
15
+ # страницей (обобщение chatgpt Sentinel). Механика в ките, семантика (trigger/
16
+ # match) — у сервиса. Тянет только stdlib — реэкспорт наверх безопасен.
17
+ from librarykit.antibot import TokenHarvest, WarmupTokenMinter
13
18
  from librarykit.auth import (
14
19
  CSRF_DEFAULT_HEADER,
15
20
  BrowserLoginAuth,
@@ -244,7 +249,7 @@ from librarykit.windows import (
244
249
  parse_window,
245
250
  )
246
251
 
247
- __version__ = "0.2.0"
252
+ __version__ = "0.3.0"
248
253
 
249
254
  __all__ = [
250
255
  "__version__",
@@ -383,6 +388,9 @@ __all__ = [
383
388
  "CalendarWindow",
384
389
  "FixedWindow",
385
390
  "parse_window",
391
+ # antibot: warmup-минт одноразовых токенов тёплой страницей (генерик)
392
+ "WarmupTokenMinter",
393
+ "TokenHarvest",
386
394
  # pool: балансер N аккаунтов одного сервиса (селектор поверх SessionStore)
387
395
  "SessionPool",
388
396
  "PoolChoice",
@@ -91,6 +91,7 @@ from .page_evaluate import (
91
91
  page_fetch,
92
92
  sync_page_fetch,
93
93
  )
94
+ from .warmup_minter import TokenHarvest, WarmupTokenMinter
94
95
 
95
96
  # Первичные ролевые имена исполнителей запроса (роль снаружи, вендор — внутри):
96
97
  # ImpersonatedHttpExecutor — TLS/JA3-impersonate (curl-cffi) [было CurlCffiTransport];
@@ -116,6 +117,9 @@ __all__ = [
116
117
  "page_fetch",
117
118
  "sync_page_fetch",
118
119
  "pick_chrome_impersonate",
120
+ # warmup-минт одноразовых токенов тёплой страницей (генерик chatgpt-паттерна)
121
+ "WarmupTokenMinter",
122
+ "TokenHarvest",
119
123
  # первичные ролевые имена (псевдонимы вендор-классов выше)
120
124
  "ImpersonatedHttpExecutor",
121
125
  "BrowserRequestExecutor",
@@ -0,0 +1,255 @@
1
+ """Генерик-«монетный двор» одноразовых токенов тёплой браузер-страницей.
2
+
3
+ Обобщение эталон-паттерна chatgpt (`sentinel_flow.mint_from_page`): некоторые
4
+ антибот-защиты (ChatGPT Sentinel, ряд signing-схем) выдают СВЕЖИЙ одноразовый
5
+ токен в служебном запросе, который стреляет сама страница при взаимодействии
6
+ (например ввод в композер БЕЗ отправки). Токен НЕ привязан к TLS-сессии браузера
7
+ → его можно ПЕРЕНЕСТИ во внешний httpx. Браузер нужен лишь чтобы РАЗ в окно TTL
8
+ начеканить неизрасходованный токен; сам запрос браузер НЕ сабмитит (иначе токен
9
+ сгорит и во httpx даст 403).
10
+
11
+ Механика в ките (нейтральна к сервису), СЕМАНТИКА — у сервиса:
12
+
13
+ - сервис даёт `trigger(page)` — как спровоцировать служебный запрос (ввод в
14
+ композер, клик, `page.evaluate` — что угодно, но БЕЗ финальной отправки);
15
+ - сервис даёт `match(request)` — как опознать нужный запрос (по URL/методу);
16
+ - кит навешивает `page.on("request"/"response")`, перехватывает `all_headers()`
17
+ + `post_data` запроса и тело ответа, снимает cookies из `context`, собирает
18
+ всё в :class:`TokenHarvest` для внешнего httpx.
19
+
20
+ Работает поверх нейтральной :class:`librarykit.contract.BrowserSession`
21
+ (`ensure_page()` / `context` / `aclose()`) — движок (CamouFox / CDP-Edge /
22
+ обычный chromium) выбирается фабрикой снаружи, минтеру безразличен.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import contextlib
28
+ import time
29
+ from collections.abc import Awaitable, Callable, Iterable
30
+ from dataclasses import dataclass, field
31
+ from typing import Any
32
+
33
+ from librarykit.errors import Blocked
34
+
35
+ __all__ = ["TokenHarvest", "WarmupTokenMinter"]
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class TokenHarvest:
40
+ """Снимок одного «окна чеканки»: всё нужное внешнему httpx для одного turn'а.
41
+
42
+ Собирается :meth:`WarmupTokenMinter.mint` из перехвата служебного запроса
43
+ (request `all_headers` + `post_data`) и его ответа (тело), плюс cookies живой
44
+ сессии. Токен внутри `response_body`/`request_body` — как правило ОДНОРАЗОВЫЙ;
45
+ окно валидности задаёт сервис (см. :meth:`is_expired`).
46
+ """
47
+
48
+ request_headers: dict[str, str] # живые заголовки служебного запроса (lower-case)
49
+ request_body: str | None # сырое тело запроса (proof/turnstile/prepare и пр.)
50
+ response_body: str | None # сырое тело ответа (обычно содержит `.token`)
51
+ cookies: list[dict[str, Any]] = field(default_factory=list) # снимок context.cookies()
52
+ minted_at: float = 0.0 # time.time() момента минта
53
+
54
+ def cookie_header(self, domain_substr: str = "") -> str:
55
+ """Склеить `Cookie:`-заголовок из cookies нужного домена для httpx.
56
+
57
+ `domain_substr` — подстрока домена cookie (напр. ``"chatgpt.com"``);
58
+ пустая строка → берём все cookies. Пары `name=value` через `"; "`.
59
+ """
60
+ return "; ".join(
61
+ f"{c.get('name')}={c.get('value')}"
62
+ for c in self.cookies
63
+ if not domain_substr or domain_substr in (c.get("domain") or "")
64
+ )
65
+
66
+ def pick_headers(self, keep: Iterable[str]) -> dict[str, str]:
67
+ """Подмножество `request_headers` по allowlist (регистронезависимо).
68
+
69
+ Возвращает только те заголовки, чьё имя (в нижнем регистре) есть в `keep` —
70
+ чтобы перенести во внешний httpx лишь живые/нужные (authorization/oai-*/
71
+ sec-*), не таща служебные (`content-length`, `cookie` и пр.).
72
+ """
73
+ wanted = {k.lower() for k in keep}
74
+ return {k: v for k, v in self.request_headers.items() if k.lower() in wanted}
75
+
76
+ def is_expired(self, ttl_s: float, skew: float = 0.0) -> bool:
77
+ """Окно чеканки исчерпано: `now >= minted_at + ttl_s - skew`.
78
+
79
+ `skew` — запас (сек), чтобы обновиться ДО фактического протухания.
80
+ Не минтился (`minted_at == 0`) → считается протухшим.
81
+ """
82
+ if self.minted_at <= 0:
83
+ return True
84
+ return time.time() >= (self.minted_at + ttl_s - skew)
85
+
86
+
87
+ # Значения `capture`: что ждём от перехвата, прежде чем считать чеканку успешной.
88
+ _CAPTURE_MODES = frozenset({"both", "request", "response"})
89
+
90
+
91
+ class WarmupTokenMinter:
92
+ """Чеканит :class:`TokenHarvest` из служебного запроса тёплой страницы.
93
+
94
+ Кит владеет МЕХАНИКОЙ (навесить листенеры, перехватить request/response,
95
+ снять cookies, не сабмитить), сервис — СЕМАНТИКОЙ (`trigger` провоцирует
96
+ запрос, `match` опознаёт его). Страница поднимается лениво из нейтральной
97
+ `session_factory` (`() -> BrowserSession`); при провале «уставшей» тёплой
98
+ страницы (нет match / исключение) `recreate_on_fail` пересоздаёт сессию и
99
+ повторяет ОДИН раз со свежей — это чинит деградацию тёплой страницы на 3-м+
100
+ вызове (эмпирика chatgpt).
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ session_factory: Callable[[], Any],
106
+ *,
107
+ recreate_on_fail: bool = True,
108
+ ) -> None:
109
+ self._session_factory = session_factory
110
+ self._recreate_on_fail = recreate_on_fail
111
+
112
+ async def mint(
113
+ self,
114
+ *,
115
+ trigger: Callable[[Any], Awaitable[None]],
116
+ match: Callable[[Any], bool],
117
+ timeout_ms: int = 20_000,
118
+ capture: str = "both",
119
+ ) -> TokenHarvest:
120
+ """Начеканить свежий токен: `trigger` стреляет запрос, `match` его ловит.
121
+
122
+ - `trigger(page)` — спровоцировать служебный запрос (ввод/клик БЕЗ отправки);
123
+ - `match(request)` — предикат «это нужный запрос» (по URL/методу);
124
+ - `timeout_ms` — сколько ждать перехвата после `trigger`;
125
+ - `capture` — что требуется поймать: ``"request"`` (заголовки+тело запроса),
126
+ ``"response"`` (тело ответа) или ``"both"`` (по умолчанию).
127
+
128
+ Запрос НЕ сабмитится. При провале и `recreate_on_fail` — закрыть сессию и
129
+ повторить один раз со свежей. Бросает :class:`librarykit.errors.Blocked`,
130
+ если перехват не удался.
131
+ """
132
+ if capture not in _CAPTURE_MODES:
133
+ raise ValueError(
134
+ f"capture должен быть одним из {sorted(_CAPTURE_MODES)}, не {capture!r}"
135
+ )
136
+
137
+ session = self._session_factory()
138
+ try:
139
+ return await self._mint_once(
140
+ session, trigger=trigger, match=match, timeout_ms=timeout_ms, capture=capture,
141
+ )
142
+ except Exception:
143
+ if not self._recreate_on_fail:
144
+ raise
145
+ await self._safe_aclose(session)
146
+
147
+ # Повтор ОДИН раз со свежей сессией (тёплая страница «устала»).
148
+ fresh = self._session_factory()
149
+ try:
150
+ return await self._mint_once(
151
+ fresh, trigger=trigger, match=match, timeout_ms=timeout_ms, capture=capture,
152
+ )
153
+ except Exception:
154
+ await self._safe_aclose(fresh)
155
+ raise
156
+
157
+ async def _mint_once(
158
+ self,
159
+ session: Any,
160
+ *,
161
+ trigger: Callable[[Any], Awaitable[None]],
162
+ match: Callable[[Any], bool],
163
+ timeout_ms: int,
164
+ capture: str,
165
+ ) -> TokenHarvest:
166
+ """Одна попытка чеканки на конкретной сессии (без пересоздания)."""
167
+ page = await session.ensure_page()
168
+ state: dict[str, Any] = {"req_headers": None, "req_body": None, "resp_body": None}
169
+ pending: list[asyncio.Future[Any]] = []
170
+
171
+ async def _grab_request(req: Any) -> None:
172
+ try:
173
+ if state["req_headers"] is None and match(req):
174
+ state["req_headers"] = dict(await req.all_headers())
175
+ state["req_body"] = req.post_data
176
+ except Exception: # перехват best-effort, не должен рушить минт
177
+ pass
178
+
179
+ async def _grab_response(resp: Any) -> None:
180
+ try:
181
+ req = getattr(resp, "request", None)
182
+ if state["resp_body"] is None and req is not None and match(req):
183
+ body = await resp.body()
184
+ if isinstance(body, (bytes, bytearray)):
185
+ state["resp_body"] = bytes(body).decode("utf-8", "replace")
186
+ elif body is not None:
187
+ state["resp_body"] = str(body)
188
+ except Exception: # перехват best-effort, не должен рушить минт
189
+ pass
190
+
191
+ def _on_request(req: Any) -> None:
192
+ pending.append(asyncio.ensure_future(_grab_request(req)))
193
+
194
+ def _on_response(resp: Any) -> None:
195
+ pending.append(asyncio.ensure_future(_grab_response(resp)))
196
+
197
+ page.on("request", _on_request)
198
+ page.on("response", _on_response)
199
+ try:
200
+ await trigger(page) # провоцируем служебный запрос; НЕ сабмитим
201
+ loop = asyncio.get_running_loop()
202
+ deadline = loop.time() + timeout_ms / 1000
203
+ while loop.time() < deadline:
204
+ if pending:
205
+ await asyncio.gather(*pending, return_exceptions=True)
206
+ pending.clear()
207
+ if _is_captured(state, capture):
208
+ break
209
+ await asyncio.sleep(0.05)
210
+ if pending: # добрать последние перехваты
211
+ await asyncio.gather(*pending, return_exceptions=True)
212
+ pending.clear()
213
+ cookies = list(await session.context.cookies())
214
+ finally:
215
+ with contextlib.suppress(Exception):
216
+ page.remove_listener("request", _on_request)
217
+ with contextlib.suppress(Exception):
218
+ page.remove_listener("response", _on_response)
219
+
220
+ if not _is_captured(state, capture):
221
+ raise Blocked(
222
+ f"чеканка токена не удалась: служебный запрос не перехвачен "
223
+ f"(capture={capture!r}, получено: {_captured_keys(state)}). "
224
+ "Проверь trigger/match и что страница залогинена/тёплая."
225
+ )
226
+
227
+ return TokenHarvest(
228
+ request_headers=state["req_headers"] or {},
229
+ request_body=state["req_body"],
230
+ response_body=state["resp_body"],
231
+ cookies=cookies,
232
+ minted_at=time.time(),
233
+ )
234
+
235
+ @staticmethod
236
+ async def _safe_aclose(session: Any) -> None:
237
+ """Закрыть сессию, гася любые ошибки закрытия (best-effort cleanup)."""
238
+ with contextlib.suppress(Exception):
239
+ await session.aclose()
240
+
241
+
242
+ def _is_captured(state: dict[str, Any], capture: str) -> bool:
243
+ """Достаточно ли перехвачено под режим `capture`."""
244
+ have_req = state["req_headers"] is not None
245
+ have_resp = state["resp_body"] is not None
246
+ if capture == "request":
247
+ return have_req
248
+ if capture == "response":
249
+ return have_resp
250
+ return have_req and have_resp
251
+
252
+
253
+ def _captured_keys(state: dict[str, Any]) -> list[str]:
254
+ """Что уже поймано (для диагностического сообщения)."""
255
+ return sorted(k for k, v in state.items() if v is not None)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "s-librarykit"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Жирный КОРЕНЬ китов: единая error-иерархия, config/retry/paths-утили, checkpoint/resume, транспорт/auth/sessions — общий код для clikit, adapterkit и доменных пакетов."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -0,0 +1,319 @@
1
+ """Тесты генерик-минтера одноразовых токенов (`WarmupTokenMinter` + `TokenHarvest`).
2
+
3
+ Без реального браузера: фейковая `BrowserSession` (`ensure_page` / `context` /
4
+ `aclose`) с фейковой playwright-подобной страницей, которая на `trigger`
5
+ «эмитит» перехватываемые request/response через сохранённые листенеры. Проверяем:
6
+ - trigger вызывается, match фильтрует, harvest собирает headers/body/cookies;
7
+ - capture-режимы (`request` / `response` / `both`);
8
+ - при провале `recreate_on_fail` пересоздаёт сессию и повторяет один раз;
9
+ - helper'ы `TokenHarvest` (cookie_header / pick_headers / is_expired).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ from typing import Any
15
+
16
+ import pytest
17
+
18
+ from librarykit import TokenHarvest, WarmupTokenMinter
19
+ from librarykit.antibot import TokenHarvest as TokenHarvestFromAntibot
20
+ from librarykit.antibot import WarmupTokenMinter as MinterFromAntibot
21
+ from librarykit.errors import Blocked
22
+
23
+ # --------------------------------------------------------------------------- #
24
+ # Фейки playwright-страницы/сессии (форма page.on / request / response) #
25
+ # --------------------------------------------------------------------------- #
26
+
27
+
28
+ class _FakeRequest:
29
+ def __init__(
30
+ self,
31
+ url: str,
32
+ *,
33
+ method: str = "POST",
34
+ headers: dict[str, str] | None = None,
35
+ post_data: str | None = None,
36
+ ) -> None:
37
+ self.url = url
38
+ self.method = method
39
+ self._headers = headers or {}
40
+ self.post_data = post_data
41
+
42
+ async def all_headers(self) -> dict[str, str]:
43
+ return dict(self._headers)
44
+
45
+
46
+ class _FakeResponse:
47
+ def __init__(self, request: _FakeRequest, *, body: bytes = b"") -> None:
48
+ self.request = request
49
+ self.url = request.url
50
+ self._body = body
51
+
52
+ async def body(self) -> bytes:
53
+ return self._body
54
+
55
+
56
+ class _FakePage:
57
+ """Страница: копит листенеры, на `emit_all()` дёргает их эмитируемыми событиями."""
58
+
59
+ def __init__(self, events: list[tuple[str, Any]]) -> None:
60
+ self._listeners: dict[str, list[Any]] = {}
61
+ self._events = events
62
+
63
+ def on(self, event: str, cb: Any) -> None:
64
+ self._listeners.setdefault(event, []).append(cb)
65
+
66
+ def remove_listener(self, event: str, cb: Any) -> None:
67
+ self._listeners.get(event, []).remove(cb)
68
+
69
+ async def emit_all(self) -> None:
70
+ for event, obj in self._events:
71
+ for cb in list(self._listeners.get(event, [])):
72
+ cb(obj)
73
+
74
+
75
+ class _FakeContext:
76
+ def __init__(self, cookies: list[dict[str, Any]]) -> None:
77
+ self._cookies = cookies
78
+
79
+ async def cookies(self) -> list[dict[str, Any]]:
80
+ return list(self._cookies)
81
+
82
+
83
+ class _FakeSession:
84
+ """BrowserSession-совместимый фейк: ensure_page / context / aclose."""
85
+
86
+ def __init__(
87
+ self,
88
+ page: _FakePage,
89
+ cookies: list[dict[str, Any]] | None = None,
90
+ ) -> None:
91
+ self._page = page
92
+ self._context = _FakeContext(cookies or [])
93
+ self.closed = False
94
+ self.ensure_calls = 0
95
+
96
+ async def ensure_page(self) -> _FakePage:
97
+ self.ensure_calls += 1
98
+ return self._page
99
+
100
+ @property
101
+ def context(self) -> _FakeContext:
102
+ return self._context
103
+
104
+ async def aclose(self) -> None:
105
+ self.closed = True
106
+
107
+
108
+ def _finalize_match(req: Any) -> bool:
109
+ return req.url.endswith("/finalize")
110
+
111
+
112
+ async def _emit_trigger(page: Any) -> None:
113
+ await page.emit_all()
114
+
115
+
116
+ def _good_session() -> _FakeSession:
117
+ req = _FakeRequest(
118
+ "https://svc.example/backend/sentinel/finalize",
119
+ headers={"authorization": "Bearer XYZ", "content-length": "42"},
120
+ post_data='{"proofofwork": "pw", "turnstile": "ts"}',
121
+ )
122
+ resp = _FakeResponse(req, body=b'{"token": "REQ-TOKEN"}')
123
+ page = _FakePage([("request", req), ("response", resp)])
124
+ cookies = [
125
+ {"name": "oai-sc", "value": "abc", "domain": ".svc.example"},
126
+ {"name": "other", "value": "zzz", "domain": ".cdn.other"},
127
+ ]
128
+ return _FakeSession(page, cookies)
129
+
130
+
131
+ # --------------------------------------------------------------------------- #
132
+ # WarmupTokenMinter.mint #
133
+ # --------------------------------------------------------------------------- #
134
+
135
+
136
+ async def test_mint_collects_headers_body_cookies() -> None:
137
+ session = _good_session()
138
+ triggered: list[Any] = []
139
+
140
+ async def trigger(page: Any) -> None:
141
+ triggered.append(page)
142
+ await page.emit_all()
143
+
144
+ minter = WarmupTokenMinter(lambda: session)
145
+ harvest = await minter.mint(trigger=trigger, match=_finalize_match)
146
+
147
+ assert triggered, "trigger должен быть вызван"
148
+ assert harvest.request_headers["authorization"] == "Bearer XYZ"
149
+ assert harvest.request_body == '{"proofofwork": "pw", "turnstile": "ts"}'
150
+ assert harvest.response_body == '{"token": "REQ-TOKEN"}'
151
+ assert len(harvest.cookies) == 2
152
+ assert harvest.minted_at > 0
153
+ # успех НЕ закрывает сессию (тёплую страницу переиспользуют)
154
+ assert session.closed is False
155
+
156
+
157
+ async def test_match_filters_out_unrelated_requests() -> None:
158
+ req_noise = _FakeRequest("https://svc.example/telemetry/beacon", post_data="noise")
159
+ req_good = _FakeRequest(
160
+ "https://svc.example/finalize",
161
+ headers={"authorization": "Bearer OK"},
162
+ post_data="good",
163
+ )
164
+ resp_good = _FakeResponse(req_good, body=b'{"token": "T"}')
165
+ page = _FakePage(
166
+ [("request", req_noise), ("request", req_good), ("response", resp_good)]
167
+ )
168
+ session = _FakeSession(page)
169
+
170
+ minter = WarmupTokenMinter(lambda: session)
171
+ harvest = await minter.mint(trigger=_emit_trigger, match=_finalize_match)
172
+
173
+ # взят только match'нутый запрос, шум отфильтрован
174
+ assert harvest.request_body == "good"
175
+ assert harvest.request_headers == {"authorization": "Bearer OK"}
176
+
177
+
178
+ async def test_capture_request_only_ignores_response() -> None:
179
+ req = _FakeRequest("https://svc.example/finalize", post_data="body")
180
+ # ответа НЕ эмитим — при capture="request" этого достаточно
181
+ page = _FakePage([("request", req)])
182
+ session = _FakeSession(page)
183
+
184
+ minter = WarmupTokenMinter(lambda: session)
185
+ harvest = await minter.mint(
186
+ trigger=_emit_trigger, match=_finalize_match, capture="request"
187
+ )
188
+ assert harvest.request_body == "body"
189
+ assert harvest.response_body is None
190
+
191
+
192
+ async def test_capture_response_only() -> None:
193
+ req = _FakeRequest("https://svc.example/finalize")
194
+ resp = _FakeResponse(req, body=b"pure-response")
195
+ page = _FakePage([("response", resp)])
196
+ session = _FakeSession(page)
197
+
198
+ minter = WarmupTokenMinter(lambda: session)
199
+ harvest = await minter.mint(
200
+ trigger=_emit_trigger, match=_finalize_match, capture="response"
201
+ )
202
+ assert harvest.response_body == "pure-response"
203
+ assert harvest.request_headers == {}
204
+
205
+
206
+ async def test_mint_rejects_bad_capture_mode() -> None:
207
+ minter = WarmupTokenMinter(_good_session)
208
+ with pytest.raises(ValueError, match="capture"):
209
+ await minter.mint(trigger=_emit_trigger, match=_finalize_match, capture="nope")
210
+
211
+
212
+ async def test_recreate_on_fail_retries_with_fresh_session() -> None:
213
+ # 1-я сессия «уставшая»: ничего не эмитит → timeout; 2-я — здоровая.
214
+ dead_page = _FakePage([]) # нет событий → match не сработает
215
+ dead = _FakeSession(dead_page)
216
+ good = _good_session()
217
+ sessions = iter([dead, good])
218
+
219
+ minter = WarmupTokenMinter(lambda: next(sessions), recreate_on_fail=True)
220
+ harvest = await minter.mint(
221
+ trigger=_emit_trigger, match=_finalize_match, timeout_ms=150
222
+ )
223
+
224
+ assert harvest.response_body == '{"token": "REQ-TOKEN"}'
225
+ assert dead.closed is True, "уставшую сессию закрыли перед повтором"
226
+ assert good.closed is False, "здоровую сессию не закрывают при успехе"
227
+
228
+
229
+ async def test_no_recreate_raises_blocked_on_fail() -> None:
230
+ dead = _FakeSession(_FakePage([]))
231
+ minter = WarmupTokenMinter(lambda: dead, recreate_on_fail=False)
232
+ with pytest.raises(Blocked):
233
+ await minter.mint(
234
+ trigger=_emit_trigger, match=_finalize_match, timeout_ms=150
235
+ )
236
+ # без recreate_on_fail сессию не закрываем — ею владеет вызывающий
237
+ assert dead.closed is False
238
+
239
+
240
+ async def test_recreate_second_fail_closes_and_raises() -> None:
241
+ first = _FakeSession(_FakePage([]))
242
+ second = _FakeSession(_FakePage([]))
243
+ sessions = iter([first, second])
244
+
245
+ minter = WarmupTokenMinter(lambda: next(sessions), recreate_on_fail=True)
246
+ with pytest.raises(Blocked):
247
+ await minter.mint(
248
+ trigger=_emit_trigger, match=_finalize_match, timeout_ms=120
249
+ )
250
+ assert first.closed is True
251
+ assert second.closed is True
252
+
253
+
254
+ # --------------------------------------------------------------------------- #
255
+ # TokenHarvest helpers #
256
+ # --------------------------------------------------------------------------- #
257
+
258
+
259
+ def test_cookie_header_filters_by_domain() -> None:
260
+ h = TokenHarvest(
261
+ request_headers={},
262
+ request_body=None,
263
+ response_body=None,
264
+ cookies=[
265
+ {"name": "a", "value": "1", "domain": ".svc.example"},
266
+ {"name": "b", "value": "2", "domain": ".svc.example"},
267
+ {"name": "c", "value": "3", "domain": ".other.net"},
268
+ ],
269
+ minted_at=time.time(),
270
+ )
271
+ assert h.cookie_header("svc.example") == "a=1; b=2"
272
+ # пустая подстрока → все cookies
273
+ assert h.cookie_header() == "a=1; b=2; c=3"
274
+ assert h.cookie_header("nomatch.tld") == ""
275
+
276
+
277
+ def test_pick_headers_allowlist_case_insensitive() -> None:
278
+ h = TokenHarvest(
279
+ request_headers={
280
+ "authorization": "Bearer X",
281
+ "user-agent": "UA",
282
+ "content-length": "10",
283
+ },
284
+ request_body=None,
285
+ response_body=None,
286
+ cookies=[],
287
+ minted_at=time.time(),
288
+ )
289
+ picked = h.pick_headers(["Authorization", "USER-AGENT"])
290
+ assert picked == {"authorization": "Bearer X", "user-agent": "UA"}
291
+ assert "content-length" not in picked
292
+
293
+
294
+ def test_is_expired_ttl_and_skew() -> None:
295
+ fresh = TokenHarvest(
296
+ request_headers={}, request_body=None, response_body=None,
297
+ cookies=[], minted_at=time.time(),
298
+ )
299
+ assert fresh.is_expired(ttl_s=600) is False
300
+ # skew близко к ttl → уже «протух» (обновись заранее)
301
+ assert fresh.is_expired(ttl_s=600, skew=600) is True
302
+
303
+ old = TokenHarvest(
304
+ request_headers={}, request_body=None, response_body=None,
305
+ cookies=[], minted_at=time.time() - 1000,
306
+ )
307
+ assert old.is_expired(ttl_s=540) is True
308
+
309
+ never = TokenHarvest(
310
+ request_headers={}, request_body=None, response_body=None,
311
+ cookies=[], minted_at=0.0,
312
+ )
313
+ assert never.is_expired(ttl_s=10_000) is True
314
+
315
+
316
+ def test_reexport_identity() -> None:
317
+ # корневой реэкспорт и фасад antibot — те же объекты
318
+ assert TokenHarvest is TokenHarvestFromAntibot
319
+ assert WarmupTokenMinter is MinterFromAntibot
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes