deepwrap 0.1.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.
- deepwrap/__init__.py +3 -0
- deepwrap/__main__.py +437 -0
- deepwrap/api/__init__.py +7 -0
- deepwrap/api/base.py +31 -0
- deepwrap/api/chat_session.py +303 -0
- deepwrap/api/chats.py +101 -0
- deepwrap/api/pow.py +92 -0
- deepwrap/client.py +136 -0
- deepwrap/config.py +74 -0
- deepwrap/core/__init__.py +4 -0
- deepwrap/core/auth.py +123 -0
- deepwrap/core/session_manager.py +55 -0
- deepwrap/interfaces/__init__.py +1 -0
- deepwrap/interfaces/api.py +373 -0
- deepwrap/interfaces/cli.py +966 -0
- deepwrap/modules/__init__.py +3 -0
- deepwrap/modules/pow_asm.py +305 -0
- deepwrap/py.typed +0 -0
- deepwrap/utils/bearer_token_extractor.py +56 -0
- deepwrap/utils/browser_finder.py +172 -0
- deepwrap/utils/browser_process.py +72 -0
- deepwrap/utils/cdp_client.py +82 -0
- deepwrap/utils/config_store.py +101 -0
- deepwrap/utils/dev_tools_http.py +90 -0
- deepwrap/utils/port_finder.py +17 -0
- deepwrap-0.1.0.dist-info/METADATA +860 -0
- deepwrap-0.1.0.dist-info/RECORD +31 -0
- deepwrap-0.1.0.dist-info/WHEEL +5 -0
- deepwrap-0.1.0.dist-info/entry_points.txt +2 -0
- deepwrap-0.1.0.dist-info/licenses/LICENSE +21 -0
- deepwrap-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import struct
|
|
5
|
+
import urllib.request
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from wasmtime import Instance, Store
|
|
11
|
+
from deepwrap.config import Config
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class PowChallenge:
|
|
15
|
+
"""
|
|
16
|
+
Represents a DeepSeek proof-of-work challenge.
|
|
17
|
+
|
|
18
|
+
This object contains all server-provided fields required to solve the
|
|
19
|
+
challenge and build the `x-ds-pow-response` header.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
algorithm:
|
|
23
|
+
The PoW algorithm identifier returned by the API, such as
|
|
24
|
+
"DeepSeekHashV1".
|
|
25
|
+
|
|
26
|
+
challenge:
|
|
27
|
+
The hex-encoded challenge string passed to the WASM solver.
|
|
28
|
+
|
|
29
|
+
salt:
|
|
30
|
+
The server-provided salt used to build the solver prefix.
|
|
31
|
+
|
|
32
|
+
signature:
|
|
33
|
+
The server-provided signature that must be echoed back unchanged
|
|
34
|
+
in the PoW response payload.
|
|
35
|
+
|
|
36
|
+
difficulty:
|
|
37
|
+
The difficulty value passed to the WASM solver.
|
|
38
|
+
|
|
39
|
+
expire_at:
|
|
40
|
+
Challenge expiration timestamp in Unix milliseconds.
|
|
41
|
+
|
|
42
|
+
expire_after:
|
|
43
|
+
Challenge lifetime in milliseconds.
|
|
44
|
+
|
|
45
|
+
target_path:
|
|
46
|
+
The API path this challenge was issued for, such as
|
|
47
|
+
"/api/v0/chat/completion".
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
algorithm: str
|
|
51
|
+
challenge: str
|
|
52
|
+
salt: str
|
|
53
|
+
signature: str
|
|
54
|
+
difficulty: int
|
|
55
|
+
expire_at: int
|
|
56
|
+
expire_after: int
|
|
57
|
+
target_path: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ProofOfWorkSolver:
|
|
61
|
+
"""
|
|
62
|
+
Solves DeepSeek proof-of-work challenges using the browser-compatible WASM module.
|
|
63
|
+
|
|
64
|
+
Browser worker behavior:
|
|
65
|
+
prefix = f"{salt}_{expireAt}_"
|
|
66
|
+
wasm_solve(ret_ptr, challenge_ptr, challenge_len, prefix_ptr, prefix_len, difficulty)
|
|
67
|
+
ok = getInt32(ret_ptr + 0, little_endian=True)
|
|
68
|
+
answer = getFloat64(ret_ptr + 8, little_endian=True)
|
|
69
|
+
|
|
70
|
+
Notes:
|
|
71
|
+
- The second string passed into the solver is the prefix, not the raw salt.
|
|
72
|
+
- The final numeric argument is `difficulty` as float64.
|
|
73
|
+
- `expire_at` is used only when building the prefix string.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, wasm_path: Optional[str] = None) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Initialize the solver.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
wasm_path:
|
|
82
|
+
Optional local path to the PoW WASM binary. If omitted, the WASM
|
|
83
|
+
module is downloaded from the configured URL.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
self._wasm_path = wasm_path
|
|
87
|
+
self._wasm_bytes: Optional[bytes] = None
|
|
88
|
+
self._engine = None
|
|
89
|
+
self._module = None
|
|
90
|
+
|
|
91
|
+
def _load_bytes(self) -> bytes:
|
|
92
|
+
"""
|
|
93
|
+
Load the WASM module bytes.
|
|
94
|
+
|
|
95
|
+
The bytes are loaded once and cached in memory. If `wasm_path` points
|
|
96
|
+
to an existing file, that file is used. Otherwise, the module is
|
|
97
|
+
downloaded from `Config.wasm_url`.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The raw WASM bytes.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
if self._wasm_bytes is not None:
|
|
104
|
+
return self._wasm_bytes
|
|
105
|
+
|
|
106
|
+
if self._wasm_path and os.path.isfile(self._wasm_path):
|
|
107
|
+
with open(self._wasm_path, "rb") as fh:
|
|
108
|
+
self._wasm_bytes = fh.read()
|
|
109
|
+
return self._wasm_bytes
|
|
110
|
+
|
|
111
|
+
with urllib.request.urlopen(Config.wasm_url, timeout=20) as resp:
|
|
112
|
+
self._wasm_bytes = resp.read()
|
|
113
|
+
|
|
114
|
+
return self._wasm_bytes
|
|
115
|
+
|
|
116
|
+
def _write(self, ptr: int, data: bytes, memory, store) -> None:
|
|
117
|
+
"""
|
|
118
|
+
Write bytes into WASM linear memory.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
ptr:
|
|
122
|
+
Destination pointer inside WASM memory.
|
|
123
|
+
data:
|
|
124
|
+
Bytes to write.
|
|
125
|
+
memory:
|
|
126
|
+
Exported WASM memory object.
|
|
127
|
+
store:
|
|
128
|
+
The active WASM store.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
raw = memory.data_ptr(store)
|
|
132
|
+
|
|
133
|
+
for i, b in enumerate(data):
|
|
134
|
+
raw[ptr + i] = b
|
|
135
|
+
|
|
136
|
+
def _read(self, ptr: int, size: int, memory, store) -> bytes:
|
|
137
|
+
"""
|
|
138
|
+
Read bytes from WASM linear memory.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
ptr:
|
|
142
|
+
Source pointer inside WASM memory.
|
|
143
|
+
size:
|
|
144
|
+
Number of bytes to read.
|
|
145
|
+
memory:
|
|
146
|
+
Exported WASM memory object.
|
|
147
|
+
store:
|
|
148
|
+
The active WASM store.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
The bytes read from WASM memory.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
return bytes(memory.data_ptr(store)[ptr + i] for i in range(size))
|
|
155
|
+
|
|
156
|
+
def _pass_string(self, value: str, malloc, realloc, memory, store) -> tuple[int, int]:
|
|
157
|
+
"""
|
|
158
|
+
Copy a Python string into WASM memory.
|
|
159
|
+
|
|
160
|
+
This mirrors the wasm-bindgen string-passing behavior used by the
|
|
161
|
+
browser worker. ASCII-only strings use a fast path; non-ASCII strings
|
|
162
|
+
fall back to reallocation-based UTF-8 encoding.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
value:
|
|
166
|
+
The Python string to copy.
|
|
167
|
+
malloc:
|
|
168
|
+
WASM malloc export.
|
|
169
|
+
realloc:
|
|
170
|
+
WASM realloc export.
|
|
171
|
+
memory:
|
|
172
|
+
Exported WASM memory object.
|
|
173
|
+
store:
|
|
174
|
+
The active WASM store.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
A tuple of `(ptr, length)` describing the copied UTF-8 string in
|
|
178
|
+
WASM memory.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
encoded = value.encode("utf-8")
|
|
182
|
+
|
|
183
|
+
if all(b < 128 for b in encoded):
|
|
184
|
+
ptr = malloc(store, len(encoded), 1)
|
|
185
|
+
self._write(ptr, encoded, memory, store)
|
|
186
|
+
|
|
187
|
+
return ptr, len(encoded)
|
|
188
|
+
|
|
189
|
+
ptr = malloc(store, len(value), 1)
|
|
190
|
+
raw = memory.data_ptr(store)
|
|
191
|
+
offset = 0
|
|
192
|
+
|
|
193
|
+
while offset < len(value):
|
|
194
|
+
ch = ord(value[offset])
|
|
195
|
+
|
|
196
|
+
if ch > 127:
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
raw[ptr + offset] = ch
|
|
200
|
+
offset += 1
|
|
201
|
+
|
|
202
|
+
if offset != len(value):
|
|
203
|
+
remainder = value[offset:].encode("utf-8")
|
|
204
|
+
ptr = realloc(store, ptr, len(value), offset + len(remainder), 1)
|
|
205
|
+
self._write(ptr + offset, remainder, memory, store)
|
|
206
|
+
|
|
207
|
+
return ptr, offset + len(remainder)
|
|
208
|
+
|
|
209
|
+
return ptr, offset
|
|
210
|
+
|
|
211
|
+
def warmup(self) -> None:
|
|
212
|
+
"""
|
|
213
|
+
Preload and compile the WASM module.
|
|
214
|
+
|
|
215
|
+
This reduces latency for the first challenge solve by downloading and
|
|
216
|
+
compiling the module ahead of time. Repeated calls are no-ops.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
RuntimeError:
|
|
220
|
+
If the `wasmtime` dependency is missing.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
if self._module is not None:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
from wasmtime import Engine, Module
|
|
228
|
+
|
|
229
|
+
except ImportError as exc:
|
|
230
|
+
raise RuntimeError(
|
|
231
|
+
"Missing dependency: wasmtime. Install it with: pip install wasmtime"
|
|
232
|
+
) from exc
|
|
233
|
+
|
|
234
|
+
self._engine = Engine()
|
|
235
|
+
self._module = Module(self._engine, self._load_bytes())
|
|
236
|
+
|
|
237
|
+
def solve(self, challenge: PowChallenge) -> int:
|
|
238
|
+
"""
|
|
239
|
+
Solve a DeepSeek proof-of-work challenge.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
challenge:
|
|
243
|
+
The server-issued proof-of-work challenge.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
The integer nonce that satisfies the challenge.
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
RuntimeError:
|
|
250
|
+
If the WASM solver reports that no valid solution was found.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
if self._module is None:
|
|
254
|
+
self.warmup()
|
|
255
|
+
|
|
256
|
+
store = Store(self._engine)
|
|
257
|
+
instance = Instance(store, self._module, [])
|
|
258
|
+
exports = instance.exports(store)
|
|
259
|
+
|
|
260
|
+
memory = exports["memory"]
|
|
261
|
+
malloc = exports["__wbindgen_export_0"]
|
|
262
|
+
realloc = exports["__wbindgen_export_1"]
|
|
263
|
+
stack_ptr_fn = exports["__wbindgen_add_to_stack_pointer"]
|
|
264
|
+
wasm_solve = exports["wasm_solve"]
|
|
265
|
+
|
|
266
|
+
challenge_ptr, challenge_len = self._pass_string(
|
|
267
|
+
challenge.challenge, malloc, realloc, memory, store
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
prefix_ptr, prefix_len = self._pass_string(
|
|
271
|
+
f"{challenge.salt}_{challenge.expire_at}_",
|
|
272
|
+
malloc,
|
|
273
|
+
realloc,
|
|
274
|
+
memory,
|
|
275
|
+
store
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
ret_ptr = stack_ptr_fn(store, -16)
|
|
279
|
+
try:
|
|
280
|
+
wasm_solve(
|
|
281
|
+
store,
|
|
282
|
+
ret_ptr,
|
|
283
|
+
challenge_ptr,
|
|
284
|
+
challenge_len,
|
|
285
|
+
prefix_ptr,
|
|
286
|
+
prefix_len,
|
|
287
|
+
float(challenge.difficulty),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
raw = self._read(ret_ptr, 16, memory, store)
|
|
291
|
+
ok = struct.unpack_from("<i", raw, 0)[0]
|
|
292
|
+
answer = struct.unpack_from("<d", raw, 8)[0]
|
|
293
|
+
|
|
294
|
+
finally:
|
|
295
|
+
stack_ptr_fn(store, 16)
|
|
296
|
+
|
|
297
|
+
if ok == 0:
|
|
298
|
+
raise RuntimeError(
|
|
299
|
+
"No solution found: "
|
|
300
|
+
f"algorithm={challenge.algorithm}, "
|
|
301
|
+
f"challenge={challenge.challenge}, "
|
|
302
|
+
f"difficulty={challenge.difficulty}, "
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
return int(answer)
|
deepwrap/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from typing import Any, Dict, Optional
|
|
2
|
+
|
|
3
|
+
class BearerTokenExtractor:
|
|
4
|
+
"""
|
|
5
|
+
Utility class to extract bearer tokens from CDP messages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
@staticmethod
|
|
9
|
+
def from_headers(headers: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
10
|
+
"""
|
|
11
|
+
Extract a bearer token from the given headers dictionary.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
headers (Optional[Dict[str, Any]]): A dictionary of HTTP headers.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Optional[str]: The extracted bearer token if found, otherwise None.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
if not headers:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
for key, value in headers.items():
|
|
24
|
+
if key.lower() != "authorization":
|
|
25
|
+
continue
|
|
26
|
+
|
|
27
|
+
value = str(value)
|
|
28
|
+
|
|
29
|
+
if value.lower().startswith("bearer "):
|
|
30
|
+
return value.split(" ", 1)[1]
|
|
31
|
+
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_cdp_message(cls, message: Dict[str, Any]) -> Optional[str]:
|
|
36
|
+
"""
|
|
37
|
+
Extract a bearer token from a CDP message if it contains relevant network request information.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
message (Dict[str, Any]): A CDP message represented as a dictionary.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Optional[str]: The extracted bearer token if found, otherwise None.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
method = message.get("method")
|
|
47
|
+
params = message.get("params", {})
|
|
48
|
+
|
|
49
|
+
if method == "Network.requestWillBeSent":
|
|
50
|
+
request = params.get("request", {})
|
|
51
|
+
return cls.from_headers(request.get("headers", {}))
|
|
52
|
+
|
|
53
|
+
if method == "Network.requestWillBeSentExtraInfo":
|
|
54
|
+
return cls.from_headers(params.get("headers", {}))
|
|
55
|
+
|
|
56
|
+
return None
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from shutil import which
|
|
6
|
+
from typing import Any, List
|
|
7
|
+
|
|
8
|
+
class BrowserFinder:
|
|
9
|
+
"""
|
|
10
|
+
Utility class to find installed Chromium-based browsers on the system.
|
|
11
|
+
This is used by the authentication module to launch a browser instance for user login when no active session is found.
|
|
12
|
+
The finder checks common installation paths for major browsers across Windows, macOS, and Linux, as well as the system PATH.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
CHROMIUM_EXECUTABLE_NAMES = [
|
|
16
|
+
"chrome",
|
|
17
|
+
"chrome.exe",
|
|
18
|
+
"msedge",
|
|
19
|
+
"msedge.exe",
|
|
20
|
+
"brave",
|
|
21
|
+
"brave.exe",
|
|
22
|
+
"brave-browser",
|
|
23
|
+
"chromium",
|
|
24
|
+
"chromium-browser",
|
|
25
|
+
"opera",
|
|
26
|
+
"opera.exe",
|
|
27
|
+
"vivaldi",
|
|
28
|
+
"vivaldi.exe",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def find(cls) -> List[str]:
|
|
33
|
+
"""
|
|
34
|
+
Find installed Chromium-based browsers.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A list of file paths to detected browser executables. The list may be empty if no browsers are found.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
paths = []
|
|
41
|
+
paths.extend(cls._from_path())
|
|
42
|
+
|
|
43
|
+
if sys.platform.startswith("win"):
|
|
44
|
+
paths.extend(cls._windows_paths())
|
|
45
|
+
|
|
46
|
+
elif sys.platform == "darwin":
|
|
47
|
+
paths.extend(cls._macos_paths())
|
|
48
|
+
|
|
49
|
+
else:
|
|
50
|
+
paths.extend(cls._linux_paths())
|
|
51
|
+
|
|
52
|
+
return cls._unique_existing(paths)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def _from_path(cls) -> List[str]:
|
|
56
|
+
"""
|
|
57
|
+
Find browser executables in the system PATH.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
A list of file paths to browser executables found in the PATH.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
paths = []
|
|
64
|
+
|
|
65
|
+
for name in cls.CHROMIUM_EXECUTABLE_NAMES:
|
|
66
|
+
found = which(name)
|
|
67
|
+
|
|
68
|
+
if found:
|
|
69
|
+
paths.append(found)
|
|
70
|
+
|
|
71
|
+
return paths
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _windows_paths() -> List[Path]:
|
|
75
|
+
"""
|
|
76
|
+
Common installation paths for Chromium-based browsers on Windows.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
A list of file paths to browser executables based on common Windows installation directories.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
local = os.environ.get("LOCALAPPDATA", "")
|
|
83
|
+
pf = os.environ.get("PROGRAMFILES", "")
|
|
84
|
+
pf86 = os.environ.get("PROGRAMFILES(X86)", "")
|
|
85
|
+
|
|
86
|
+
return [
|
|
87
|
+
Path(local) / "Google" / "Chrome" / "Application" / "chrome.exe",
|
|
88
|
+
Path(pf) / "Google" / "Chrome" / "Application" / "chrome.exe",
|
|
89
|
+
Path(pf86) / "Google" / "Chrome" / "Application" / "chrome.exe",
|
|
90
|
+
|
|
91
|
+
Path(local) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
|
|
92
|
+
Path(pf) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
|
|
93
|
+
Path(pf86) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
|
|
94
|
+
|
|
95
|
+
Path(local) / "BraveSoftware" / "Brave-Browser" / "Application" / "brave.exe",
|
|
96
|
+
Path(pf) / "BraveSoftware" / "Brave-Browser" / "Application" / "brave.exe",
|
|
97
|
+
Path(pf86) / "BraveSoftware" / "Brave-Browser" / "Application" / "brave.exe",
|
|
98
|
+
|
|
99
|
+
Path(local) / "Programs" / "Opera" / "opera.exe",
|
|
100
|
+
Path(pf) / "Opera" / "opera.exe",
|
|
101
|
+
|
|
102
|
+
Path(local) / "Vivaldi" / "Application" / "vivaldi.exe",
|
|
103
|
+
Path(pf) / "Vivaldi" / "Application" / "vivaldi.exe",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _macos_paths() -> List[str]:
|
|
108
|
+
"""
|
|
109
|
+
Common installation paths for Chromium-based browsers on macOS.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
A list of file paths to browser executables based on common macOS installation directories.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
return [
|
|
116
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
117
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
118
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
119
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
120
|
+
"/Applications/Opera.app/Contents/MacOS/Opera",
|
|
121
|
+
"/Applications/Vivaldi.app/Contents/MacOS/Vivaldi",
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _linux_paths() -> List[str]:
|
|
126
|
+
"""
|
|
127
|
+
Common installation paths for Chromium-based browsers on Linux.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
A list of file paths to browser executables based on common Linux installation directories.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
return [
|
|
134
|
+
"/usr/bin/google-chrome",
|
|
135
|
+
"/usr/bin/google-chrome-stable",
|
|
136
|
+
"/usr/bin/chromium",
|
|
137
|
+
"/usr/bin/chromium-browser",
|
|
138
|
+
"/usr/bin/microsoft-edge",
|
|
139
|
+
"/usr/bin/brave-browser",
|
|
140
|
+
"/usr/bin/opera",
|
|
141
|
+
"/usr/bin/vivaldi",
|
|
142
|
+
"/snap/bin/chromium",
|
|
143
|
+
"/snap/bin/brave",
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _unique_existing(paths: List[Any]) -> List[str]:
|
|
148
|
+
"""
|
|
149
|
+
Filter the given list of paths to include only unique entries that exist on the filesystem.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
A list of unique file paths that exist on the filesystem.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
seen = set()
|
|
156
|
+
result = []
|
|
157
|
+
|
|
158
|
+
for path in paths:
|
|
159
|
+
if not path:
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
path = str(Path(path))
|
|
163
|
+
normalized = path.lower()
|
|
164
|
+
|
|
165
|
+
if normalized in seen:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
if Path(path).exists():
|
|
169
|
+
seen.add(normalized)
|
|
170
|
+
result.append(path)
|
|
171
|
+
|
|
172
|
+
return result
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
|
|
2
|
+
import subprocess
|
|
3
|
+
import tempfile
|
|
4
|
+
import shutil
|
|
5
|
+
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from .browser_finder import BrowserFinder
|
|
8
|
+
|
|
9
|
+
class BrowserProcess:
|
|
10
|
+
"""
|
|
11
|
+
Manages the lifecycle of a browser process launched for authentication.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, url: str, port: int) -> None:
|
|
15
|
+
self.url = url
|
|
16
|
+
self.port = port
|
|
17
|
+
self.process: Optional[subprocess.Popen] = None
|
|
18
|
+
self.browser_path: Optional[str] = None
|
|
19
|
+
self.user_data_dir: Optional[str] = None
|
|
20
|
+
|
|
21
|
+
def start(self) -> "BrowserProcess":
|
|
22
|
+
"""
|
|
23
|
+
Start the browser process with the appropriate flags for remote debugging.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
browsers = BrowserFinder.find()
|
|
27
|
+
|
|
28
|
+
if not browsers:
|
|
29
|
+
raise Exception(
|
|
30
|
+
"No Chromium-based browser was found. Please install Chrome, Edge, Brave, Chromium, Opera, or Vivaldi."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
self.browser_path = browsers[0]
|
|
34
|
+
self.user_data_dir = tempfile.mkdtemp(prefix="deepwrap-auth-profile-")
|
|
35
|
+
|
|
36
|
+
args = [
|
|
37
|
+
self.browser_path,
|
|
38
|
+
f"--remote-debugging-port={self.port}",
|
|
39
|
+
"--remote-debugging-address=127.0.0.1",
|
|
40
|
+
"--remote-allow-origins=*",
|
|
41
|
+
f"--user-data-dir={self.user_data_dir}",
|
|
42
|
+
"--no-first-run",
|
|
43
|
+
"--no-default-browser-check",
|
|
44
|
+
"--disable-popup-blocking",
|
|
45
|
+
self.url,
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
self.process = subprocess.Popen(
|
|
49
|
+
args,
|
|
50
|
+
stdout=subprocess.DEVNULL,
|
|
51
|
+
stderr=subprocess.DEVNULL,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def terminate(self) -> None:
|
|
57
|
+
"""
|
|
58
|
+
Terminate the browser process and clean up the user data directory.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
if self.process:
|
|
62
|
+
try:
|
|
63
|
+
self.process.terminate()
|
|
64
|
+
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
if self.user_data_dir:
|
|
69
|
+
try:
|
|
70
|
+
shutil.rmtree(self.user_data_dir, ignore_errors=True)
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import websocket
|
|
3
|
+
|
|
4
|
+
from typing import Dict, Any, Optional
|
|
5
|
+
|
|
6
|
+
class CDPClient:
|
|
7
|
+
"""
|
|
8
|
+
Simple wrapper around a DevTools Protocol WebSocket connection.
|
|
9
|
+
- Provides methods to send commands and receive responses from the DevTools interface.
|
|
10
|
+
- Manages message IDs and connection lifecycle.
|
|
11
|
+
- Includes convenience methods to enable the Network and Page domains and to close the browser.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, websocket_url: str, timeout: int = 1) -> None:
|
|
15
|
+
self.websocket_url = websocket_url
|
|
16
|
+
self.timeout = timeout
|
|
17
|
+
self.message_id = 0
|
|
18
|
+
self.ws = websocket.create_connection(
|
|
19
|
+
websocket_url,
|
|
20
|
+
timeout=timeout,
|
|
21
|
+
suppress_origin=True,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def send(self, method: str, params: Optional[Dict[str, Any]] = None) -> int:
|
|
25
|
+
"""
|
|
26
|
+
Send a command to the DevTools interface.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
method (str): The DevTools method to invoke (e.g., "Network.enable").
|
|
30
|
+
params (Optional[Dict[str, Any]]): An optional dictionary of parameters to include with the command.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
int: The message ID assigned to this command, which can be used to correlate responses.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
self.message_id += 1
|
|
37
|
+
|
|
38
|
+
payload = {
|
|
39
|
+
"id": self.message_id,
|
|
40
|
+
"method": method,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if params is not None:
|
|
44
|
+
payload["params"] = params
|
|
45
|
+
|
|
46
|
+
self.ws.send(json.dumps(payload))
|
|
47
|
+
return self.message_id
|
|
48
|
+
|
|
49
|
+
def recv(self) -> Dict[str, Any]:
|
|
50
|
+
"""
|
|
51
|
+
Receive a message from the DevTools interface and parse it as JSON.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
return json.loads(self.ws.recv())
|
|
55
|
+
|
|
56
|
+
def enable_network(self) -> None:
|
|
57
|
+
"""
|
|
58
|
+
Convenience method to enable the Network domain.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
self.send("Network.enable")
|
|
62
|
+
self.send("Page.enable")
|
|
63
|
+
|
|
64
|
+
def close_browser(self) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Send the command to close the browser window.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
self.send("Browser.close")
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Close the WebSocket connection.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
self.ws.close()
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|