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/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