ysbrowser-sdk 0.11.0__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.
- ysbrowser_sdk/__init__.py +20 -0
- ysbrowser_sdk/__main__.py +4 -0
- ysbrowser_sdk/_async_runtime.py +34 -0
- ysbrowser_sdk/_sync_runtime.py +9 -0
- ysbrowser_sdk/async_api.py +14 -0
- ysbrowser_sdk/browser.py +561 -0
- ysbrowser_sdk/cli.py +54 -0
- ysbrowser_sdk/configuration.py +119 -0
- ysbrowser_sdk/diagnostics.py +209 -0
- ysbrowser_sdk/errors.py +37 -0
- ysbrowser_sdk/installer.py +248 -0
- ysbrowser_sdk/models.py +262 -0
- ysbrowser_sdk/motion/__init__.py +5 -0
- ysbrowser_sdk/motion/assets/MODEL_INFO.txt +10 -0
- ysbrowser_sdk/motion/assets/model.json +12654 -0
- ysbrowser_sdk/motion/assets/vector_field.onnx +0 -0
- ysbrowser_sdk/motion/contract.py +56 -0
- ysbrowser_sdk/motion/model.py +122 -0
- ysbrowser_sdk/motion/predictor.py +108 -0
- ysbrowser_sdk/mouse.py +655 -0
- ysbrowser_sdk/options.py +167 -0
- ysbrowser_sdk/py.typed +0 -0
- ysbrowser_sdk/runtime.py +37 -0
- ysbrowser_sdk/sync_api.py +511 -0
- ysbrowser_sdk/sync_diagnostics.py +190 -0
- ysbrowser_sdk-0.11.0.dist-info/METADATA +63 -0
- ysbrowser_sdk-0.11.0.dist-info/RECORD +29 -0
- ysbrowser_sdk-0.11.0.dist-info/WHEEL +4 -0
- ysbrowser_sdk-0.11.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Async context configuration for YSbrowser; Page and Locator remain native."""
|
|
2
|
+
|
|
3
|
+
from .browser import YSBrowser, YSContext
|
|
4
|
+
from .errors import SDKError, ConfigurationError, CompatibilityError, FingerprintLockedError, ProxyCheckError
|
|
5
|
+
from .configuration import ContextConfig
|
|
6
|
+
from .models import BrowserCapabilities, ProxyHealthResult, Direct, FingerprintProfile, ProxyCheck, ProxyConfig, ProxyStatus
|
|
7
|
+
|
|
8
|
+
__version__ = "0.11.0"
|
|
9
|
+
__all__ = [
|
|
10
|
+
"YSBrowser", "YSContext", "ContextConfig", "BrowserCapabilities", "ProxyHealthResult", "Direct", "FingerprintProfile", "ProxyCheck", "ProxyConfig",
|
|
11
|
+
"ProxyStatus", "SDKError", "ConfigurationError", "CompatibilityError",
|
|
12
|
+
"FingerprintLockedError", "ProxyCheckError", "__version__",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
from .mouse import AsyncTrajectoryMouse as TrajectoryMouse, MotionExecution
|
|
16
|
+
from .motion import TrajectoryModel, MotionPlan, MotionRequest
|
|
17
|
+
__all__ += ["TrajectoryMouse", "TrajectoryModel", "MotionPlan", "MotionRequest", "MotionExecution"]
|
|
18
|
+
|
|
19
|
+
from .options import ContextOptions
|
|
20
|
+
__all__ += ["ContextOptions"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Cancellation-aware ownership transfer for native resources."""
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
async def _settle(operation):
|
|
6
|
+
task = asyncio.ensure_future(operation)
|
|
7
|
+
cancelled = False
|
|
8
|
+
while not task.done():
|
|
9
|
+
try:
|
|
10
|
+
await asyncio.shield(task)
|
|
11
|
+
except asyncio.CancelledError:
|
|
12
|
+
if task.cancelled():
|
|
13
|
+
raise
|
|
14
|
+
cancelled = True
|
|
15
|
+
except BaseException:
|
|
16
|
+
break
|
|
17
|
+
return task.result(), cancelled
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def complete(operation):
|
|
21
|
+
"""Finish cleanup despite repeated caller cancellation, then propagate it."""
|
|
22
|
+
result, cancelled = await _settle(operation)
|
|
23
|
+
if cancelled:
|
|
24
|
+
raise asyncio.CancelledError()
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def acquire(operation, cleanup):
|
|
29
|
+
"""Never abandon a resource whose creation has reached the native driver."""
|
|
30
|
+
result, cancelled = await _settle(operation)
|
|
31
|
+
if cancelled:
|
|
32
|
+
await complete(cleanup(result))
|
|
33
|
+
raise asyncio.CancelledError()
|
|
34
|
+
return result
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Native asynchronous Patchright API with YSbrowser configuration."""
|
|
2
|
+
from .browser import YSBrowser, YSContext
|
|
3
|
+
from .configuration import ContextConfig
|
|
4
|
+
from .models import BrowserCapabilities, ProxyHealthResult, Direct, FingerprintProfile, ProxyCheck, ProxyConfig, ProxyStatus
|
|
5
|
+
from .errors import SDKError, ConfigurationError, CompatibilityError, FingerprintLockedError, ProxyCheckError
|
|
6
|
+
|
|
7
|
+
__all__ = ["YSBrowser", "YSContext", "ContextConfig", "BrowserCapabilities", "ProxyHealthResult", "Direct", "FingerprintProfile", "ProxyCheck", "ProxyConfig", "ProxyStatus", "SDKError", "ConfigurationError", "CompatibilityError", "FingerprintLockedError", "ProxyCheckError"]
|
|
8
|
+
|
|
9
|
+
from .mouse import AsyncTrajectoryMouse as TrajectoryMouse, MotionExecution
|
|
10
|
+
from .motion import TrajectoryModel, MotionPlan, MotionRequest
|
|
11
|
+
__all__ += ["TrajectoryMouse", "TrajectoryModel", "MotionPlan", "MotionRequest", "MotionExecution"]
|
|
12
|
+
|
|
13
|
+
from .options import ContextOptions
|
|
14
|
+
__all__ += ["ContextOptions"]
|
ysbrowser_sdk/browser.py
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import copy
|
|
5
|
+
import inspect
|
|
6
|
+
import time
|
|
7
|
+
from collections import deque
|
|
8
|
+
import re
|
|
9
|
+
import socket
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from patchright.async_api import Browser, BrowserContext, CDPSession, Page, async_playwright
|
|
14
|
+
|
|
15
|
+
from .errors import CompatibilityError, ConfigurationError, FingerprintLockedError, ProxyCheckError, SDKError
|
|
16
|
+
from .diagnostics import DiagnosticsMixin
|
|
17
|
+
from .options import ContextOptions
|
|
18
|
+
from .configuration import ContextConfig, write_json
|
|
19
|
+
from .models import BrowserCapabilities, ProxyHealthResult, safe_url, Direct, FingerprintProfile, ProxyCheck, ProxyConfig, ProxyStatus
|
|
20
|
+
from .mouse import AsyncTrajectoryMouse as TrajectoryMouse, MotionExecution
|
|
21
|
+
from .motion import TrajectoryModel, MotionPlan, MotionRequest
|
|
22
|
+
from ._async_runtime import acquire, complete
|
|
23
|
+
from .runtime import launch_policy, runtime_info
|
|
24
|
+
from .installer import get_browser_path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class YSContext(DiagnosticsMixin):
|
|
28
|
+
"""A configured context; raw is the original Patchright BrowserContext."""
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def options(self) -> ContextOptions:
|
|
32
|
+
"""Return a detached copy of the options used to create this Context."""
|
|
33
|
+
return copy.deepcopy(self._options)
|
|
34
|
+
|
|
35
|
+
def __init__(self, owner: YSBrowser, raw: BrowserContext, context_id: str, name: str):
|
|
36
|
+
self._owner = owner
|
|
37
|
+
self.raw = raw
|
|
38
|
+
self.context_id = context_id
|
|
39
|
+
self.name = name
|
|
40
|
+
self._closed = False
|
|
41
|
+
self._gate = asyncio.Lock()
|
|
42
|
+
self._close_gate = asyncio.Lock()
|
|
43
|
+
self._fingerprint = FingerprintProfile()
|
|
44
|
+
self._proxy: ProxyConfig | Direct = Direct()
|
|
45
|
+
self._proxy_generation: str | None = None
|
|
46
|
+
self._no_viewport = True
|
|
47
|
+
self._options = ContextOptions()
|
|
48
|
+
self._recent_errors: deque[dict[str, Any]] = deque(maxlen=20)
|
|
49
|
+
self.raw.on("close", self._on_closed)
|
|
50
|
+
self.raw.on("requestfailed", self._on_request_failed)
|
|
51
|
+
self.raw.on("response", self._on_response)
|
|
52
|
+
self._init_diagnostics()
|
|
53
|
+
|
|
54
|
+
def _on_closed(self, *_: Any) -> None:
|
|
55
|
+
self._closed = True
|
|
56
|
+
self._diagnostic_trace = False
|
|
57
|
+
self._detach_diagnostics()
|
|
58
|
+
if self._owner._contexts.get(self.name) is self:
|
|
59
|
+
self._owner._contexts.pop(self.name, None)
|
|
60
|
+
|
|
61
|
+
def _on_request_failed(self, request: Any) -> None:
|
|
62
|
+
try:
|
|
63
|
+
if request.is_navigation_request():
|
|
64
|
+
match = re.search(r"net::ERR_[A-Z_]+", request.failure or "")
|
|
65
|
+
self._recent_errors.append({"kind": "navigation_network_error", "at": time.time() * 1000,
|
|
66
|
+
"origin": safe_url(request.url), "network_error": match.group() if match else "unknown"})
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def _on_response(self, response: Any) -> None:
|
|
71
|
+
try:
|
|
72
|
+
if response.status >= 400 and response.request.is_navigation_request():
|
|
73
|
+
self._recent_errors.append({"kind": "navigation_http_error", "at": time.time() * 1000,
|
|
74
|
+
"origin": safe_url(response.url), "http_status": response.status})
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ensure_open(self) -> None:
|
|
80
|
+
if self._owner._unexpected_disconnect:
|
|
81
|
+
raise SDKError("BROWSER_DISCONNECTED", "context", "Browser connection was lost; create a new browser and restore saved configuration/state", context_id=self.context_id)
|
|
82
|
+
if self._closed:
|
|
83
|
+
raise SDKError("CONTEXT_CLOSED", "context", "Context has been closed", context_id=self.context_id)
|
|
84
|
+
|
|
85
|
+
async def _send(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
86
|
+
self._ensure_open()
|
|
87
|
+
assert self._owner._cdp is not None
|
|
88
|
+
return await self._owner._cdp.send(method, {"browserContextId": self.context_id, **(params or {})})
|
|
89
|
+
|
|
90
|
+
async def fingerprint_status(self) -> dict[str, Any]:
|
|
91
|
+
try:
|
|
92
|
+
return (await self._send("Browser.getFingerprintProfile"))["status"]
|
|
93
|
+
except SDKError:
|
|
94
|
+
raise
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
raise CompatibilityError("FINGERPRINT_STATUS_FAILED", "fingerprint.status",
|
|
97
|
+
"YSbrowser fingerprint CDP API is unavailable or failed", context_id=self.context_id,
|
|
98
|
+
original_error=exc) from None
|
|
99
|
+
|
|
100
|
+
async def _apply_fingerprint(self, profile: FingerprintProfile) -> None:
|
|
101
|
+
payload = profile.to_cdp()
|
|
102
|
+
capabilities = self._owner.capabilities
|
|
103
|
+
unknown = sorted(set(payload) - set(capabilities.fingerprint_fields))
|
|
104
|
+
if unknown:
|
|
105
|
+
raise CompatibilityError("UNSUPPORTED_FINGERPRINT_FIELDS", "fingerprint.set", "Browser does not support requested fingerprint fields", details={"fields": unknown})
|
|
106
|
+
if profile.server_gpu_profile is not None and (not capabilities.context_gpu_available or profile.server_gpu_profile not in capabilities.gpu_profiles):
|
|
107
|
+
raise ConfigurationError("Requested GPU profile is unsupported or Context GPU mode is unavailable")
|
|
108
|
+
status = await self.fingerprint_status()
|
|
109
|
+
if status["locked"]:
|
|
110
|
+
raise FingerprintLockedError("FINGERPRINT_LOCKED", "fingerprint.set",
|
|
111
|
+
"Create a new context; this context has already started a renderer", context_id=self.context_id)
|
|
112
|
+
try:
|
|
113
|
+
response = await self._send("Browser.setFingerprintProfile", {"profile": payload})
|
|
114
|
+
if profile.server_gpu_profile is not None and response.get("status", {}).get("profile", {}).get("serverGpuProfile") != profile.server_gpu_profile:
|
|
115
|
+
raise CompatibilityError("CONTEXT_GPU_UNSUPPORTED", "fingerprint.set",
|
|
116
|
+
"This browser does not acknowledge per-context GPU profiles; install the context GPU patch",
|
|
117
|
+
context_id=self.context_id)
|
|
118
|
+
self._fingerprint = profile
|
|
119
|
+
except CompatibilityError:
|
|
120
|
+
raise
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
# Raw contexts may be manipulated by another client, so also inspect lock after failure.
|
|
123
|
+
status = await self.fingerprint_status()
|
|
124
|
+
if status["locked"]:
|
|
125
|
+
raise FingerprintLockedError("FINGERPRINT_LOCKED", "fingerprint.set", "A renderer started before configuration completed",
|
|
126
|
+
context_id=self.context_id, original_error=exc) from None
|
|
127
|
+
raise SDKError("FINGERPRINT_SET_FAILED", "fingerprint.set", "Browser rejected the profile; check supported values",
|
|
128
|
+
context_id=self.context_id, original_error=exc) from None
|
|
129
|
+
|
|
130
|
+
async def set_fingerprint(self, profile: FingerprintProfile) -> None:
|
|
131
|
+
async with self._gate:
|
|
132
|
+
await self._apply_fingerprint(profile)
|
|
133
|
+
|
|
134
|
+
async def proxy_status(self) -> ProxyStatus:
|
|
135
|
+
try:
|
|
136
|
+
return ProxyStatus.from_cdp((await self._send("Browser.getProxyStatus"))["status"])
|
|
137
|
+
except SDKError:
|
|
138
|
+
raise
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
raise CompatibilityError("PROXY_STATUS_FAILED", "proxy.status", "YSbrowser proxy CDP API is unavailable or failed",
|
|
141
|
+
context_id=self.context_id, original_error=exc) from None
|
|
142
|
+
|
|
143
|
+
async def _apply_proxy(self, proxy: ProxyConfig | Direct, check: ProxyCheck | None,
|
|
144
|
+
mode: str | None = None, drain_timeout_ms: int | None = None) -> ProxyStatus:
|
|
145
|
+
if not isinstance(proxy, (ProxyConfig, Direct)) or (check is not None and not isinstance(check, ProxyCheck)):
|
|
146
|
+
raise ConfigurationError("proxy must be ProxyConfig or Direct; check must be ProxyCheck")
|
|
147
|
+
if self._options.client_certificates and not isinstance(proxy, Direct):
|
|
148
|
+
raise ConfigurationError('Client-certificate Contexts currently support Direct only; the native certificate tunnel bypasses runtime proxy changes')
|
|
149
|
+
if mode not in (None, "drain", "immediate"):
|
|
150
|
+
raise ConfigurationError("mode must be 'drain', 'immediate', or None (legacy)")
|
|
151
|
+
if drain_timeout_ms is not None and (type(drain_timeout_ms) is not int or not 1 <= drain_timeout_ms <= 60000):
|
|
152
|
+
raise ConfigurationError("drain_timeout_ms must be an integer between 1 and 60000")
|
|
153
|
+
if mode is None and drain_timeout_ms is not None:
|
|
154
|
+
raise ConfigurationError("drain_timeout_ms requires mode")
|
|
155
|
+
if mode is not None and not self._owner.capabilities.supports("proxySwitchBarrier"):
|
|
156
|
+
raise CompatibilityError("PROXY_SWITCH_UNSUPPORTED", "proxy.switch", "Install the browser proxy switch barrier patch", context_id=self.context_id)
|
|
157
|
+
payload = proxy.to_cdp()
|
|
158
|
+
if mode is not None:
|
|
159
|
+
payload["switchMode"] = mode
|
|
160
|
+
if drain_timeout_ms is not None:
|
|
161
|
+
payload["drainTimeoutMs"] = drain_timeout_ms
|
|
162
|
+
if check is not None:
|
|
163
|
+
payload.update(check.to_cdp())
|
|
164
|
+
try:
|
|
165
|
+
response = await self._send("Browser.setProxy", payload)
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
try:
|
|
168
|
+
status = (await self.proxy_status()).to_dict()
|
|
169
|
+
except Exception:
|
|
170
|
+
status = {"check_state": "unavailable"}
|
|
171
|
+
# A failed command has no authoritative operation revision. Another
|
|
172
|
+
# CDP client may have changed the route before the follow-up query.
|
|
173
|
+
# Never associate unknown credentials with that observed revision.
|
|
174
|
+
switch_code = next((code for code in (
|
|
175
|
+
"PROXY_DRAIN_TIMEOUT", "PROXY_SWITCH_IN_PROGRESS",
|
|
176
|
+
"PROXY_SWITCH_CONTEXT_LOST", "PROXY_SWITCH_CANCELLED",
|
|
177
|
+
"PROXY_SWITCH_LEASE_EXPIRED", "PROXY_SWITCH_MULTIPLE_PARTITIONS_UNSUPPORTED",
|
|
178
|
+
"PROXY_SWITCH_BUSY_OR_INVALID") if code in str(exc)), None)
|
|
179
|
+
# A drain timeout or rejected concurrent operation does not apply a
|
|
180
|
+
# proxy. Preserve exportability only if the original revision remains.
|
|
181
|
+
unchanged = switch_code in ("PROXY_DRAIN_TIMEOUT", "PROXY_SWITCH_IN_PROGRESS", "PROXY_SWITCH_MULTIPLE_PARTITIONS_UNSUPPORTED") and status.get("generation") == self._proxy_generation
|
|
182
|
+
if not unchanged:
|
|
183
|
+
self._proxy_generation = None
|
|
184
|
+
if switch_code:
|
|
185
|
+
raise SDKError(switch_code, "proxy.switch", "Guarded proxy switch did not complete; inspect proxy_status before continuing", context_id=self.context_id,
|
|
186
|
+
details={"proxy_status": status, "mode": mode}, original_error=exc) from None
|
|
187
|
+
error_class = ProxyCheckError if check is not None else SDKError
|
|
188
|
+
raise error_class("PROXY_CHECK_FAILED" if check is not None else "PROXY_SET_FAILED", "proxy.set",
|
|
189
|
+
"Proxy operation failed; inspect status for rollback outcome", context_id=self.context_id,
|
|
190
|
+
details={"proxy_status": status}, original_error=exc) from None
|
|
191
|
+
observed = ProxyStatus.from_cdp(response["status"])
|
|
192
|
+
self._proxy = proxy
|
|
193
|
+
self._proxy_generation = observed.generation
|
|
194
|
+
return observed
|
|
195
|
+
|
|
196
|
+
async def set_proxy(self, proxy: ProxyConfig | Direct, *, check: ProxyCheck | None = None,
|
|
197
|
+
mode: str | None = None, drain_timeout_ms: int | None = None) -> ProxyStatus:
|
|
198
|
+
"""Switch routes; explicit drain/immediate modes pause new native requests.
|
|
199
|
+
|
|
200
|
+
None preserves the legacy API. A drain timeout keeps the old proxy.
|
|
201
|
+
Long-lived streams count as active work; they can make drain time out.
|
|
202
|
+
Cancelling this Python await does not undo an already-issued CDP command.
|
|
203
|
+
"""
|
|
204
|
+
async with self._gate:
|
|
205
|
+
return await self._apply_proxy(proxy, check, mode, drain_timeout_ms)
|
|
206
|
+
|
|
207
|
+
async def check_proxy(self, check_url: str, *, expected_exit_ip: str | None = None,
|
|
208
|
+
timeout_ms: int = 10000) -> ProxyHealthResult:
|
|
209
|
+
check = ProxyCheck(check_url, expected_exit_ip=expected_exit_ip, timeout_ms=timeout_ms)
|
|
210
|
+
payload = check.to_cdp()
|
|
211
|
+
payload.pop("rollbackOnFailure")
|
|
212
|
+
# Intentionally no mutation lock: native generation/check IDs protect late results.
|
|
213
|
+
try:
|
|
214
|
+
result = ProxyHealthResult.from_cdp((await self._send("Browser.checkProxy", payload))["result"])
|
|
215
|
+
except SDKError:
|
|
216
|
+
raise
|
|
217
|
+
except Exception as exc:
|
|
218
|
+
raise SDKError("PROXY_CHECK_COMMAND_FAILED", "proxy.check", "Independent proxy check could not be completed by the protocol", context_id=self.context_id, original_error=exc) from None
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
async def diagnostics(self) -> dict[str, Any]:
|
|
222
|
+
result: dict[str, Any] = {"context_id": self.context_id, "closed": self._closed,
|
|
223
|
+
"browser_connected": self._owner._browser is not None and self._owner._browser.is_connected(),
|
|
224
|
+
"versions": self._owner.diagnostics(), "recent_network_errors": copy.deepcopy(list(self._recent_errors))}
|
|
225
|
+
result['recent_page_events'] = [{k: v for k, v in e.items() if k != 'message'} for e in self._diagnostic_events]
|
|
226
|
+
result['diagnostics_collecting'] = self._diagnostic_enabled
|
|
227
|
+
if self._closed or self._owner._unexpected_disconnect:
|
|
228
|
+
return result
|
|
229
|
+
try:
|
|
230
|
+
fingerprint = await self.fingerprint_status()
|
|
231
|
+
result["fingerprint"] = {"locked": fingerprint["locked"],
|
|
232
|
+
"configured_fields": sorted(fingerprint["profile"]),
|
|
233
|
+
"server_gpu_profile": fingerprint["profile"].get("serverGpuProfile")}
|
|
234
|
+
result["proxy"] = (await self.proxy_status()).to_dict()
|
|
235
|
+
except SDKError as exc:
|
|
236
|
+
result["status_error"] = exc.to_dict()
|
|
237
|
+
return result
|
|
238
|
+
|
|
239
|
+
async def export_config(self, path: str | Path | None = None, *,
|
|
240
|
+
include_proxy_credentials: bool = False, include_sensitive_options: bool = False) -> dict[str, Any]:
|
|
241
|
+
async with self._gate:
|
|
242
|
+
self._ensure_open()
|
|
243
|
+
proxy = await self.proxy_status()
|
|
244
|
+
fingerprint = await self.fingerprint_status()
|
|
245
|
+
if proxy.generation != self._proxy_generation or fingerprint["profile"] != self._fingerprint.to_cdp():
|
|
246
|
+
raise SDKError("CONFIG_CHANGED_EXTERNALLY", "context.export", "Current configuration cannot be confirmed from this SDK Context; reapply it successfully before exporting", context_id=self.context_id)
|
|
247
|
+
config = ContextConfig(self._fingerprint, self._proxy, self._no_viewport, self._options)
|
|
248
|
+
data = config.to_dict(include_proxy_credentials=include_proxy_credentials, include_sensitive_options=include_sensitive_options)
|
|
249
|
+
if path is not None:
|
|
250
|
+
write_json(path, data)
|
|
251
|
+
return data
|
|
252
|
+
|
|
253
|
+
async def save_storage_state(self, path: str | Path | None = None, *, indexed_db: bool = True) -> dict[str, Any]:
|
|
254
|
+
async with self._gate:
|
|
255
|
+
self._ensure_open()
|
|
256
|
+
if type(indexed_db) is not bool:
|
|
257
|
+
raise ConfigurationError("indexed_db must be boolean")
|
|
258
|
+
try:
|
|
259
|
+
state = await self.raw.storage_state(indexed_db=indexed_db)
|
|
260
|
+
if path is not None:
|
|
261
|
+
write_json(path, state)
|
|
262
|
+
return state
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
raise SDKError("STORAGE_SAVE_FAILED", "context.storage.save", "Could not export login storage state", context_id=self.context_id, original_error=exc) from None
|
|
265
|
+
|
|
266
|
+
async def restore_storage_state(self, state: dict[str, Any] | str | Path) -> None:
|
|
267
|
+
async with self._gate:
|
|
268
|
+
self._ensure_open()
|
|
269
|
+
if self.raw.pages:
|
|
270
|
+
raise SDKError("STORAGE_RESTORE_REQUIRES_EMPTY_CONTEXT", "context.storage.restore", "Restore before opening pages, preferably in a new Context", context_id=self.context_id)
|
|
271
|
+
try:
|
|
272
|
+
await self.raw.set_storage_state(state)
|
|
273
|
+
except Exception as exc:
|
|
274
|
+
raise SDKError("STORAGE_RESTORE_FAILED", "context.storage.restore", "Could not restore login storage state", context_id=self.context_id, original_error=exc) from None
|
|
275
|
+
|
|
276
|
+
def mouse(self, page: Page, *, model: TrajectoryModel | str | Path | None = None,
|
|
277
|
+
fallback: str = "error", viewport: tuple[float, float] | None = None, **model_options) -> TrajectoryMouse:
|
|
278
|
+
"""Bind a trajectory mouse to a native Page owned by this Context.
|
|
279
|
+
|
|
280
|
+
Call synchronize once before moving. Adapters for the same Page share
|
|
281
|
+
position and serialization. Native mouse/Locator calls are not intercepted.
|
|
282
|
+
"""
|
|
283
|
+
self._ensure_open()
|
|
284
|
+
if page.context is not self.raw:
|
|
285
|
+
raise ValueError("page must belong to this YSContext")
|
|
286
|
+
if page.is_closed():
|
|
287
|
+
raise ValueError("page is closed")
|
|
288
|
+
return TrajectoryMouse(page, model, fallback=fallback, viewport=viewport, **model_options)
|
|
289
|
+
|
|
290
|
+
async def new_page(self) -> Page:
|
|
291
|
+
async with self._gate:
|
|
292
|
+
self._ensure_open()
|
|
293
|
+
page = await self.raw.new_page()
|
|
294
|
+
await self._attach_diagnostic_page(page)
|
|
295
|
+
return page
|
|
296
|
+
|
|
297
|
+
async def close(self) -> None:
|
|
298
|
+
# Closing must interrupt a draining switch instead of waiting for its
|
|
299
|
+
# mutation lock/deadline. A separate lock preserves idempotent close.
|
|
300
|
+
async with self._close_gate:
|
|
301
|
+
if not self._closed:
|
|
302
|
+
self._closed = True
|
|
303
|
+
try:
|
|
304
|
+
await complete(self.raw.close())
|
|
305
|
+
finally:
|
|
306
|
+
self._on_closed()
|
|
307
|
+
|
|
308
|
+
async def __aenter__(self) -> YSContext:
|
|
309
|
+
self._ensure_open()
|
|
310
|
+
return self
|
|
311
|
+
|
|
312
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
313
|
+
await self.close()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class YSBrowser:
|
|
317
|
+
def __init__(self, executable_path: str | Path | None = None, *, headless: bool = False,
|
|
318
|
+
server_gpu_profile: str | None = None, media_router: bool = True,
|
|
319
|
+
context_gpu: bool = True, server_gpu_backend: str = "warp",
|
|
320
|
+
default_proxy: ProxyConfig | Direct | None = None, args: list[str] | None = None,
|
|
321
|
+
cdp_port: int | None = None):
|
|
322
|
+
self.executable_path = str(Path(executable_path).resolve() if executable_path is not None else get_browser_path())
|
|
323
|
+
if not Path(self.executable_path).is_file():
|
|
324
|
+
raise ConfigurationError("executable_path must point to an existing YSbrowser executable")
|
|
325
|
+
if server_gpu_profile is not None and (not isinstance(server_gpu_profile, str) or not re.fullmatch(r"[a-zA-Z0-9_-]+", server_gpu_profile)):
|
|
326
|
+
raise ConfigurationError("server_gpu_profile must be a GPU profile identifier")
|
|
327
|
+
if default_proxy is not None and not isinstance(default_proxy, (ProxyConfig, Direct)):
|
|
328
|
+
raise ConfigurationError("default_proxy must be ProxyConfig or Direct")
|
|
329
|
+
if type(context_gpu) is not bool or server_gpu_backend not in ("warp", "swiftshader"):
|
|
330
|
+
raise ConfigurationError("context_gpu must be boolean; server_gpu_backend must be warp or swiftshader")
|
|
331
|
+
self.context_gpu = context_gpu
|
|
332
|
+
if cdp_port is not None and (type(cdp_port) is not int or not 0 <= cdp_port <= 65535):
|
|
333
|
+
raise ConfigurationError("cdp_port must be None or an integer from 0 to 65535")
|
|
334
|
+
self.cdp_port = cdp_port
|
|
335
|
+
self._cdp_port: int | None = None
|
|
336
|
+
self.server_gpu_backend = server_gpu_backend
|
|
337
|
+
self.headless = headless
|
|
338
|
+
self.server_gpu_profile = server_gpu_profile
|
|
339
|
+
self.media_router = media_router
|
|
340
|
+
self.default_proxy = default_proxy if default_proxy is not None else Direct()
|
|
341
|
+
self.args = list(args or [])
|
|
342
|
+
managed = ("--disable-features", "--enable-features", "--proxy-", "--user-data-dir", "--remote-debugging", "--server-gpu-")
|
|
343
|
+
if any(not isinstance(arg, str) or arg.startswith(managed) for arg in self.args):
|
|
344
|
+
raise ConfigurationError("args must not override SDK feature policy, proxy, profile directory, debugging transport or GPU profile")
|
|
345
|
+
self._pw: Any = None
|
|
346
|
+
self._browser: Browser | None = None
|
|
347
|
+
self._cdp: CDPSession | None = None
|
|
348
|
+
self._contexts: dict[str, YSContext] = {}
|
|
349
|
+
self._creation_lock = asyncio.Lock()
|
|
350
|
+
self._lifecycle_lock = asyncio.Lock()
|
|
351
|
+
self._info: dict[str, Any] = {}
|
|
352
|
+
self._counter = 0
|
|
353
|
+
self._closing = False
|
|
354
|
+
self._unexpected_disconnect = False
|
|
355
|
+
self._disconnect_handlers: list[Any] = []
|
|
356
|
+
self._capabilities: BrowserCapabilities | None = None
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def capabilities(self) -> BrowserCapabilities:
|
|
360
|
+
if self._capabilities is None:
|
|
361
|
+
raise SDKError("BROWSER_NOT_RUNNING", "capabilities", "Start the browser before reading its capabilities")
|
|
362
|
+
return self._capabilities
|
|
363
|
+
|
|
364
|
+
def get_context(self, name: str) -> YSContext:
|
|
365
|
+
try:
|
|
366
|
+
return self._contexts[name]
|
|
367
|
+
except KeyError:
|
|
368
|
+
raise ConfigurationError("No open Context with this name") from None
|
|
369
|
+
|
|
370
|
+
async def close_context(self, name: str) -> None:
|
|
371
|
+
await self.get_context(name).close()
|
|
372
|
+
|
|
373
|
+
def on_disconnected(self, callback: Any) -> None:
|
|
374
|
+
if not callable(callback) or inspect.iscoroutinefunction(callback):
|
|
375
|
+
raise ConfigurationError("Disconnect callback must be a synchronous callable receiving one event dictionary")
|
|
376
|
+
self._disconnect_handlers.append(callback)
|
|
377
|
+
|
|
378
|
+
def _on_disconnected(self, *_: Any) -> None:
|
|
379
|
+
self._unexpected_disconnect = not self._closing
|
|
380
|
+
event = {"unexpected": self._unexpected_disconnect, "at": time.time() * 1000,
|
|
381
|
+
"context_count": len(self._contexts)}
|
|
382
|
+
self._info["disconnect"] = event
|
|
383
|
+
for context in list(self._contexts.values()):
|
|
384
|
+
context._closed = True
|
|
385
|
+
for callback in tuple(self._disconnect_handlers):
|
|
386
|
+
try:
|
|
387
|
+
callback(dict(event))
|
|
388
|
+
except Exception as exc:
|
|
389
|
+
self._info["disconnect_callback_error"] = type(exc).__name__
|
|
390
|
+
|
|
391
|
+
@property
|
|
392
|
+
def raw(self) -> Browser:
|
|
393
|
+
if self._browser is None or not self._browser.is_connected():
|
|
394
|
+
raise SDKError("BROWSER_NOT_RUNNING", "browser", "Start the browser using async with or await start()")
|
|
395
|
+
return self._browser
|
|
396
|
+
|
|
397
|
+
@property
|
|
398
|
+
def contexts(self) -> tuple[YSContext, ...]:
|
|
399
|
+
return tuple(self._contexts.values())
|
|
400
|
+
|
|
401
|
+
@property
|
|
402
|
+
def cdp_endpoint(self) -> str | None:
|
|
403
|
+
"""Optional loopback CDP endpoint for trusted integrations; None unless enabled."""
|
|
404
|
+
self.raw
|
|
405
|
+
return f"http://127.0.0.1:{self._cdp_port}" if self._cdp_port is not None else None
|
|
406
|
+
|
|
407
|
+
async def start(self) -> YSBrowser:
|
|
408
|
+
async with self._lifecycle_lock:
|
|
409
|
+
return await self._start_unlocked()
|
|
410
|
+
|
|
411
|
+
async def _start_unlocked(self) -> YSBrowser:
|
|
412
|
+
if self._browser is not None:
|
|
413
|
+
if not self._browser.is_connected():
|
|
414
|
+
raise SDKError("BROWSER_DISCONNECTED", "browser.start", "Close this instance before restarting it")
|
|
415
|
+
return self
|
|
416
|
+
self._info = runtime_info()
|
|
417
|
+
policy = launch_policy(self.media_router)
|
|
418
|
+
launch_args = ["--no-first-run", "--no-default-browser-check", *policy["args"], *self.args]
|
|
419
|
+
self._cdp_port = self.cdp_port
|
|
420
|
+
if self.cdp_port == 0:
|
|
421
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
422
|
+
probe.bind(("127.0.0.1", 0))
|
|
423
|
+
self._cdp_port = probe.getsockname()[1]
|
|
424
|
+
if self._cdp_port is not None:
|
|
425
|
+
launch_args.extend([f"--remote-debugging-port={self._cdp_port}", "--remote-debugging-address=127.0.0.1"])
|
|
426
|
+
if self.context_gpu:
|
|
427
|
+
launch_args.append("--server-gpu-contexts")
|
|
428
|
+
if self.context_gpu or self.server_gpu_profile is not None:
|
|
429
|
+
launch_args.append(f"--server-gpu-backend={self.server_gpu_backend}")
|
|
430
|
+
if self.server_gpu_profile is not None:
|
|
431
|
+
launch_args.append(f"--server-gpu-profile={self.server_gpu_profile}")
|
|
432
|
+
try:
|
|
433
|
+
self._pw = await acquire(async_playwright().start(), lambda pw: pw.stop())
|
|
434
|
+
self._browser = await acquire(self._pw.chromium.launch(executable_path=self.executable_path, headless=self.headless,
|
|
435
|
+
chromium_sandbox=True, args=launch_args,
|
|
436
|
+
ignore_default_args=policy["ignore_default_args"]), lambda browser: browser.close())
|
|
437
|
+
self._closing = False
|
|
438
|
+
self._unexpected_disconnect = False
|
|
439
|
+
self._browser.on("disconnected", self._on_disconnected)
|
|
440
|
+
self._cdp = await self._browser.new_browser_cdp_session()
|
|
441
|
+
try:
|
|
442
|
+
self._capabilities = BrowserCapabilities.from_cdp((await self._cdp.send("Browser.getCapabilities"))["capabilities"])
|
|
443
|
+
except Exception as exc:
|
|
444
|
+
raise CompatibilityError("BROWSER_CAPABILITIES_UNAVAILABLE", "browser.compatibility", "This SDK requires a browser with the capabilities/proxy-health patch", original_error=exc) from None
|
|
445
|
+
required = {"contextFingerprint", "contextProxy", "proxyCheck", "proxyGeneration"}
|
|
446
|
+
missing = sorted(required - set(self.capabilities.features))
|
|
447
|
+
if self.capabilities.protocol_version.split(".")[0] != "1" or missing:
|
|
448
|
+
raise CompatibilityError("BROWSER_CAPABILITIES_UNSUPPORTED", "browser.compatibility", "Browser protocol or capabilities are unsupported", details={"missing_features": missing})
|
|
449
|
+
self._info["capabilities"] = self.capabilities.to_dict()
|
|
450
|
+
self._info["browser"] = await self._cdp.send("Browser.getVersion")
|
|
451
|
+
self._info["context_gpu"] = self.context_gpu
|
|
452
|
+
self._info["server_gpu_backend"] = self.server_gpu_backend if self.context_gpu or self.server_gpu_profile is not None else None
|
|
453
|
+
self._info["media_router"] = self.media_router
|
|
454
|
+
self._info["server_gpu_profile"] = self.server_gpu_profile
|
|
455
|
+
self._closing = False
|
|
456
|
+
return self
|
|
457
|
+
except BaseException as exc:
|
|
458
|
+
await complete(self._close_unlocked())
|
|
459
|
+
if not isinstance(exc, Exception) or isinstance(exc, SDKError):
|
|
460
|
+
raise
|
|
461
|
+
raise SDKError("LAUNCH_FAILED", "browser.start", "Could not start the requested browser", original_error=exc) from None
|
|
462
|
+
|
|
463
|
+
async def new_context(self, *, name: str | None = None, fingerprint: FingerprintProfile | None = None,
|
|
464
|
+
proxy: ProxyConfig | Direct | None = None, check: ProxyCheck | None = None,
|
|
465
|
+
no_viewport: bool | None = None, config: ContextConfig | None = None,
|
|
466
|
+
options: ContextOptions | None = None,
|
|
467
|
+
storage_state: dict[str, Any] | str | Path | None = None) -> YSContext:
|
|
468
|
+
if config is not None and not isinstance(config, ContextConfig):
|
|
469
|
+
raise ConfigurationError("config must be ContextConfig")
|
|
470
|
+
selected_options = options if options is not None else (config.options if config else ContextOptions())
|
|
471
|
+
if not isinstance(selected_options, ContextOptions):
|
|
472
|
+
raise ConfigurationError('options must be ContextOptions')
|
|
473
|
+
selected_options = copy.deepcopy(selected_options)
|
|
474
|
+
native_options = selected_options.to_native()
|
|
475
|
+
if selected_options.viewport is not None and no_viewport is True:
|
|
476
|
+
raise ConfigurationError('viewport conflicts with no_viewport=True')
|
|
477
|
+
profile = fingerprint if fingerprint is not None else (config.fingerprint if config else FingerprintProfile())
|
|
478
|
+
selected_proxy = proxy if proxy is not None else (config.proxy if config else self.default_proxy)
|
|
479
|
+
if selected_options.client_certificates and not isinstance(selected_proxy, Direct):
|
|
480
|
+
raise ConfigurationError('Client-certificate Contexts currently support Direct only; proxy routing requires a separate adapter')
|
|
481
|
+
viewport = no_viewport if no_viewport is not None else (config.no_viewport if config else True)
|
|
482
|
+
if selected_options.viewport is not None and no_viewport is None:
|
|
483
|
+
viewport = False
|
|
484
|
+
if type(viewport) is not bool:
|
|
485
|
+
raise ConfigurationError("no_viewport must be boolean")
|
|
486
|
+
if viewport and selected_options.device_scale_factor is not None:
|
|
487
|
+
raise ConfigurationError('device_scale_factor requires options.viewport or no_viewport=False')
|
|
488
|
+
if not isinstance(profile, FingerprintProfile):
|
|
489
|
+
raise ConfigurationError("fingerprint must be FingerprintProfile")
|
|
490
|
+
profile.to_cdp()
|
|
491
|
+
if profile.server_gpu_profile is not None and not self.context_gpu and self.server_gpu_profile is None:
|
|
492
|
+
raise ConfigurationError("Context GPU profiles require context_gpu=True or a startup GPU profile")
|
|
493
|
+
async with self._lifecycle_lock, self._creation_lock:
|
|
494
|
+
if self._closing:
|
|
495
|
+
raise SDKError("BROWSER_CLOSING", "context.create", "Browser is closing")
|
|
496
|
+
browser = self.raw
|
|
497
|
+
assert self._cdp is not None
|
|
498
|
+
if name is None:
|
|
499
|
+
while name is None or name in self._contexts:
|
|
500
|
+
self._counter += 1
|
|
501
|
+
name = f"context-{self._counter}"
|
|
502
|
+
if not isinstance(name, str) or not name.strip() or name in self._contexts:
|
|
503
|
+
raise ConfigurationError("context name must be non-empty and unique among open contexts")
|
|
504
|
+
before = set((await self._cdp.send("Target.getBrowserContexts"))["browserContextIds"])
|
|
505
|
+
raw = await acquire(browser.new_context(no_viewport=viewport, **native_options), lambda context: context.close())
|
|
506
|
+
try:
|
|
507
|
+
after = set((await self._cdp.send("Target.getBrowserContexts"))["browserContextIds"])
|
|
508
|
+
ids = after - before
|
|
509
|
+
if len(ids) != 1:
|
|
510
|
+
raise SDKError("CONTEXT_ID_AMBIGUOUS", "context.create", "Cannot map context ID; avoid concurrent creation through raw or external CDP clients")
|
|
511
|
+
context = YSContext(self, raw, ids.pop(), name)
|
|
512
|
+
context._options = selected_options
|
|
513
|
+
await context._apply_fingerprint(profile)
|
|
514
|
+
await context._apply_proxy(selected_proxy, check)
|
|
515
|
+
context._no_viewport = viewport
|
|
516
|
+
if selected_options.default_timeout_ms is not None:
|
|
517
|
+
raw.set_default_timeout(selected_options.default_timeout_ms)
|
|
518
|
+
if selected_options.navigation_timeout_ms is not None:
|
|
519
|
+
raw.set_default_navigation_timeout(selected_options.navigation_timeout_ms)
|
|
520
|
+
# Native restore may create temporary renderers: configure fingerprint first.
|
|
521
|
+
if storage_state is not None:
|
|
522
|
+
await context.restore_storage_state(storage_state)
|
|
523
|
+
self._contexts[name] = context
|
|
524
|
+
return context
|
|
525
|
+
except BaseException:
|
|
526
|
+
await complete(raw.close())
|
|
527
|
+
raise
|
|
528
|
+
|
|
529
|
+
def diagnostics(self) -> dict[str, Any]:
|
|
530
|
+
# No custom argv, proxy credentials, seeds, URLs or user-supplied names.
|
|
531
|
+
return copy.deepcopy({**self._info, "context_count": len(self._contexts),
|
|
532
|
+
"connected": self._browser is not None and self._browser.is_connected(),
|
|
533
|
+
"contexts": [{"context_id": c.context_id} for c in self.contexts]})
|
|
534
|
+
|
|
535
|
+
async def close(self) -> None:
|
|
536
|
+
async with self._lifecycle_lock, self._creation_lock:
|
|
537
|
+
await complete(self._close_unlocked())
|
|
538
|
+
|
|
539
|
+
async def _close_unlocked(self) -> None:
|
|
540
|
+
self._closing = True
|
|
541
|
+
try:
|
|
542
|
+
if self._browser is not None:
|
|
543
|
+
await self._browser.close()
|
|
544
|
+
finally:
|
|
545
|
+
for context in list(self._contexts.values()):
|
|
546
|
+
context._closed = True
|
|
547
|
+
self._contexts.clear()
|
|
548
|
+
self._browser = None
|
|
549
|
+
self._cdp = None
|
|
550
|
+
self._capabilities = None
|
|
551
|
+
if self._pw is not None:
|
|
552
|
+
try:
|
|
553
|
+
await self._pw.stop()
|
|
554
|
+
finally:
|
|
555
|
+
self._pw = None
|
|
556
|
+
|
|
557
|
+
async def __aenter__(self) -> YSBrowser:
|
|
558
|
+
return await self.start()
|
|
559
|
+
|
|
560
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
561
|
+
await self.close()
|