aimlib 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aimlib/__init__.py +917 -0
- aimlib-0.4.2.dist-info/METADATA +112 -0
- aimlib-0.4.2.dist-info/RECORD +6 -0
- aimlib-0.4.2.dist-info/WHEEL +5 -0
- aimlib-0.4.2.dist-info/licenses/LICENSE +21 -0
- aimlib-0.4.2.dist-info/top_level.txt +1 -0
aimlib/__init__.py
ADDED
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
"""Async customer SDK for aimlib mobile proxies and remote-browser sessions.
|
|
2
|
+
|
|
3
|
+
The SDK reads ``AIMLIB_API_KEY`` and ``AIMLIB_BASE_URL`` by default. Proxy URLs and browser
|
|
4
|
+
connection tokens are credentials; use them without printing or logging them. Remote browsers run on
|
|
5
|
+
the leased phone, so no browser binary is downloaded or launched on the customer's computer.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import importlib
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from typing import Optional
|
|
14
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Aimlib", "Device", "Proxy", "BrowserSession", "Ticket",
|
|
20
|
+
"AimlibError", "BrowserPolicyError", "BrowserUnavailableError", "CapacityError",
|
|
21
|
+
"LeaseInactiveError", "DataCapError", "OperationTimeout", "SessionExpiredError", "SessionTimeout", "TabLimitError",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AimlibError(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BrowserPolicyError(AimlibError):
|
|
30
|
+
"""The requested browser capability is intentionally disabled by managed-browser policy."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CapacityError(AimlibError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LeaseInactiveError(AimlibError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DataCapError(AimlibError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SessionExpiredError(AimlibError):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SessionTimeout(AimlibError):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OperationTimeout(AimlibError):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TabLimitError(AimlibError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class BrowserUnavailableError(AimlibError):
|
|
62
|
+
"""The remote browser cannot currently be created or reached."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_ERROR_BY_CODE = {
|
|
66
|
+
"capacity_unavailable": CapacityError,
|
|
67
|
+
"lease_inactive": LeaseInactiveError,
|
|
68
|
+
"data_cap_exceeded": DataCapError,
|
|
69
|
+
"session_expired": SessionExpiredError,
|
|
70
|
+
"session_provisioning": SessionTimeout,
|
|
71
|
+
"browser_unavailable": BrowserUnavailableError,
|
|
72
|
+
"device_unavailable": BrowserUnavailableError,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ttl_seconds(v) -> int:
|
|
77
|
+
if isinstance(v, (int, float)):
|
|
78
|
+
return int(v)
|
|
79
|
+
m = re.fullmatch(r"\s*(\d+)\s*([smh]?)\s*", str(v))
|
|
80
|
+
if not m:
|
|
81
|
+
raise ValueError(f"bad duration {v!r}")
|
|
82
|
+
return int(m.group(1)) * {"s": 1, "m": 60, "h": 3600}[m.group(2) or "s"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _raise_for_typed(r: httpx.Response):
|
|
86
|
+
if r.status_code < 400:
|
|
87
|
+
return
|
|
88
|
+
code, msg = "", ""
|
|
89
|
+
try:
|
|
90
|
+
body = r.json()
|
|
91
|
+
code = body.get("error", "")
|
|
92
|
+
msg = body.get("message", code)
|
|
93
|
+
except Exception: # noqa: BLE001
|
|
94
|
+
msg = r.text[:200]
|
|
95
|
+
raise _ERROR_BY_CODE.get(code, AimlibError)(msg or f"HTTP {r.status_code}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def _reject_detectable_browser_binding(*_args, **_kwargs):
|
|
99
|
+
raise BrowserPolicyError(
|
|
100
|
+
"page and context bindings are unavailable in managed-browser sessions"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _apply_browser_driver_policy(driver_api):
|
|
105
|
+
"""Disable page-visible Python bindings before returning the raw driver objects to callers."""
|
|
106
|
+
try:
|
|
107
|
+
driver_types = (driver_api.Page, driver_api.BrowserContext)
|
|
108
|
+
async_playwright = driver_api.async_playwright
|
|
109
|
+
except AttributeError as exc:
|
|
110
|
+
raise AimlibError("browser driver is incompatible with this SDK release") from exc
|
|
111
|
+
for driver_type in driver_types:
|
|
112
|
+
driver_type.expose_function = _reject_detectable_browser_binding
|
|
113
|
+
driver_type.expose_binding = _reject_detectable_browser_binding
|
|
114
|
+
return async_playwright
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _async_playwright():
|
|
118
|
+
"""Load the supported browser client and fail closed when it is unavailable."""
|
|
119
|
+
try:
|
|
120
|
+
return _apply_browser_driver_policy(importlib.import_module("patchright.async_api"))
|
|
121
|
+
except ImportError as driver_error:
|
|
122
|
+
raise AimlibError(
|
|
123
|
+
'browser support is not installed; install the SDK with: pip install "aimlib[browser]"'
|
|
124
|
+
) from driver_error
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _browser_connect_failure_code(exc: Exception) -> str:
|
|
128
|
+
"""Reduce a driver failure to a stable, non-secret diagnostic category.
|
|
129
|
+
|
|
130
|
+
Browser-driver errors can include the full connection endpoint in their call log. The SDK must
|
|
131
|
+
not copy that text into customer logs because it contains the browser-session identifier. A
|
|
132
|
+
small category distinguishes transport, authorization, policy, and browser-startup failures
|
|
133
|
+
while keeping URLs, headers, tokens, and device addresses out of the exception.
|
|
134
|
+
"""
|
|
135
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
136
|
+
return "discovery_timeout"
|
|
137
|
+
if isinstance(exc, httpx.TransportError):
|
|
138
|
+
return "discovery_transport_failed"
|
|
139
|
+
if isinstance(exc, BrowserPolicyError):
|
|
140
|
+
return "browser_policy_denied"
|
|
141
|
+
text = str(exc).lower()
|
|
142
|
+
if "managed download guard unavailable" in text:
|
|
143
|
+
return "browser_security_unavailable"
|
|
144
|
+
if "blocked by " in text or (
|
|
145
|
+
"blocked" in text and ("browser" in text or "connection" in text)
|
|
146
|
+
):
|
|
147
|
+
return "browser_policy_denied"
|
|
148
|
+
if "403" in text and ("websocket" in text or "unexpected server response" in text):
|
|
149
|
+
return "browser_access_denied"
|
|
150
|
+
if "502" in text:
|
|
151
|
+
return "browser_unavailable"
|
|
152
|
+
if "target page, context or browser has been closed" in text or "browser closed" in text:
|
|
153
|
+
return "browser_closed"
|
|
154
|
+
if "websocket" in text:
|
|
155
|
+
return "browser_connection_failed"
|
|
156
|
+
if "timeout" in text or "timed out" in text:
|
|
157
|
+
return "browser_connection_timeout"
|
|
158
|
+
return "browser_connection_failed"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _validated_ua_brand_rows(value, *, label: str) -> list[dict[str, str]]:
|
|
162
|
+
if not isinstance(value, list) or not 1 <= len(value) <= 8:
|
|
163
|
+
raise BrowserPolicyError(f"managed browser {label} metadata is unavailable")
|
|
164
|
+
rows: list[dict[str, str]] = []
|
|
165
|
+
for row in value:
|
|
166
|
+
if not isinstance(row, dict):
|
|
167
|
+
raise BrowserPolicyError(f"managed browser {label} metadata is invalid")
|
|
168
|
+
brand = row.get("brand")
|
|
169
|
+
version = row.get("version")
|
|
170
|
+
if (
|
|
171
|
+
not isinstance(brand, str)
|
|
172
|
+
or not isinstance(version, str)
|
|
173
|
+
or not 1 <= len(brand) <= 64
|
|
174
|
+
or not 1 <= len(version) <= 64
|
|
175
|
+
):
|
|
176
|
+
raise BrowserPolicyError(f"managed browser {label} metadata is invalid")
|
|
177
|
+
rows.append({"brand": brand, "version": version})
|
|
178
|
+
return rows
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _with_google_chrome_brand(rows: list[dict[str, str]], *, label: str) -> list[dict[str, str]]:
|
|
182
|
+
if any(row["brand"] == "Google Chrome" for row in rows):
|
|
183
|
+
return rows
|
|
184
|
+
chromium_index = next(
|
|
185
|
+
(index for index, row in enumerate(rows) if row["brand"] == "Chromium"),
|
|
186
|
+
None,
|
|
187
|
+
)
|
|
188
|
+
if chromium_index is None:
|
|
189
|
+
raise BrowserPolicyError(f"managed browser {label} Chromium brand is unavailable")
|
|
190
|
+
branded = list(rows)
|
|
191
|
+
branded.insert(
|
|
192
|
+
chromium_index + 1,
|
|
193
|
+
{"brand": "Google Chrome", "version": rows[chromium_index]["version"]},
|
|
194
|
+
)
|
|
195
|
+
return branded
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _google_chrome_user_agent_override(identity: object) -> Optional[dict]:
|
|
199
|
+
"""Build a coherent ChromePublic -> Google Chrome UA-CH override from native phone values."""
|
|
200
|
+
if not isinstance(identity, dict):
|
|
201
|
+
raise BrowserPolicyError("managed browser identity metadata is unavailable")
|
|
202
|
+
user_agent = identity.get("userAgent")
|
|
203
|
+
navigator_platform = identity.get("navigatorPlatform")
|
|
204
|
+
ua_platform = identity.get("platform")
|
|
205
|
+
string_fields = {
|
|
206
|
+
"userAgent": user_agent,
|
|
207
|
+
"navigatorPlatform": navigator_platform,
|
|
208
|
+
"platform": ua_platform,
|
|
209
|
+
"platformVersion": identity.get("platformVersion"),
|
|
210
|
+
"architecture": identity.get("architecture"),
|
|
211
|
+
"model": identity.get("model"),
|
|
212
|
+
"bitness": identity.get("bitness"),
|
|
213
|
+
}
|
|
214
|
+
if any(not isinstance(value, str) or len(value) > 512 for value in string_fields.values()):
|
|
215
|
+
raise BrowserPolicyError("managed browser identity metadata is invalid")
|
|
216
|
+
if not user_agent or "Android" not in user_agent or "Mobile" not in user_agent:
|
|
217
|
+
raise BrowserPolicyError("managed browser did not present a mobile Android user agent")
|
|
218
|
+
if ua_platform != "Android" or identity.get("mobile") is not True:
|
|
219
|
+
raise BrowserPolicyError("managed browser did not present Android client hints")
|
|
220
|
+
|
|
221
|
+
brands = _validated_ua_brand_rows(identity.get("brands"), label="brand")
|
|
222
|
+
full_versions = _validated_ua_brand_rows(
|
|
223
|
+
identity.get("fullVersionList"),
|
|
224
|
+
label="full-version brand",
|
|
225
|
+
)
|
|
226
|
+
low_has_google = any(row["brand"] == "Google Chrome" for row in brands)
|
|
227
|
+
full_has_google = any(row["brand"] == "Google Chrome" for row in full_versions)
|
|
228
|
+
if low_has_google != full_has_google:
|
|
229
|
+
raise BrowserPolicyError("managed browser Chrome brand metadata is inconsistent")
|
|
230
|
+
if low_has_google:
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
"userAgent": user_agent,
|
|
235
|
+
"platform": navigator_platform,
|
|
236
|
+
"userAgentMetadata": {
|
|
237
|
+
"brands": _with_google_chrome_brand(brands, label="brand"),
|
|
238
|
+
"fullVersionList": _with_google_chrome_brand(
|
|
239
|
+
full_versions,
|
|
240
|
+
label="full-version brand",
|
|
241
|
+
),
|
|
242
|
+
"platform": ua_platform,
|
|
243
|
+
"platformVersion": identity["platformVersion"],
|
|
244
|
+
"architecture": identity["architecture"],
|
|
245
|
+
"model": identity["model"],
|
|
246
|
+
"mobile": True,
|
|
247
|
+
"bitness": identity["bitness"],
|
|
248
|
+
"wow64": identity.get("wow64") is True,
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
_READ_NATIVE_BROWSER_IDENTITY = r"""async () => {
|
|
254
|
+
const data = navigator.userAgentData;
|
|
255
|
+
if (!data) return null;
|
|
256
|
+
let high = {};
|
|
257
|
+
try {
|
|
258
|
+
high = await data.getHighEntropyValues([
|
|
259
|
+
'architecture', 'bitness', 'fullVersionList', 'model', 'platformVersion', 'wow64'
|
|
260
|
+
]);
|
|
261
|
+
} catch (_) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
const copyBrands = value => Array.isArray(value)
|
|
265
|
+
? value.map(row => ({brand: String(row.brand || ''), version: String(row.version || '')}))
|
|
266
|
+
: [];
|
|
267
|
+
return {
|
|
268
|
+
userAgent: String(navigator.userAgent || ''),
|
|
269
|
+
navigatorPlatform: String(navigator.platform || ''),
|
|
270
|
+
brands: copyBrands(data.brands),
|
|
271
|
+
fullVersionList: copyBrands(high.fullVersionList),
|
|
272
|
+
platform: String(data.platform || ''),
|
|
273
|
+
platformVersion: String(high.platformVersion || ''),
|
|
274
|
+
architecture: String(high.architecture || ''),
|
|
275
|
+
model: String(high.model || ''),
|
|
276
|
+
mobile: data.mobile === true,
|
|
277
|
+
bitness: String(high.bitness || ''),
|
|
278
|
+
wow64: high.wow64 === true,
|
|
279
|
+
};
|
|
280
|
+
}"""
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
_VERIFY_GOOGLE_CHROME_BRAND = r"""async () => {
|
|
284
|
+
const data = navigator.userAgentData;
|
|
285
|
+
// UA-CH is unavailable on Chrome's internal startup page. That is not an override failure:
|
|
286
|
+
// the same target exposes the metadata after its first secure navigation.
|
|
287
|
+
if (!data || !Array.isArray(data.brands)) return null;
|
|
288
|
+
let high = {};
|
|
289
|
+
try {
|
|
290
|
+
high = await data.getHighEntropyValues(['fullVersionList']);
|
|
291
|
+
} catch (_) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
if (!Array.isArray(high.fullVersionList)) return null;
|
|
295
|
+
return data.brands.some(row => row.brand === 'Google Chrome') &&
|
|
296
|
+
high.fullVersionList.some(row => row.brand === 'Google Chrome');
|
|
297
|
+
}"""
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class Proxy:
|
|
301
|
+
def __init__(self, d: dict):
|
|
302
|
+
self.id = d.get("id")
|
|
303
|
+
self.url = d.get("url")
|
|
304
|
+
self.http_url = d.get("http_url") or self._with_scheme("http")
|
|
305
|
+
self.socks5_url = d.get("socks5_url") or self._with_scheme("socks5")
|
|
306
|
+
self.socks5h_url = d.get("socks5h_url") or self._with_scheme("socks5h")
|
|
307
|
+
self.protocols = tuple(d.get("protocols") or ("http", "socks5"))
|
|
308
|
+
# Kept for older callers. The endpoint itself auto-detects both protocols.
|
|
309
|
+
self.protocol = d.get("protocol")
|
|
310
|
+
self.status = d.get("status")
|
|
311
|
+
parsed = urlsplit(self.url or "")
|
|
312
|
+
self.host = parsed.hostname
|
|
313
|
+
self.port = parsed.port
|
|
314
|
+
|
|
315
|
+
def _with_scheme(self, scheme: str) -> Optional[str]:
|
|
316
|
+
if not self.url:
|
|
317
|
+
return None
|
|
318
|
+
parsed = urlsplit(self.url)
|
|
319
|
+
return urlunsplit((scheme, parsed.netloc, parsed.path, parsed.query, parsed.fragment))
|
|
320
|
+
|
|
321
|
+
def __repr__(self):
|
|
322
|
+
# Never put proxy credentials in a REPL/log merely because the object was printed.
|
|
323
|
+
return (
|
|
324
|
+
f"Proxy(host={self.host!r}, port={self.port!r}, "
|
|
325
|
+
f"protocols={self.protocols!r}, status={self.status!r})"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class Device:
|
|
330
|
+
def __init__(self, ai: "Aimlib", d: dict):
|
|
331
|
+
self._ai = ai
|
|
332
|
+
self.id = d["device_id"]
|
|
333
|
+
self.region = d.get("region")
|
|
334
|
+
self.carrier = d.get("carrier")
|
|
335
|
+
self.current_egress_ip = d.get("current_egress_ip")
|
|
336
|
+
self.proxy = Proxy(d["proxy"]) if d.get("proxy") else None
|
|
337
|
+
browser = d.get("browser") or {}
|
|
338
|
+
self.browser_available = browser.get("available")
|
|
339
|
+
lease = d.get("lease") or {}
|
|
340
|
+
self.lease_id = lease.get("id")
|
|
341
|
+
self.lease_ends_at = lease.get("ends_at")
|
|
342
|
+
|
|
343
|
+
def __repr__(self):
|
|
344
|
+
return f"Device(id={self.id!r}, region={self.region!r}, carrier={self.carrier!r})"
|
|
345
|
+
|
|
346
|
+
async def list_footprints(self) -> list:
|
|
347
|
+
"""Return footprint choices available for this device.
|
|
348
|
+
|
|
349
|
+
Pass a returned slug to ``device.browser(footprint=...)`` or
|
|
350
|
+
``session.set_footprint(...)``.
|
|
351
|
+
"""
|
|
352
|
+
r = await self._ai._http.get(f"/v1/devices/{self.id}/footprints")
|
|
353
|
+
_raise_for_typed(r)
|
|
354
|
+
return r.json().get("footprints", [])
|
|
355
|
+
|
|
356
|
+
async def browser(self, footprint=None, ttl=None, idle_timeout=None, sticky=None) -> "BrowserSession":
|
|
357
|
+
"""Start (provision) a managed-browser session on this phone. `footprint` is a clean-tier
|
|
358
|
+
slug (from list_footprints); omit it to leave the phone's persistent state authentic. Returns an
|
|
359
|
+
unconnected BrowserSession; `async with await device.browser(...) as b` connects + auto-stops.
|
|
360
|
+
connect() waits until the footprint is live on-device."""
|
|
361
|
+
body: dict = {}
|
|
362
|
+
if ttl is not None:
|
|
363
|
+
body["ttl"] = _ttl_seconds(ttl)
|
|
364
|
+
if idle_timeout is not None:
|
|
365
|
+
body["idle_timeout"] = _ttl_seconds(idle_timeout)
|
|
366
|
+
if footprint is not None:
|
|
367
|
+
body["footprint"] = footprint
|
|
368
|
+
# Retained only for call compatibility. Lease/session policy owns IP stickiness; the browser
|
|
369
|
+
# endpoint has never implemented this request field, so do not imply that it changes state.
|
|
370
|
+
_ = sticky
|
|
371
|
+
r = await self._ai._http.post(f"/v1/devices/{self.id}/browser", json=body)
|
|
372
|
+
_raise_for_typed(r)
|
|
373
|
+
session_data = r.json()
|
|
374
|
+
sess = BrowserSession(self._ai, self, session_data)
|
|
375
|
+
if footprint and session_data.get("desired_footprint") != footprint:
|
|
376
|
+
# Compatibility with servers predating atomic footprint selection. A current server
|
|
377
|
+
# validates and stores the footprint in the create transaction and echoes it above.
|
|
378
|
+
try:
|
|
379
|
+
fr = await self._ai._http.post(f"/v1/devices/{self.id}/footprint", json={"footprint": footprint})
|
|
380
|
+
_raise_for_typed(fr)
|
|
381
|
+
sess.desired_footprint = footprint
|
|
382
|
+
except BaseException as exc:
|
|
383
|
+
try:
|
|
384
|
+
await sess.stop()
|
|
385
|
+
except Exception as cleanup_exc: # noqa: BLE001
|
|
386
|
+
raise AimlibError(
|
|
387
|
+
"footprint selection failed and the new browser session could not be torn down"
|
|
388
|
+
) from cleanup_exc
|
|
389
|
+
raise exc
|
|
390
|
+
elif footprint:
|
|
391
|
+
sess.desired_footprint = footprint
|
|
392
|
+
return sess
|
|
393
|
+
|
|
394
|
+
async def rotate_ip(self, wait: bool = True, timeout: float = 240) -> dict:
|
|
395
|
+
"""Request a new cellular egress IP for this device.
|
|
396
|
+
|
|
397
|
+
Blocking mode returns a terminal operation dictionary and updates ``current_egress_ip``
|
|
398
|
+
when ``new_ip`` is present. With ``wait=False``, poll the returned ``operation_id`` through
|
|
399
|
+
``ai.operations``. Leave at least 30 seconds between radio operations on one device.
|
|
400
|
+
"""
|
|
401
|
+
body = {"wait": wait, "timeout_s": int(timeout)}
|
|
402
|
+
r = await self._ai._http.post(
|
|
403
|
+
f"/v1/devices/{self.id}/rotate-ip", json=body, timeout=timeout + 30
|
|
404
|
+
)
|
|
405
|
+
_raise_for_typed(r)
|
|
406
|
+
out = r.json()
|
|
407
|
+
if out.get("new_ip"):
|
|
408
|
+
self.current_egress_ip = out["new_ip"]
|
|
409
|
+
return out
|
|
410
|
+
|
|
411
|
+
async def switch_carrier(self, carrier: str, wait: bool = True, timeout: float = 200) -> dict:
|
|
412
|
+
"""Switch to an available carrier identifier.
|
|
413
|
+
|
|
414
|
+
Accepted identifiers are ``tmobile``, ``att``, and ``verizon``; availability is
|
|
415
|
+
device-specific. Blocking mode returns a terminal operation dictionary. With
|
|
416
|
+
``wait=False``, poll the returned ``operation_id`` through ``ai.operations``.
|
|
417
|
+
"""
|
|
418
|
+
body = {"carrier": carrier, "wait": wait, "timeout_s": int(timeout)}
|
|
419
|
+
r = await self._ai._http.post(
|
|
420
|
+
f"/v1/devices/{self.id}/carrier", json=body, timeout=timeout + 30
|
|
421
|
+
)
|
|
422
|
+
_raise_for_typed(r)
|
|
423
|
+
out = r.json()
|
|
424
|
+
if out.get("status") == "succeeded" and out.get("carrier"):
|
|
425
|
+
self.carrier = out["carrier"]
|
|
426
|
+
if out.get("new_ip"):
|
|
427
|
+
self.current_egress_ip = out["new_ip"]
|
|
428
|
+
return out
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class _Devices:
|
|
432
|
+
def __init__(self, ai: "Aimlib"):
|
|
433
|
+
self._ai = ai
|
|
434
|
+
|
|
435
|
+
async def list(self):
|
|
436
|
+
r = await self._ai._http.get("/v1/devices")
|
|
437
|
+
_raise_for_typed(r)
|
|
438
|
+
return [Device(self._ai, d) for d in r.json()]
|
|
439
|
+
|
|
440
|
+
async def get(self, device_id: str) -> Device:
|
|
441
|
+
for d in await self.list():
|
|
442
|
+
if d.id == device_id:
|
|
443
|
+
return d
|
|
444
|
+
raise AimlibError(f"device {device_id} not leased to this account")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class _Operations:
|
|
448
|
+
def __init__(self, ai: "Aimlib"):
|
|
449
|
+
self._ai = ai
|
|
450
|
+
|
|
451
|
+
async def get(self, operation_id: str) -> dict:
|
|
452
|
+
"""Fetch a queued IP-rotation or carrier-switch operation owned by this account."""
|
|
453
|
+
r = await self._ai._http.get(f"/v1/operations/{operation_id}")
|
|
454
|
+
_raise_for_typed(r)
|
|
455
|
+
return r.json()
|
|
456
|
+
|
|
457
|
+
async def wait(
|
|
458
|
+
self,
|
|
459
|
+
operation_id: str,
|
|
460
|
+
timeout: float = 300,
|
|
461
|
+
poll_interval: float = 1.5,
|
|
462
|
+
) -> dict:
|
|
463
|
+
"""Poll an operation until it succeeds, fails, or reaches its server timeout."""
|
|
464
|
+
loop = asyncio.get_running_loop()
|
|
465
|
+
deadline = loop.time() + timeout
|
|
466
|
+
while True:
|
|
467
|
+
operation = await self.get(operation_id)
|
|
468
|
+
if operation.get("status") in {"succeeded", "failed", "timeout"}:
|
|
469
|
+
return operation
|
|
470
|
+
remaining = deadline - loop.time()
|
|
471
|
+
if remaining <= 0:
|
|
472
|
+
raise OperationTimeout("operation polling timed out")
|
|
473
|
+
await asyncio.sleep(min(poll_interval, remaining))
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class BrowserSession:
|
|
477
|
+
def __init__(self, ai: "Aimlib", device: Device, data: dict):
|
|
478
|
+
self._ai = ai
|
|
479
|
+
self.device = device
|
|
480
|
+
self.id = data["session_id"]
|
|
481
|
+
self.status = data.get("status")
|
|
482
|
+
self.connect_url = data["connect_url"]
|
|
483
|
+
self.connect_token = data.get("connect_token")
|
|
484
|
+
self.max_tabs = int(data.get("max_tabs") or 5) # per-session tab cap (server-configured)
|
|
485
|
+
self._gave_initial = False
|
|
486
|
+
self.egress_ip = None
|
|
487
|
+
self.fingerprint = {}
|
|
488
|
+
self.desired_footprint = data.get("desired_footprint") or ""
|
|
489
|
+
self.applied_footprint = data.get("applied_footprint") or ""
|
|
490
|
+
self.expires_at = data.get("expires_at")
|
|
491
|
+
self.region = data.get("region")
|
|
492
|
+
self.ready_in_s = data.get("ready_in_s")
|
|
493
|
+
self._pw = None
|
|
494
|
+
self.browser = None # the live Playwright Browser once connected
|
|
495
|
+
self._identity_cdp_sessions = []
|
|
496
|
+
self._identity_pages: set[int] = set()
|
|
497
|
+
self._native_browser_identity = None
|
|
498
|
+
|
|
499
|
+
async def wait_until_ready(self, timeout: float = 180):
|
|
500
|
+
loop = asyncio.get_running_loop()
|
|
501
|
+
deadline = loop.time() + timeout
|
|
502
|
+
while loop.time() < deadline:
|
|
503
|
+
r = await self._ai._http.get(f"/v1/devices/{self.device.id}/browser")
|
|
504
|
+
if r.status_code == 404:
|
|
505
|
+
raise SessionExpiredError("session not found / expired")
|
|
506
|
+
_raise_for_typed(r)
|
|
507
|
+
data = r.json()
|
|
508
|
+
if data.get("session_id") != self.id:
|
|
509
|
+
raise SessionExpiredError("session was replaced by a newer browser session")
|
|
510
|
+
self.status = data.get("status")
|
|
511
|
+
self.applied_footprint = data.get("applied_footprint") or ""
|
|
512
|
+
self.expires_at = data.get("expires_at") or self.expires_at
|
|
513
|
+
self.egress_ip = data.get("egress_ip")
|
|
514
|
+
self.fingerprint = data.get("resolved_profile") or {}
|
|
515
|
+
# Do not connect until a requested browser footprint is fully active.
|
|
516
|
+
footprint_ok = (not self.desired_footprint) or (self.applied_footprint == self.desired_footprint)
|
|
517
|
+
if self.status in ("ready", "active", "idle") and footprint_ok:
|
|
518
|
+
return
|
|
519
|
+
if self.status in ("expiring", "gone"):
|
|
520
|
+
raise SessionExpiredError(f"session {self.status}")
|
|
521
|
+
if self.status == "failed":
|
|
522
|
+
raise AimlibError("session failed")
|
|
523
|
+
await asyncio.sleep(2)
|
|
524
|
+
raise SessionTimeout(f"session not ready within {timeout}s")
|
|
525
|
+
|
|
526
|
+
async def connect(self, timeout: float = 180):
|
|
527
|
+
"""Wait until ready and connect the supported async browser client.
|
|
528
|
+
|
|
529
|
+
Transient connection failures are retried against the same remote session. Failed local
|
|
530
|
+
client instances are stopped before retrying.
|
|
531
|
+
"""
|
|
532
|
+
if self.browser is not None:
|
|
533
|
+
try:
|
|
534
|
+
if self.browser.is_connected():
|
|
535
|
+
return self.browser
|
|
536
|
+
except (AttributeError, TypeError):
|
|
537
|
+
pass
|
|
538
|
+
await self._disconnect()
|
|
539
|
+
|
|
540
|
+
loop = asyncio.get_running_loop()
|
|
541
|
+
deadline = loop.time() + timeout
|
|
542
|
+
await self.wait_until_ready(timeout)
|
|
543
|
+
# The browser client expects the HTTP form of the returned connection endpoint.
|
|
544
|
+
disco = self.connect_url.replace("wss://", "https://").replace("ws://", "http://")
|
|
545
|
+
last_error: Exception | None = None
|
|
546
|
+
attempts = 3
|
|
547
|
+
for attempt in range(1, attempts + 1):
|
|
548
|
+
remaining = deadline - loop.time()
|
|
549
|
+
if remaining <= 0:
|
|
550
|
+
break
|
|
551
|
+
try:
|
|
552
|
+
# Check reachability before the browser client reduces transport failures to a
|
|
553
|
+
# generic exception. Authentication stays in a header and never enters a URL or log.
|
|
554
|
+
discovery = await self._ai._http.get(
|
|
555
|
+
disco.rstrip("/") + "/json/version",
|
|
556
|
+
headers={"Authorization": f"Bearer {self.connect_token}"},
|
|
557
|
+
timeout=max(1, min(remaining, 15)),
|
|
558
|
+
)
|
|
559
|
+
if discovery.status_code >= 500:
|
|
560
|
+
raise BrowserUnavailableError("remote browser is temporarily unavailable")
|
|
561
|
+
_raise_for_typed(discovery)
|
|
562
|
+
try:
|
|
563
|
+
ws_url = discovery.json().get("webSocketDebuggerUrl")
|
|
564
|
+
except (ValueError, AttributeError, TypeError):
|
|
565
|
+
ws_url = None
|
|
566
|
+
if not ws_url:
|
|
567
|
+
raise BrowserUnavailableError("remote browser returned invalid connection metadata")
|
|
568
|
+
async_playwright = _async_playwright()
|
|
569
|
+
self._pw = await async_playwright().start()
|
|
570
|
+
self.browser = await self._pw.chromium.connect_over_cdp(
|
|
571
|
+
disco,
|
|
572
|
+
headers={"Authorization": f"Bearer {self.connect_token}"},
|
|
573
|
+
timeout=max(1, int(min(remaining, 30) * 1000)),
|
|
574
|
+
)
|
|
575
|
+
for context in self.browser.contexts:
|
|
576
|
+
for page in context.pages:
|
|
577
|
+
await self._apply_page_identity(page)
|
|
578
|
+
return self.browser
|
|
579
|
+
except Exception as exc: # noqa: BLE001 - retry a bounded transient connection failure
|
|
580
|
+
last_error = exc
|
|
581
|
+
await self._disconnect()
|
|
582
|
+
if isinstance(exc, BrowserPolicyError):
|
|
583
|
+
raise
|
|
584
|
+
if attempt < attempts and loop.time() < deadline:
|
|
585
|
+
await asyncio.sleep(min(3, max(0, deadline - loop.time())))
|
|
586
|
+
if isinstance(last_error, BrowserUnavailableError):
|
|
587
|
+
raise BrowserUnavailableError(
|
|
588
|
+
f"remote browser did not become reachable after {attempts} attempts"
|
|
589
|
+
) from last_error
|
|
590
|
+
failure_code = _browser_connect_failure_code(last_error) if last_error else "deadline_exhausted"
|
|
591
|
+
raise SessionTimeout(
|
|
592
|
+
f"remote browser did not become reachable within {timeout}s after {attempts} attempts "
|
|
593
|
+
f"({failure_code})"
|
|
594
|
+
) from last_error
|
|
595
|
+
|
|
596
|
+
async def set_footprint(self, footprint: Optional[str], timeout: float = 120):
|
|
597
|
+
"""Apply an available footprint and reconnect this session.
|
|
598
|
+
|
|
599
|
+
Pass ``None`` or an empty string to restore the device default. Select non-empty values from
|
|
600
|
+
``device.list_footprints()``.
|
|
601
|
+
"""
|
|
602
|
+
fp = footprint or ""
|
|
603
|
+
r = await self._ai._http.post(f"/v1/devices/{self.device.id}/footprint", json={"footprint": fp})
|
|
604
|
+
_raise_for_typed(r)
|
|
605
|
+
self.desired_footprint = fp
|
|
606
|
+
await self._disconnect() # Applying a footprint invalidates the existing connection.
|
|
607
|
+
loop = asyncio.get_running_loop()
|
|
608
|
+
deadline = loop.time() + timeout
|
|
609
|
+
while loop.time() < deadline:
|
|
610
|
+
r = await self._ai._http.get(f"/v1/devices/{self.device.id}/browser")
|
|
611
|
+
if r.status_code == 404:
|
|
612
|
+
raise SessionExpiredError("session not found / expired")
|
|
613
|
+
data = r.json()
|
|
614
|
+
self.applied_footprint = data.get("applied_footprint") or ""
|
|
615
|
+
if self.applied_footprint == fp and data.get("status") in ("ready", "active", "idle"):
|
|
616
|
+
await self.connect(timeout=timeout) # transparent reconnect to the same session_id
|
|
617
|
+
return
|
|
618
|
+
await asyncio.sleep(2)
|
|
619
|
+
raise SessionTimeout(f"footprint {fp!r} not applied within {timeout}s")
|
|
620
|
+
|
|
621
|
+
async def _disconnect(self):
|
|
622
|
+
for cdp_session in reversed(self._identity_cdp_sessions):
|
|
623
|
+
try:
|
|
624
|
+
await cdp_session.detach()
|
|
625
|
+
except Exception: # noqa: BLE001
|
|
626
|
+
pass
|
|
627
|
+
self._identity_cdp_sessions = []
|
|
628
|
+
self._identity_pages.clear()
|
|
629
|
+
self._native_browser_identity = None
|
|
630
|
+
for closer in (lambda: self.browser and self.browser.close(), lambda: self._pw and self._pw.stop()):
|
|
631
|
+
try:
|
|
632
|
+
c = closer()
|
|
633
|
+
if c is not None:
|
|
634
|
+
await c
|
|
635
|
+
except Exception: # noqa: BLE001
|
|
636
|
+
pass
|
|
637
|
+
self.browser = None
|
|
638
|
+
self._pw = None
|
|
639
|
+
self._gave_initial = False
|
|
640
|
+
|
|
641
|
+
async def _apply_page_identity(self, page):
|
|
642
|
+
"""Brand ChromePublic coherently without changing the phone's native version or model."""
|
|
643
|
+
page_key = id(page)
|
|
644
|
+
if page_key in self._identity_pages:
|
|
645
|
+
return
|
|
646
|
+
cdp_session = None
|
|
647
|
+
try:
|
|
648
|
+
identity = await self._native_identity_for_page(page)
|
|
649
|
+
override = _google_chrome_user_agent_override(identity)
|
|
650
|
+
if override is not None:
|
|
651
|
+
cdp_session = await page.context.new_cdp_session(page)
|
|
652
|
+
await cdp_session.send("Emulation.setUserAgentOverride", override)
|
|
653
|
+
verified = await page.evaluate(_VERIFY_GOOGLE_CHROME_BRAND)
|
|
654
|
+
# Internal/about:blank startup pages cannot expose UA-CH. The override has already
|
|
655
|
+
# been validated on the secure bootstrap tab below; a definitive false result on a
|
|
656
|
+
# page that does expose UA-CH still fails closed.
|
|
657
|
+
if verified is False:
|
|
658
|
+
raise BrowserPolicyError("managed browser Chrome branding did not apply")
|
|
659
|
+
self._identity_cdp_sessions.append(cdp_session)
|
|
660
|
+
cdp_session = None
|
|
661
|
+
self._identity_pages.add(page_key)
|
|
662
|
+
except BrowserPolicyError:
|
|
663
|
+
raise
|
|
664
|
+
except Exception as exc: # noqa: BLE001 - never expose CDP endpoint or device details
|
|
665
|
+
raise BrowserPolicyError("managed browser identity policy could not be applied") from exc
|
|
666
|
+
finally:
|
|
667
|
+
if cdp_session is not None:
|
|
668
|
+
try:
|
|
669
|
+
await cdp_session.detach()
|
|
670
|
+
except Exception: # noqa: BLE001
|
|
671
|
+
pass
|
|
672
|
+
|
|
673
|
+
async def _native_identity_for_page(self, page):
|
|
674
|
+
"""Read UA-CH in a secure first-party context when Chrome's startup tab cannot expose it."""
|
|
675
|
+
if self._native_browser_identity is not None:
|
|
676
|
+
return self._native_browser_identity
|
|
677
|
+
identity = await page.evaluate(_READ_NATIVE_BROWSER_IDENTITY)
|
|
678
|
+
if identity is None:
|
|
679
|
+
bootstrap_page = None
|
|
680
|
+
bootstrap_cdp_session = None
|
|
681
|
+
try:
|
|
682
|
+
bootstrap_page = await page.context.new_page()
|
|
683
|
+
await bootstrap_page.goto(
|
|
684
|
+
"https://docs.aimlib.com/",
|
|
685
|
+
wait_until="domcontentloaded",
|
|
686
|
+
timeout=30_000,
|
|
687
|
+
)
|
|
688
|
+
identity = await bootstrap_page.evaluate(_READ_NATIVE_BROWSER_IDENTITY)
|
|
689
|
+
override = _google_chrome_user_agent_override(identity)
|
|
690
|
+
if override is not None:
|
|
691
|
+
bootstrap_cdp_session = await page.context.new_cdp_session(
|
|
692
|
+
bootstrap_page
|
|
693
|
+
)
|
|
694
|
+
await bootstrap_cdp_session.send(
|
|
695
|
+
"Emulation.setUserAgentOverride",
|
|
696
|
+
override,
|
|
697
|
+
)
|
|
698
|
+
verified = await bootstrap_page.evaluate(
|
|
699
|
+
_VERIFY_GOOGLE_CHROME_BRAND
|
|
700
|
+
)
|
|
701
|
+
if verified is not True:
|
|
702
|
+
raise BrowserPolicyError(
|
|
703
|
+
"managed browser Chrome branding did not apply"
|
|
704
|
+
)
|
|
705
|
+
except Exception as exc: # noqa: BLE001 - keep URL/session details out of the error
|
|
706
|
+
if isinstance(exc, BrowserPolicyError):
|
|
707
|
+
raise
|
|
708
|
+
raise BrowserPolicyError(
|
|
709
|
+
"managed browser identity bootstrap was unavailable"
|
|
710
|
+
) from exc
|
|
711
|
+
finally:
|
|
712
|
+
if bootstrap_cdp_session is not None:
|
|
713
|
+
try:
|
|
714
|
+
await bootstrap_cdp_session.detach()
|
|
715
|
+
except Exception: # noqa: BLE001
|
|
716
|
+
pass
|
|
717
|
+
if bootstrap_page is not None:
|
|
718
|
+
try:
|
|
719
|
+
await bootstrap_page.close()
|
|
720
|
+
except Exception: # noqa: BLE001
|
|
721
|
+
pass
|
|
722
|
+
if identity is None:
|
|
723
|
+
raise BrowserPolicyError("managed browser identity metadata is unavailable")
|
|
724
|
+
self._native_browser_identity = identity
|
|
725
|
+
return identity
|
|
726
|
+
|
|
727
|
+
async def disconnect(self):
|
|
728
|
+
"""Disconnect this local SDK client without stopping the remote session.
|
|
729
|
+
|
|
730
|
+
A later connect() or new_page() reconnects to the same session. Use stop() when the remote
|
|
731
|
+
browser should be torn down.
|
|
732
|
+
"""
|
|
733
|
+
await self._disconnect()
|
|
734
|
+
|
|
735
|
+
async def new_page(self):
|
|
736
|
+
"""Open a tab. Reuses the session's initial tab on the first call, then opens new tabs up to
|
|
737
|
+
the per-session limit (self.max_tabs, server-configured). Raises TabLimitError past the
|
|
738
|
+
limit — open a separate session for more parallelism (tabs share one IP + fingerprint)."""
|
|
739
|
+
b = self.browser
|
|
740
|
+
if b is not None:
|
|
741
|
+
try:
|
|
742
|
+
if not b.is_connected():
|
|
743
|
+
b = None
|
|
744
|
+
except (AttributeError, TypeError):
|
|
745
|
+
pass
|
|
746
|
+
if b is None:
|
|
747
|
+
b = await self.connect()
|
|
748
|
+
ctx = b.contexts[0] if b.contexts else await b.new_context()
|
|
749
|
+
if not self._gave_initial and ctx.pages:
|
|
750
|
+
self._gave_initial = True
|
|
751
|
+
page = ctx.pages[0]
|
|
752
|
+
await self._apply_page_identity(page)
|
|
753
|
+
return page
|
|
754
|
+
if len(ctx.pages) >= self.max_tabs:
|
|
755
|
+
raise TabLimitError(
|
|
756
|
+
f"tab limit reached ({self.max_tabs} per session); close a tab or start a separate session"
|
|
757
|
+
)
|
|
758
|
+
page = await ctx.new_page()
|
|
759
|
+
await self._apply_page_identity(page)
|
|
760
|
+
return page
|
|
761
|
+
|
|
762
|
+
async def wait_until_stopped(self, timeout: float = 120):
|
|
763
|
+
"""Wait until the service confirms this session is no longer live."""
|
|
764
|
+
loop = asyncio.get_running_loop()
|
|
765
|
+
deadline = loop.time() + timeout
|
|
766
|
+
while loop.time() < deadline:
|
|
767
|
+
r = await self._ai._http.get(f"/v1/devices/{self.device.id}/browser")
|
|
768
|
+
if r.status_code == 404:
|
|
769
|
+
self.status = "gone"
|
|
770
|
+
return
|
|
771
|
+
_raise_for_typed(r)
|
|
772
|
+
data = r.json()
|
|
773
|
+
if data.get("session_id") != self.id or data.get("status") in ("gone", "failed"):
|
|
774
|
+
self.status = "gone"
|
|
775
|
+
return
|
|
776
|
+
self.status = data.get("status") or self.status
|
|
777
|
+
await asyncio.sleep(2)
|
|
778
|
+
raise SessionTimeout(f"session teardown not confirmed within {timeout}s")
|
|
779
|
+
|
|
780
|
+
async def stop(self, wait: bool = True, timeout: float = 120):
|
|
781
|
+
"""Close the local driver, request on-phone teardown, and optionally wait for confirmation."""
|
|
782
|
+
await self._disconnect()
|
|
783
|
+
try:
|
|
784
|
+
r = await self._ai._http.delete(f"/v1/devices/{self.device.id}/browser")
|
|
785
|
+
except Exception as exc: # noqa: BLE001
|
|
786
|
+
raise AimlibError("could not request browser teardown") from exc
|
|
787
|
+
if r.status_code == 404:
|
|
788
|
+
self.status = "gone"
|
|
789
|
+
return
|
|
790
|
+
_raise_for_typed(r)
|
|
791
|
+
self.status = "expiring"
|
|
792
|
+
if wait:
|
|
793
|
+
await self.wait_until_stopped(timeout)
|
|
794
|
+
|
|
795
|
+
async def __aenter__(self):
|
|
796
|
+
try:
|
|
797
|
+
await self.connect()
|
|
798
|
+
return self
|
|
799
|
+
except BaseException as exc:
|
|
800
|
+
try:
|
|
801
|
+
await self.stop()
|
|
802
|
+
except Exception as cleanup_exc: # noqa: BLE001
|
|
803
|
+
raise AimlibError(
|
|
804
|
+
f"browser connection failed: {exc}; automatic teardown was not confirmed"
|
|
805
|
+
) from cleanup_exc
|
|
806
|
+
raise exc
|
|
807
|
+
|
|
808
|
+
async def __aexit__(self, exc_type, exc, traceback):
|
|
809
|
+
try:
|
|
810
|
+
await self.stop()
|
|
811
|
+
except Exception as cleanup_exc: # noqa: BLE001
|
|
812
|
+
if exc is not None:
|
|
813
|
+
raise AimlibError(
|
|
814
|
+
f"browser operation failed: {exc}; automatic teardown was not confirmed"
|
|
815
|
+
) from cleanup_exc
|
|
816
|
+
raise
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
class Ticket:
|
|
820
|
+
"""A support ticket. A ticket is only COMPLETE (`status == "closed"`) once BOTH you and the
|
|
821
|
+
operator have closed it; any new message reopens it. `awaiting_close_from` says who still needs
|
|
822
|
+
to close ('customer', 'operator', or 'both').
|
|
823
|
+
|
|
824
|
+
`acknowledged` is True once a support agent has opened or replied to the ticket — i.e. an
|
|
825
|
+
investigation is underway and you can check back later. `status_detail` is a human-readable
|
|
826
|
+
summary of where it stands (e.g. "A support agent is reviewing your ticket")."""
|
|
827
|
+
|
|
828
|
+
def __init__(self, ai: "Aimlib", d: dict):
|
|
829
|
+
self._ai = ai
|
|
830
|
+
self.id = d["id"]
|
|
831
|
+
self.subject = d.get("subject")
|
|
832
|
+
self.status = d.get("status")
|
|
833
|
+
self.acknowledged = bool(d.get("acknowledged"))
|
|
834
|
+
self.acknowledged_at = d.get("acknowledged_at")
|
|
835
|
+
self.status_detail = d.get("status_detail")
|
|
836
|
+
self.customer_closed = bool(d.get("customer_closed"))
|
|
837
|
+
self.operator_closed = bool(d.get("operator_closed"))
|
|
838
|
+
self.awaiting_close_from = d.get("awaiting_close_from")
|
|
839
|
+
self.messages = d.get("messages", []) # [{author: 'customer'|'operator', body, created_at}]
|
|
840
|
+
self.created_at = d.get("created_at")
|
|
841
|
+
self.updated_at = d.get("updated_at")
|
|
842
|
+
self.closed_at = d.get("closed_at")
|
|
843
|
+
|
|
844
|
+
@property
|
|
845
|
+
def complete(self) -> bool:
|
|
846
|
+
return self.status == "closed"
|
|
847
|
+
|
|
848
|
+
async def reply(self, body: str) -> "Ticket":
|
|
849
|
+
return await self._ai.tickets.reply(self.id, body)
|
|
850
|
+
|
|
851
|
+
async def close(self) -> "Ticket":
|
|
852
|
+
return await self._ai.tickets.close(self.id)
|
|
853
|
+
|
|
854
|
+
def __repr__(self):
|
|
855
|
+
return f"Ticket(id={self.id!r}, subject={self.subject!r}, status={self.status!r})"
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
class _Tickets:
|
|
859
|
+
def __init__(self, ai: "Aimlib"):
|
|
860
|
+
self._ai = ai
|
|
861
|
+
|
|
862
|
+
async def create(self, subject: str, body: str) -> Ticket:
|
|
863
|
+
"""File a new support ticket (subject + first message)."""
|
|
864
|
+
r = await self._ai._http.post("/v1/tickets", json={"subject": subject, "body": body})
|
|
865
|
+
_raise_for_typed(r)
|
|
866
|
+
return Ticket(self._ai, r.json())
|
|
867
|
+
|
|
868
|
+
async def list(self) -> "list[Ticket]":
|
|
869
|
+
r = await self._ai._http.get("/v1/tickets")
|
|
870
|
+
_raise_for_typed(r)
|
|
871
|
+
return [Ticket(self._ai, d) for d in r.json()]
|
|
872
|
+
|
|
873
|
+
async def get(self, ticket_id: str) -> Ticket:
|
|
874
|
+
"""Fetch one ticket including its full message thread."""
|
|
875
|
+
r = await self._ai._http.get(f"/v1/tickets/{ticket_id}")
|
|
876
|
+
_raise_for_typed(r)
|
|
877
|
+
return Ticket(self._ai, r.json())
|
|
878
|
+
|
|
879
|
+
async def reply(self, ticket_id: str, body: str) -> Ticket:
|
|
880
|
+
"""Post a message on a ticket (reopens it if it was closed)."""
|
|
881
|
+
r = await self._ai._http.post(f"/v1/tickets/{ticket_id}/messages", json={"body": body})
|
|
882
|
+
_raise_for_typed(r)
|
|
883
|
+
return Ticket(self._ai, r.json())
|
|
884
|
+
|
|
885
|
+
async def close(self, ticket_id: str) -> Ticket:
|
|
886
|
+
"""Mark the ticket closed from your side. It's only complete once the operator closes too."""
|
|
887
|
+
r = await self._ai._http.post(f"/v1/tickets/{ticket_id}/close")
|
|
888
|
+
_raise_for_typed(r)
|
|
889
|
+
return Ticket(self._ai, r.json())
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
class Aimlib:
|
|
893
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, timeout: float = 60):
|
|
894
|
+
self.api_key = api_key or os.environ.get("AIMLIB_API_KEY")
|
|
895
|
+
if not self.api_key:
|
|
896
|
+
raise AimlibError("no API key (pass api_key= or set AIMLIB_API_KEY)")
|
|
897
|
+
# Use the regional customer API URL assigned to the account unless explicitly overridden.
|
|
898
|
+
self.base_url = (base_url or os.environ.get("AIMLIB_BASE_URL", "https://uswest1.aimlib.com")).rstrip("/")
|
|
899
|
+
self._http = httpx.AsyncClient(
|
|
900
|
+
base_url=self.base_url, timeout=timeout,
|
|
901
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
902
|
+
)
|
|
903
|
+
self.devices = _Devices(self)
|
|
904
|
+
self.operations = _Operations(self)
|
|
905
|
+
self.tickets = _Tickets(self)
|
|
906
|
+
|
|
907
|
+
async def device(self, device_id: str) -> Device:
|
|
908
|
+
return await self.devices.get(device_id)
|
|
909
|
+
|
|
910
|
+
async def aclose(self):
|
|
911
|
+
await self._http.aclose()
|
|
912
|
+
|
|
913
|
+
async def __aenter__(self):
|
|
914
|
+
return self
|
|
915
|
+
|
|
916
|
+
async def __aexit__(self, *exc):
|
|
917
|
+
await self.aclose()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aimlib
|
|
3
|
+
Version: 0.4.2
|
|
4
|
+
Summary: Python SDK for aimlib mobile proxies and remote-browser sessions
|
|
5
|
+
Author: aimlib
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Documentation, https://docs.aimlib.com/
|
|
8
|
+
Project-URL: Changelog, https://github.com/rukouai/aimlib-python/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/rukouai/aimlib-python/issues
|
|
10
|
+
Project-URL: Source, https://github.com/rukouai/aimlib-python
|
|
11
|
+
Keywords: aimlib,mobile-proxy,proxy,remote-browser,asyncio
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: httpx<1,>=0.27
|
|
29
|
+
Provides-Extra: browser
|
|
30
|
+
Requires-Dist: patchright==1.61.2; extra == "browser"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# aimlib Python SDK
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/aimlib/)
|
|
36
|
+
[](https://pypi.org/project/aimlib/)
|
|
37
|
+
[](https://github.com/rukouai/aimlib-python/actions/workflows/ci.yml)
|
|
38
|
+
|
|
39
|
+
Asynchronous Python client for aimlib mobile proxies, on-device remote-browser sessions, radio
|
|
40
|
+
operations, and support tickets. Python 3.10 or newer is required.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
For proxy, device, radio-operation, and support APIs:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
python -m pip install --upgrade aimlib
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Include managed remote-browser support when needed:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
python -m pip install --upgrade "aimlib[browser]"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The browser extra connects to Chromium on the leased phone. It does not download or launch a browser
|
|
57
|
+
binary on your computer.
|
|
58
|
+
|
|
59
|
+
## Configure
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
export AIMLIB_API_KEY="<api-key>"
|
|
63
|
+
export AIMLIB_BASE_URL="https://uswest1.aimlib.com"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
API keys, credential-bearing proxy URLs, and browser connection tokens are secrets. Do not print or
|
|
67
|
+
log them.
|
|
68
|
+
|
|
69
|
+
## Example
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from aimlib import Aimlib
|
|
73
|
+
|
|
74
|
+
async with Aimlib() as ai:
|
|
75
|
+
device = (await ai.devices.list())[0]
|
|
76
|
+
|
|
77
|
+
http_proxy = device.proxy.http_url
|
|
78
|
+
socks_remote_dns_proxy = device.proxy.socks5h_url
|
|
79
|
+
|
|
80
|
+
async with await device.browser(ttl="10m") as session:
|
|
81
|
+
page = await session.new_page()
|
|
82
|
+
await page.goto("https://example.com")
|
|
83
|
+
print(await page.title())
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Only devices in your active rentals are returned. A remote browser can be created only after a
|
|
87
|
+
rental is active and the phone has been assigned to it.
|
|
88
|
+
|
|
89
|
+
## Main APIs
|
|
90
|
+
|
|
91
|
+
- `ai.devices.list()` and `ai.device(id)` return devices in active rentals.
|
|
92
|
+
- `device.proxy.http_url`, `.socks5_url`, and `.socks5h_url` address the same proxy host and port.
|
|
93
|
+
- `device.browser()` creates a remote-browser session for the active rental.
|
|
94
|
+
- `session.new_page()`, `.disconnect()`, `.connect()`, and `.stop()` manage that session.
|
|
95
|
+
- `device.rotate_ip()` and `device.switch_carrier()` support blocking or queued operation modes.
|
|
96
|
+
- `ai.operations.get(id)` and `.wait(id)` poll queued radio operations.
|
|
97
|
+
- `ai.tickets` creates, lists, reads, replies to, and closes support tickets.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
python -m pip install -e ".[browser]"
|
|
103
|
+
python -m unittest discover -s tests -v
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
See the [customer documentation](https://docs.aimlib.com/) for the complete API contract, limits,
|
|
107
|
+
security policy, and examples. Release notes are kept in the
|
|
108
|
+
[changelog](https://github.com/rukouai/aimlib-python/blob/main/CHANGELOG.md).
|
|
109
|
+
|
|
110
|
+
Report security issues privately as described in the
|
|
111
|
+
[security policy](https://github.com/rukouai/aimlib-python/blob/main/SECURITY.md). Never include API
|
|
112
|
+
keys, credential-bearing proxy URLs, browser connection tokens, or customer data in an issue or log.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
aimlib/__init__.py,sha256=q-Eztna4ZIm3JqZ9GqWVoWAXzZzp3oiaIeyJ33OjfB8,38610
|
|
2
|
+
aimlib-0.4.2.dist-info/licenses/LICENSE,sha256=E4gr--9s1ym-JdC3PDjiov8NhCLGbpkLhOJev6cVT7s,1063
|
|
3
|
+
aimlib-0.4.2.dist-info/METADATA,sha256=z8MhElu8CYdjnPGJvGb6__C2HBbK5U695AGhnMKQuWU,4201
|
|
4
|
+
aimlib-0.4.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
5
|
+
aimlib-0.4.2.dist-info/top_level.txt,sha256=Pl-OMtWVob-P5rBf_AS2kwWal-lMaDMpPXq08JijDjA,7
|
|
6
|
+
aimlib-0.4.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aimlib
|
|
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 @@
|
|
|
1
|
+
aimlib
|