dawg-replay-kit 1.1.3__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.
- dawg_replay_kit-1.1.3/.gitignore +5 -0
- dawg_replay_kit-1.1.3/LICENSE +21 -0
- dawg_replay_kit-1.1.3/PKG-INFO +47 -0
- dawg_replay_kit-1.1.3/README.md +21 -0
- dawg_replay_kit-1.1.3/pyproject.toml +43 -0
- dawg_replay_kit-1.1.3/src/dawg_replay_kit/__init__.py +141 -0
- dawg_replay_kit-1.1.3/src/dawg_replay_kit/browser.py +378 -0
- dawg_replay_kit-1.1.3/src/dawg_replay_kit/cancel.py +114 -0
- dawg_replay_kit-1.1.3/src/dawg_replay_kit/dawg_api.py +314 -0
- dawg_replay_kit-1.1.3/src/dawg_replay_kit/events.py +332 -0
- dawg_replay_kit-1.1.3/src/dawg_replay_kit/manifest.py +617 -0
- dawg_replay_kit-1.1.3/src/dawg_replay_kit/parameters.py +286 -0
- dawg_replay_kit-1.1.3/src/dawg_replay_kit/runtime.py +123 -0
- dawg_replay_kit-1.1.3/src/dawg_replay_kit/stdio.py +35 -0
- dawg_replay_kit-1.1.3/tests/conftest.py +96 -0
- dawg_replay_kit-1.1.3/tests/test_browser.py +312 -0
- dawg_replay_kit-1.1.3/tests/test_cancel.py +64 -0
- dawg_replay_kit-1.1.3/tests/test_events.py +392 -0
- dawg_replay_kit-1.1.3/tests/test_manifest.py +444 -0
- dawg_replay_kit-1.1.3/tests/test_parameters.py +305 -0
- dawg_replay_kit-1.1.3/tests/test_runtime.py +193 -0
- dawg_replay_kit-1.1.3/tests/test_version.py +13 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dawgswarm
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dawg-replay-kit
|
|
3
|
+
Version: 1.1.3
|
|
4
|
+
Summary: Библиотека для скриптов dawg-replay: параметры, события, браузеры DAWG
|
|
5
|
+
Project-URL: Homepage, https://dawgswarm.ru
|
|
6
|
+
Project-URL: Source, https://github.com/dawgswarm/dawg-replay
|
|
7
|
+
Project-URL: Issues, https://github.com/dawgswarm/dawg-replay/issues
|
|
8
|
+
Author: dawgswarm
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: tomli>=2; python_version < '3.11'
|
|
21
|
+
Provides-Extra: browser
|
|
22
|
+
Requires-Dist: dawg-sdk-python<0.5,>=0.4; extra == 'browser'
|
|
23
|
+
Requires-Dist: httpx>=0.27; extra == 'browser'
|
|
24
|
+
Requires-Dist: playwright<2,>=1.58; extra == 'browser'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# dawg-replay-kit
|
|
28
|
+
|
|
29
|
+
Библиотека для скриптов приложения [dawg-replay](https://pypi.org/project/dawg-replay/): параметры запуска, события прогресса и результата, облачные браузеры DAWG, которые закрываются при выходе, ошибке и отмене.
|
|
30
|
+
|
|
31
|
+
Пакет нужен тем, кто пишет скрипты для dawg-replay. Обычно это кодинг-агент со скиллом dawg-replay-builder, руками ставить пакет не нужно: скрипт объявляет его в заголовке PEP 723, и `uv run` подтягивает его сам.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
# /// script
|
|
35
|
+
# requires-python = ">=3.10"
|
|
36
|
+
# dependencies = ["dawg-replay-kit>=1.1,<2"]
|
|
37
|
+
# ///
|
|
38
|
+
import dawg_replay_kit as kit
|
|
39
|
+
|
|
40
|
+
kit.stage("Сбор")
|
|
41
|
+
kit.progress("pages", "Страницы", 1, 5)
|
|
42
|
+
kit.result("ok", "собрано 120 карточек")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Мажорная версия kit совпадает с мажорной версией контракта скрипта. Контракт (манифест, типы параметров, события, окружение запуска): https://github.com/dawgswarm/dawg-replay/blob/main/docs/contract.md
|
|
46
|
+
|
|
47
|
+
Лицензия MIT.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# dawg-replay-kit
|
|
2
|
+
|
|
3
|
+
Библиотека для скриптов приложения [dawg-replay](https://pypi.org/project/dawg-replay/): параметры запуска, события прогресса и результата, облачные браузеры DAWG, которые закрываются при выходе, ошибке и отмене.
|
|
4
|
+
|
|
5
|
+
Пакет нужен тем, кто пишет скрипты для dawg-replay. Обычно это кодинг-агент со скиллом dawg-replay-builder, руками ставить пакет не нужно: скрипт объявляет его в заголовке PEP 723, и `uv run` подтягивает его сам.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
# /// script
|
|
9
|
+
# requires-python = ">=3.10"
|
|
10
|
+
# dependencies = ["dawg-replay-kit>=1.1,<2"]
|
|
11
|
+
# ///
|
|
12
|
+
import dawg_replay_kit as kit
|
|
13
|
+
|
|
14
|
+
kit.stage("Сбор")
|
|
15
|
+
kit.progress("pages", "Страницы", 1, 5)
|
|
16
|
+
kit.result("ok", "собрано 120 карточек")
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Мажорная версия kit совпадает с мажорной версией контракта скрипта. Контракт (манифест, типы параметров, события, окружение запуска): https://github.com/dawgswarm/dawg-replay/blob/main/docs/contract.md
|
|
20
|
+
|
|
21
|
+
Лицензия MIT.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dawg-replay-kit"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Библиотека для скриптов dawg-replay: параметры, события, браузеры DAWG"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
authors = [{ name = "dawgswarm" }]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Operating System :: OS Independent",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
14
|
+
"Programming Language :: Python :: 3.10",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["tomli>=2; python_version < '3.11'"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://dawgswarm.ru"
|
|
24
|
+
Source = "https://github.com/dawgswarm/dawg-replay"
|
|
25
|
+
Issues = "https://github.com/dawgswarm/dawg-replay/issues"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
# Для kit.browser(). В скриптах эти пакеты и так перечислены в заголовке PEP 723.
|
|
29
|
+
browser = ["dawg-sdk-python>=0.4,<0.5", "playwright>=1.58,<2", "httpx>=0.27"]
|
|
30
|
+
|
|
31
|
+
[tool.hatch.version]
|
|
32
|
+
# Единственное место версии: __version__ в пакете. Мажор kit совпадает с мажором контракта.
|
|
33
|
+
path = "src/dawg_replay_kit/__init__.py"
|
|
34
|
+
|
|
35
|
+
[tool.uv]
|
|
36
|
+
# Версия динамическая (__version__ в __init__.py), а uv по умолчанию пересобирает пакет только
|
|
37
|
+
# при изменении pyproject.toml. Без этого ключа uv sync и uv run оставляют старую версию kit.
|
|
38
|
+
# Ключи заменяют набор uv по умолчанию, поэтому pyproject.toml указан явно.
|
|
39
|
+
cache-keys = [{ file = "pyproject.toml" }, { file = "src/dawg_replay_kit/__init__.py" }]
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["hatchling>=1.27"]
|
|
43
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""dawg-replay-kit: библиотека для скриптов dawg-replay.
|
|
2
|
+
|
|
3
|
+
import dawg_replay_kit as kit
|
|
4
|
+
|
|
5
|
+
kit.stage("Поиск")
|
|
6
|
+
for page in range(1, kit.params.max_pages + 1):
|
|
7
|
+
...
|
|
8
|
+
kit.progress("pages", "Страницы", page, kit.params.max_pages)
|
|
9
|
+
kit.result("ok", "Собрано 120 карточек")
|
|
10
|
+
|
|
11
|
+
Окружение читается при первом обращении к kit: манифест из файла скрипта,
|
|
12
|
+
параметры из `DAWG_SCRIPT_PARAMS` или из аргументов командной строки.
|
|
13
|
+
Если параметры не подходят, скрипт завершается с кодом 2 и объяснением в stderr.
|
|
14
|
+
|
|
15
|
+
При импорте kit переводит stdout и stderr в UTF-8: на Windows вывод в файл
|
|
16
|
+
иначе идёт в кодировке локали, и кириллица в print ломает скрипт.
|
|
17
|
+
|
|
18
|
+
При инициализации kit ставит обработчик SIGTERM (на Windows ещё CTRL_BREAK)
|
|
19
|
+
и следит за файлом остановки из `DAWG_SCRIPT_STOP_FILE`: отмена запуска из
|
|
20
|
+
приложения превращается в SystemExit(143), поэтому `finally` и `atexit`
|
|
21
|
+
срабатывают, а отложенные события дописываются в файл.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import atexit
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from dawg_replay_kit import cancel, stdio
|
|
33
|
+
from dawg_replay_kit.browser import BrowserLimitReached, BrowserSession
|
|
34
|
+
from dawg_replay_kit.dawg_api import BrowserError, BrowserLimitError
|
|
35
|
+
from dawg_replay_kit.events import Log
|
|
36
|
+
from dawg_replay_kit.manifest import SUPPORTED_CONTRACTS
|
|
37
|
+
from dawg_replay_kit.runtime import EXIT_BAD_PARAMS, Kit, StartupError
|
|
38
|
+
|
|
39
|
+
__version__ = "1.1.3" # мажор равен мажору контракта; версия задаётся только здесь
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"SUPPORTED_CONTRACTS",
|
|
43
|
+
"BrowserError",
|
|
44
|
+
"BrowserLimitError",
|
|
45
|
+
"BrowserLimitReached",
|
|
46
|
+
"BrowserSession",
|
|
47
|
+
"Kit",
|
|
48
|
+
"StartupError",
|
|
49
|
+
"__version__",
|
|
50
|
+
"artifact",
|
|
51
|
+
"browser",
|
|
52
|
+
"init",
|
|
53
|
+
"log",
|
|
54
|
+
"metric",
|
|
55
|
+
"progress",
|
|
56
|
+
"result",
|
|
57
|
+
"rows",
|
|
58
|
+
"set_browser_limit",
|
|
59
|
+
"stage",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
stdio.use_utf8()
|
|
63
|
+
|
|
64
|
+
_kit: Kit | None = None
|
|
65
|
+
_lock = threading.Lock()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def init(**kwargs: Any) -> Kit:
|
|
69
|
+
"""Явная инициализация. Обычно не нужна: kit поднимается при первом обращении.
|
|
70
|
+
|
|
71
|
+
Аргументы те же, что у `Kit.from_environment`. Ошибку старта kit печатает
|
|
72
|
+
в stderr и завершает процесс с кодом 2.
|
|
73
|
+
"""
|
|
74
|
+
global _kit
|
|
75
|
+
with _lock:
|
|
76
|
+
if _kit is None:
|
|
77
|
+
try:
|
|
78
|
+
_kit = Kit.from_environment(**kwargs)
|
|
79
|
+
except StartupError as e:
|
|
80
|
+
print(f"dawg-replay-kit: {e}", file=sys.stderr, flush=True)
|
|
81
|
+
raise SystemExit(EXIT_BAD_PARAMS) from None
|
|
82
|
+
atexit.register(_kit.close)
|
|
83
|
+
# Сигнал отмены и файл остановки превращаются в SystemExit: finally и atexit успевают отработать.
|
|
84
|
+
cancel.install()
|
|
85
|
+
return _kit
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _runtime() -> Kit:
|
|
89
|
+
return _kit if _kit is not None else init()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def __getattr__(name: str) -> Any:
|
|
93
|
+
if name in ("params", "output_dir", "run_id", "autonomous"):
|
|
94
|
+
return getattr(_runtime(), name)
|
|
95
|
+
raise AttributeError(f"module 'dawg_replay_kit' has no attribute {name!r}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
log = Log(lambda: _runtime().events)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def stage(name: str) -> None:
|
|
102
|
+
_runtime().events.stage(name)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def progress(id: str, label: str, done: int | float, total: int | float | None = None) -> None:
|
|
106
|
+
_runtime().events.progress(id, label, done, total)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def metric(key: str, label: str, value: int | float | str, unit: str | None = None) -> None:
|
|
110
|
+
_runtime().events.metric(key, label, value, unit)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def rows(table: str, rows: Any, columns: Any = None) -> None:
|
|
114
|
+
_runtime().events.rows(table, rows, columns)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def artifact(path: str | Path, label: str, mime: str | None = None) -> None:
|
|
118
|
+
_runtime().events.artifact(path, label, mime)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def result(status: str, summary: str) -> None:
|
|
122
|
+
_runtime().events.result(status, summary)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def browser(
|
|
126
|
+
proxy: str | None = None,
|
|
127
|
+
geo: str | tuple[float, float] | None = None,
|
|
128
|
+
ready_timeout: float = 120.0,
|
|
129
|
+
) -> BrowserSession:
|
|
130
|
+
"""Браузер DAWG для `with` или `async with`.
|
|
131
|
+
|
|
132
|
+
proxy: `socks5://user:pass@host:port` и подобное; geo: slug города (`moskva`)
|
|
133
|
+
или координаты `"55.75,37.61"`. Внутри доступны `.page`, `.context`, `.browser`,
|
|
134
|
+
`.session_id`.
|
|
135
|
+
"""
|
|
136
|
+
return BrowserSession(_runtime().browsers, proxy, geo, ready_timeout)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def set_browser_limit(n: int) -> None:
|
|
140
|
+
"""Сколько браузеров скрипт держит одновременно. По умолчанию 1."""
|
|
141
|
+
_runtime().browsers.set_limit(n)
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""`kit.browser()`: облачный Chromium DAWG через Playwright по CDP (docs/contract.md, раздел 6).
|
|
2
|
+
|
|
3
|
+
with kit.browser(geo="moskva") as b:
|
|
4
|
+
b.page.goto("https://www.ozon.ru")
|
|
5
|
+
|
|
6
|
+
async with kit.browser() as b:
|
|
7
|
+
await b.page.goto("https://www.ozon.ru")
|
|
8
|
+
|
|
9
|
+
Порядок событий `session`:
|
|
10
|
+
|
|
11
|
+
- `provisioning` до запроса на создание;
|
|
12
|
+
- `open` с `session_id` сразу после ответа на POST, до ожидания готовности;
|
|
13
|
+
- `close` после освобождения, когда сессии больше нет в списке ключа;
|
|
14
|
+
- `failed`, если платформа ответила ошибкой и браузер точно не создан.
|
|
15
|
+
|
|
16
|
+
Браузер освобождается при выходе из `with`, при исключении, по сигналу отмены
|
|
17
|
+
(kit превращает его в SystemExit) и при выходе из процесса (atexit). Если процесс
|
|
18
|
+
убит жёстко, браузер освобождает приложение по событиям `open` без `close`.
|
|
19
|
+
|
|
20
|
+
Сколько браузеров одновременно держит скрипт, задаётся явно:
|
|
21
|
+
`kit.set_browser_limit(n)`, по умолчанию 1. Превышение даёт ошибку сразу,
|
|
22
|
+
без ожидания.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import threading
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from dawg_replay_kit.dawg_api import (
|
|
34
|
+
READY_TIMEOUT,
|
|
35
|
+
AsyncRestProvisioner,
|
|
36
|
+
BrowserError,
|
|
37
|
+
Created,
|
|
38
|
+
RestProvisioner,
|
|
39
|
+
Sessions,
|
|
40
|
+
build_payload,
|
|
41
|
+
)
|
|
42
|
+
from dawg_replay_kit.events import Emitter
|
|
43
|
+
|
|
44
|
+
DEFAULT_LIMIT = 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BrowserLimitReached(BrowserError):
|
|
48
|
+
"""Скрипт пытается открыть больше браузеров, чем разрешил kit.set_browser_limit()."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Connection:
|
|
53
|
+
browser: Any
|
|
54
|
+
context: Any
|
|
55
|
+
page: Any
|
|
56
|
+
close: Callable[[], Any] # для async возвращает корутину
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- подключение Playwright -------------------------------------------------
|
|
60
|
+
|
|
61
|
+
_local = threading.local()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _sync_connect(ws_url: str) -> Connection:
|
|
65
|
+
"""Подключение sync API. Один экземпляр Playwright на поток, пока открыт хоть один браузер."""
|
|
66
|
+
from playwright.sync_api import sync_playwright
|
|
67
|
+
|
|
68
|
+
if getattr(_local, "pw", None) is None:
|
|
69
|
+
_local.pw = sync_playwright().start()
|
|
70
|
+
_local.users = 0
|
|
71
|
+
_local.users += 1
|
|
72
|
+
try:
|
|
73
|
+
browser = _local.pw.chromium.connect_over_cdp(ws_url)
|
|
74
|
+
context = browser.contexts[0] if browser.contexts else browser.new_context()
|
|
75
|
+
page = context.pages[0] if context.pages else context.new_page()
|
|
76
|
+
except BaseException:
|
|
77
|
+
_sync_release_pw()
|
|
78
|
+
raise
|
|
79
|
+
|
|
80
|
+
def close() -> None:
|
|
81
|
+
try:
|
|
82
|
+
browser.close()
|
|
83
|
+
finally:
|
|
84
|
+
_sync_release_pw()
|
|
85
|
+
|
|
86
|
+
return Connection(browser, context, page, close)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _sync_release_pw() -> None:
|
|
90
|
+
_local.users -= 1
|
|
91
|
+
if _local.users <= 0 and _local.pw is not None:
|
|
92
|
+
pw, _local.pw = _local.pw, None
|
|
93
|
+
try:
|
|
94
|
+
pw.stop()
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_async_pw: dict[int, list[Any]] = {} # id цикла -> [playwright, users]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _async_connect(ws_url: str) -> Connection:
|
|
103
|
+
from playwright.async_api import async_playwright
|
|
104
|
+
|
|
105
|
+
key = id(asyncio.get_running_loop())
|
|
106
|
+
if key not in _async_pw:
|
|
107
|
+
_async_pw[key] = [await async_playwright().start(), 0]
|
|
108
|
+
entry = _async_pw[key]
|
|
109
|
+
entry[1] += 1
|
|
110
|
+
try:
|
|
111
|
+
browser = await entry[0].chromium.connect_over_cdp(ws_url)
|
|
112
|
+
context = browser.contexts[0] if browser.contexts else await browser.new_context()
|
|
113
|
+
page = context.pages[0] if context.pages else await context.new_page()
|
|
114
|
+
except BaseException:
|
|
115
|
+
await _async_release_pw(key)
|
|
116
|
+
raise
|
|
117
|
+
|
|
118
|
+
async def close() -> None:
|
|
119
|
+
try:
|
|
120
|
+
await browser.close()
|
|
121
|
+
finally:
|
|
122
|
+
await _async_release_pw(key)
|
|
123
|
+
|
|
124
|
+
return Connection(browser, context, page, close)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _async_release_pw(key: int) -> None:
|
|
128
|
+
entry = _async_pw.get(key)
|
|
129
|
+
if entry is None:
|
|
130
|
+
return
|
|
131
|
+
entry[1] -= 1
|
|
132
|
+
if entry[1] <= 0:
|
|
133
|
+
_async_pw.pop(key, None)
|
|
134
|
+
try:
|
|
135
|
+
await entry[0].stop()
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- менеджер ---------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Browsers:
|
|
144
|
+
"""Учёт браузеров скрипта: лимит, события, освобождение."""
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
events: Emitter,
|
|
149
|
+
api_key: str | None,
|
|
150
|
+
base_url: str,
|
|
151
|
+
*,
|
|
152
|
+
provisioner: Callable[[], RestProvisioner] | None = None,
|
|
153
|
+
async_provisioner: Callable[[], AsyncRestProvisioner] | None = None,
|
|
154
|
+
sessions: Callable[[], Sessions] | None = None,
|
|
155
|
+
connect: Callable[[str], Connection] = _sync_connect,
|
|
156
|
+
aconnect: Callable[[str], Any] = _async_connect,
|
|
157
|
+
):
|
|
158
|
+
self.events = events
|
|
159
|
+
self._api_key = api_key
|
|
160
|
+
self._base_url = base_url
|
|
161
|
+
self._provisioner = provisioner or (lambda: RestProvisioner(self._key(), self._base_url))
|
|
162
|
+
self._async_provisioner = async_provisioner or (lambda: AsyncRestProvisioner(self._key(), self._base_url))
|
|
163
|
+
self._sessions_factory = sessions or (lambda: Sessions(self._key(), self._base_url))
|
|
164
|
+
self._sessions: Sessions | None = None
|
|
165
|
+
self.connect = connect
|
|
166
|
+
self.aconnect = aconnect
|
|
167
|
+
self._limit = DEFAULT_LIMIT
|
|
168
|
+
self._in_use = 0
|
|
169
|
+
self._open: dict[str, str] = {} # session_id -> browser_id, ещё не освобождённые
|
|
170
|
+
self._lock = threading.Lock()
|
|
171
|
+
|
|
172
|
+
def _key(self) -> str:
|
|
173
|
+
if not self._api_key:
|
|
174
|
+
raise BrowserError("нет ключа DAWG: задайте переменную DAWG_API_KEY", created=False)
|
|
175
|
+
return self._api_key
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def limit(self) -> int:
|
|
179
|
+
return self._limit
|
|
180
|
+
|
|
181
|
+
def set_limit(self, n: int) -> None:
|
|
182
|
+
if isinstance(n, bool) or not isinstance(n, int) or n < 1:
|
|
183
|
+
raise ValueError("лимит браузеров должен быть целым не меньше 1")
|
|
184
|
+
self._limit = n
|
|
185
|
+
|
|
186
|
+
def open_sessions(self) -> dict[str, str]:
|
|
187
|
+
with self._lock:
|
|
188
|
+
return dict(self._open)
|
|
189
|
+
|
|
190
|
+
# --- слот лимита ---
|
|
191
|
+
|
|
192
|
+
def acquire(self) -> None:
|
|
193
|
+
with self._lock:
|
|
194
|
+
if self._in_use >= self._limit:
|
|
195
|
+
raise BrowserLimitReached(
|
|
196
|
+
f"скрипт уже держит {self._in_use} из {self._limit} разрешённых браузеров; "
|
|
197
|
+
"закройте один или поднимите лимит kit.set_browser_limit(n)",
|
|
198
|
+
created=False,
|
|
199
|
+
)
|
|
200
|
+
self._in_use += 1
|
|
201
|
+
|
|
202
|
+
def release_slot(self) -> None:
|
|
203
|
+
with self._lock:
|
|
204
|
+
self._in_use = max(0, self._in_use - 1)
|
|
205
|
+
|
|
206
|
+
# --- события ---
|
|
207
|
+
|
|
208
|
+
def on_retry(self, attempt: int, wait: float) -> None:
|
|
209
|
+
self.events.log("warn", f"платформа отказала в браузере (лимит тарифа), повтор {attempt} через {wait:.0f} с")
|
|
210
|
+
|
|
211
|
+
def provisioning(self) -> None:
|
|
212
|
+
self.events.session("provisioning")
|
|
213
|
+
|
|
214
|
+
def opened(self, created: Created) -> None:
|
|
215
|
+
with self._lock:
|
|
216
|
+
self._open[created.session_id] = created.browser_id
|
|
217
|
+
self.events.session("open", created.session_id, created.browser_id)
|
|
218
|
+
|
|
219
|
+
def failed(self, error: BrowserError) -> None:
|
|
220
|
+
if error.created is False:
|
|
221
|
+
self.events.session("failed")
|
|
222
|
+
|
|
223
|
+
# --- освобождение ---
|
|
224
|
+
|
|
225
|
+
def release(self, session_id: str) -> bool:
|
|
226
|
+
"""Освобождает сессию. `close` пишется, только если её больше нет в списке ключа."""
|
|
227
|
+
with self._lock:
|
|
228
|
+
if session_id not in self._open:
|
|
229
|
+
return True
|
|
230
|
+
try:
|
|
231
|
+
if self._sessions is None:
|
|
232
|
+
self._sessions = self._sessions_factory()
|
|
233
|
+
gone = self._sessions.release(session_id)
|
|
234
|
+
except Exception as e:
|
|
235
|
+
self.events.log("error", f"браузер {session_id} не освобождён: {e}; его освободит приложение")
|
|
236
|
+
return False
|
|
237
|
+
if not gone:
|
|
238
|
+
self.events.log("error", f"браузер {session_id} всё ещё в списке сессий; его освободит приложение")
|
|
239
|
+
return False
|
|
240
|
+
with self._lock:
|
|
241
|
+
browser_id = self._open.pop(session_id, None)
|
|
242
|
+
self.events.session("close", session_id, browser_id)
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
def release_all(self) -> None:
|
|
246
|
+
for sid in list(self.open_sessions()):
|
|
247
|
+
self.release(sid)
|
|
248
|
+
|
|
249
|
+
def close(self) -> None:
|
|
250
|
+
self.release_all()
|
|
251
|
+
if self._sessions is not None:
|
|
252
|
+
try:
|
|
253
|
+
self._sessions.close()
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
self._sessions = None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class BrowserSession:
|
|
260
|
+
"""Результат `kit.browser()`. Работает как `with` и как `async with`."""
|
|
261
|
+
|
|
262
|
+
def __init__(
|
|
263
|
+
self,
|
|
264
|
+
manager: Browsers,
|
|
265
|
+
proxy: str | None = None,
|
|
266
|
+
geo: str | tuple[float, float] | None = None,
|
|
267
|
+
ready_timeout: float = READY_TIMEOUT,
|
|
268
|
+
):
|
|
269
|
+
self._m = manager
|
|
270
|
+
self._payload = build_payload(proxy, geo)
|
|
271
|
+
self._ready_timeout = ready_timeout
|
|
272
|
+
self.session_id: str | None = None
|
|
273
|
+
self.browser_id: str | None = None
|
|
274
|
+
self.browser: Any = None
|
|
275
|
+
self.context: Any = None
|
|
276
|
+
self.page: Any = None
|
|
277
|
+
self._conn: Connection | None = None
|
|
278
|
+
self._slot = False
|
|
279
|
+
|
|
280
|
+
def __repr__(self) -> str:
|
|
281
|
+
return f"<BrowserSession {self.session_id or 'not open'}>"
|
|
282
|
+
|
|
283
|
+
# --- sync ---
|
|
284
|
+
|
|
285
|
+
def __enter__(self) -> BrowserSession:
|
|
286
|
+
self._m.acquire()
|
|
287
|
+
self._slot = True
|
|
288
|
+
try:
|
|
289
|
+
prov = self._m._provisioner()
|
|
290
|
+
try:
|
|
291
|
+
self._m.provisioning()
|
|
292
|
+
try:
|
|
293
|
+
created = prov.create(self._payload, self._m.on_retry)
|
|
294
|
+
except BrowserError as e:
|
|
295
|
+
self._m.failed(e)
|
|
296
|
+
raise
|
|
297
|
+
self._opened(created)
|
|
298
|
+
ws_url = prov.wait_ready(created, self._ready_timeout)
|
|
299
|
+
finally:
|
|
300
|
+
prov.close()
|
|
301
|
+
self._attach(self._m.connect(ws_url))
|
|
302
|
+
except BaseException:
|
|
303
|
+
self._cleanup_sync()
|
|
304
|
+
raise
|
|
305
|
+
return self
|
|
306
|
+
|
|
307
|
+
def __exit__(self, *exc: object) -> None:
|
|
308
|
+
self._cleanup_sync()
|
|
309
|
+
|
|
310
|
+
def _cleanup_sync(self) -> None:
|
|
311
|
+
try:
|
|
312
|
+
if self._conn is not None:
|
|
313
|
+
conn, self._conn = self._conn, None
|
|
314
|
+
try:
|
|
315
|
+
conn.close()
|
|
316
|
+
except Exception:
|
|
317
|
+
pass
|
|
318
|
+
finally:
|
|
319
|
+
self._release_remote()
|
|
320
|
+
|
|
321
|
+
# --- async ---
|
|
322
|
+
|
|
323
|
+
async def __aenter__(self) -> BrowserSession:
|
|
324
|
+
self._m.acquire()
|
|
325
|
+
self._slot = True
|
|
326
|
+
try:
|
|
327
|
+
prov = self._m._async_provisioner()
|
|
328
|
+
try:
|
|
329
|
+
self._m.provisioning()
|
|
330
|
+
try:
|
|
331
|
+
created = await prov.create(self._payload, self._m.on_retry)
|
|
332
|
+
except BrowserError as e:
|
|
333
|
+
self._m.failed(e)
|
|
334
|
+
raise
|
|
335
|
+
self._opened(created)
|
|
336
|
+
ws_url = await prov.wait_ready(created, self._ready_timeout)
|
|
337
|
+
finally:
|
|
338
|
+
await prov.close()
|
|
339
|
+
self._attach(await self._m.aconnect(ws_url))
|
|
340
|
+
except BaseException:
|
|
341
|
+
await self._cleanup_async()
|
|
342
|
+
raise
|
|
343
|
+
return self
|
|
344
|
+
|
|
345
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
346
|
+
await self._cleanup_async()
|
|
347
|
+
|
|
348
|
+
async def _cleanup_async(self) -> None:
|
|
349
|
+
try:
|
|
350
|
+
if self._conn is not None:
|
|
351
|
+
conn, self._conn = self._conn, None
|
|
352
|
+
try:
|
|
353
|
+
await conn.close()
|
|
354
|
+
except Exception:
|
|
355
|
+
pass
|
|
356
|
+
finally:
|
|
357
|
+
# Освобождение короткое (DELETE и список), идёт синхронно: так оно
|
|
358
|
+
# не прерывается отменой задачи на выходе из цикла.
|
|
359
|
+
self._release_remote()
|
|
360
|
+
|
|
361
|
+
# --- общее ---
|
|
362
|
+
|
|
363
|
+
def _opened(self, created: Created) -> None:
|
|
364
|
+
self.session_id, self.browser_id = created.session_id, created.browser_id
|
|
365
|
+
self._m.opened(created)
|
|
366
|
+
|
|
367
|
+
def _attach(self, conn: Connection) -> None:
|
|
368
|
+
self._conn = conn
|
|
369
|
+
self.browser, self.context, self.page = conn.browser, conn.context, conn.page
|
|
370
|
+
|
|
371
|
+
def _release_remote(self) -> None:
|
|
372
|
+
try:
|
|
373
|
+
if self.session_id is not None:
|
|
374
|
+
self._m.release(self.session_id)
|
|
375
|
+
finally:
|
|
376
|
+
if self._slot:
|
|
377
|
+
self._slot = False
|
|
378
|
+
self._m.release_slot()
|