dscli-agent 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.
- dscli/__init__.py +3 -0
- dscli/__main__.py +4 -0
- dscli/agent.py +312 -0
- dscli/attachments.py +122 -0
- dscli/browser.py +1354 -0
- dscli/cli.py +842 -0
- dscli/config.py +191 -0
- dscli/display.py +108 -0
- dscli/prompts.py +263 -0
- dscli/protocol.py +633 -0
- dscli/scraper.py +268 -0
- dscli/tools.py +674 -0
- dscli/tui.py +574 -0
- dscli/ui.py +72 -0
- dscli_agent-0.1.0.dist-info/METADATA +379 -0
- dscli_agent-0.1.0.dist-info/RECORD +20 -0
- dscli_agent-0.1.0.dist-info/WHEEL +5 -0
- dscli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- dscli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- dscli_agent-0.1.0.dist-info/top_level.txt +1 -0
dscli/browser.py
ADDED
|
@@ -0,0 +1,1354 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ctypes
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
|
|
17
|
+
from playwright.sync_api import (
|
|
18
|
+
BrowserContext,
|
|
19
|
+
Error as PlaywrightError,
|
|
20
|
+
Page,
|
|
21
|
+
TimeoutError as PlaywrightTimeoutError,
|
|
22
|
+
sync_playwright,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .config import Config, debug_dir
|
|
26
|
+
from .scraper import (
|
|
27
|
+
DIAGNOSTICS_JS,
|
|
28
|
+
EXTRACT_LAST_ANSWER_JS,
|
|
29
|
+
MARK_JS,
|
|
30
|
+
SCROLL_TO_BOTTOM_JS,
|
|
31
|
+
SNAPSHOT_JS,
|
|
32
|
+
TOASTS_JS,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger("dscli")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BrowserError(RuntimeError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LoginRequired(BrowserError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HumanCheckRequired(BrowserError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ChatError(BrowserError):
|
|
51
|
+
def __init__(self, message: str, *, screenshot: str | None = None) -> None:
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
self.screenshot = screenshot
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class UploadError(BrowserError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _VS_FIXEDFILEINFO(ctypes.Structure):
|
|
61
|
+
_fields_ = [
|
|
62
|
+
("dwSignature", ctypes.c_uint32),
|
|
63
|
+
("dwStrucVersion", ctypes.c_uint32),
|
|
64
|
+
("dwFileVersionMS", ctypes.c_uint32),
|
|
65
|
+
("dwFileVersionLS", ctypes.c_uint32),
|
|
66
|
+
("dwProductVersionMS", ctypes.c_uint32),
|
|
67
|
+
("dwProductVersionLS", ctypes.c_uint32),
|
|
68
|
+
("dwFileFlagsMask", ctypes.c_uint32),
|
|
69
|
+
("dwFileFlags", ctypes.c_uint32),
|
|
70
|
+
("dwFileOS", ctypes.c_uint32),
|
|
71
|
+
("dwFileType", ctypes.c_uint32),
|
|
72
|
+
("dwFileSubtype", ctypes.c_uint32),
|
|
73
|
+
("dwFileDateMS", ctypes.c_uint32),
|
|
74
|
+
("dwFileDateLS", ctypes.c_uint32),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _file_version(path: Path) -> str | None:
|
|
79
|
+
"""Read a Windows executable's product version without launching it."""
|
|
80
|
+
if os.name != "nt":
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
size = ctypes.windll.version.GetFileVersionInfoSizeW(str(path), None)
|
|
84
|
+
if not size:
|
|
85
|
+
return None
|
|
86
|
+
data = ctypes.create_string_buffer(size)
|
|
87
|
+
if not ctypes.windll.version.GetFileVersionInfoW(str(path), 0, size, data):
|
|
88
|
+
return None
|
|
89
|
+
ptr = ctypes.c_void_p()
|
|
90
|
+
length = ctypes.c_uint()
|
|
91
|
+
if not ctypes.windll.version.VerQueryValueW(
|
|
92
|
+
data, ctypes.c_wchar_p("\\"), ctypes.byref(ptr), ctypes.byref(length)
|
|
93
|
+
):
|
|
94
|
+
return None
|
|
95
|
+
info = ctypes.cast(ptr, ctypes.POINTER(_VS_FIXEDFILEINFO)).contents
|
|
96
|
+
ms, ls = info.dwProductVersionMS, info.dwProductVersionLS
|
|
97
|
+
if not ms and not ls:
|
|
98
|
+
ms, ls = info.dwFileVersionMS, info.dwFileVersionLS
|
|
99
|
+
if not ms and not ls:
|
|
100
|
+
return None
|
|
101
|
+
return f"{ms >> 16}.{ms & 0xFFFF}.{ls >> 16}.{ls & 0xFFFF}"
|
|
102
|
+
except (AttributeError, OSError, ValueError):
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _version_from_registry(channel: str | None) -> str | None:
|
|
107
|
+
if os.name != "nt":
|
|
108
|
+
return None
|
|
109
|
+
try:
|
|
110
|
+
import winreg
|
|
111
|
+
except ImportError:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
if channel == "msedge":
|
|
115
|
+
keys = [
|
|
116
|
+
(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Edge\BLBeacon", "version"),
|
|
117
|
+
]
|
|
118
|
+
else:
|
|
119
|
+
keys = [
|
|
120
|
+
(winreg.HKEY_CURRENT_USER, r"Software\Google\Chrome\BLBeacon", "version"),
|
|
121
|
+
(
|
|
122
|
+
winreg.HKEY_LOCAL_MACHINE,
|
|
123
|
+
r"SOFTWARE\WOW6432Node\Google\Update\Clients"
|
|
124
|
+
r"\{8A69D345-D564-463c-AFF1-A69D9E530F96}",
|
|
125
|
+
"pv",
|
|
126
|
+
),
|
|
127
|
+
(
|
|
128
|
+
winreg.HKEY_LOCAL_MACHINE,
|
|
129
|
+
r"SOFTWARE\Google\Update\Clients"
|
|
130
|
+
r"\{8A69D345-D564-463c-AFF1-A69D9E530F96}",
|
|
131
|
+
"pv",
|
|
132
|
+
),
|
|
133
|
+
]
|
|
134
|
+
for root, key_path, value_name in keys:
|
|
135
|
+
try:
|
|
136
|
+
with winreg.OpenKey(root, key_path) as key:
|
|
137
|
+
value, _ = winreg.QueryValueEx(key, value_name)
|
|
138
|
+
except OSError:
|
|
139
|
+
continue
|
|
140
|
+
if isinstance(value, str) and re.match(r"^\d+\.", value):
|
|
141
|
+
return value
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _candidate_executables(channel: str | None) -> list[Path]:
|
|
146
|
+
env = os.environ
|
|
147
|
+
candidates: list[Path] = []
|
|
148
|
+
if os.name == "nt":
|
|
149
|
+
if channel == "msedge":
|
|
150
|
+
bases = [env.get("PROGRAMFILES(X86)"), env.get("PROGRAMFILES"), env.get("LOCALAPPDATA")]
|
|
151
|
+
for base in bases:
|
|
152
|
+
if base:
|
|
153
|
+
candidates.append(Path(base) / "Microsoft" / "Edge" / "Application" / "msedge.exe")
|
|
154
|
+
else:
|
|
155
|
+
bases = [env.get("PROGRAMFILES"), env.get("PROGRAMFILES(X86)"), env.get("LOCALAPPDATA")]
|
|
156
|
+
for base in bases:
|
|
157
|
+
if base:
|
|
158
|
+
candidates.append(Path(base) / "Google" / "Chrome" / "Application" / "chrome.exe")
|
|
159
|
+
elif sys.platform == "darwin":
|
|
160
|
+
names = (
|
|
161
|
+
"Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
|
|
162
|
+
if channel == "msedge"
|
|
163
|
+
else "Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
164
|
+
)
|
|
165
|
+
candidates.append(Path("/Applications") / names)
|
|
166
|
+
else:
|
|
167
|
+
if channel == "msedge":
|
|
168
|
+
names = ("microsoft-edge", "microsoft-edge-stable")
|
|
169
|
+
else:
|
|
170
|
+
names = ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser")
|
|
171
|
+
import shutil
|
|
172
|
+
|
|
173
|
+
for name in names:
|
|
174
|
+
found = shutil.which(name)
|
|
175
|
+
if found:
|
|
176
|
+
candidates.append(Path(found))
|
|
177
|
+
return candidates
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _version_from_executable(path: Path) -> str | None:
|
|
181
|
+
if not path.exists():
|
|
182
|
+
return None
|
|
183
|
+
if os.name == "nt":
|
|
184
|
+
return _file_version(path)
|
|
185
|
+
try:
|
|
186
|
+
proc = subprocess.run(
|
|
187
|
+
[str(path), "--version"],
|
|
188
|
+
capture_output=True,
|
|
189
|
+
text=True,
|
|
190
|
+
timeout=8,
|
|
191
|
+
)
|
|
192
|
+
except (OSError, subprocess.SubprocessError):
|
|
193
|
+
return None
|
|
194
|
+
match = re.search(r"(\d+\.\d+\.\d+\.\d+)", (proc.stdout or "") + (proc.stderr or ""))
|
|
195
|
+
return match.group(1) if match else None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _installed_version(channel: str | None) -> str | None:
|
|
199
|
+
version = _version_from_registry(channel)
|
|
200
|
+
if version:
|
|
201
|
+
return version
|
|
202
|
+
for candidate in _candidate_executables(channel):
|
|
203
|
+
version = _version_from_executable(candidate)
|
|
204
|
+
if version:
|
|
205
|
+
return version
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _browser_user_agent(major: str, channel: str | None) -> str:
|
|
210
|
+
if platform.system() == "Darwin":
|
|
211
|
+
system = "Macintosh; Intel Mac OS X 10_15_7"
|
|
212
|
+
elif platform.system() == "Linux":
|
|
213
|
+
system = "X11; Linux x86_64"
|
|
214
|
+
else:
|
|
215
|
+
system = "Windows NT 10.0; Win64; x64"
|
|
216
|
+
ua = (
|
|
217
|
+
f"Mozilla/5.0 ({system}) AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
218
|
+
f"Chrome/{major}.0.0.0 Safari/537.36"
|
|
219
|
+
)
|
|
220
|
+
if channel == "msedge":
|
|
221
|
+
ua += f" Edg/{major}.0.0.0"
|
|
222
|
+
return ua
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass
|
|
226
|
+
class SendReceipt:
|
|
227
|
+
prev_key: str | None
|
|
228
|
+
prev_count: int
|
|
229
|
+
prev_preview: str
|
|
230
|
+
sent_at: float
|
|
231
|
+
url: str
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
_ERROR_RE = re.compile(
|
|
235
|
+
r"\b(error|failed|failure|busy|try again|too large|unsupported|network|"
|
|
236
|
+
r"something went wrong|rate limit|quota)\b|"
|
|
237
|
+
r"upload (?:failed|error)|failed to (?:upload|read)|"
|
|
238
|
+
r"解析失败|上传失败|服务器",
|
|
239
|
+
re.IGNORECASE,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class ChatSession:
|
|
244
|
+
def __init__(self, config: Config, *, headless: bool | None = None) -> None:
|
|
245
|
+
self.cfg = config
|
|
246
|
+
self.headless = config.headless if headless is None else headless
|
|
247
|
+
self.selectors = config.merged_selectors()
|
|
248
|
+
self._pw = None
|
|
249
|
+
self.context: BrowserContext | None = None
|
|
250
|
+
self.page: Page | None = None
|
|
251
|
+
self._chrome_session: str | None = None
|
|
252
|
+
self._chrome_login_token: str | None = None
|
|
253
|
+
|
|
254
|
+
# ------------------------------------------------------------------ setup
|
|
255
|
+
def _chrome_profile_name(self, source: Path) -> str:
|
|
256
|
+
name = self.cfg.chrome_profile.strip()
|
|
257
|
+
if not name:
|
|
258
|
+
name = "Default"
|
|
259
|
+
try:
|
|
260
|
+
local_state = json.loads((source / "Local State").read_text(encoding="utf-8"))
|
|
261
|
+
last_used = local_state.get("profile", {}).get("last_used")
|
|
262
|
+
if isinstance(last_used, str) and last_used.strip():
|
|
263
|
+
name = last_used.strip()
|
|
264
|
+
except (OSError, json.JSONDecodeError):
|
|
265
|
+
pass
|
|
266
|
+
if name in {".", ".."} or Path(name).name != name:
|
|
267
|
+
raise BrowserError("chrome_profile must be a directory name, such as Default or Profile 1.")
|
|
268
|
+
return name
|
|
269
|
+
|
|
270
|
+
def _chrome_session_files(self, source: Path, profile: str) -> list[Path]:
|
|
271
|
+
network = source / profile / "Network"
|
|
272
|
+
return [
|
|
273
|
+
path
|
|
274
|
+
for path in (
|
|
275
|
+
source / "Local State",
|
|
276
|
+
network / "Cookies",
|
|
277
|
+
network / "Cookies-journal",
|
|
278
|
+
network / "Cookies-wal",
|
|
279
|
+
network / "Cookies-shm",
|
|
280
|
+
)
|
|
281
|
+
if path.is_file()
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
@staticmethod
|
|
285
|
+
def _extract_user_token(data: bytes) -> str | None:
|
|
286
|
+
"""Read DeepSeek's JSON-wrapped userToken from raw Chrome LevelDB bytes."""
|
|
287
|
+
marker = b"userToken"
|
|
288
|
+
prefix = b'{"value":"'
|
|
289
|
+
suffix = b'","__version"'
|
|
290
|
+
token = None
|
|
291
|
+
position = 0
|
|
292
|
+
while True:
|
|
293
|
+
key = data.find(marker, position)
|
|
294
|
+
if key < 0:
|
|
295
|
+
break
|
|
296
|
+
start = data.find(prefix, key + len(marker), key + len(marker) + 256)
|
|
297
|
+
if start >= 0:
|
|
298
|
+
begin = start + len(prefix)
|
|
299
|
+
end = data.find(suffix, begin, begin + 4096)
|
|
300
|
+
if end > begin:
|
|
301
|
+
candidate = data[begin:end]
|
|
302
|
+
if 16 <= len(candidate) <= 4096 and all(
|
|
303
|
+
32 <= byte < 127 for byte in candidate
|
|
304
|
+
):
|
|
305
|
+
token = candidate.decode("ascii")
|
|
306
|
+
position = key + len(marker)
|
|
307
|
+
return token
|
|
308
|
+
|
|
309
|
+
def _read_chrome_user_token(self, source: Path, profile: str) -> str | None:
|
|
310
|
+
directory = source / profile / "Local Storage" / "leveldb"
|
|
311
|
+
files: list[tuple[int, str, Path]] = []
|
|
312
|
+
try:
|
|
313
|
+
for path in directory.iterdir():
|
|
314
|
+
if path.is_file() and path.suffix.lower() in {".ldb", ".log"}:
|
|
315
|
+
try:
|
|
316
|
+
files.append((path.stat().st_mtime_ns, path.name, path))
|
|
317
|
+
except OSError:
|
|
318
|
+
continue
|
|
319
|
+
except OSError:
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
token = None
|
|
323
|
+
for _, _, path in sorted(files):
|
|
324
|
+
try:
|
|
325
|
+
data = path.read_bytes()
|
|
326
|
+
except OSError:
|
|
327
|
+
continue
|
|
328
|
+
found = self._extract_user_token(data)
|
|
329
|
+
if found is not None:
|
|
330
|
+
token = found
|
|
331
|
+
return token
|
|
332
|
+
|
|
333
|
+
def _sync_chrome_session(self) -> str | None:
|
|
334
|
+
self._chrome_login_token = None
|
|
335
|
+
configured = self.cfg.chrome_user_data_dir.strip()
|
|
336
|
+
if not configured:
|
|
337
|
+
return None
|
|
338
|
+
source = Path(configured).expanduser()
|
|
339
|
+
if not source.is_dir():
|
|
340
|
+
return None
|
|
341
|
+
profile = self._chrome_profile_name(source)
|
|
342
|
+
if not (source / profile).is_dir():
|
|
343
|
+
return None
|
|
344
|
+
target = Path(self.cfg.profile_dir)
|
|
345
|
+
if source.resolve() == target.resolve():
|
|
346
|
+
raise BrowserError(
|
|
347
|
+
"profile_dir must be separate from chrome_user_data_dir; dscli uses an "
|
|
348
|
+
"isolated copy instead of automating Chrome's live profile."
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Chrome 136+ blocks DevTools automation on its live default profile.
|
|
352
|
+
self._chrome_login_token = self._read_chrome_user_token(source, profile)
|
|
353
|
+
try:
|
|
354
|
+
candidates = self._chrome_session_files(source, profile)
|
|
355
|
+
except OSError as exc:
|
|
356
|
+
raise BrowserError(f"Could not read Chrome's session files: {exc}") from exc
|
|
357
|
+
files: list[Path] = []
|
|
358
|
+
signature: list[list[Any]] = []
|
|
359
|
+
for path in candidates:
|
|
360
|
+
try:
|
|
361
|
+
stat = path.stat()
|
|
362
|
+
except OSError:
|
|
363
|
+
continue
|
|
364
|
+
files.append(path)
|
|
365
|
+
signature.append([str(path.relative_to(source)), stat.st_size, stat.st_mtime_ns])
|
|
366
|
+
state = {"source": str(source), "profile": profile, "files": signature}
|
|
367
|
+
self._chrome_session = f"{profile} ({source})"
|
|
368
|
+
marker = target / ".chrome-session.json"
|
|
369
|
+
try:
|
|
370
|
+
previous = json.loads(marker.read_text(encoding="utf-8"))
|
|
371
|
+
except (OSError, json.JSONDecodeError):
|
|
372
|
+
previous = None
|
|
373
|
+
previous_files = {}
|
|
374
|
+
already_copied = set()
|
|
375
|
+
if (
|
|
376
|
+
isinstance(previous, dict)
|
|
377
|
+
and previous.get("source") == str(source)
|
|
378
|
+
and previous.get("profile") == profile
|
|
379
|
+
):
|
|
380
|
+
previous_files = {
|
|
381
|
+
entry[0]: entry[1:]
|
|
382
|
+
for entry in previous.get("files", [])
|
|
383
|
+
if isinstance(entry, list) and len(entry) == 3
|
|
384
|
+
}
|
|
385
|
+
copied_value = previous.get("copied", [])
|
|
386
|
+
if isinstance(copied_value, list):
|
|
387
|
+
already_copied = {entry for entry in copied_value if isinstance(entry, str)}
|
|
388
|
+
|
|
389
|
+
copied: set[str] = set()
|
|
390
|
+
pending: list[tuple[Path, str]] = []
|
|
391
|
+
for path, entry in zip(files, signature):
|
|
392
|
+
relative = entry[0]
|
|
393
|
+
if relative in already_copied and previous_files.get(relative) == entry[1:]:
|
|
394
|
+
copied.add(relative)
|
|
395
|
+
else:
|
|
396
|
+
pending.append((path, relative))
|
|
397
|
+
if not pending:
|
|
398
|
+
return profile
|
|
399
|
+
|
|
400
|
+
imported = False
|
|
401
|
+
try:
|
|
402
|
+
for path, relative in pending:
|
|
403
|
+
destination = target / relative
|
|
404
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
405
|
+
try:
|
|
406
|
+
shutil.copy2(path, destination)
|
|
407
|
+
copied.add(relative)
|
|
408
|
+
imported = True
|
|
409
|
+
except PermissionError:
|
|
410
|
+
log.debug("Chrome has this session file open; skipping it: %s", path)
|
|
411
|
+
state["copied"] = sorted(copied)
|
|
412
|
+
state["complete"] = len(copied) == len(files)
|
|
413
|
+
temporary = marker.with_suffix(".tmp")
|
|
414
|
+
temporary.write_text(json.dumps(state), encoding="utf-8")
|
|
415
|
+
os.replace(temporary, marker)
|
|
416
|
+
except OSError as exc:
|
|
417
|
+
raise BrowserError(
|
|
418
|
+
f"Could not copy Chrome's session into dscli's profile: {exc}. "
|
|
419
|
+
"Close other dscli browser windows and try again."
|
|
420
|
+
) from exc
|
|
421
|
+
if imported:
|
|
422
|
+
log.debug("Reusing the saved DeepSeek session from Chrome's %s profile.", profile)
|
|
423
|
+
return profile
|
|
424
|
+
|
|
425
|
+
def _resolve_user_agent(self, channel: str | None, *, bundled: bool = False) -> str | None:
|
|
426
|
+
"""Return a normal Chrome UA for headless runs.
|
|
427
|
+
|
|
428
|
+
CloudFront rejects the ``HeadlessChrome/...`` user agent with a 403
|
|
429
|
+
before the DeepSeek app even loads. Swap in the installed browser's
|
|
430
|
+
real major version so headless runs look like a regular Chrome window.
|
|
431
|
+
"""
|
|
432
|
+
if self.cfg.user_agent.strip():
|
|
433
|
+
return self.cfg.user_agent.strip()
|
|
434
|
+
if not self.headless:
|
|
435
|
+
return None
|
|
436
|
+
version: str | None = None
|
|
437
|
+
if not bundled:
|
|
438
|
+
version = _installed_version(channel)
|
|
439
|
+
if not version and self._pw is not None:
|
|
440
|
+
try:
|
|
441
|
+
exe = self._pw.chromium.executable_path
|
|
442
|
+
except (AttributeError, PlaywrightError):
|
|
443
|
+
exe = None
|
|
444
|
+
if exe:
|
|
445
|
+
version = _version_from_executable(Path(exe))
|
|
446
|
+
match = re.match(r"(\d+)", version or "")
|
|
447
|
+
major = match.group(1) if match else "131"
|
|
448
|
+
ua = _browser_user_agent(major, channel)
|
|
449
|
+
log.debug("Using headless user agent: %s", ua)
|
|
450
|
+
return ua
|
|
451
|
+
|
|
452
|
+
def start(self) -> None:
|
|
453
|
+
if self.context is not None:
|
|
454
|
+
return
|
|
455
|
+
Path(self.cfg.profile_dir).mkdir(parents=True, exist_ok=True)
|
|
456
|
+
debug_dir().mkdir(parents=True, exist_ok=True)
|
|
457
|
+
chrome_profile = self._sync_chrome_session()
|
|
458
|
+
self._pw = sync_playwright().start()
|
|
459
|
+
|
|
460
|
+
args = [
|
|
461
|
+
"--disable-blink-features=AutomationControlled",
|
|
462
|
+
"--no-first-run",
|
|
463
|
+
"--no-default-browser-check",
|
|
464
|
+
]
|
|
465
|
+
if chrome_profile:
|
|
466
|
+
args.append(f"--profile-directory={chrome_profile}")
|
|
467
|
+
launch_kwargs: dict[str, Any] = {
|
|
468
|
+
"user_data_dir": self.cfg.profile_dir,
|
|
469
|
+
"headless": self.headless,
|
|
470
|
+
"locale": self.cfg.locale,
|
|
471
|
+
"viewport": {"width": 1440, "height": 960},
|
|
472
|
+
"args": args,
|
|
473
|
+
"ignore_default_args": ["--enable-automation"],
|
|
474
|
+
}
|
|
475
|
+
context: BrowserContext | None = None
|
|
476
|
+
try:
|
|
477
|
+
if self.cfg.channel:
|
|
478
|
+
try:
|
|
479
|
+
ua = self._resolve_user_agent(self.cfg.channel)
|
|
480
|
+
if ua:
|
|
481
|
+
launch_kwargs["user_agent"] = ua
|
|
482
|
+
context = self._pw.chromium.launch_persistent_context(
|
|
483
|
+
channel=self.cfg.channel, **launch_kwargs
|
|
484
|
+
)
|
|
485
|
+
except PlaywrightError as exc:
|
|
486
|
+
log.debug("Could not launch channel %s: %s", self.cfg.channel, exc)
|
|
487
|
+
log.debug(
|
|
488
|
+
"Could not launch installed Chrome; falling back to Playwright Chromium."
|
|
489
|
+
)
|
|
490
|
+
launch_kwargs.pop("user_agent", None)
|
|
491
|
+
if context is None:
|
|
492
|
+
ua = self._resolve_user_agent(None, bundled=True)
|
|
493
|
+
if ua:
|
|
494
|
+
launch_kwargs["user_agent"] = ua
|
|
495
|
+
context = self._pw.chromium.launch_persistent_context(**launch_kwargs)
|
|
496
|
+
except PlaywrightError as exc:
|
|
497
|
+
self._stop_pw()
|
|
498
|
+
message = str(exc)
|
|
499
|
+
if "ProcessSingleton" in message or "already in use" in message.lower():
|
|
500
|
+
raise BrowserError(
|
|
501
|
+
"The dscli browser profile is already in use. Close any other dscli "
|
|
502
|
+
"browser window and try again."
|
|
503
|
+
) from exc
|
|
504
|
+
if "Executable doesn't exist" in message or "playwright install" in message:
|
|
505
|
+
raise BrowserError(
|
|
506
|
+
"Playwright's browser is not installed. Run: python -m playwright install chromium"
|
|
507
|
+
) from exc
|
|
508
|
+
raise BrowserError(f"Could not start the browser: {message}") from exc
|
|
509
|
+
|
|
510
|
+
self.context = context
|
|
511
|
+
context.set_default_timeout(self.cfg.page_timeout_ms)
|
|
512
|
+
context.add_init_script(
|
|
513
|
+
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined});"
|
|
514
|
+
)
|
|
515
|
+
if self._chrome_login_token:
|
|
516
|
+
stored_value = json.dumps(
|
|
517
|
+
{"value": self._chrome_login_token, "__version": "0"},
|
|
518
|
+
separators=(",", ":"),
|
|
519
|
+
)
|
|
520
|
+
context.add_init_script(
|
|
521
|
+
"""
|
|
522
|
+
(() => {
|
|
523
|
+
try {
|
|
524
|
+
if (location.hostname !== "chat.deepseek.com") return;
|
|
525
|
+
const current = localStorage.getItem("userToken");
|
|
526
|
+
if (!current || current === "null") {
|
|
527
|
+
localStorage.setItem("userToken", %s);
|
|
528
|
+
}
|
|
529
|
+
} catch (e) {}
|
|
530
|
+
})();
|
|
531
|
+
"""
|
|
532
|
+
% json.dumps(stored_value)
|
|
533
|
+
)
|
|
534
|
+
self.page = context.new_page()
|
|
535
|
+
self.page.set_default_navigation_timeout(60_000)
|
|
536
|
+
self.page.on("dialog", self._dismiss_dialog)
|
|
537
|
+
try:
|
|
538
|
+
self.page.goto(self.cfg.url, wait_until="domcontentloaded", timeout=60_000)
|
|
539
|
+
except PlaywrightTimeoutError:
|
|
540
|
+
log.debug("The DeepSeek page took a long time to load; continuing.")
|
|
541
|
+
self.page.wait_for_timeout(1200)
|
|
542
|
+
|
|
543
|
+
def _dismiss_dialog(self, dialog) -> None:
|
|
544
|
+
try:
|
|
545
|
+
dialog.dismiss()
|
|
546
|
+
except PlaywrightError:
|
|
547
|
+
pass
|
|
548
|
+
|
|
549
|
+
def _stop_pw(self) -> None:
|
|
550
|
+
if self._pw is not None:
|
|
551
|
+
try:
|
|
552
|
+
self._pw.stop()
|
|
553
|
+
except Exception:
|
|
554
|
+
pass
|
|
555
|
+
self._pw = None
|
|
556
|
+
|
|
557
|
+
def close(self) -> None:
|
|
558
|
+
context = self.context
|
|
559
|
+
self.context = None
|
|
560
|
+
self.page = None
|
|
561
|
+
if context is not None:
|
|
562
|
+
try:
|
|
563
|
+
context.close()
|
|
564
|
+
except Exception:
|
|
565
|
+
pass
|
|
566
|
+
self._stop_pw()
|
|
567
|
+
|
|
568
|
+
# --------------------------------------------------------------- selectors
|
|
569
|
+
def _locator_visible(self, page: Page, selector: str) -> bool:
|
|
570
|
+
try:
|
|
571
|
+
loc = page.locator(selector)
|
|
572
|
+
if loc.count() == 0:
|
|
573
|
+
return False
|
|
574
|
+
return loc.first.is_visible()
|
|
575
|
+
except PlaywrightError:
|
|
576
|
+
return False
|
|
577
|
+
|
|
578
|
+
def _find_composer(self, *, pages: list[Page] | None = None) -> Any | None:
|
|
579
|
+
if self.context is None:
|
|
580
|
+
return None
|
|
581
|
+
candidates = pages or [p for p in self.context.pages if not p.is_closed()]
|
|
582
|
+
# Prefer pages already showing the chat app.
|
|
583
|
+
candidates = sorted(
|
|
584
|
+
candidates,
|
|
585
|
+
key=lambda p: (self.cfg.url.split("//")[-1].split("/")[0] not in (p.url or ""),),
|
|
586
|
+
)
|
|
587
|
+
for page in candidates:
|
|
588
|
+
if page.is_closed():
|
|
589
|
+
continue
|
|
590
|
+
for selector in (self.selectors["composer"], self.selectors["composer_fallback"]):
|
|
591
|
+
try:
|
|
592
|
+
loc = page.locator(selector)
|
|
593
|
+
count = min(loc.count(), 5)
|
|
594
|
+
for i in range(count):
|
|
595
|
+
item = loc.nth(i)
|
|
596
|
+
if item.is_visible():
|
|
597
|
+
self.page = page
|
|
598
|
+
return item
|
|
599
|
+
except PlaywrightError:
|
|
600
|
+
continue
|
|
601
|
+
return None
|
|
602
|
+
|
|
603
|
+
def _composer_css(self) -> str:
|
|
604
|
+
return f"{self.selectors['composer']}, {self.selectors['composer_fallback']}"
|
|
605
|
+
|
|
606
|
+
def _send_button(self) -> Any | None:
|
|
607
|
+
if self.page is None:
|
|
608
|
+
return None
|
|
609
|
+
try:
|
|
610
|
+
loc = self.page.locator(self.selectors["send_button"])
|
|
611
|
+
for i in range(min(loc.count(), 5)):
|
|
612
|
+
item = loc.nth(i)
|
|
613
|
+
if item.is_visible():
|
|
614
|
+
return item
|
|
615
|
+
except PlaywrightError:
|
|
616
|
+
pass
|
|
617
|
+
return None
|
|
618
|
+
|
|
619
|
+
def _assistant_count(self) -> int:
|
|
620
|
+
if self.page is None:
|
|
621
|
+
return 0
|
|
622
|
+
try:
|
|
623
|
+
return self.page.locator(self.selectors["assistant_message"]).count()
|
|
624
|
+
except PlaywrightError:
|
|
625
|
+
return 0
|
|
626
|
+
|
|
627
|
+
def _cloudflare_visible(self) -> bool:
|
|
628
|
+
if self.context is None:
|
|
629
|
+
return False
|
|
630
|
+
for page in [p for p in self.context.pages if not p.is_closed()]:
|
|
631
|
+
if self._locator_visible(page, self.selectors["cloudflare"]):
|
|
632
|
+
return True
|
|
633
|
+
return False
|
|
634
|
+
|
|
635
|
+
# ------------------------------------------------------------------- login
|
|
636
|
+
def wait_for_composer(
|
|
637
|
+
self, timeout_sec: float = 15, *, interactive: bool = False
|
|
638
|
+
) -> None:
|
|
639
|
+
start = time.monotonic()
|
|
640
|
+
announced_login = False
|
|
641
|
+
announced_cf = False
|
|
642
|
+
modal_checked = False
|
|
643
|
+
while time.monotonic() - start < timeout_sec:
|
|
644
|
+
if self.context is None:
|
|
645
|
+
raise BrowserError("The browser is not running.")
|
|
646
|
+
if self._find_composer() is not None:
|
|
647
|
+
return
|
|
648
|
+
if self._cloudflare_visible():
|
|
649
|
+
if self.headless:
|
|
650
|
+
raise HumanCheckRequired(
|
|
651
|
+
"DeepSeek is showing a Cloudflare human check. Rerun with --headed, "
|
|
652
|
+
"complete the check once, then retry. The login profile is preserved."
|
|
653
|
+
)
|
|
654
|
+
if not announced_cf:
|
|
655
|
+
log.warning(
|
|
656
|
+
"DeepSeek is showing a Cloudflare / human check. Complete it in the "
|
|
657
|
+
"browser window; waiting..."
|
|
658
|
+
)
|
|
659
|
+
announced_cf = True
|
|
660
|
+
else:
|
|
661
|
+
if interactive and not announced_login:
|
|
662
|
+
log.info(
|
|
663
|
+
"Log in to DeepSeek in the browser window (including any Cloudflare "
|
|
664
|
+
"check). Waiting for the chat composer..."
|
|
665
|
+
)
|
|
666
|
+
announced_login = True
|
|
667
|
+
if not modal_checked and self.page is not None:
|
|
668
|
+
modal_checked = True
|
|
669
|
+
self._dismiss_common_modals()
|
|
670
|
+
time.sleep(0.5)
|
|
671
|
+
|
|
672
|
+
if self.headless:
|
|
673
|
+
raise LoginRequired(
|
|
674
|
+
"Not logged in to DeepSeek. Run `dscli login` once, or rerun with --headed. "
|
|
675
|
+
f"Profile: {self.cfg.profile_dir}"
|
|
676
|
+
)
|
|
677
|
+
raise LoginRequired(
|
|
678
|
+
"The DeepSeek chat composer did not appear. Make sure you are logged in and have "
|
|
679
|
+
"passed any Cloudflare check, then try again."
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
def import_chrome_login(self, timeout_sec: float = 30.0) -> bool:
|
|
683
|
+
configured = self.cfg.chrome_user_data_dir.strip()
|
|
684
|
+
if not configured:
|
|
685
|
+
return False
|
|
686
|
+
source = Path(configured).expanduser()
|
|
687
|
+
if not source.is_dir():
|
|
688
|
+
return False
|
|
689
|
+
try:
|
|
690
|
+
profile = self._chrome_profile_name(source)
|
|
691
|
+
except BrowserError:
|
|
692
|
+
return False
|
|
693
|
+
if not (source / profile).is_dir():
|
|
694
|
+
return False
|
|
695
|
+
token = self._read_chrome_user_token(source, profile)
|
|
696
|
+
self._chrome_login_token = token
|
|
697
|
+
page = self.page
|
|
698
|
+
if token is None or page is None or page.is_closed():
|
|
699
|
+
return False
|
|
700
|
+
|
|
701
|
+
value = json.dumps({"value": token, "__version": "0"}, separators=(",", ":"))
|
|
702
|
+
try:
|
|
703
|
+
if "chat.deepseek.com" not in (page.url or ""):
|
|
704
|
+
page.goto(self.cfg.url, wait_until="domcontentloaded", timeout=60_000)
|
|
705
|
+
page.evaluate(
|
|
706
|
+
"(value) => localStorage.setItem('userToken', value)", value
|
|
707
|
+
)
|
|
708
|
+
page.goto(self.cfg.url, wait_until="domcontentloaded", timeout=60_000)
|
|
709
|
+
page.wait_for_timeout(1000)
|
|
710
|
+
self.wait_for_composer(timeout_sec, interactive=False)
|
|
711
|
+
except (PlaywrightError, LoginRequired, HumanCheckRequired) as exc:
|
|
712
|
+
log.debug("Could not use Chrome's DeepSeek login: %s", exc)
|
|
713
|
+
return False
|
|
714
|
+
log.debug("Using the DeepSeek login from Chrome's %s profile.", profile)
|
|
715
|
+
return True
|
|
716
|
+
|
|
717
|
+
def _dismiss_common_modals(self) -> None:
|
|
718
|
+
if self.page is None or self.page.is_closed():
|
|
719
|
+
return
|
|
720
|
+
for name in ("Accept all", "Accept", "I agree", "Got it"):
|
|
721
|
+
try:
|
|
722
|
+
btn = self.page.get_by_role("button", name=name, exact=True)
|
|
723
|
+
if btn.count() and btn.first.is_visible():
|
|
724
|
+
btn.first.click(timeout=1500)
|
|
725
|
+
self.page.wait_for_timeout(250)
|
|
726
|
+
return
|
|
727
|
+
except PlaywrightError:
|
|
728
|
+
continue
|
|
729
|
+
|
|
730
|
+
def login(self) -> None:
|
|
731
|
+
self.headless = False
|
|
732
|
+
self.start()
|
|
733
|
+
self.wait_for_composer(self.cfg.login_timeout_sec, interactive=True)
|
|
734
|
+
log.info("Logged in. Profile saved at %s", self.cfg.profile_dir)
|
|
735
|
+
|
|
736
|
+
# ------------------------------------------------------------------ chats
|
|
737
|
+
def new_chat(self) -> None:
|
|
738
|
+
if self.context is None:
|
|
739
|
+
raise BrowserError("Browser not started.")
|
|
740
|
+
self._find_composer()
|
|
741
|
+
if self.page is None:
|
|
742
|
+
raise LoginRequired("No DeepSeek page is available.")
|
|
743
|
+
if self._cloudflare_visible():
|
|
744
|
+
raise HumanCheckRequired(
|
|
745
|
+
"Cloudflare check is blocking the chat. Rerun with --headed and complete it."
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
before_url = self.page.url
|
|
749
|
+
self.page.goto(self.cfg.url, wait_until="domcontentloaded", timeout=60_000)
|
|
750
|
+
self.page.wait_for_timeout(1500)
|
|
751
|
+
self._dismiss_common_modals()
|
|
752
|
+
if self._find_composer() is None:
|
|
753
|
+
raise LoginRequired(
|
|
754
|
+
"The DeepSeek composer is not visible. Run `dscli login` and try again."
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
if self._assistant_count() > 0 or "/a/chat/s/" in (self.page.url or ""):
|
|
758
|
+
clicked = self._click_new_chat()
|
|
759
|
+
if not clicked:
|
|
760
|
+
log.debug("New chat control not found by text; reloading the home page.")
|
|
761
|
+
deadline = time.monotonic() + 12
|
|
762
|
+
while time.monotonic() < deadline:
|
|
763
|
+
if self._assistant_count() == 0:
|
|
764
|
+
break
|
|
765
|
+
time.sleep(0.4)
|
|
766
|
+
if self._assistant_count() > 0:
|
|
767
|
+
# One more attempt: click rather than reload, so we do not land
|
|
768
|
+
# back on the previous conversation.
|
|
769
|
+
self._click_new_chat()
|
|
770
|
+
deadline = time.monotonic() + 8
|
|
771
|
+
while time.monotonic() < deadline:
|
|
772
|
+
if self._assistant_count() == 0:
|
|
773
|
+
break
|
|
774
|
+
time.sleep(0.4)
|
|
775
|
+
if self._assistant_count() > 0:
|
|
776
|
+
raise BrowserError(
|
|
777
|
+
"Could not start a new chat; the previous conversation is still visible. "
|
|
778
|
+
"Run `dscli doctor --headed` to inspect the UI."
|
|
779
|
+
)
|
|
780
|
+
log.debug("New chat ready at %s (was %s)", self.page.url, before_url)
|
|
781
|
+
|
|
782
|
+
def _click_new_chat(self) -> bool:
|
|
783
|
+
if self.page is None:
|
|
784
|
+
return False
|
|
785
|
+
# The button is a div[tabindex] with a span child; conversation links
|
|
786
|
+
# are <a> elements and must not be clicked by mistake.
|
|
787
|
+
labels = ("New chat", "New Chat", "新对话")
|
|
788
|
+
pattern = re.compile(r"^\s*(?:" + "|".join(re.escape(x) for x in labels) + r")\s*$")
|
|
789
|
+
try:
|
|
790
|
+
loc = self.page.locator('div[tabindex="0"]').filter(has_text=pattern)
|
|
791
|
+
for i in range(min(loc.count(), 4)):
|
|
792
|
+
item = loc.nth(i)
|
|
793
|
+
if item.is_visible():
|
|
794
|
+
item.click(timeout=3000)
|
|
795
|
+
self.page.wait_for_timeout(700)
|
|
796
|
+
return True
|
|
797
|
+
except PlaywrightError:
|
|
798
|
+
pass
|
|
799
|
+
for label in labels:
|
|
800
|
+
try:
|
|
801
|
+
loc = self.page.get_by_text(label, exact=True)
|
|
802
|
+
for i in range(min(loc.count(), 4)):
|
|
803
|
+
item = loc.nth(i)
|
|
804
|
+
if item.is_visible():
|
|
805
|
+
item.click(timeout=3000)
|
|
806
|
+
self.page.wait_for_timeout(700)
|
|
807
|
+
return True
|
|
808
|
+
except PlaywrightError:
|
|
809
|
+
continue
|
|
810
|
+
# A JS fallback for a control that Playwright considers covered.
|
|
811
|
+
try:
|
|
812
|
+
return bool(
|
|
813
|
+
self.page.evaluate(
|
|
814
|
+
"""(labels) => {
|
|
815
|
+
const wanted = labels.map(s => s.trim().toUpperCase());
|
|
816
|
+
const nodes = Array.from(document.querySelectorAll('div[tabindex],button'));
|
|
817
|
+
const node = nodes.find(n => {
|
|
818
|
+
if (!n || !n.innerText) return false;
|
|
819
|
+
const t = n.innerText.trim().toUpperCase();
|
|
820
|
+
if (!wanted.includes(t)) return false;
|
|
821
|
+
const r = n.getBoundingClientRect();
|
|
822
|
+
return r.width > 0 && r.height > 0;
|
|
823
|
+
});
|
|
824
|
+
if (!node) return false;
|
|
825
|
+
node.click();
|
|
826
|
+
return true;
|
|
827
|
+
}""",
|
|
828
|
+
list(labels),
|
|
829
|
+
)
|
|
830
|
+
)
|
|
831
|
+
except PlaywrightError:
|
|
832
|
+
return False
|
|
833
|
+
|
|
834
|
+
def set_modes(self) -> None:
|
|
835
|
+
if self.page is None:
|
|
836
|
+
return
|
|
837
|
+
for label, desired in (("DeepThink", self.cfg.deepthink), ("Search", self.cfg.search)):
|
|
838
|
+
try:
|
|
839
|
+
loc = self.page.locator(self.selectors["toggle"], has_text=label)
|
|
840
|
+
if loc.count() == 0:
|
|
841
|
+
log.debug("Toggle %s not found; leaving it as-is.", label)
|
|
842
|
+
continue
|
|
843
|
+
toggle = loc.first
|
|
844
|
+
if not toggle.is_visible():
|
|
845
|
+
continue
|
|
846
|
+
current = (toggle.get_attribute("aria-pressed") or "").lower() == "true"
|
|
847
|
+
if current != desired:
|
|
848
|
+
toggle.click(timeout=3000)
|
|
849
|
+
self.page.wait_for_timeout(300)
|
|
850
|
+
log.debug("%s -> %s", label, "on" if desired else "off")
|
|
851
|
+
except PlaywrightError as exc:
|
|
852
|
+
log.debug("Could not set %s: %s", label, exc)
|
|
853
|
+
|
|
854
|
+
# -------------------------------------------------------------- attachments
|
|
855
|
+
def attach_files(self, paths: list[Path]) -> None:
|
|
856
|
+
if not paths:
|
|
857
|
+
return
|
|
858
|
+
if self.page is None:
|
|
859
|
+
raise UploadError("Browser page is not available.")
|
|
860
|
+
try:
|
|
861
|
+
input_loc = self.page.locator(self.selectors["file_input"]).first
|
|
862
|
+
input_loc.wait_for(state="attached", timeout=10_000)
|
|
863
|
+
except PlaywrightTimeoutError as exc:
|
|
864
|
+
raise UploadError(
|
|
865
|
+
"The chat file-upload input is not present. DeepSeek may have changed its UI."
|
|
866
|
+
) from exc
|
|
867
|
+
|
|
868
|
+
accept = (input_loc.get_attribute("accept") or "").lower()
|
|
869
|
+
tokens = {t.strip() for t in accept.split(",") if t.strip()}
|
|
870
|
+
for path in paths:
|
|
871
|
+
if tokens and path.suffix.lower() and path.suffix.lower() not in tokens:
|
|
872
|
+
log.warning(
|
|
873
|
+
"%s may not be an accepted attachment type by the chat UI; trying anyway.",
|
|
874
|
+
path.name,
|
|
875
|
+
)
|
|
876
|
+
try:
|
|
877
|
+
input_loc.set_input_files([str(p) for p in paths])
|
|
878
|
+
except PlaywrightError as exc:
|
|
879
|
+
raise UploadError(f"Could not attach files: {exc}") from exc
|
|
880
|
+
|
|
881
|
+
settle = max(2.0, self.cfg.upload_settle_seconds)
|
|
882
|
+
if sum(p.stat().st_size for p in paths) > 5 * 1024 * 1024:
|
|
883
|
+
settle += 3.0
|
|
884
|
+
deadline = time.monotonic() + settle
|
|
885
|
+
while time.monotonic() < deadline:
|
|
886
|
+
error = self._error_toast()
|
|
887
|
+
if error:
|
|
888
|
+
raise UploadError(f"The chat UI rejected an attachment: {error}")
|
|
889
|
+
time.sleep(0.4)
|
|
890
|
+
log.debug("Attached %d file(s)", len(paths))
|
|
891
|
+
|
|
892
|
+
# ------------------------------------------------------------------- send
|
|
893
|
+
def _wait_until_idle(self, timeout_sec: float = 6) -> None:
|
|
894
|
+
deadline = time.monotonic() + timeout_sec
|
|
895
|
+
while time.monotonic() < deadline:
|
|
896
|
+
try:
|
|
897
|
+
snap = self._snapshot()
|
|
898
|
+
except PlaywrightError:
|
|
899
|
+
return
|
|
900
|
+
generating = snap.get("stop") or (
|
|
901
|
+
snap.get("composerEmpty") and snap.get("sendPresent") and not snap.get("sendDisabled")
|
|
902
|
+
)
|
|
903
|
+
if not generating:
|
|
904
|
+
return
|
|
905
|
+
time.sleep(0.3)
|
|
906
|
+
raise ChatError(
|
|
907
|
+
"DeepSeek is still generating a response. Wait for it to finish before sending "
|
|
908
|
+
"another message."
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
def _fill_composer(self, composer, text: str) -> None:
|
|
912
|
+
composer.click()
|
|
913
|
+
composer.fill("")
|
|
914
|
+
composer.fill(text)
|
|
915
|
+
try:
|
|
916
|
+
value = composer.input_value()
|
|
917
|
+
except PlaywrightError:
|
|
918
|
+
value = ""
|
|
919
|
+
if value != text:
|
|
920
|
+
if self.page is None:
|
|
921
|
+
raise ChatError("Browser page is not available.")
|
|
922
|
+
self.page.evaluate(
|
|
923
|
+
"""(args) => {
|
|
924
|
+
const el = document.querySelector(args.sel);
|
|
925
|
+
if (!el) return;
|
|
926
|
+
const proto = el instanceof HTMLTextAreaElement
|
|
927
|
+
? HTMLTextAreaElement.prototype : el.constructor.prototype;
|
|
928
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
929
|
+
if (setter && setter.set) setter.set.call(el, args.text);
|
|
930
|
+
else el.value = args.text;
|
|
931
|
+
el.dispatchEvent(new Event('input', {bubbles: true}));
|
|
932
|
+
el.dispatchEvent(new Event('change', {bubbles: true}));
|
|
933
|
+
}""",
|
|
934
|
+
{"sel": self._composer_css(), "text": text},
|
|
935
|
+
)
|
|
936
|
+
composer.click()
|
|
937
|
+
value = composer.input_value()
|
|
938
|
+
if value != text:
|
|
939
|
+
raise ChatError(
|
|
940
|
+
f"The composer did not accept the full message "
|
|
941
|
+
f"({len(value)} of {len(text)} characters). It may exceed the UI limit."
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
def send(self, text: str, files: list[Path] | None = None) -> SendReceipt:
|
|
945
|
+
if self.context is None:
|
|
946
|
+
raise BrowserError("Browser not started.")
|
|
947
|
+
composer = self._find_composer()
|
|
948
|
+
if composer is None:
|
|
949
|
+
raise LoginRequired("The DeepSeek composer disappeared; the session may have expired.")
|
|
950
|
+
if self._cloudflare_visible():
|
|
951
|
+
raise HumanCheckRequired(
|
|
952
|
+
"Cloudflare is blocking the chat. Rerun with --headed to complete the check."
|
|
953
|
+
)
|
|
954
|
+
self._wait_until_idle()
|
|
955
|
+
if files:
|
|
956
|
+
self.attach_files(files)
|
|
957
|
+
|
|
958
|
+
composer = self._find_composer()
|
|
959
|
+
if composer is None:
|
|
960
|
+
raise LoginRequired("The DeepSeek composer disappeared before sending.")
|
|
961
|
+
self._fill_composer(composer, text)
|
|
962
|
+
|
|
963
|
+
snapshot = self._snapshot()
|
|
964
|
+
page = self.page
|
|
965
|
+
if page is None:
|
|
966
|
+
raise ChatError("Browser page is not available.")
|
|
967
|
+
page.evaluate(MARK_JS, {"assistant": self.selectors["assistant_message"]})
|
|
968
|
+
receipt = SendReceipt(
|
|
969
|
+
prev_key=snapshot.get("key"),
|
|
970
|
+
prev_count=int(snapshot.get("count") or 0),
|
|
971
|
+
prev_preview=snapshot.get("preview") or "",
|
|
972
|
+
sent_at=time.monotonic(),
|
|
973
|
+
url=page.url,
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
deadline = time.monotonic() + 20
|
|
977
|
+
button = self._send_button()
|
|
978
|
+
while time.monotonic() < deadline:
|
|
979
|
+
button = self._send_button()
|
|
980
|
+
if button is not None:
|
|
981
|
+
try:
|
|
982
|
+
disabled = "ds-button--disabled" in (button.get_attribute("class") or "")
|
|
983
|
+
except PlaywrightError:
|
|
984
|
+
disabled = True
|
|
985
|
+
if not disabled:
|
|
986
|
+
break
|
|
987
|
+
error = self._error_toast()
|
|
988
|
+
if error:
|
|
989
|
+
raise ChatError(f"DeepSeek reported an error before sending: {error}")
|
|
990
|
+
time.sleep(0.2)
|
|
991
|
+
else:
|
|
992
|
+
raise ChatError(
|
|
993
|
+
"The send button did not become enabled. The attachment may still be uploading, "
|
|
994
|
+
"or the chat UI has changed."
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
try:
|
|
998
|
+
button.click(timeout=5000)
|
|
999
|
+
except PlaywrightError:
|
|
1000
|
+
try:
|
|
1001
|
+
composer.press("Enter")
|
|
1002
|
+
except PlaywrightError as exc:
|
|
1003
|
+
raise ChatError(f"Could not send the message: {exc}") from exc
|
|
1004
|
+
|
|
1005
|
+
cleared = False
|
|
1006
|
+
end = time.monotonic() + 12
|
|
1007
|
+
while time.monotonic() < end:
|
|
1008
|
+
try:
|
|
1009
|
+
if not (composer.input_value() or "").strip():
|
|
1010
|
+
cleared = True
|
|
1011
|
+
break
|
|
1012
|
+
except PlaywrightError:
|
|
1013
|
+
break
|
|
1014
|
+
error = self._error_toast()
|
|
1015
|
+
if error:
|
|
1016
|
+
raise ChatError(f"DeepSeek reported an error while sending: {error}")
|
|
1017
|
+
time.sleep(0.2)
|
|
1018
|
+
if not cleared:
|
|
1019
|
+
try:
|
|
1020
|
+
composer.click()
|
|
1021
|
+
composer.press("Enter")
|
|
1022
|
+
end = time.monotonic() + 6
|
|
1023
|
+
while time.monotonic() < end:
|
|
1024
|
+
if not (composer.input_value() or "").strip():
|
|
1025
|
+
cleared = True
|
|
1026
|
+
break
|
|
1027
|
+
time.sleep(0.2)
|
|
1028
|
+
except PlaywrightError:
|
|
1029
|
+
pass
|
|
1030
|
+
if not cleared:
|
|
1031
|
+
raise ChatError(
|
|
1032
|
+
"The message did not send. The composer still contains text; check the chat UI "
|
|
1033
|
+
"with `dscli doctor`."
|
|
1034
|
+
)
|
|
1035
|
+
log.debug("Sent %d characters (new answer key was %s)", len(text), receipt.prev_key)
|
|
1036
|
+
return receipt
|
|
1037
|
+
|
|
1038
|
+
# --------------------------------------------------------------- responses
|
|
1039
|
+
def _snapshot(self) -> dict[str, Any]:
|
|
1040
|
+
if self.page is None:
|
|
1041
|
+
raise ChatError("Browser page is not available.")
|
|
1042
|
+
return self.page.evaluate(
|
|
1043
|
+
SNAPSHOT_JS,
|
|
1044
|
+
{
|
|
1045
|
+
"assistant": self.selectors["assistant_message"],
|
|
1046
|
+
"item": self.selectors["chat_item"],
|
|
1047
|
+
"send": self.selectors["send_button"],
|
|
1048
|
+
"stop": self.selectors["stop"],
|
|
1049
|
+
"composer": self._composer_css(),
|
|
1050
|
+
},
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
def _extract_last_answer(self) -> str:
|
|
1054
|
+
if self.page is None:
|
|
1055
|
+
raise ChatError("Browser page is not available.")
|
|
1056
|
+
data = self.page.evaluate(
|
|
1057
|
+
EXTRACT_LAST_ANSWER_JS, self.selectors["assistant_message"]
|
|
1058
|
+
)
|
|
1059
|
+
if not data:
|
|
1060
|
+
return ""
|
|
1061
|
+
return (data.get("text") or "").strip()
|
|
1062
|
+
|
|
1063
|
+
def scroll_to_bottom(self) -> None:
|
|
1064
|
+
if self.page is None:
|
|
1065
|
+
return
|
|
1066
|
+
try:
|
|
1067
|
+
self.page.evaluate(SCROLL_TO_BOTTOM_JS)
|
|
1068
|
+
except PlaywrightError:
|
|
1069
|
+
pass
|
|
1070
|
+
|
|
1071
|
+
def _toasts(self) -> list[str]:
|
|
1072
|
+
if self.page is None:
|
|
1073
|
+
return []
|
|
1074
|
+
try:
|
|
1075
|
+
return list(self.page.evaluate(TOASTS_JS, self.selectors["notification"]) or [])
|
|
1076
|
+
except PlaywrightError:
|
|
1077
|
+
return []
|
|
1078
|
+
|
|
1079
|
+
def _error_toast(self) -> str | None:
|
|
1080
|
+
for text in self._toasts():
|
|
1081
|
+
if _ERROR_RE.search(text):
|
|
1082
|
+
return text
|
|
1083
|
+
return None
|
|
1084
|
+
|
|
1085
|
+
def wait_for_response(
|
|
1086
|
+
self,
|
|
1087
|
+
receipt: SendReceipt,
|
|
1088
|
+
timeout_sec: float | None = None,
|
|
1089
|
+
*,
|
|
1090
|
+
on_reply_snapshot: Callable[[str], None] | None = None,
|
|
1091
|
+
) -> str:
|
|
1092
|
+
timeout = float(timeout_sec or self.cfg.response_timeout_sec)
|
|
1093
|
+
start = time.monotonic()
|
|
1094
|
+
started = False
|
|
1095
|
+
detected_key: str | None = None
|
|
1096
|
+
started_at = start
|
|
1097
|
+
last_text = ""
|
|
1098
|
+
last_change = start
|
|
1099
|
+
next_extract = 0.0
|
|
1100
|
+
last_notice = start
|
|
1101
|
+
scroll_at = 0.0
|
|
1102
|
+
first_phase_deadline = start + min(
|
|
1103
|
+
float(self.cfg.first_response_timeout_sec), timeout
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
while True:
|
|
1107
|
+
now = time.monotonic()
|
|
1108
|
+
elapsed = now - start
|
|
1109
|
+
if elapsed > timeout:
|
|
1110
|
+
message = (
|
|
1111
|
+
f"DeepSeek did not finish a response within {int(timeout)}s "
|
|
1112
|
+
f"(url: {self.page.url if self.page else '?'})."
|
|
1113
|
+
)
|
|
1114
|
+
raise ChatError(message, screenshot=self.save_screenshot("timeout"))
|
|
1115
|
+
|
|
1116
|
+
try:
|
|
1117
|
+
if now >= scroll_at:
|
|
1118
|
+
self.scroll_to_bottom()
|
|
1119
|
+
scroll_at = now + 1.5
|
|
1120
|
+
snap = self._snapshot()
|
|
1121
|
+
except PlaywrightError as exc:
|
|
1122
|
+
raise ChatError(f"The DeepSeek page became unavailable: {exc}") from exc
|
|
1123
|
+
|
|
1124
|
+
if not started:
|
|
1125
|
+
new_answer = False
|
|
1126
|
+
if receipt.prev_key is not None:
|
|
1127
|
+
new_answer = (
|
|
1128
|
+
snap.get("key") is not None
|
|
1129
|
+
and snap.get("key") != receipt.prev_key
|
|
1130
|
+
)
|
|
1131
|
+
if (
|
|
1132
|
+
not new_answer
|
|
1133
|
+
and receipt.prev_key is not None
|
|
1134
|
+
and int(snap.get("count") or 0) > receipt.prev_count
|
|
1135
|
+
and not snap.get("seen")
|
|
1136
|
+
):
|
|
1137
|
+
new_answer = True
|
|
1138
|
+
if receipt.prev_key is None:
|
|
1139
|
+
new_answer = int(snap.get("count") or 0) > receipt.prev_count
|
|
1140
|
+
if not new_answer and int(snap.get("count") or 0) > 0:
|
|
1141
|
+
new_answer = (
|
|
1142
|
+
not snap.get("seen")
|
|
1143
|
+
and (snap.get("preview") or "") != receipt.prev_preview
|
|
1144
|
+
)
|
|
1145
|
+
if new_answer:
|
|
1146
|
+
started = True
|
|
1147
|
+
detected_key = snap.get("key")
|
|
1148
|
+
started_at = now
|
|
1149
|
+
last_change = now
|
|
1150
|
+
next_extract = 0.0
|
|
1151
|
+
log.debug("Response started (key=%s)", detected_key)
|
|
1152
|
+
else:
|
|
1153
|
+
toast = self._error_toast()
|
|
1154
|
+
if toast:
|
|
1155
|
+
raise ChatError(
|
|
1156
|
+
f"DeepSeek reported an error: {toast}",
|
|
1157
|
+
screenshot=self.save_screenshot("error-toast"),
|
|
1158
|
+
)
|
|
1159
|
+
if now > first_phase_deadline:
|
|
1160
|
+
toasts = " | ".join(self._toasts())
|
|
1161
|
+
message = (
|
|
1162
|
+
"No assistant response appeared within "
|
|
1163
|
+
f"{int(self.cfg.first_response_timeout_sec)}s."
|
|
1164
|
+
)
|
|
1165
|
+
if toasts:
|
|
1166
|
+
message += f" Chat notices: {toasts}"
|
|
1167
|
+
raise ChatError(message, screenshot=self.save_screenshot("no-response"))
|
|
1168
|
+
|
|
1169
|
+
if started:
|
|
1170
|
+
if (
|
|
1171
|
+
detected_key is not None
|
|
1172
|
+
and snap.get("key") is not None
|
|
1173
|
+
and snap.get("key") != detected_key
|
|
1174
|
+
):
|
|
1175
|
+
# A second assistant item for this turn appeared (for
|
|
1176
|
+
# example, reasoning followed by the final answer).
|
|
1177
|
+
log.debug(
|
|
1178
|
+
"Assistant item changed %s -> %s; waiting on the newer one.",
|
|
1179
|
+
detected_key,
|
|
1180
|
+
snap.get("key"),
|
|
1181
|
+
)
|
|
1182
|
+
detected_key = snap.get("key")
|
|
1183
|
+
started_at = now
|
|
1184
|
+
last_text = ""
|
|
1185
|
+
last_change = now
|
|
1186
|
+
next_extract = 0.0
|
|
1187
|
+
|
|
1188
|
+
if now >= next_extract:
|
|
1189
|
+
text = self._extract_last_answer()
|
|
1190
|
+
next_extract = now + 0.55
|
|
1191
|
+
if text != last_text:
|
|
1192
|
+
last_text = text
|
|
1193
|
+
last_change = now
|
|
1194
|
+
if on_reply_snapshot is not None:
|
|
1195
|
+
on_reply_snapshot(text)
|
|
1196
|
+
|
|
1197
|
+
if started and not last_text and now - started_at > 60:
|
|
1198
|
+
raise ChatError(
|
|
1199
|
+
"An assistant message appeared but contained no text after 60s.",
|
|
1200
|
+
screenshot=self.save_screenshot("empty-response"),
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
if last_text:
|
|
1204
|
+
stable_for = now - last_change
|
|
1205
|
+
generating = bool(snap.get("stop")) or (
|
|
1206
|
+
snap.get("composerEmpty")
|
|
1207
|
+
and snap.get("sendPresent")
|
|
1208
|
+
and not snap.get("sendDisabled")
|
|
1209
|
+
)
|
|
1210
|
+
# The Read aloud / copy toolbar appears when DeepSeek has
|
|
1211
|
+
# finished a message. Requiring it (plus a stable stop
|
|
1212
|
+
# button) avoids treating a streaming pause as the end.
|
|
1213
|
+
complete = (
|
|
1214
|
+
stable_for >= self.cfg.stable_seconds
|
|
1215
|
+
and not generating
|
|
1216
|
+
and bool(snap.get("hasActions"))
|
|
1217
|
+
)
|
|
1218
|
+
if complete or stable_for >= self.cfg.fallback_stable_seconds:
|
|
1219
|
+
log.debug(
|
|
1220
|
+
"Response complete: %d chars, stable %.1fs, actions=%s, generating=%s",
|
|
1221
|
+
len(last_text),
|
|
1222
|
+
stable_for,
|
|
1223
|
+
snap.get("hasActions"),
|
|
1224
|
+
generating,
|
|
1225
|
+
)
|
|
1226
|
+
return last_text
|
|
1227
|
+
|
|
1228
|
+
if on_reply_snapshot is None and now - last_notice >= 25:
|
|
1229
|
+
log.info("Still working... (%ds)", int(elapsed))
|
|
1230
|
+
last_notice = now
|
|
1231
|
+
if self.page is not None and self.page.is_closed():
|
|
1232
|
+
raise ChatError("The DeepSeek tab was closed.")
|
|
1233
|
+
time.sleep(0.35)
|
|
1234
|
+
|
|
1235
|
+
def send_and_wait(
|
|
1236
|
+
self,
|
|
1237
|
+
text: str,
|
|
1238
|
+
*,
|
|
1239
|
+
files: list[Path] | None = None,
|
|
1240
|
+
timeout_sec: float | None = None,
|
|
1241
|
+
on_reply_snapshot: Callable[[str], None] | None = None,
|
|
1242
|
+
) -> str:
|
|
1243
|
+
receipt = self.send(text, files=files)
|
|
1244
|
+
return self.wait_for_response(
|
|
1245
|
+
receipt, timeout_sec, on_reply_snapshot=on_reply_snapshot
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
def stop_generation(self, timeout_ms: int = 2000) -> bool:
|
|
1249
|
+
"""Best-effort click on DeepSeek's stop control; never touches send."""
|
|
1250
|
+
if self.page is None or self.page.is_closed():
|
|
1251
|
+
return False
|
|
1252
|
+
try:
|
|
1253
|
+
locator = self.page.locator(self.selectors["stop"])
|
|
1254
|
+
for index in range(min(locator.count(), 5)):
|
|
1255
|
+
item = locator.nth(index)
|
|
1256
|
+
try:
|
|
1257
|
+
if not item.is_visible():
|
|
1258
|
+
continue
|
|
1259
|
+
label = " ".join(
|
|
1260
|
+
filter(
|
|
1261
|
+
None,
|
|
1262
|
+
(
|
|
1263
|
+
item.get_attribute("aria-label"),
|
|
1264
|
+
item.get_attribute("data-testid"),
|
|
1265
|
+
item.get_attribute("title"),
|
|
1266
|
+
),
|
|
1267
|
+
)
|
|
1268
|
+
)
|
|
1269
|
+
if not re.search(r"\bstop\b|停止", label, re.IGNORECASE):
|
|
1270
|
+
continue
|
|
1271
|
+
item.click(timeout=timeout_ms)
|
|
1272
|
+
log.debug("Stopped DeepSeek generation.")
|
|
1273
|
+
return True
|
|
1274
|
+
except PlaywrightError:
|
|
1275
|
+
continue
|
|
1276
|
+
except Exception:
|
|
1277
|
+
return False
|
|
1278
|
+
return False
|
|
1279
|
+
|
|
1280
|
+
# --------------------------------------------------------------- debugging
|
|
1281
|
+
def save_screenshot(self, tag: str) -> str | None:
|
|
1282
|
+
if self.page is None:
|
|
1283
|
+
return None
|
|
1284
|
+
try:
|
|
1285
|
+
target = debug_dir() / f"{time.strftime('%Y%m%d-%H%M%S')}-{tag}.png"
|
|
1286
|
+
self.page.screenshot(path=str(target), full_page=False)
|
|
1287
|
+
log.warning("Saved screenshot: %s", target)
|
|
1288
|
+
return str(target)
|
|
1289
|
+
except Exception:
|
|
1290
|
+
return None
|
|
1291
|
+
|
|
1292
|
+
def save_last_response(self, text: str, tag: str = "response") -> str | None:
|
|
1293
|
+
try:
|
|
1294
|
+
target = debug_dir() / f"{time.strftime('%Y%m%d-%H%M%S')}-{tag}.txt"
|
|
1295
|
+
target.write_text(text, encoding="utf-8")
|
|
1296
|
+
log.debug("Saved raw output: %s", target)
|
|
1297
|
+
return str(target)
|
|
1298
|
+
except OSError:
|
|
1299
|
+
return None
|
|
1300
|
+
|
|
1301
|
+
def diagnostics(self) -> dict[str, Any]:
|
|
1302
|
+
if self.context is None:
|
|
1303
|
+
raise BrowserError("Browser not started.")
|
|
1304
|
+
self._find_composer()
|
|
1305
|
+
if self.page is None:
|
|
1306
|
+
raise BrowserError("No page available.")
|
|
1307
|
+
result = self.page.evaluate(
|
|
1308
|
+
DIAGNOSTICS_JS,
|
|
1309
|
+
{
|
|
1310
|
+
"composer": self._composer_css(),
|
|
1311
|
+
"send": self.selectors["send_button"],
|
|
1312
|
+
"assistant": self.selectors["assistant_message"],
|
|
1313
|
+
"fileInput": self.selectors["file_input"],
|
|
1314
|
+
"cloudflare": self.selectors["cloudflare"],
|
|
1315
|
+
"notification": self.selectors["notification"],
|
|
1316
|
+
"toggle": self.selectors["toggle"],
|
|
1317
|
+
},
|
|
1318
|
+
)
|
|
1319
|
+
toggles = []
|
|
1320
|
+
try:
|
|
1321
|
+
loc = self.page.locator(self.selectors["toggle"])
|
|
1322
|
+
for i in range(loc.count()):
|
|
1323
|
+
item = loc.nth(i)
|
|
1324
|
+
toggles.append(
|
|
1325
|
+
{
|
|
1326
|
+
"text": (item.inner_text() or "").strip(),
|
|
1327
|
+
"pressed": (item.get_attribute("aria-pressed") or "") == "true",
|
|
1328
|
+
}
|
|
1329
|
+
)
|
|
1330
|
+
except PlaywrightError:
|
|
1331
|
+
pass
|
|
1332
|
+
result["toggles"] = toggles
|
|
1333
|
+
result["newChatMatches"] = [
|
|
1334
|
+
label
|
|
1335
|
+
for label in ("New chat", "New Chat", "新对话")
|
|
1336
|
+
if self.page.get_by_text(label, exact=True).count() > 0
|
|
1337
|
+
]
|
|
1338
|
+
result["profile"] = self.cfg.profile_dir
|
|
1339
|
+
result["chrome_session"] = self._chrome_session
|
|
1340
|
+
result["headless"] = self.headless
|
|
1341
|
+
return result
|
|
1342
|
+
|
|
1343
|
+
def test_roundtrip(self) -> str:
|
|
1344
|
+
self.new_chat()
|
|
1345
|
+
self.set_modes()
|
|
1346
|
+
prompt = (
|
|
1347
|
+
"[CONNECTIVITY TEST]\n"
|
|
1348
|
+
"This is a test from the local dscli harness. Reply with exactly one word: pong"
|
|
1349
|
+
)
|
|
1350
|
+
return self.send_and_wait(prompt, timeout_sec=120)
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
# Keep a reference so type checkers do not complain about the unused import.
|
|
1354
|
+
_ = Page
|