aetheris-sdk 0.1.12__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.
- aetheris_sdk-0.1.12/PKG-INFO +39 -0
- aetheris_sdk-0.1.12/README.md +29 -0
- aetheris_sdk-0.1.12/pyproject.toml +18 -0
- aetheris_sdk-0.1.12/setup.cfg +4 -0
- aetheris_sdk-0.1.12/src/aetheris_client/__init__.py +479 -0
- aetheris_sdk-0.1.12/src/aetheris_sdk.egg-info/PKG-INFO +39 -0
- aetheris_sdk-0.1.12/src/aetheris_sdk.egg-info/SOURCES.txt +8 -0
- aetheris_sdk-0.1.12/src/aetheris_sdk.egg-info/dependency_links.txt +1 -0
- aetheris_sdk-0.1.12/src/aetheris_sdk.egg-info/requires.txt +2 -0
- aetheris_sdk-0.1.12/src/aetheris_sdk.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aetheris-sdk
|
|
3
|
+
Version: 0.1.12
|
|
4
|
+
Summary: Aetheris SDK: браузер профиля в твоём процессе, наружу — Playwright
|
|
5
|
+
Project-URL: Homepage, https://aetheris.h316.online
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: camoufox[geoip]>=0.5.4
|
|
10
|
+
|
|
11
|
+
# aetheris-sdk
|
|
12
|
+
|
|
13
|
+
Aetheris SDK: браузер профиля в твоём процессе, наружу — Playwright
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import asyncio
|
|
17
|
+
from aetheris_client import Aetheris
|
|
18
|
+
|
|
19
|
+
async def main():
|
|
20
|
+
async with Aetheris("http://127.0.0.1:8766/api/v1", token=KEY) as ae:
|
|
21
|
+
async with ae.open_profile(2, headless=False) as ctx:
|
|
22
|
+
page = await ctx.new_page()
|
|
23
|
+
await page.goto("https://example.com")
|
|
24
|
+
print(await page.title())
|
|
25
|
+
|
|
26
|
+
asyncio.run(main())
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`open_profile` отдаёт настоящий `BrowserContext` Playwright — работает всё,
|
|
30
|
+
что умеет Playwright. Аренда профиля, отпечаток, прокси, выгрузка каталога и
|
|
31
|
+
освобождение аренды делаются за тебя; выходить из `async with` обязательно.
|
|
32
|
+
|
|
33
|
+
Ключ создаётся в самом Aetheris, на экране «API и MCP», и читается из файла, а
|
|
34
|
+
не вписывается в код.
|
|
35
|
+
|
|
36
|
+
Полное описание установки — `http://127.0.0.1:8766/guide` при запущенном
|
|
37
|
+
Aetheris.
|
|
38
|
+
|
|
39
|
+
Версия 0.1.012, она же версия клиента, к которому эта раздача подходит.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# aetheris-sdk
|
|
2
|
+
|
|
3
|
+
Aetheris SDK: браузер профиля в твоём процессе, наружу — Playwright
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import asyncio
|
|
7
|
+
from aetheris_client import Aetheris
|
|
8
|
+
|
|
9
|
+
async def main():
|
|
10
|
+
async with Aetheris("http://127.0.0.1:8766/api/v1", token=KEY) as ae:
|
|
11
|
+
async with ae.open_profile(2, headless=False) as ctx:
|
|
12
|
+
page = await ctx.new_page()
|
|
13
|
+
await page.goto("https://example.com")
|
|
14
|
+
print(await page.title())
|
|
15
|
+
|
|
16
|
+
asyncio.run(main())
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`open_profile` отдаёт настоящий `BrowserContext` Playwright — работает всё,
|
|
20
|
+
что умеет Playwright. Аренда профиля, отпечаток, прокси, выгрузка каталога и
|
|
21
|
+
освобождение аренды делаются за тебя; выходить из `async with` обязательно.
|
|
22
|
+
|
|
23
|
+
Ключ создаётся в самом Aetheris, на экране «API и MCP», и читается из файла, а
|
|
24
|
+
не вписывается в код.
|
|
25
|
+
|
|
26
|
+
Полное описание установки — `http://127.0.0.1:8766/guide` при запущенном
|
|
27
|
+
Aetheris.
|
|
28
|
+
|
|
29
|
+
Версия 0.1.012, она же версия клиента, к которому эта раздача подходит.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aetheris-sdk"
|
|
7
|
+
version = "0.1.012"
|
|
8
|
+
description = "Aetheris SDK: браузер профиля в твоём процессе, наружу — Playwright"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = ["httpx>=0.27", "camoufox[geoip]>=0.5.4"]
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Homepage = "https://aetheris.h316.online"
|
|
15
|
+
|
|
16
|
+
[tool.setuptools]
|
|
17
|
+
package-dir = {"" = "src"}
|
|
18
|
+
packages = ["aetheris_client"]
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""Тонкий клиент Aetheris для автоматизации. Решение — ADR-0006.
|
|
2
|
+
|
|
3
|
+
Браузер поднимается **в процессе того, кто автоматизирует**, потому что папка
|
|
4
|
+
профиля лежит на диске его машины. По HTTP ходят только аренда и метаданные.
|
|
5
|
+
|
|
6
|
+
from aetheris_client import Aetheris
|
|
7
|
+
|
|
8
|
+
client = Aetheris("http://127.0.0.1:8765", token="...")
|
|
9
|
+
|
|
10
|
+
async with client.open_profile(12) as context: # BrowserContext
|
|
11
|
+
page = await context.new_page()
|
|
12
|
+
await page.goto("https://www.tiktok.com")
|
|
13
|
+
|
|
14
|
+
Цикл — аренда, забрать каталог, запуск, полное закрытие, выгрузка, отпустить
|
|
15
|
+
аренду — живёт здесь целиком. Написанный руками в клиенте, он был бы забыт
|
|
16
|
+
наполовину, и первым забыли бы выгрузку.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from contextlib import asynccontextmanager
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Aetheris",
|
|
31
|
+
"AetherisError",
|
|
32
|
+
"AccountInfo",
|
|
33
|
+
"BrowserStillRunning",
|
|
34
|
+
"ProfileBusy",
|
|
35
|
+
"ProfileInfo",
|
|
36
|
+
"ProxyDead",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AetherisError(RuntimeError):
|
|
41
|
+
"""Ошибка API. Код сравнивается, текст читается человеком."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, code: str, message: str) -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.code = code
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ProfileBusy(AetherisError):
|
|
49
|
+
"""Профиль открыт другой машиной. Ждать в цикле бессмысленно."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ProxyDead(AetherisError):
|
|
53
|
+
"""Прокси помечен мёртвым — нужно решение владельца, а не повтор."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BrowserStillRunning(AetherisError):
|
|
57
|
+
"""Выгрузка на живом браузере. Это ошибка в самом клиенте."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
#: Код ошибки API -> тип исключения. Клиент ловит смысл, а не разбирает строки.
|
|
61
|
+
ERRORS = {
|
|
62
|
+
"profile_locked": ProfileBusy,
|
|
63
|
+
"proxy_dead": ProxyDead,
|
|
64
|
+
"proxy_conflict": ProxyDead,
|
|
65
|
+
"browser_running": BrowserStillRunning,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class AccountInfo:
|
|
71
|
+
"""Аккаунт профиля. **Пароля здесь нет и не будет** (ADR-0008)."""
|
|
72
|
+
|
|
73
|
+
id: int
|
|
74
|
+
platform: str
|
|
75
|
+
username: str
|
|
76
|
+
label: str | None = None
|
|
77
|
+
has_credentials: bool = False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class ProfileInfo:
|
|
82
|
+
id: int
|
|
83
|
+
slug: str
|
|
84
|
+
name: str
|
|
85
|
+
status: str
|
|
86
|
+
is_open: bool
|
|
87
|
+
accounts: tuple[AccountInfo, ...] = ()
|
|
88
|
+
proxy_ip: str | None = None
|
|
89
|
+
proxy_country: str | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Aetheris:
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
base_url: str = "http://127.0.0.1:8765",
|
|
96
|
+
token: str | None = None,
|
|
97
|
+
timeout: float = 120.0,
|
|
98
|
+
addons_root: str | Path | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
self._base = base_url.rstrip("/") + "/api/v1"
|
|
101
|
+
self._token = token
|
|
102
|
+
self._timeout = timeout
|
|
103
|
+
# Корень распакованных расширений ЭТОЙ машины. Ядро отдаёт имена
|
|
104
|
+
# каталогов, путь собирается здесь: то же правило, что у пути к бинарю.
|
|
105
|
+
self._addons_root = Path(addons_root or os.getenv("ADDONS_ROOT", "./data/addons"))
|
|
106
|
+
|
|
107
|
+
# -- транспорт ---------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
110
|
+
import httpx
|
|
111
|
+
|
|
112
|
+
headers = {"Authorization": f"Bearer {self._token}"} if self._token else {}
|
|
113
|
+
async with httpx.AsyncClient(timeout=self._timeout) as http:
|
|
114
|
+
response = await http.request(
|
|
115
|
+
method, self._base + path, headers=headers, **kwargs
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if response.status_code == 204:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
body = response.json() if response.content else None
|
|
122
|
+
if response.is_error:
|
|
123
|
+
envelope = (body or {}).get("error", {})
|
|
124
|
+
code = envelope.get("code", "unknown")
|
|
125
|
+
message = envelope.get("message") or f"запрос не прошёл ({response.status_code})"
|
|
126
|
+
raise ERRORS.get(code, AetherisError)(code, message)
|
|
127
|
+
return body
|
|
128
|
+
|
|
129
|
+
# -- справочное --------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
async def profiles(self, **filters: Any) -> list[ProfileInfo]:
|
|
132
|
+
params = {key: value for key, value in filters.items() if value is not None}
|
|
133
|
+
rows = await self._request("GET", "/profiles", params=params)
|
|
134
|
+
return [_profile(row) for row in rows]
|
|
135
|
+
|
|
136
|
+
async def profile(self, profile_id: int) -> ProfileInfo:
|
|
137
|
+
return _profile(await self._request("GET", f"/profiles/{profile_id}"))
|
|
138
|
+
|
|
139
|
+
async def find_account(self, platform: str, username: str) -> ProfileInfo | None:
|
|
140
|
+
"""В каком профиле живёт этот аккаунт.
|
|
141
|
+
|
|
142
|
+
Закрывает вопрос, ради которого продукт и затевался: не держать
|
|
143
|
+
в отдельной таблице соответствие «профиль — аккаунт».
|
|
144
|
+
"""
|
|
145
|
+
for profile in await self.profiles(platform=platform):
|
|
146
|
+
for account in profile.accounts:
|
|
147
|
+
if account.username.lower() == username.lower():
|
|
148
|
+
return profile
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
# -- отпечаток ---------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
async def fingerprint(self, profile_id: int) -> dict[str, Any]:
|
|
154
|
+
"""Отпечаток профиля: разложенный по разделам и правимые ручки.
|
|
155
|
+
|
|
156
|
+
Значения ручек лежат в ``editable``, режимы возможностей браузера —
|
|
157
|
+
в ``settings``. Теми же именами их и меняют.
|
|
158
|
+
"""
|
|
159
|
+
return await self._request("GET", f"/profiles/{profile_id}/fingerprint")
|
|
160
|
+
|
|
161
|
+
async def tune_fingerprint(
|
|
162
|
+
self,
|
|
163
|
+
profile_id: int,
|
|
164
|
+
*,
|
|
165
|
+
reseed: list[str] | None = None,
|
|
166
|
+
settings: dict[str, str] | None = None,
|
|
167
|
+
**knobs: Any,
|
|
168
|
+
) -> dict[str, Any]:
|
|
169
|
+
"""Поправить отпечаток профиля.
|
|
170
|
+
|
|
171
|
+
::
|
|
172
|
+
|
|
173
|
+
await client.tune_fingerprint(7, timezone="Asia/Almaty", cores=8)
|
|
174
|
+
await client.tune_fingerprint(7, reseed=["canvas"])
|
|
175
|
+
await client.tune_fingerprint(7, settings={"webrtc": "off"})
|
|
176
|
+
|
|
177
|
+
Присланная ручка меняется, не присланная не трогается, ``None`` снимает
|
|
178
|
+
подмену — значение отдаст сам браузер.
|
|
179
|
+
|
|
180
|
+
Правки применяются **при следующем запуске**: конфигурация уезжает
|
|
181
|
+
в браузер в момент старта, и профиль на это время должен быть закрыт.
|
|
182
|
+
"""
|
|
183
|
+
body: dict[str, Any] = dict(knobs)
|
|
184
|
+
if reseed:
|
|
185
|
+
body["reseed"] = reseed
|
|
186
|
+
if settings:
|
|
187
|
+
body["settings"] = settings
|
|
188
|
+
return await self._request("PATCH", f"/profiles/{profile_id}/fingerprint", json=body)
|
|
189
|
+
|
|
190
|
+
async def reroll_fingerprint(self, profile_id: int, reason: str = "") -> ProfileInfo:
|
|
191
|
+
"""Выдать профилю другой набор железа целиком.
|
|
192
|
+
|
|
193
|
+
Для профиля с живыми аккаунтами это выглядит как вход с нового
|
|
194
|
+
устройства. Причина необязательна и идёт в заметки профиля.
|
|
195
|
+
"""
|
|
196
|
+
row = await self._request(
|
|
197
|
+
"POST", f"/profiles/{profile_id}/reroll-fingerprint", json={"reason": reason}
|
|
198
|
+
)
|
|
199
|
+
return _profile(row)
|
|
200
|
+
|
|
201
|
+
# -- запуск браузера ---------------------------------------------------
|
|
202
|
+
|
|
203
|
+
@asynccontextmanager
|
|
204
|
+
async def open_profile(self, profile_id: int, *, headless: bool = True):
|
|
205
|
+
"""Взять профиль в работу и отдать ``BrowserContext``.
|
|
206
|
+
|
|
207
|
+
Порядок шагов — условие корректности, а не стиль. ``pull`` идёт до
|
|
208
|
+
запуска, потому что после него Firefox уже держит файлы профиля.
|
|
209
|
+
Выгрузка — только после ПОЛНОГО закрытия: Firefox дописывает свои
|
|
210
|
+
файлы при завершении. Аренда отпускается последней, иначе вторая
|
|
211
|
+
машина заберёт профиль, пока эта ещё пишет.
|
|
212
|
+
"""
|
|
213
|
+
from camoufox.async_api import AsyncCamoufox
|
|
214
|
+
|
|
215
|
+
lease = await self._request("POST", f"/profiles/{profile_id}/lease")
|
|
216
|
+
directory = Path(lease["dir"])
|
|
217
|
+
options = dict(lease["launch_options"])
|
|
218
|
+
|
|
219
|
+
keepalive = asyncio.create_task(self._keep_lease(profile_id))
|
|
220
|
+
try:
|
|
221
|
+
if lease.get("sync_enabled"):
|
|
222
|
+
await self._request("POST", f"/profiles/{profile_id}/sync/pull")
|
|
223
|
+
|
|
224
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
|
|
226
|
+
# Настройки профиля — файлом в каталог, а не опцией запуска: через
|
|
227
|
+
# Playwright они не доезжают вовсе. Пишется всегда, в том числе
|
|
228
|
+
# пустым, — иначе выключенный вчера WebRTC остался бы выключенным
|
|
229
|
+
# после того, как его включили.
|
|
230
|
+
#
|
|
231
|
+
# Без этого автоматизация поднимала браузер БЕЗ защиты от утечки
|
|
232
|
+
# WebRTC, при том что интерфейс показывал её включённой: настройку
|
|
233
|
+
# клал только путь вебморды, а аренда её не отдавала вовсе.
|
|
234
|
+
apply_machine_part(
|
|
235
|
+
options,
|
|
236
|
+
lease,
|
|
237
|
+
directory=directory,
|
|
238
|
+
headless=headless,
|
|
239
|
+
addons_root=self._addons_root,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
async with AsyncCamoufox(persistent_context=True, from_options=options) as ctx:
|
|
243
|
+
yield ctx
|
|
244
|
+
finally:
|
|
245
|
+
keepalive.cancel()
|
|
246
|
+
try:
|
|
247
|
+
await keepalive
|
|
248
|
+
except asyncio.CancelledError:
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
if lease.get("sync_enabled"):
|
|
252
|
+
# Выгрузка может не пройти — профиль останется помеченным,
|
|
253
|
+
# но аренду отпустить надо в любом случае.
|
|
254
|
+
try:
|
|
255
|
+
await self._request("POST", f"/profiles/{profile_id}/sync/push")
|
|
256
|
+
except AetherisError:
|
|
257
|
+
pass
|
|
258
|
+
await self._request("POST", f"/profiles/{profile_id}/release")
|
|
259
|
+
|
|
260
|
+
async def _keep_lease(self, profile_id: int) -> None:
|
|
261
|
+
"""Продлевать аренду, пока браузер жив (ADR-0010).
|
|
262
|
+
|
|
263
|
+
Интервал заведомо меньше срока аренды: две подряд пропущенные попытки
|
|
264
|
+
ещё не приводят к истечению.
|
|
265
|
+
"""
|
|
266
|
+
while True:
|
|
267
|
+
await asyncio.sleep(60)
|
|
268
|
+
try:
|
|
269
|
+
await self._request("POST", f"/profiles/{profile_id}/renew")
|
|
270
|
+
except AetherisError:
|
|
271
|
+
# Аренду сняли принудительно. Молча продолжать нельзя: профиль
|
|
272
|
+
# уже может открывать другая машина.
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
#: Приставка кусков, на которые Camoufox режет конфигурацию отпечатка.
|
|
277
|
+
_CONFIG_PREFIX = "CAMOU_CONFIG_"
|
|
278
|
+
|
|
279
|
+
#: Ключ, которым Camoufox рисует курсор поверх страницы. Умолчание у него —
|
|
280
|
+
#: «рисовать», и с `humanize` оно не связано: замерено, что без `humanize`
|
|
281
|
+
#: кружок всё равно виден (`camoucfg.jvv`, `"showcursor": "bool"`).
|
|
282
|
+
#:
|
|
283
|
+
#: Имя дублирует `aetheris.browser.fingerprint.CURSOR_KEY` в ядре. Разъехаться
|
|
284
|
+
#: им негде: это имя ключа в чужом формате, и меняет его Camoufox, а не мы.
|
|
285
|
+
CURSOR_KEY = "showcursor"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
#: Как называется исполняемый файл браузера после ребрендинга установки.
|
|
289
|
+
#:
|
|
290
|
+
#: Именно это имя человек и видит: и `ps`, и Мониторинг системы, и список
|
|
291
|
+
#: приложений показывают **имя файла**, а не `CFBundleName` — замерено. Отсюда
|
|
292
|
+
#: пробел: «Aetheris Browser» читается, «aetheris-browser» выглядит служебным.
|
|
293
|
+
#: Кавычек он не требует — путь уходит списком аргументов, а не строкой в
|
|
294
|
+
#: оболочку.
|
|
295
|
+
#:
|
|
296
|
+
#: Живёт здесь, а не в модуле ребрендинга: отсюда его читает запуск, и разъезд
|
|
297
|
+
#: этих двух имён означал бы профиль, который перестал открываться.
|
|
298
|
+
BROWSER_EXECUTABLE = "Aetheris Browser"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _launch_path() -> str:
|
|
302
|
+
"""Путь к браузеру с поправкой на наше переименование.
|
|
303
|
+
|
|
304
|
+
`camoufox.pkgman.launch_path` собирает путь из своего имени и о
|
|
305
|
+
переименовании не знает. Без этой развилки первый же ребрендинг сделал бы
|
|
306
|
+
все профили неоткрываемыми — причём с ошибкой «executable doesn't exist»,
|
|
307
|
+
по которой не догадаться, что дело в имени.
|
|
308
|
+
"""
|
|
309
|
+
from camoufox.pkgman import launch_path
|
|
310
|
+
|
|
311
|
+
original = Path(launch_path())
|
|
312
|
+
renamed = original.with_name(BROWSER_EXECUTABLE)
|
|
313
|
+
return str(renamed if renamed.exists() else original)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _profile(row: dict[str, Any]) -> ProfileInfo:
|
|
317
|
+
proxy = row.get("proxy") or {}
|
|
318
|
+
return ProfileInfo(
|
|
319
|
+
id=row["id"],
|
|
320
|
+
slug=row["slug"],
|
|
321
|
+
name=row["name"],
|
|
322
|
+
status=row["status"],
|
|
323
|
+
is_open=row.get("is_open", False),
|
|
324
|
+
accounts=tuple(
|
|
325
|
+
AccountInfo(
|
|
326
|
+
id=account["id"],
|
|
327
|
+
platform=account["platform"],
|
|
328
|
+
username=account["username"],
|
|
329
|
+
label=account.get("label"),
|
|
330
|
+
has_credentials=account.get("has_credentials", False),
|
|
331
|
+
)
|
|
332
|
+
for account in row.get("accounts") or ()
|
|
333
|
+
),
|
|
334
|
+
proxy_ip=proxy.get("external_ip"),
|
|
335
|
+
proxy_country=proxy.get("country"),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def read_config(options: dict[str, Any]) -> dict[str, Any]:
|
|
342
|
+
"""Собрать конфигурацию отпечатка из кусков окружения.
|
|
343
|
+
|
|
344
|
+
Camoufox режет её на `CAMOU_CONFIG_1..N`: целиком она длиннее предела
|
|
345
|
+
переменной окружения. Пустой словарь означает, что кусков нет вовсе.
|
|
346
|
+
"""
|
|
347
|
+
env = options.get("env") or {}
|
|
348
|
+
chunks = sorted(
|
|
349
|
+
(key for key in env if key.startswith(_CONFIG_PREFIX)),
|
|
350
|
+
key=lambda key: int(key.removeprefix(_CONFIG_PREFIX)),
|
|
351
|
+
)
|
|
352
|
+
return json.loads("".join(env[key] for key in chunks)) if chunks else {}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def write_config(
|
|
356
|
+
options: dict[str, Any], config: dict[str, Any]
|
|
357
|
+
) -> dict[str, Any]:
|
|
358
|
+
"""Разложить конфигурацию обратно по кускам. Правит `options` на месте.
|
|
359
|
+
|
|
360
|
+
Число кусков сохраняется прежним: их считает не длина, а то, на сколько
|
|
361
|
+
частей резал Camoufox, и менять это на ходу незачем.
|
|
362
|
+
"""
|
|
363
|
+
env = dict(options.get("env") or {})
|
|
364
|
+
chunks = sorted(
|
|
365
|
+
(key for key in env if key.startswith(_CONFIG_PREFIX)),
|
|
366
|
+
key=lambda key: int(key.removeprefix(_CONFIG_PREFIX)),
|
|
367
|
+
)
|
|
368
|
+
if not chunks:
|
|
369
|
+
return options
|
|
370
|
+
|
|
371
|
+
packed = json.dumps(config, ensure_ascii=False)
|
|
372
|
+
for key in chunks:
|
|
373
|
+
env.pop(key)
|
|
374
|
+
size = max(1, -(-len(packed) // len(chunks)))
|
|
375
|
+
for number, start in enumerate(range(0, len(packed), size), start=1):
|
|
376
|
+
env[f"{_CONFIG_PREFIX}{number}"] = packed[start : start + size]
|
|
377
|
+
options["env"] = env
|
|
378
|
+
return options
|
|
379
|
+
|
|
380
|
+
def apply_machine_part(
|
|
381
|
+
options: dict[str, Any],
|
|
382
|
+
lease: dict[str, Any],
|
|
383
|
+
*,
|
|
384
|
+
directory: Path,
|
|
385
|
+
headless: bool,
|
|
386
|
+
addons_root: Path,
|
|
387
|
+
show_cursor: bool = True,
|
|
388
|
+
) -> None:
|
|
389
|
+
"""Достроить опции запуска тем, что принадлежит ЭТОЙ машине.
|
|
390
|
+
|
|
391
|
+
Ядро отдаёт всё, что переносится между машинами. Не переносится ровно
|
|
392
|
+
четыре вещи, и все четыре подставляются здесь: каталог профиля, путь к
|
|
393
|
+
бинарю Camoufox, окружение процесса и пути расширений.
|
|
394
|
+
|
|
395
|
+
Общая на двоих: этим пользуется и тонкий клиент, и агент (ADR-0018).
|
|
396
|
+
Второй копии не будет — разойтись им негде.
|
|
397
|
+
|
|
398
|
+
Настройки профиля кладутся файлом, а не опцией: через Playwright они не
|
|
399
|
+
доезжают вовсе. Пишется всегда, в том числе пустым, — иначе выключенный
|
|
400
|
+
вчера WebRTC остался бы выключенным после того, как его включили.
|
|
401
|
+
|
|
402
|
+
``show_cursor`` решается **здесь**, а не в ядре. Аренда одна на всех, а
|
|
403
|
+
красный кружок поверх страницы означает «браузером управляет программа, не
|
|
404
|
+
лезь руками» — то есть зависит не от профиля, а от того, кто его открыл.
|
|
405
|
+
Знает это только та сторона, которая браузер поднимает: агент по кнопке
|
|
406
|
+
открывает человеку, тонкий клиент — автоматизации.
|
|
407
|
+
"""
|
|
408
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
409
|
+
(directory / "user.js").write_text(lease.get("user_js", ""), encoding="utf-8")
|
|
410
|
+
|
|
411
|
+
options.update(
|
|
412
|
+
user_data_dir=str(directory),
|
|
413
|
+
headless=headless,
|
|
414
|
+
executable_path=_launch_path(),
|
|
415
|
+
)
|
|
416
|
+
# Окружение машины — ПОД конфигурацией отпечатка: Playwright этим полем
|
|
417
|
+
# окружение браузера заменяет, а не дополняет, и одни куски конфигурации
|
|
418
|
+
# означали бы Firefox без PATH и TEMP.
|
|
419
|
+
options["env"] = {**os.environ, **(options.get("env") or {})}
|
|
420
|
+
_patch_config(
|
|
421
|
+
options,
|
|
422
|
+
addons=lease.get("addons") or [],
|
|
423
|
+
addons_root=addons_root,
|
|
424
|
+
show_cursor=show_cursor,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _patch_config(
|
|
429
|
+
options: dict[str, Any],
|
|
430
|
+
*,
|
|
431
|
+
addons: list[str],
|
|
432
|
+
addons_root: Path,
|
|
433
|
+
show_cursor: bool,
|
|
434
|
+
) -> None:
|
|
435
|
+
"""Дописать в конфигурацию отпечатка то, что знает только эта сторона.
|
|
436
|
+
|
|
437
|
+
Конфигурация лежит кусками в переменных окружения: Camoufox режет её,
|
|
438
|
+
потому что целиком она длиннее лимита переменной. Собирается и режется
|
|
439
|
+
обратно она здесь — **один раз на запуск**. Два круга «прочитать —
|
|
440
|
+
записать» ради двух ключей раскладывали бы её дважды без всякой пользы.
|
|
441
|
+
|
|
442
|
+
Пути расширений: ядро отдаёт имена каталогов, а не пути, потому что путь
|
|
443
|
+
принадлежит машине — то же правило, что у ``executable_path``. Пропала
|
|
444
|
+
папка — расширение молча выпадает из списка: профиль с кошельком не должен
|
|
445
|
+
переставать открываться из-за того, что кто-то удалил аддон. Видно это в
|
|
446
|
+
библиотеке, а не в отказе.
|
|
447
|
+
|
|
448
|
+
Курсор: красный кружок поверх страницы рисуется отдельной настройкой со
|
|
449
|
+
своим умолчанием, и умолчание это — «рисовать». Снимать его приходится
|
|
450
|
+
явно.
|
|
451
|
+
"""
|
|
452
|
+
# Путь **абсолютный**, и это несущее. Относительный браузер разрешает от
|
|
453
|
+
# своего рабочего каталога, а не от нашего: расширение с таким путём молча
|
|
454
|
+
# не грузится — замерено, `DEBUG: Installed 1 addon(s)` появляется только с
|
|
455
|
+
# абсолютным. Та же ловушка однажды уже сломала запуск через обёртку
|
|
456
|
+
# значка профиля (research/window-chrome.md).
|
|
457
|
+
root = Path(addons_root).absolute()
|
|
458
|
+
paths = [
|
|
459
|
+
str(root / name)
|
|
460
|
+
for name in addons
|
|
461
|
+
if (root / name / "manifest.json").exists()
|
|
462
|
+
]
|
|
463
|
+
|
|
464
|
+
# Ранней проверки здесь нет намеренно: пустая, но существующая
|
|
465
|
+
# конфигурация — это `{}`, то есть ложь, и выход по ней пропускал бы
|
|
466
|
+
# расширения. `write_config` сам молчит, когда кусков нет.
|
|
467
|
+
config = read_config(options)
|
|
468
|
+
|
|
469
|
+
if paths:
|
|
470
|
+
config["addons"] = paths
|
|
471
|
+
else:
|
|
472
|
+
# Пустой список СНИМАЕТ ключ, а не оставляет прежний: иначе выключенное
|
|
473
|
+
# расширение продолжало бы ставиться до пересоздания профиля.
|
|
474
|
+
config.pop("addons", None)
|
|
475
|
+
|
|
476
|
+
if not show_cursor:
|
|
477
|
+
config[CURSOR_KEY] = False
|
|
478
|
+
|
|
479
|
+
write_config(options, config)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aetheris-sdk
|
|
3
|
+
Version: 0.1.12
|
|
4
|
+
Summary: Aetheris SDK: браузер профиля в твоём процессе, наружу — Playwright
|
|
5
|
+
Project-URL: Homepage, https://aetheris.h316.online
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: camoufox[geoip]>=0.5.4
|
|
10
|
+
|
|
11
|
+
# aetheris-sdk
|
|
12
|
+
|
|
13
|
+
Aetheris SDK: браузер профиля в твоём процессе, наружу — Playwright
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import asyncio
|
|
17
|
+
from aetheris_client import Aetheris
|
|
18
|
+
|
|
19
|
+
async def main():
|
|
20
|
+
async with Aetheris("http://127.0.0.1:8766/api/v1", token=KEY) as ae:
|
|
21
|
+
async with ae.open_profile(2, headless=False) as ctx:
|
|
22
|
+
page = await ctx.new_page()
|
|
23
|
+
await page.goto("https://example.com")
|
|
24
|
+
print(await page.title())
|
|
25
|
+
|
|
26
|
+
asyncio.run(main())
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`open_profile` отдаёт настоящий `BrowserContext` Playwright — работает всё,
|
|
30
|
+
что умеет Playwright. Аренда профиля, отпечаток, прокси, выгрузка каталога и
|
|
31
|
+
освобождение аренды делаются за тебя; выходить из `async with` обязательно.
|
|
32
|
+
|
|
33
|
+
Ключ создаётся в самом Aetheris, на экране «API и MCP», и читается из файла, а
|
|
34
|
+
не вписывается в код.
|
|
35
|
+
|
|
36
|
+
Полное описание установки — `http://127.0.0.1:8766/guide` при запущенном
|
|
37
|
+
Aetheris.
|
|
38
|
+
|
|
39
|
+
Версия 0.1.012, она же версия клиента, к которому эта раздача подходит.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/aetheris_client/__init__.py
|
|
4
|
+
src/aetheris_sdk.egg-info/PKG-INFO
|
|
5
|
+
src/aetheris_sdk.egg-info/SOURCES.txt
|
|
6
|
+
src/aetheris_sdk.egg-info/dependency_links.txt
|
|
7
|
+
src/aetheris_sdk.egg-info/requires.txt
|
|
8
|
+
src/aetheris_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aetheris_client
|