solari-browser 0.1.1__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.
- solari_browser/__init__.py +60 -0
- solari_browser/browser_session.py +94 -0
- solari_browser/client.py +521 -0
- solari_browser/errors.py +68 -0
- solari_browser/types.py +141 -0
- solari_browser-0.1.1.dist-info/METADATA +66 -0
- solari_browser-0.1.1.dist-info/RECORD +8 -0
- solari_browser-0.1.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Solari Browser — Python SDK.
|
|
2
|
+
|
|
3
|
+
Managed, stealthy remote Chromium over the Playwright wire protocol / raw CDP.
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from solari_browser import Solari
|
|
7
|
+
|
|
8
|
+
async def main():
|
|
9
|
+
async with Solari(api_key="slr_live_...") as solari:
|
|
10
|
+
async with await solari.launch(stealth=True) as browser:
|
|
11
|
+
page = await browser.new_page()
|
|
12
|
+
await page.goto("https://example.com")
|
|
13
|
+
print(await page.title())
|
|
14
|
+
|
|
15
|
+
asyncio.run(main())
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .browser_session import BrowserSession
|
|
19
|
+
from .client import Solari, derive_cdp_from_ws
|
|
20
|
+
from .errors import (
|
|
21
|
+
BROWSER_UNHEALTHY,
|
|
22
|
+
CONCURRENCY_LIMIT_EXCEEDED,
|
|
23
|
+
FEATURE_REQUIRES_PLAN,
|
|
24
|
+
PLAN_LIMIT_EXCEEDED,
|
|
25
|
+
SolariError,
|
|
26
|
+
)
|
|
27
|
+
from .types import (
|
|
28
|
+
DEFAULT_REGION,
|
|
29
|
+
REGION_URLS,
|
|
30
|
+
UNSET,
|
|
31
|
+
Profile,
|
|
32
|
+
ProxyRequest,
|
|
33
|
+
ReplayUrl,
|
|
34
|
+
ResolvedProxyConfig,
|
|
35
|
+
SaveResult,
|
|
36
|
+
Session,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"Solari",
|
|
43
|
+
"BrowserSession",
|
|
44
|
+
"SolariError",
|
|
45
|
+
"Session",
|
|
46
|
+
"Profile",
|
|
47
|
+
"ProxyRequest",
|
|
48
|
+
"ResolvedProxyConfig",
|
|
49
|
+
"ReplayUrl",
|
|
50
|
+
"SaveResult",
|
|
51
|
+
"UNSET",
|
|
52
|
+
"REGION_URLS",
|
|
53
|
+
"DEFAULT_REGION",
|
|
54
|
+
"derive_cdp_from_ws",
|
|
55
|
+
"FEATURE_REQUIRES_PLAN",
|
|
56
|
+
"CONCURRENCY_LIMIT_EXCEEDED",
|
|
57
|
+
"PLAN_LIMIT_EXCEEDED",
|
|
58
|
+
"BROWSER_UNHEALTHY",
|
|
59
|
+
"__version__",
|
|
60
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""BrowserSession — a connected browser + its Solari session. Mirrors sdk/src/browser-session.ts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, List, Optional
|
|
6
|
+
|
|
7
|
+
from .types import ResolvedProxyConfig, Session
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
10
|
+
from .client import Solari
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BrowserSession:
|
|
14
|
+
"""Wraps a patchright `Browser` and the Solari session backing it.
|
|
15
|
+
|
|
16
|
+
`close()` closes the browser AND releases the session — a browser close alone
|
|
17
|
+
would leave the slot held until its plan deadline.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, client: "Solari", session: Session, browser: Any) -> None:
|
|
21
|
+
self._client = client
|
|
22
|
+
self._session = session
|
|
23
|
+
self._browser = browser
|
|
24
|
+
self._closed = False
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def id(self) -> str:
|
|
28
|
+
return self._session.id
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def expires_at(self) -> str:
|
|
32
|
+
"""ISO 8601 UTC deadline; the session auto-releases at this point."""
|
|
33
|
+
return self._session.expires_at
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def proxy(self) -> Optional[ResolvedProxyConfig]:
|
|
37
|
+
return self._session.proxy
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def ws_endpoint(self) -> str:
|
|
41
|
+
"""Upstream Playwright wire-protocol endpoint."""
|
|
42
|
+
return self._session.ws_endpoint
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def cdp_endpoint(self) -> str:
|
|
46
|
+
"""Upstream raw-CDP endpoint.
|
|
47
|
+
|
|
48
|
+
NOTE: driving the browser over raw CDP bypasses the pool's Playwright-path
|
|
49
|
+
input humanization (see CLAUDE.md) — relevant if you care about stealth.
|
|
50
|
+
"""
|
|
51
|
+
return self._session.cdp_endpoint
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def session(self) -> Session:
|
|
55
|
+
return self._session
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def raw(self) -> Any:
|
|
59
|
+
"""The underlying patchright `Browser`."""
|
|
60
|
+
return self._browser
|
|
61
|
+
|
|
62
|
+
def is_connected(self) -> bool:
|
|
63
|
+
return bool(self._browser.is_connected())
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def version(self) -> str:
|
|
67
|
+
return str(self._browser.version)
|
|
68
|
+
|
|
69
|
+
def contexts(self) -> List[Any]:
|
|
70
|
+
"""Existing browser contexts. The default context is `contexts()[0]`."""
|
|
71
|
+
return list(self._browser.contexts)
|
|
72
|
+
|
|
73
|
+
async def new_context(self, **kwargs: Any) -> Any:
|
|
74
|
+
return await self._browser.new_context(**kwargs)
|
|
75
|
+
|
|
76
|
+
async def new_page(self) -> Any:
|
|
77
|
+
return await self._browser.new_page()
|
|
78
|
+
|
|
79
|
+
async def close(self) -> None:
|
|
80
|
+
"""Close the browser and release the session. Idempotent."""
|
|
81
|
+
if self._closed:
|
|
82
|
+
return
|
|
83
|
+
self._closed = True
|
|
84
|
+
try:
|
|
85
|
+
await self._browser.close()
|
|
86
|
+
except Exception: # noqa: BLE001 - releasing the slot matters more
|
|
87
|
+
pass
|
|
88
|
+
await self._client.sessions.release_and_wait(self._session.id)
|
|
89
|
+
|
|
90
|
+
async def __aenter__(self) -> "BrowserSession":
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
async def __aexit__(self, *_exc: Any) -> None:
|
|
94
|
+
await self.close()
|
solari_browser/client.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""Solari Browser client — the async Python port of sdk/src/index.ts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Dict, List, Optional, Union
|
|
10
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .errors import BROWSER_UNHEALTHY, SolariError, code_from_body
|
|
15
|
+
from .types import (
|
|
16
|
+
DEFAULT_REGION,
|
|
17
|
+
REGION_URLS,
|
|
18
|
+
UNSET,
|
|
19
|
+
Profile,
|
|
20
|
+
ProxyRequest,
|
|
21
|
+
ProxySpec,
|
|
22
|
+
ReplayUrl,
|
|
23
|
+
ResolvedProxyConfig,
|
|
24
|
+
SaveResult,
|
|
25
|
+
Session,
|
|
26
|
+
StorageState,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger("solari_browser")
|
|
30
|
+
|
|
31
|
+
DEFAULT_MAX_ATTEMPTS = 2
|
|
32
|
+
DEFAULT_BACKOFF_MS = 500
|
|
33
|
+
DEFAULT_TIMEOUT_MS = 90_000
|
|
34
|
+
_STORAGE_STATE_TIMEOUT_S = 8.0
|
|
35
|
+
|
|
36
|
+
# Only these are retried. Mirrors `isRetryableStatus` — note this is a FIXED backoff
|
|
37
|
+
# and a short attempt count, unlike the desktop SDK's exponential+jitter.
|
|
38
|
+
_RETRYABLE_STATUS = frozenset({502, 503, 504})
|
|
39
|
+
|
|
40
|
+
# Mirrors `isLikelyTransientConnect`: which connect failures are worth relaunching.
|
|
41
|
+
_TRANSIENT_CONNECT = re.compile(
|
|
42
|
+
r"ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|socket hang up|WebSocket|connect|"
|
|
43
|
+
r"handshake|Browser closed|Target page, context or browser has been closed",
|
|
44
|
+
re.IGNORECASE,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def derive_cdp_from_ws(ws_endpoint: str) -> str:
|
|
49
|
+
"""`/ws/<id>` -> `/cdp/<id>`. Mirrors `deriveCdpFromWs`.
|
|
50
|
+
|
|
51
|
+
The gateway usually returns cdpEndpoint explicitly; this is the fallback for
|
|
52
|
+
older gateways. Returns the input unchanged if it isn't a parseable /ws/ URL.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
parts = urlsplit(ws_endpoint)
|
|
56
|
+
if parts.path.startswith("/ws/"):
|
|
57
|
+
new_path = "/cdp/" + parts.path[len("/ws/") :]
|
|
58
|
+
return urlunsplit((parts.scheme, parts.netloc, new_path, parts.query, parts.fragment))
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
return ws_endpoint
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _Sessions:
|
|
65
|
+
"""`solari.sessions` — mirrors SessionsResource."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, client: "Solari") -> None:
|
|
68
|
+
self._client = client
|
|
69
|
+
|
|
70
|
+
async def create(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
profile_id: Optional[str] = None,
|
|
74
|
+
recording: bool = False,
|
|
75
|
+
stealth: bool = False,
|
|
76
|
+
captcha: bool = False,
|
|
77
|
+
web_bot_auth: bool = False,
|
|
78
|
+
proxy: Optional[ProxySpec] = None,
|
|
79
|
+
) -> Session:
|
|
80
|
+
"""Create a browser session.
|
|
81
|
+
|
|
82
|
+
captcha and proxy require stealth=True (enforced gateway-side).
|
|
83
|
+
"""
|
|
84
|
+
body: Dict[str, Any] = {}
|
|
85
|
+
if profile_id:
|
|
86
|
+
body["profileId"] = profile_id
|
|
87
|
+
if recording:
|
|
88
|
+
body["recording"] = True
|
|
89
|
+
if stealth:
|
|
90
|
+
body["stealth"] = True
|
|
91
|
+
if captcha:
|
|
92
|
+
body["captcha"] = True
|
|
93
|
+
if web_bot_auth:
|
|
94
|
+
body["webBotAuth"] = True
|
|
95
|
+
if proxy is not None:
|
|
96
|
+
body["proxy"] = proxy.to_wire() if isinstance(proxy, ProxyRequest) else proxy
|
|
97
|
+
|
|
98
|
+
# An empty body is sent as no body at all, matching the TS SDK.
|
|
99
|
+
res = await self._client.request("POST", "/sessions", body if body else None)
|
|
100
|
+
if res.status_code >= 400:
|
|
101
|
+
text = res.text
|
|
102
|
+
raise SolariError(
|
|
103
|
+
f"Solari POST /sessions failed: {res.status_code} {text}",
|
|
104
|
+
res.status_code,
|
|
105
|
+
None,
|
|
106
|
+
code_from_body(text),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
data = _json(res, "/sessions")
|
|
110
|
+
session_id = data.get("sessionId")
|
|
111
|
+
ws_endpoint = data.get("wsEndpoint")
|
|
112
|
+
if not session_id or not ws_endpoint:
|
|
113
|
+
raise SolariError(f"Solari: unexpected session response: {json.dumps(data)}")
|
|
114
|
+
|
|
115
|
+
cdp_endpoint = data.get("cdpEndpoint") or derive_cdp_from_ws(ws_endpoint)
|
|
116
|
+
expires_at = data.get("expiresAt")
|
|
117
|
+
if not expires_at:
|
|
118
|
+
# Mirrors the TS fallback: assume the plan's 60m default rather than
|
|
119
|
+
# leaving callers with an unusable empty deadline.
|
|
120
|
+
import datetime as _dt
|
|
121
|
+
|
|
122
|
+
expires_at = (
|
|
123
|
+
_dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(minutes=60)
|
|
124
|
+
).isoformat().replace("+00:00", "Z")
|
|
125
|
+
|
|
126
|
+
session = Session(
|
|
127
|
+
id=session_id,
|
|
128
|
+
ws_endpoint=ws_endpoint,
|
|
129
|
+
cdp_endpoint=cdp_endpoint,
|
|
130
|
+
expires_at=expires_at,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# storage_state is only populated when a profile was requested. UNSET means
|
|
134
|
+
# "no profile attached"; None means "profile exists but is empty".
|
|
135
|
+
if profile_id is not None:
|
|
136
|
+
url_obj = data.get("storageStateUrl")
|
|
137
|
+
if url_obj is not None:
|
|
138
|
+
session.storage_state = await _fetch_presigned_storage_state(url_obj.get("url"))
|
|
139
|
+
else:
|
|
140
|
+
session.storage_state = data.get("storageState")
|
|
141
|
+
|
|
142
|
+
if data.get("proxy"):
|
|
143
|
+
session.proxy = ResolvedProxyConfig.from_wire(data["proxy"])
|
|
144
|
+
return session
|
|
145
|
+
|
|
146
|
+
async def release(self, session_id: str) -> None:
|
|
147
|
+
"""Best-effort release. Never raises — use `release_and_wait` for confirmation."""
|
|
148
|
+
try:
|
|
149
|
+
res = await self._client.request(
|
|
150
|
+
"DELETE", f"/sessions/{_q(session_id)}"
|
|
151
|
+
)
|
|
152
|
+
if res.status_code >= 400 and res.status_code != 404:
|
|
153
|
+
log.warning(
|
|
154
|
+
"[Solari] DELETE /sessions/%s returned %s: %s",
|
|
155
|
+
session_id,
|
|
156
|
+
res.status_code,
|
|
157
|
+
res.text[:256],
|
|
158
|
+
)
|
|
159
|
+
except Exception as err: # noqa: BLE001 - fire-and-forget by contract
|
|
160
|
+
log.warning("[Solari] DELETE /sessions/%s failed (best-effort): %s", session_id, err)
|
|
161
|
+
|
|
162
|
+
async def release_and_wait(self, session_id: str) -> None:
|
|
163
|
+
"""Release and confirm. A 404 is success — the session is already gone."""
|
|
164
|
+
res = await self._client.request("DELETE", f"/sessions/{_q(session_id)}")
|
|
165
|
+
if res.status_code >= 400 and res.status_code != 404:
|
|
166
|
+
raise SolariError(
|
|
167
|
+
f"Solari DELETE /sessions/{session_id} failed: {res.status_code} {res.text}",
|
|
168
|
+
res.status_code,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async def get(self, session_id: str) -> Dict[str, Any]:
|
|
172
|
+
"""Raw session view from the gateway."""
|
|
173
|
+
res = await self._client.request("GET", f"/sessions/{_q(session_id)}")
|
|
174
|
+
if res.status_code >= 400:
|
|
175
|
+
raise SolariError(
|
|
176
|
+
f"Solari GET /sessions/{session_id} failed: {res.status_code} {res.text}",
|
|
177
|
+
res.status_code,
|
|
178
|
+
)
|
|
179
|
+
return _json(res, f"/sessions/{session_id}")
|
|
180
|
+
|
|
181
|
+
async def get_replay_url(self, session_id: str) -> ReplayUrl:
|
|
182
|
+
"""Presigned replay URL. Available ~1-3s after `release_and_wait`."""
|
|
183
|
+
res = await self._client.request("GET", f"/sessions/{_q(session_id)}/replay-url")
|
|
184
|
+
if res.status_code >= 400:
|
|
185
|
+
raise SolariError(
|
|
186
|
+
f"Solari GET /sessions/{session_id}/replay-url failed: "
|
|
187
|
+
f"{res.status_code} {res.text}",
|
|
188
|
+
res.status_code,
|
|
189
|
+
)
|
|
190
|
+
data = _json(res, "replay-url")
|
|
191
|
+
if not data.get("url"):
|
|
192
|
+
raise SolariError(f"Solari: unexpected replay-url response: {json.dumps(data)}")
|
|
193
|
+
return ReplayUrl(
|
|
194
|
+
url=data["url"],
|
|
195
|
+
expires_in_seconds=data.get("expiresInSeconds") or 0,
|
|
196
|
+
content_encoding=data.get("contentEncoding") or "gzip",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
async def download_replay(self, session_id: str) -> bytes:
|
|
200
|
+
"""Download the replay as NDJSON bytes (gzip-encoded per `content_encoding`)."""
|
|
201
|
+
replay = await self.get_replay_url(session_id)
|
|
202
|
+
async with httpx.AsyncClient(timeout=self._client._timeout_s) as http:
|
|
203
|
+
res = await http.get(replay.url)
|
|
204
|
+
if res.status_code >= 400:
|
|
205
|
+
raise SolariError(
|
|
206
|
+
f"Solari: replay download failed: {res.status_code}", res.status_code
|
|
207
|
+
)
|
|
208
|
+
return res.content
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class _Profiles:
|
|
212
|
+
"""`solari.profiles` — mirrors ProfilesResource."""
|
|
213
|
+
|
|
214
|
+
def __init__(self, client: "Solari") -> None:
|
|
215
|
+
self._client = client
|
|
216
|
+
|
|
217
|
+
async def create(self, name: str) -> Profile:
|
|
218
|
+
res = await self._client.request("POST", "/profiles", {"name": name})
|
|
219
|
+
if res.status_code >= 400:
|
|
220
|
+
text = res.text
|
|
221
|
+
raise SolariError(
|
|
222
|
+
f"Solari POST /profiles failed: {res.status_code} {text}",
|
|
223
|
+
res.status_code,
|
|
224
|
+
None,
|
|
225
|
+
code_from_body(text),
|
|
226
|
+
)
|
|
227
|
+
return Profile.from_wire(_json(res, "/profiles"))
|
|
228
|
+
|
|
229
|
+
async def list(self) -> List[Profile]:
|
|
230
|
+
res = await self._client.request("GET", "/profiles")
|
|
231
|
+
if res.status_code >= 400:
|
|
232
|
+
raise SolariError(
|
|
233
|
+
f"Solari GET /profiles failed: {res.status_code} {res.text}", res.status_code
|
|
234
|
+
)
|
|
235
|
+
data = _json(res, "/profiles")
|
|
236
|
+
items = data if isinstance(data, list) else data.get("profiles", [])
|
|
237
|
+
return [Profile.from_wire(p) for p in items]
|
|
238
|
+
|
|
239
|
+
async def delete(self, profile_id: str) -> None:
|
|
240
|
+
"""Delete a profile. A 404 is success — it's already gone."""
|
|
241
|
+
res = await self._client.request("DELETE", f"/profiles/{_q(profile_id)}")
|
|
242
|
+
if res.status_code >= 400 and res.status_code != 404:
|
|
243
|
+
raise SolariError(
|
|
244
|
+
f"Solari DELETE /profiles/{profile_id} failed: {res.status_code} {res.text}",
|
|
245
|
+
res.status_code,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
async def save(self, profile_id: str, storage_state: StorageState) -> SaveResult:
|
|
249
|
+
"""Persist a Playwright storage state into the profile."""
|
|
250
|
+
res = await self._client.request(
|
|
251
|
+
"POST", f"/profiles/{_q(profile_id)}/save", {"storageState": storage_state}
|
|
252
|
+
)
|
|
253
|
+
if res.status_code >= 400:
|
|
254
|
+
raise SolariError(
|
|
255
|
+
f"Solari POST /profiles/{profile_id}/save failed: {res.status_code} {res.text}",
|
|
256
|
+
res.status_code,
|
|
257
|
+
)
|
|
258
|
+
data = _json(res, "profiles/save")
|
|
259
|
+
return SaveResult(
|
|
260
|
+
version=data.get("version") or 0,
|
|
261
|
+
size_bytes=data.get("sizeBytes") or 0,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class Solari:
|
|
266
|
+
"""Solari Browser client.
|
|
267
|
+
|
|
268
|
+
solari = Solari(api_key="slr_live_...")
|
|
269
|
+
browser = await solari.launch(stealth=True)
|
|
270
|
+
page = await browser.new_page()
|
|
271
|
+
await page.goto("https://example.com")
|
|
272
|
+
await browser.close()
|
|
273
|
+
await solari.close()
|
|
274
|
+
|
|
275
|
+
Or as an async context manager:
|
|
276
|
+
|
|
277
|
+
async with Solari(api_key="slr_live_...") as solari:
|
|
278
|
+
async with await solari.launch(stealth=True) as browser:
|
|
279
|
+
...
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
def __init__(
|
|
283
|
+
self,
|
|
284
|
+
api_key: str,
|
|
285
|
+
*,
|
|
286
|
+
region: str = DEFAULT_REGION,
|
|
287
|
+
base_url: Optional[str] = None,
|
|
288
|
+
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
|
|
289
|
+
backoff_ms: int = DEFAULT_BACKOFF_MS,
|
|
290
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
291
|
+
) -> None:
|
|
292
|
+
if not api_key:
|
|
293
|
+
raise SolariError("Solari: api_key is required")
|
|
294
|
+
region_url = REGION_URLS.get(region)
|
|
295
|
+
if not region_url:
|
|
296
|
+
raise SolariError(
|
|
297
|
+
f'Solari: unsupported region "{region}". '
|
|
298
|
+
f"Supported: {', '.join(REGION_URLS)}."
|
|
299
|
+
)
|
|
300
|
+
self._api_key = api_key
|
|
301
|
+
# base_url wins over region entirely — that's how you point at staging or a
|
|
302
|
+
# self-hosted gateway.
|
|
303
|
+
self._base_url = (base_url or region_url).rstrip("/")
|
|
304
|
+
self._max_attempts = max_attempts
|
|
305
|
+
self._backoff_ms = backoff_ms
|
|
306
|
+
self._timeout_s = timeout_ms / 1000.0
|
|
307
|
+
|
|
308
|
+
self.sessions = _Sessions(self)
|
|
309
|
+
self.profiles = _Profiles(self)
|
|
310
|
+
|
|
311
|
+
self._http: Optional[httpx.AsyncClient] = None
|
|
312
|
+
self._playwright: Any = None # lazily started; only for launch()
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def base_url(self) -> str:
|
|
316
|
+
return self._base_url
|
|
317
|
+
|
|
318
|
+
async def __aenter__(self) -> "Solari":
|
|
319
|
+
return self
|
|
320
|
+
|
|
321
|
+
async def __aexit__(self, *_exc: Any) -> None:
|
|
322
|
+
await self.close()
|
|
323
|
+
|
|
324
|
+
async def close(self) -> None:
|
|
325
|
+
"""Release the HTTP client and (if launch() was used) the patchright driver."""
|
|
326
|
+
if self._http is not None:
|
|
327
|
+
await self._http.aclose()
|
|
328
|
+
self._http = None
|
|
329
|
+
if self._playwright is not None:
|
|
330
|
+
await self._playwright.stop()
|
|
331
|
+
self._playwright = None
|
|
332
|
+
|
|
333
|
+
async def request(
|
|
334
|
+
self, method: str, path: str, body: Optional[Any] = None
|
|
335
|
+
) -> httpx.Response:
|
|
336
|
+
"""Authenticated request with the SDK's retry policy.
|
|
337
|
+
|
|
338
|
+
Retries ONLY 502/503/504 and transport errors, with a FIXED backoff. A
|
|
339
|
+
non-retryable error status is RETURNED, not raised — callers parse `code`
|
|
340
|
+
out of the body themselves (mirrors the TS SDK).
|
|
341
|
+
"""
|
|
342
|
+
if self._http is None:
|
|
343
|
+
self._http = httpx.AsyncClient(timeout=self._timeout_s)
|
|
344
|
+
url = f"{self._base_url}{path}"
|
|
345
|
+
headers = {
|
|
346
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
347
|
+
"Content-Type": "application/json",
|
|
348
|
+
}
|
|
349
|
+
payload = None if body is None else json.dumps(body)
|
|
350
|
+
|
|
351
|
+
last_err: Optional[BaseException] = None
|
|
352
|
+
for attempt in range(1, self._max_attempts + 1):
|
|
353
|
+
try:
|
|
354
|
+
res = await self._http.request(
|
|
355
|
+
method, url, headers=headers, content=payload
|
|
356
|
+
)
|
|
357
|
+
if res.status_code < 400 or res.status_code not in _RETRYABLE_STATUS:
|
|
358
|
+
return res
|
|
359
|
+
last_err = SolariError(f"Solari {method} {path}: {res.status_code}", res.status_code)
|
|
360
|
+
except Exception as err: # noqa: BLE001 - all transport errors are retryable
|
|
361
|
+
last_err = err
|
|
362
|
+
|
|
363
|
+
if attempt < self._max_attempts:
|
|
364
|
+
await asyncio.sleep(self._backoff_ms / 1000.0)
|
|
365
|
+
|
|
366
|
+
raise SolariError(
|
|
367
|
+
f"Solari {method} {path}: exhausted {self._max_attempts} attempts",
|
|
368
|
+
None,
|
|
369
|
+
last_err,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
async def launch(
|
|
373
|
+
self,
|
|
374
|
+
*,
|
|
375
|
+
profile_id: Optional[str] = None,
|
|
376
|
+
recording: bool = False,
|
|
377
|
+
stealth: bool = False,
|
|
378
|
+
captcha: bool = False,
|
|
379
|
+
web_bot_auth: bool = False,
|
|
380
|
+
proxy: Optional[ProxySpec] = None,
|
|
381
|
+
retries: int = 0,
|
|
382
|
+
probe: Optional[bool] = None,
|
|
383
|
+
probe_timeout_ms: int = 2_000,
|
|
384
|
+
) -> "BrowserSession":
|
|
385
|
+
"""Create a session and return a connected browser.
|
|
386
|
+
|
|
387
|
+
This is the full-parity path: it connects over the Playwright wire protocol
|
|
388
|
+
via patchright, exactly like the TS SDK. Go/Rust/C++ cannot do this — there
|
|
389
|
+
is no Playwright client for them.
|
|
390
|
+
"""
|
|
391
|
+
from .browser_session import BrowserSession # local import: avoids cycle
|
|
392
|
+
|
|
393
|
+
retries = max(0, retries)
|
|
394
|
+
probe_enabled = (retries > 0) if probe is None else probe
|
|
395
|
+
probe_timeout_ms = max(1, probe_timeout_ms)
|
|
396
|
+
total_attempts = retries + 1
|
|
397
|
+
|
|
398
|
+
chromium = await self._chromium()
|
|
399
|
+
|
|
400
|
+
last_err: Optional[BaseException] = None
|
|
401
|
+
for attempt in range(1, total_attempts + 1):
|
|
402
|
+
session = await self.sessions.create(
|
|
403
|
+
profile_id=profile_id,
|
|
404
|
+
recording=recording,
|
|
405
|
+
stealth=stealth,
|
|
406
|
+
captcha=captcha,
|
|
407
|
+
web_bot_auth=web_bot_auth,
|
|
408
|
+
proxy=proxy,
|
|
409
|
+
)
|
|
410
|
+
browser = None
|
|
411
|
+
try:
|
|
412
|
+
browser = await chromium.connect(session.ws_endpoint)
|
|
413
|
+
if probe_enabled:
|
|
414
|
+
await self._probe_browser_health(browser, probe_timeout_ms)
|
|
415
|
+
return BrowserSession(self, session, browser)
|
|
416
|
+
except Exception as err: # noqa: BLE001
|
|
417
|
+
if browser is not None:
|
|
418
|
+
try:
|
|
419
|
+
await browser.close()
|
|
420
|
+
except Exception: # noqa: BLE001
|
|
421
|
+
pass
|
|
422
|
+
await self.sessions.release(session.id)
|
|
423
|
+
|
|
424
|
+
last_err = err
|
|
425
|
+
is_health = isinstance(err, SolariError) and err.code == BROWSER_UNHEALTHY
|
|
426
|
+
transient = (not is_health) and bool(_TRANSIENT_CONNECT.search(str(err)))
|
|
427
|
+
if attempt < total_attempts and (is_health or transient):
|
|
428
|
+
await asyncio.sleep(0.1 * attempt)
|
|
429
|
+
continue
|
|
430
|
+
raise
|
|
431
|
+
|
|
432
|
+
raise last_err or SolariError("Solari.launch: exhausted attempts")
|
|
433
|
+
|
|
434
|
+
async def _chromium(self) -> Any:
|
|
435
|
+
"""Start the patchright driver once and reuse it across launches."""
|
|
436
|
+
if self._playwright is None:
|
|
437
|
+
try:
|
|
438
|
+
from patchright.async_api import async_playwright
|
|
439
|
+
except ImportError as err: # pragma: no cover - depends on install extras
|
|
440
|
+
raise SolariError(
|
|
441
|
+
"Solari: launch() needs patchright. Install it with "
|
|
442
|
+
"`pip install 'solari-browser'` (it is a declared dependency) — "
|
|
443
|
+
"then `patchright install chromium` is NOT required, since the "
|
|
444
|
+
"browser runs remotely.",
|
|
445
|
+
None,
|
|
446
|
+
err,
|
|
447
|
+
) from err
|
|
448
|
+
self._playwright = await async_playwright().start()
|
|
449
|
+
return self._playwright.chromium
|
|
450
|
+
|
|
451
|
+
async def _probe_browser_health(self, browser: Any, timeout_ms: int) -> None:
|
|
452
|
+
"""Prove the browser actually works before handing it back.
|
|
453
|
+
|
|
454
|
+
A slot can accept the WS connect and still be wedged; without this, launch()
|
|
455
|
+
returns a browser that fails on first use. Any failure is normalised to
|
|
456
|
+
BROWSER_UNHEALTHY so launch()'s retry loop can act on it.
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
async def probe() -> None:
|
|
460
|
+
ctx = await browser.new_context()
|
|
461
|
+
try:
|
|
462
|
+
page = await ctx.new_page()
|
|
463
|
+
await page.evaluate("1")
|
|
464
|
+
finally:
|
|
465
|
+
try:
|
|
466
|
+
await ctx.close()
|
|
467
|
+
except Exception: # noqa: BLE001
|
|
468
|
+
pass
|
|
469
|
+
|
|
470
|
+
try:
|
|
471
|
+
await asyncio.wait_for(probe(), timeout=timeout_ms / 1000.0)
|
|
472
|
+
except asyncio.TimeoutError as err:
|
|
473
|
+
raise SolariError(
|
|
474
|
+
f"Solari: browser health probe timed out after {timeout_ms}ms",
|
|
475
|
+
None,
|
|
476
|
+
err,
|
|
477
|
+
BROWSER_UNHEALTHY,
|
|
478
|
+
) from err
|
|
479
|
+
except Exception as err: # noqa: BLE001
|
|
480
|
+
if isinstance(err, SolariError) and err.code == BROWSER_UNHEALTHY:
|
|
481
|
+
raise
|
|
482
|
+
raise SolariError(
|
|
483
|
+
f"Solari: browser health probe failed: {err}", None, err, BROWSER_UNHEALTHY
|
|
484
|
+
) from err
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _q(v: str) -> str:
|
|
488
|
+
from urllib.parse import quote
|
|
489
|
+
|
|
490
|
+
return quote(v, safe="")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _json(res: httpx.Response, what: str) -> Any:
|
|
494
|
+
try:
|
|
495
|
+
return res.json()
|
|
496
|
+
except Exception as err: # noqa: BLE001
|
|
497
|
+
raise SolariError(
|
|
498
|
+
f"Solari: {what} response was not valid JSON: {res.text[:256]}", res.status_code, err
|
|
499
|
+
) from err
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
async def _fetch_presigned_storage_state(url: Optional[str]) -> Optional[StorageState]:
|
|
503
|
+
"""GET the presigned storage-state object. `None` url means an empty profile."""
|
|
504
|
+
if not url:
|
|
505
|
+
return None
|
|
506
|
+
try:
|
|
507
|
+
async with httpx.AsyncClient(timeout=_STORAGE_STATE_TIMEOUT_S) as http:
|
|
508
|
+
res = await http.get(url)
|
|
509
|
+
except Exception as err: # noqa: BLE001
|
|
510
|
+
raise SolariError(f"Solari: failed to fetch storageState: {err}", None, err) from err
|
|
511
|
+
if res.status_code >= 400:
|
|
512
|
+
raise SolariError(
|
|
513
|
+
f"Solari: storageState fetch returned {res.status_code}: {res.text[:256]}",
|
|
514
|
+
res.status_code,
|
|
515
|
+
)
|
|
516
|
+
try:
|
|
517
|
+
return res.json()
|
|
518
|
+
except Exception as err: # noqa: BLE001
|
|
519
|
+
raise SolariError(
|
|
520
|
+
f"Solari: storageState response was not valid JSON: {err}", None, err
|
|
521
|
+
) from err
|
solari_browser/errors.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Errors for the Solari Browser SDK. Mirrors `SolariError` in sdk/src/index.ts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
# Codes the gateway returns in the `code` field of an error body. Mirrors
|
|
8
|
+
# `SolariErrorCode` (sdk/src/index.ts). Kept as plain strings, not an Enum: the
|
|
9
|
+
# gateway may add codes we don't know yet, and an Enum would raise on those
|
|
10
|
+
# rather than pass them through to the caller.
|
|
11
|
+
FEATURE_REQUIRES_PLAN = "FeatureRequiresPlan"
|
|
12
|
+
CONCURRENCY_LIMIT_EXCEEDED = "ConcurrencyLimitExceeded"
|
|
13
|
+
PLAN_LIMIT_EXCEEDED = "PlanLimitExceeded"
|
|
14
|
+
BROWSER_UNHEALTHY = "BrowserUnhealthy"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SolariError(Exception):
|
|
18
|
+
"""Raised for every Solari failure.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
status: HTTP status, when the failure came from an HTTP response.
|
|
22
|
+
cause: The underlying exception, when there was one.
|
|
23
|
+
code: Gateway error code (see the constants above), when the response
|
|
24
|
+
body carried one. `code == BROWSER_UNHEALTHY` is what makes
|
|
25
|
+
`launch(retries=...)` retry.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
message: str,
|
|
31
|
+
status: Optional[int] = None,
|
|
32
|
+
cause: Optional[BaseException] = None,
|
|
33
|
+
code: Optional[str] = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.status = status
|
|
37
|
+
self.cause = cause
|
|
38
|
+
self.code = code
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
41
|
+
base = super().__str__()
|
|
42
|
+
bits = []
|
|
43
|
+
if self.status is not None:
|
|
44
|
+
bits.append(f"status={self.status}")
|
|
45
|
+
if self.code:
|
|
46
|
+
bits.append(f"code={self.code}")
|
|
47
|
+
return f"{base} ({', '.join(bits)})" if bits else base
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def code_from_body(text: str) -> Optional[str]:
|
|
51
|
+
"""Best-effort extract of `{"code": "..."}` from an error body.
|
|
52
|
+
|
|
53
|
+
The gateway returns a JSON body with a `code` on plan/concurrency errors, but
|
|
54
|
+
error bodies are not guaranteed to be JSON at all (proxies, 502 HTML pages),
|
|
55
|
+
so every failure here is swallowed — a missing code must never mask the real
|
|
56
|
+
HTTP error.
|
|
57
|
+
"""
|
|
58
|
+
import json
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
parsed: Any = json.loads(text)
|
|
62
|
+
except Exception:
|
|
63
|
+
return None
|
|
64
|
+
if isinstance(parsed, dict):
|
|
65
|
+
code = parsed.get("code")
|
|
66
|
+
if isinstance(code, str):
|
|
67
|
+
return code
|
|
68
|
+
return None
|
solari_browser/types.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Wire types for the Solari Browser SDK. Mirrors the interfaces in sdk/src/index.ts.
|
|
2
|
+
|
|
3
|
+
Dataclasses (not TypedDicts) so callers get attribute access + IDE completion, with
|
|
4
|
+
`from_wire` classmethods doing the camelCase→snake_case mapping in one place.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Dict, List, Literal, Optional, Union
|
|
11
|
+
|
|
12
|
+
# Supported regions. More coming soon. Mirrors `SolariRegion`.
|
|
13
|
+
Region = Literal["us-west"]
|
|
14
|
+
REGION_URLS: Dict[str, str] = {
|
|
15
|
+
"us-west": "https://api.getsolari.com",
|
|
16
|
+
}
|
|
17
|
+
DEFAULT_REGION: str = "us-west"
|
|
18
|
+
|
|
19
|
+
ProxyTier = Literal["residential", "static", "mobile"]
|
|
20
|
+
|
|
21
|
+
# A Playwright storage state (cookies + origins/localStorage). Left as a plain dict:
|
|
22
|
+
# it is passed straight through to patchright's `storage_state`, so imposing our own
|
|
23
|
+
# schema would only risk drifting from Playwright's.
|
|
24
|
+
StorageState = Dict[str, Any]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ProxyRequest:
|
|
29
|
+
"""Managed proxy egress request. Mirrors `ProxyRequest`."""
|
|
30
|
+
|
|
31
|
+
country: Optional[str] = None # ISO-3166-1 alpha-2, lowercase. Defaults to "us".
|
|
32
|
+
tier: Optional[ProxyTier] = None # residential (default, rotating) | static | mobile
|
|
33
|
+
asn: Optional[str] = None # pin egress ASN, e.g. "20057" (AT&T Mobility)
|
|
34
|
+
session: Optional[str] = None # sticky id, alnum + dash, <=32 chars
|
|
35
|
+
session_duration: Optional[int] = None # sticky lifetime, minutes (1-30, default 10)
|
|
36
|
+
state: Optional[str] = None # US-only, e.g. "california"
|
|
37
|
+
city: Optional[str] = None # US-only, e.g. "los_angeles"
|
|
38
|
+
|
|
39
|
+
def to_wire(self) -> Dict[str, Any]:
|
|
40
|
+
"""Only non-None keys are sent — the gateway distinguishes absent from null."""
|
|
41
|
+
wire: Dict[str, Any] = {}
|
|
42
|
+
if self.country is not None:
|
|
43
|
+
wire["country"] = self.country
|
|
44
|
+
if self.tier is not None:
|
|
45
|
+
wire["tier"] = self.tier
|
|
46
|
+
if self.asn is not None:
|
|
47
|
+
wire["asn"] = self.asn
|
|
48
|
+
if self.session is not None:
|
|
49
|
+
wire["session"] = self.session
|
|
50
|
+
if self.session_duration is not None:
|
|
51
|
+
wire["sessionDuration"] = self.session_duration
|
|
52
|
+
if self.state is not None:
|
|
53
|
+
wire["state"] = self.state
|
|
54
|
+
if self.city is not None:
|
|
55
|
+
wire["city"] = self.city
|
|
56
|
+
return wire
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# `proxy=` accepts the same shapes as the TS SDK: a country string ("us"), a
|
|
60
|
+
# ProxyRequest, or the "off"/"smart" sentinels.
|
|
61
|
+
ProxySpec = Union[str, ProxyRequest]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ResolvedProxyConfig:
|
|
66
|
+
"""Resolved proxy credentials returned on the session response."""
|
|
67
|
+
|
|
68
|
+
server: str
|
|
69
|
+
username: str
|
|
70
|
+
password: str
|
|
71
|
+
timezone_id: str
|
|
72
|
+
country: str
|
|
73
|
+
tier: Optional[ProxyTier] = None
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_wire(cls, d: Dict[str, Any]) -> "ResolvedProxyConfig":
|
|
77
|
+
return cls(
|
|
78
|
+
server=d.get("server", ""),
|
|
79
|
+
username=d.get("username", ""),
|
|
80
|
+
password=d.get("password", ""),
|
|
81
|
+
timezone_id=d.get("timezoneId", ""),
|
|
82
|
+
country=d.get("country", ""),
|
|
83
|
+
tier=d.get("tier"),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Sentinel distinguishing "no profile attached" from "profile exists but empty".
|
|
88
|
+
# The TS SDK encodes this as undefined vs null on `Session.storageState`; Python has
|
|
89
|
+
# only None, so absence is this sentinel and empty-profile is None.
|
|
90
|
+
class _Unset:
|
|
91
|
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
|
92
|
+
return "UNSET"
|
|
93
|
+
|
|
94
|
+
def __bool__(self) -> bool:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
UNSET = _Unset()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Session:
|
|
103
|
+
"""A live browser session.
|
|
104
|
+
|
|
105
|
+
NOTE: unlike the TS SDK, `ws_endpoint`/`cdp_endpoint` are the UPSTREAM gateway
|
|
106
|
+
URLs. The TS SDK rewrites them to a loopback LocalProxy for connect-retry
|
|
107
|
+
purposes; that is a Node-specific device and is deliberately not reproduced here.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
id: str
|
|
111
|
+
ws_endpoint: str # Playwright wire protocol — for chromium.connect()
|
|
112
|
+
cdp_endpoint: str # raw CDP — for connect_over_cdp() / other CDP clients
|
|
113
|
+
expires_at: str # ISO 8601 UTC; session auto-releases at this point
|
|
114
|
+
storage_state: Union[StorageState, None, _Unset] = UNSET
|
|
115
|
+
proxy: Optional[ResolvedProxyConfig] = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Profile:
|
|
120
|
+
"""A stored browser profile (cookies + localStorage)."""
|
|
121
|
+
|
|
122
|
+
id: str
|
|
123
|
+
name: str
|
|
124
|
+
raw: Dict[str, Any] = field(default_factory=dict) # full wire object, forward-compatible
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_wire(cls, d: Dict[str, Any]) -> "Profile":
|
|
128
|
+
return cls(id=d.get("id", ""), name=d.get("name", ""), raw=d)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class ReplayUrl:
|
|
133
|
+
url: str
|
|
134
|
+
expires_in_seconds: int = 0
|
|
135
|
+
content_encoding: str = "gzip"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class SaveResult:
|
|
140
|
+
version: int = 0
|
|
141
|
+
size_bytes: int = 0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: solari-browser
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python SDK for Solari Browser — managed, stealthy remote Chromium over the Playwright wire protocol / raw CDP.
|
|
5
|
+
Project-URL: Homepage, https://getsolari.com
|
|
6
|
+
Author: Solari
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Keywords: automation,browser,cdp,playwright,scraping,solari,stealth
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Requires-Dist: httpx>=0.24
|
|
11
|
+
Requires-Dist: patchright<1.60,>=1.59
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
14
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# solari-browser (Python)
|
|
18
|
+
|
|
19
|
+
Python SDK for **Solari Browser** — managed, stealth-hardened remote Chromium as
|
|
20
|
+
an API, driven over the Playwright wire protocol (or raw CDP). A faithful port of
|
|
21
|
+
the TypeScript `@solarisdk/browser` package; class, method, and field names match
|
|
22
|
+
it one-for-one (snake_case where TS uses camelCase).
|
|
23
|
+
|
|
24
|
+
The browser runs remotely on Solari's pool — `pip install` pulls the driver only;
|
|
25
|
+
you do **not** run `patchright install chromium` locally.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pip install solari-browser
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quickstart
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import asyncio, os
|
|
37
|
+
from solari_browser import Solari
|
|
38
|
+
|
|
39
|
+
async def main():
|
|
40
|
+
solari = Solari(api_key=os.environ["SOLARI_API_KEY"])
|
|
41
|
+
browser = await solari.launch(stealth=True) # managed, stealth-hardened Chromium
|
|
42
|
+
page = await browser.new_page()
|
|
43
|
+
await page.goto("https://example.com")
|
|
44
|
+
print(await page.title())
|
|
45
|
+
await browser.close()
|
|
46
|
+
|
|
47
|
+
asyncio.run(main())
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Sessions
|
|
51
|
+
|
|
52
|
+
Drive a session yourself instead of `launch()`:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
solari = Solari(api_key=os.environ["SOLARI_API_KEY"])
|
|
56
|
+
session = await solari.sessions.create(stealth=True, proxy="smart")
|
|
57
|
+
# ... connect over the session's CDP/WS endpoint ...
|
|
58
|
+
await solari.close()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`Solari(...)` accepts `api_key`, `region` (default `us-west`), and `base_url`
|
|
62
|
+
(default `https://api.getsolari.com`).
|
|
63
|
+
|
|
64
|
+
## Docs
|
|
65
|
+
|
|
66
|
+
Full reference and language guides: <https://getsolari.com>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
solari_browser/__init__.py,sha256=GjqnjKtFeqZFVVz63cXAKoWvgCHIooLcoP-BFWhbUgw,1329
|
|
2
|
+
solari_browser/browser_session.py,sha256=Dfm-4t-yU_wdRdlDD7cwxc5_ax0x2qsxf8KsdWsmf-o,2840
|
|
3
|
+
solari_browser/client.py,sha256=XMRiUeLG_oYGStRFlsZ13P3xYTheBQUi-snYQ2ub9NE,19674
|
|
4
|
+
solari_browser/errors.py,sha256=56M753QaLnV_pKaV_Tg18tllJpWoyXwzbPkx-0Izug4,2319
|
|
5
|
+
solari_browser/types.py,sha256=CVY9vXlIbGLbpZa2-ILpZ75W_uXx0gxIBiEhLKnCfCk,4664
|
|
6
|
+
solari_browser-0.1.1.dist-info/METADATA,sha256=LwWa8ip1jkmu-qg3JfFd7LaAKTsxv0qjumcKEhsunLU,1943
|
|
7
|
+
solari_browser-0.1.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
solari_browser-0.1.1.dist-info/RECORD,,
|