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.
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/PKG-INFO +1 -1
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/__init__.py +9 -1
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/antibot/__init__.py +4 -0
- s_librarykit-0.3.0/librarykit/antibot/warmup_minter.py +255 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/pyproject.toml +1 -1
- s_librarykit-0.3.0/tests/test_warmup_minter.py +319 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/.gitignore +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/.gitlab-ci.yml +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/AGENTS.md +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/CHANGELOG.md +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/LICENSE +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/README.md +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/docs/superpowers/specs/2026-06-24-unified-sessions-design.md +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/antibot/_helpers.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/antibot/cdp.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/antibot/curl_cffi.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/antibot/page_evaluate.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/auth.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/blocks.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/browser/__init__.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/browser/_engine.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/browser/camoufox.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/browser/geometry.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/browser/launchers.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/browser/state.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/bulk.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/checkpoint.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/config_util.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/contract.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/entities.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/enums.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/errmap.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/errors.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/factory.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/forms.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/graphql.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/limit.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/orchestration/__init__.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/orchestration/health.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/orchestration/onboarding.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/orchestration/session_loader.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/pagination.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/pool.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/ports.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/protocols.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/redaction.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/refresh.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/retry.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/rpc.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/secret_store.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/session_bridge.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/session_paths.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/sessions/__init__.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/sessions/_const.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/sessions/_fsutil.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/stream.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/transport/__init__.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/transport/_helpers.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/transport/http_client.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/transport/permissive.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/transport/rest_client.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/transport/transport_http.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/upload.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/librarykit/windows.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/conftest.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_antibot.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_auth.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_blocks.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_browser.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_bulk.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_checkpoint.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_config_util.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_errmap.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_errors.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_factory.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_forms.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_graphql.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_limit.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_limit_contract.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_orchestration.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_pagination.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_permissive_http.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_pool.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_redaction.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_refresh.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_retry.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_rpc.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_session_bridge.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_session_paths.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_sessions.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_stream.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_transport_client.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_transport_rest.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_transport_sync.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_upload.py +0 -0
- {s_librarykit-0.2.0 → s_librarykit-0.3.0}/tests/test_windows.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|