drissionpage-cli 0.1.5__tar.gz → 0.2.0__tar.gz

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.
Files changed (25) hide show
  1. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/PKG-INFO +29 -4
  2. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/README.md +28 -3
  3. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/__init__.py +380 -19
  4. drissionpage_cli-0.2.0/drissionpage_cli/_compat.py +105 -0
  5. drissionpage_cli-0.2.0/drissionpage_cli/_feishu.py +1485 -0
  6. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/SKILL.md +99 -0
  7. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/PKG-INFO +29 -4
  8. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/SOURCES.txt +2 -0
  9. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/entry_points.txt +1 -0
  10. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/pyproject.toml +2 -1
  11. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/tests/test_unit.py +2 -1
  12. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/LICENSE +0 -0
  13. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/__main__.py +0 -0
  14. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/dual-mode.md +0 -0
  15. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/element-locators.md +0 -0
  16. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/network-listening.md +0 -0
  17. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/running-code.md +0 -0
  18. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/screenshots-pdf.md +0 -0
  19. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/session-management.md +0 -0
  20. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/storage-state.md +0 -0
  21. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/dependency_links.txt +0 -0
  22. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/requires.txt +0 -0
  23. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/top_level.txt +0 -0
  24. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/setup.cfg +0 -0
  25. {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/tests/test_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drissionpage-cli
3
- Version: 0.1.5
3
+ Version: 0.2.0
4
4
  Summary: DrissionPage CLI - Token-efficient browser automation for coding agents
5
5
  Author: DrissionPage-MCP Contributors
6
6
  License: Apache-2.0
@@ -75,13 +75,15 @@ cd DrissionPage-MCP/DrissionPage-cli
75
75
  pip install -e .
76
76
  ```
77
77
 
78
- After installation the `drissionpage-cli` command is available globally:
78
+ After installation both `drissionpage-cli` and the shortcut `dp-cli` are available globally:
79
79
 
80
80
  ```bash
81
- drissionpage-cli --version
82
- drissionpage-cli --help
81
+ dp-cli --version
82
+ dp-cli --help
83
83
  ```
84
84
 
85
+ `dp-cli` and `drissionpage-cli` are identical — use whichever you prefer.
86
+
85
87
  ### Install skills for Claude Code
86
88
 
87
89
  ```bash
@@ -170,6 +172,29 @@ For a fully isolated throwaway session (no persistent state):
170
172
  drissionpage-cli open --sandbox
171
173
  ```
172
174
 
175
+ ### Capture mode (full page archive)
176
+
177
+ ```bash
178
+ drissionpage-cli open https://example.com --capture
179
+ ```
180
+
181
+ Creates a timestamped folder `capture-<ts>/` in the current directory containing:
182
+
183
+ | File | Contents |
184
+ |------|----------|
185
+ | `snapshot.html` | Page HTML at load-complete time |
186
+ | `traffic.json` | Manifest of all requests (url, method, status, content_type, file) |
187
+ | `0001_*.{ext}` … | Every response body saved as an individual file |
188
+
189
+ Captured file types include: HTML, CSS, JS, JSON, images (jpg, png, webp, gif, svg, avif, bmp, ico), audio (mp3, ogg, wav, aac, flac), and video (mp4, webm, ogv, mov).
190
+
191
+ ```
192
+ [capture] folder → /project/capture-2026-04-14T16-06-30
193
+ [capture] snapshot → snapshot.html
194
+ [capture] traffic → traffic.json (125 requests)
195
+ [capture] media → 63 files (images/audio/video)
196
+ ```
197
+
173
198
  ## Commands
174
199
 
175
200
  ### Core
@@ -49,13 +49,15 @@ cd DrissionPage-MCP/DrissionPage-cli
49
49
  pip install -e .
50
50
  ```
51
51
 
52
- After installation the `drissionpage-cli` command is available globally:
52
+ After installation both `drissionpage-cli` and the shortcut `dp-cli` are available globally:
53
53
 
54
54
  ```bash
55
- drissionpage-cli --version
56
- drissionpage-cli --help
55
+ dp-cli --version
56
+ dp-cli --help
57
57
  ```
58
58
 
59
+ `dp-cli` and `drissionpage-cli` are identical — use whichever you prefer.
60
+
59
61
  ### Install skills for Claude Code
60
62
 
61
63
  ```bash
@@ -144,6 +146,29 @@ For a fully isolated throwaway session (no persistent state):
144
146
  drissionpage-cli open --sandbox
145
147
  ```
146
148
 
149
+ ### Capture mode (full page archive)
150
+
151
+ ```bash
152
+ drissionpage-cli open https://example.com --capture
153
+ ```
154
+
155
+ Creates a timestamped folder `capture-<ts>/` in the current directory containing:
156
+
157
+ | File | Contents |
158
+ |------|----------|
159
+ | `snapshot.html` | Page HTML at load-complete time |
160
+ | `traffic.json` | Manifest of all requests (url, method, status, content_type, file) |
161
+ | `0001_*.{ext}` … | Every response body saved as an individual file |
162
+
163
+ Captured file types include: HTML, CSS, JS, JSON, images (jpg, png, webp, gif, svg, avif, bmp, ico), audio (mp3, ogg, wav, aac, flac), and video (mp4, webm, ogv, mov).
164
+
165
+ ```
166
+ [capture] folder → /project/capture-2026-04-14T16-06-30
167
+ [capture] snapshot → snapshot.html
168
+ [capture] traffic → traffic.json (125 requests)
169
+ [capture] media → 63 files (images/audio/video)
170
+ ```
171
+
147
172
  ## Commands
148
173
 
149
174
  ### Core
@@ -9,11 +9,14 @@ Designed for token-efficient browser automation by coding agents.
9
9
  import argparse
10
10
  import json
11
11
  import os
12
+ import re
13
+ import shutil
12
14
  import signal
13
15
  import sys
14
16
  import time
15
17
  import traceback
16
18
  from pathlib import Path
19
+ from urllib.parse import urlparse
17
20
 
18
21
  from importlib.metadata import version as _pkg_version, PackageNotFoundError as _PackageNotFoundError
19
22
  try:
@@ -21,6 +24,10 @@ try:
21
24
  except _PackageNotFoundError:
22
25
  __version__ = "unknown"
23
26
 
27
+ # Apply runtime patches for Chrome compatibility before any DrissionPage usage.
28
+ from drissionpage_cli._compat import apply_patches as _apply_patches
29
+ _apply_patches()
30
+
24
31
  # Session storage directory — home-based so profile and state persist across
25
32
  # different working directories.
26
33
  CLI_DIR = Path(os.environ.get("DRISSIONPAGE_CLI_DIR", str(Path.home() / ".drissionpage-cli")))
@@ -29,6 +36,55 @@ SESSIONS_FILE = CLI_DIR / "sessions.json"
29
36
  # Default CDP port — drissionpage-cli owns this port exclusively
30
37
  DEFAULT_PORT = int(os.environ.get("DRISSIONPAGE_CLI_PORT", "9222"))
31
38
 
39
+ _BROWSER_CANDIDATES = {
40
+ "chrome": [
41
+ "google-chrome-stable",
42
+ "google-chrome",
43
+ "chrome",
44
+ ],
45
+ "chromium": [
46
+ "chromium-browser",
47
+ "chromium",
48
+ ],
49
+ "edge": [
50
+ "microsoft-edge-stable",
51
+ "microsoft-edge",
52
+ "msedge",
53
+ ],
54
+ }
55
+
56
+ _BROWSER_SEARCH_ORDER = ["chrome", "chromium", "edge"]
57
+
58
+
59
+ def _find_browser_path(browser=None):
60
+ """Return the path to a Chromium-family binary.
61
+
62
+ *browser* can be ``"chrome"``, ``"chromium"``, or ``"edge"``.
63
+ When *None*, tries all families in order and returns the first hit.
64
+ """
65
+ if browser:
66
+ key = browser.lower()
67
+ candidates = _BROWSER_CANDIDATES.get(key)
68
+ if not candidates:
69
+ raise RuntimeError(
70
+ f"Unknown browser '{browser}'. "
71
+ f"Choose from: {', '.join(_BROWSER_CANDIDATES)}"
72
+ )
73
+ for name in candidates:
74
+ p = shutil.which(name)
75
+ if p:
76
+ return p
77
+ raise RuntimeError(
78
+ f"Browser '{browser}' not found on PATH. "
79
+ f"Looked for: {', '.join(candidates)}"
80
+ )
81
+ for key in _BROWSER_SEARCH_ORDER:
82
+ for name in _BROWSER_CANDIDATES[key]:
83
+ p = shutil.which(name)
84
+ if p:
85
+ return p
86
+ return None
87
+
32
88
 
33
89
  def ensure_cli_dir():
34
90
  CLI_DIR.mkdir(parents=True, exist_ok=True)
@@ -67,17 +123,55 @@ def _kill_session(sessions, session_name):
67
123
  try:
68
124
  os.kill(pid, signal.SIGTERM)
69
125
  time.sleep(0.5)
70
- except (ProcessLookupError, PermissionError):
126
+ except (ProcessLookupError, PermissionError, OSError):
71
127
  pass
72
128
  del sessions[session_name]
73
129
  _save_sessions(sessions)
74
130
 
75
131
 
76
- def _cli_profile_path():
77
- """Return the persistent Chrome profile directory managed by drissionpage-cli."""
132
+ def _snap_name(path):
133
+ """Return the snap package name if *path* is a snap binary, else None.
134
+
135
+ Snap binaries live under ``/snap/bin/<name>`` (symlink to ``/usr/bin/snap``)
136
+ or ``/snap/<name>/...``. We check the original path (not resolved) because
137
+ resolve() follows the symlink to ``/usr/bin/snap``.
138
+ """
139
+ if not path:
140
+ return None
141
+ p = str(Path(path))
142
+ # /snap/bin/<name>
143
+ if p.startswith("/snap/bin/"):
144
+ return Path(p).name
145
+ # /snap/<name>/current/... or /snap/<name>/<rev>/...
146
+ parts = Path(p).parts
147
+ if len(parts) >= 3 and parts[1] == "snap" and parts[2] != "bin":
148
+ return parts[2]
149
+ return None
150
+
151
+
152
+ def _cli_profile_path(browser_path=None):
153
+ """Return the persistent profile directory managed by drissionpage-cli.
154
+
155
+ Snap-packaged browsers cannot write to arbitrary home-directory paths due
156
+ to AppArmor confinement, so we use ``~/snap/<name>/common/drissionpage-cli``
157
+ instead.
158
+ """
159
+ name = _snap_name(browser_path)
160
+ if name:
161
+ return Path.home() / "snap" / name / "common" / "drissionpage-cli"
78
162
  return CLI_DIR / "profile"
79
163
 
80
164
 
165
+ def _apply_chrome146_fix(page):
166
+ """No-op. Kept for call-site compatibility.
167
+
168
+ The Chrome 147+ viewport fix is now handled by the monkey-patch in
169
+ ``_compat.py`` which filters out ``chrome://newtab-footer/`` during
170
+ target selection, applied at import time.
171
+ """
172
+ pass
173
+
174
+
81
175
  def _get_page(session_name, create=False, options=None):
82
176
  """Get or create a ChromiumPage for the given session.
83
177
 
@@ -102,6 +196,7 @@ def _get_page(session_name, create=False, options=None):
102
196
  co.headless(info.get("headless", False))
103
197
  try:
104
198
  page = ChromiumPage(addr_or_opts=co)
199
+ _apply_chrome146_fix(page)
105
200
  if not create:
106
201
  return page
107
202
  # create=True but session is alive — close old one first
@@ -139,14 +234,25 @@ def _get_page(session_name, create=False, options=None):
139
234
  explicit_port = options and options.get("port")
140
235
  is_headless = options.get("headless", False) if options else False
141
236
 
237
+ # Resolve browser binary first — profile path depends on whether it's a
238
+ # snap package (AppArmor confinement restricts writable paths).
239
+ browser_path = (options and options.get("browser_path")) or None
240
+ if not browser_path:
241
+ browser_path = _find_browser_path(
242
+ options.get("browser") if options else None
243
+ )
244
+
142
245
  co = ChromiumOptions()
143
246
 
247
+ if browser_path:
248
+ co.set_browser_path(browser_path)
249
+
144
250
  if sandbox:
145
251
  # Isolated profile: random port + temporary user data dir managed by DrissionPage.
146
252
  # State is not persisted — the temp dir is cleaned up when Chrome exits.
147
253
  co.auto_port()
148
254
  else:
149
- # Persistent profile: fixed port + CLI-managed profile at ~/.drissionpage-cli/profile.
255
+ # Persistent profile: fixed port + CLI-managed profile.
150
256
  # Works in both headed and headless mode — Chrome 136+ only blocks remote debugging
151
257
  # on the OS-default profile path, not on custom paths.
152
258
  port = explicit_port or DEFAULT_PORT
@@ -158,15 +264,16 @@ def _get_page(session_name, create=False, options=None):
158
264
  )
159
265
  co.set_local_port(port)
160
266
 
161
- profile_path = (options and options.get("user_data_path")) or str(_cli_profile_path())
162
- _cli_profile_path().mkdir(parents=True, exist_ok=True)
267
+ if options and options.get("user_data_path"):
268
+ profile_path = options["user_data_path"]
269
+ else:
270
+ profile_path = str(_cli_profile_path(browser_path))
271
+ Path(profile_path).mkdir(parents=True, exist_ok=True)
163
272
  co.set_user_data_path(profile_path)
164
273
 
165
274
  if options:
166
275
  if options.get("headless") is not None:
167
276
  co.headless(options["headless"])
168
- if options.get("browser_path"):
169
- co.set_browser_path(options["browser_path"])
170
277
  if options.get("proxy"):
171
278
  co.set_proxy(options["proxy"])
172
279
  if options.get("user_agent"):
@@ -176,6 +283,7 @@ def _get_page(session_name, create=False, options=None):
176
283
  co.set_argument(arg)
177
284
 
178
285
  page = ChromiumPage(addr_or_opts=co)
286
+ _apply_chrome146_fix(page)
179
287
 
180
288
  # Record session info
181
289
  sessions[session_name] = {
@@ -189,7 +297,7 @@ def _get_page(session_name, create=False, options=None):
189
297
  return page
190
298
 
191
299
 
192
- def _format_snapshot(page, element=None):
300
+ def _format_snapshot(page, element=None, save=True):
193
301
  """Produce a text snapshot of the current page state, similar to playwright-cli's snapshot."""
194
302
  lines = []
195
303
  lines.append("### Page")
@@ -206,18 +314,177 @@ def _format_snapshot(page, element=None):
206
314
  lines.append(f" @{k}={v}")
207
315
  else:
208
316
  lines.append("### Snapshot")
209
- # Save snapshot to file
210
- timestamp = time.strftime("%Y-%m-%dT%H-%M-%S")
211
- snap_dir = CLI_DIR / "snapshots"
212
- snap_dir.mkdir(parents=True, exist_ok=True)
213
- snap_file = snap_dir / f"page-{timestamp}.html"
317
+ if save:
318
+ timestamp = time.strftime("%Y-%m-%dT%H-%M-%S")
319
+ snap_file = Path.cwd() / f"page-{timestamp}.html"
320
+ try:
321
+ snap_file.write_text(page.html, encoding="utf-8")
322
+ lines.append(f"[Snapshot]({snap_file})")
323
+ except Exception:
324
+ lines.append(f"- HTML length: {len(page.html):,} chars")
325
+ else:
326
+ lines.append(f"- HTML length: {len(page.html):,} chars")
327
+
328
+ return "\n".join(lines)
329
+
330
+
331
+ def _ct_to_ext(ct: str) -> str:
332
+ """Map a Content-Type value to a file extension."""
333
+ ct = ct.lower().split(";")[0].strip()
334
+ return {
335
+ "text/html": "html",
336
+ "text/plain": "txt",
337
+ "text/css": "css",
338
+ "text/javascript": "js",
339
+ "application/javascript": "js",
340
+ "application/x-javascript":"js",
341
+ "application/json": "json",
342
+ "application/xml": "xml",
343
+ "text/xml": "xml",
344
+ "image/png": "png",
345
+ "image/jpeg": "jpg",
346
+ "image/webp": "webp",
347
+ "image/gif": "gif",
348
+ "image/svg+xml": "svg",
349
+ "image/avif": "avif",
350
+ "image/bmp": "bmp",
351
+ "image/x-icon": "ico",
352
+ "image/vnd.microsoft.icon": "ico",
353
+ "audio/mpeg": "mp3",
354
+ "audio/mp3": "mp3",
355
+ "audio/ogg": "ogg",
356
+ "audio/wav": "wav",
357
+ "audio/x-wav": "wav",
358
+ "audio/webm": "webm",
359
+ "audio/aac": "aac",
360
+ "audio/flac": "flac",
361
+ "audio/x-flac": "flac",
362
+ "video/mp4": "mp4",
363
+ "video/webm": "webm",
364
+ "video/ogg": "ogv",
365
+ "video/x-msvideo": "avi",
366
+ "video/quicktime": "mov",
367
+ "application/pdf": "pdf",
368
+ "font/woff": "woff",
369
+ "font/woff2": "woff2",
370
+ "application/font-woff": "woff",
371
+ "application/font-woff2": "woff2",
372
+ "application/octet-stream": "bin",
373
+ }.get(ct, "bin")
374
+
375
+
376
+ def _response_filename(counter: int, url: str, ct: str) -> str:
377
+ """Build a short, readable filename for a captured response."""
378
+ from urllib.parse import urlparse
379
+ parsed = urlparse(url)
380
+ # Use last non-empty path segment, falling back to hostname
381
+ segments = [s for s in parsed.path.split("/") if s]
382
+ name = segments[-1] if segments else parsed.netloc
383
+ # Strip existing extension so we control it via content-type
384
+ name = re.sub(r"\.[^.]{1,6}$", "", name)
385
+ # Sanitize: keep alphanum, dot, hyphen; collapse everything else to _
386
+ name = re.sub(r"[^\w.\-]", "_", name)[:50].strip("_") or "response"
387
+ ext = _ct_to_ext(ct)
388
+ return f"{counter:04d}_{name}.{ext}"
389
+
390
+
391
+ def _collect_traffic(page, settle: float = 1.0, out_dir: Path = None) -> list:
392
+ """
393
+ Drain the network listener queue started before page.get().
394
+
395
+ Call after page.get() returns. ``settle`` gives async XHR/fetch calls
396
+ a moment to complete before we declare the queue exhausted.
397
+
398
+ If ``out_dir`` is given every response body is written as an individual
399
+ file inside that directory and each record gets a ``"file"`` field with
400
+ the relative filename.
401
+
402
+ Returns a list of dicts (the traffic manifest), one per request/response.
403
+ """
404
+ _TEXT_TYPES = (
405
+ "text/", "application/json", "application/javascript",
406
+ "application/xml", "application/x-www-form-urlencoded",
407
+ )
408
+
409
+ time.sleep(settle) # let in-flight async requests finish
410
+ records = []
411
+ counter = 0
412
+
413
+ for packet in page.listen.steps(timeout=2):
414
+ url = getattr(packet, "url", "") or ""
415
+
416
+ # Only keep real HTTP(S) traffic — skip chrome://, data:, etc.
417
+ if not url.startswith(("http://", "https://")):
418
+ continue
419
+
420
+ counter += 1
421
+ resp = getattr(packet, "response", None)
422
+ method = getattr(packet, "method", "GET") or "GET"
423
+ status = getattr(resp, "status", None) if resp else None
424
+
425
+ ct = ""
214
426
  try:
215
- snap_file.write_text(page.html)
216
- lines.append(f"[Snapshot]({snap_file})")
427
+ ct = (resp.headers.get("content-type") or "") if resp else ""
217
428
  except Exception:
218
- lines.append("[Snapshot could not be saved]")
429
+ pass
219
430
 
220
- return "\n".join(lines)
431
+ rec = {"url": url, "method": method, "status": status, "content_type": ct}
432
+
433
+ # Request headers
434
+ try:
435
+ req = getattr(packet, "request", None)
436
+ if req and getattr(req, "headers", None):
437
+ rec["request_headers"] = dict(req.headers)
438
+ except Exception:
439
+ pass
440
+
441
+ # Request body (POST etc.)
442
+ try:
443
+ req = getattr(packet, "request", None)
444
+ post = getattr(req, "postData", None) or getattr(req, "body", None) if req else None
445
+ if post:
446
+ rec["request_body"] = post if isinstance(post, str) else post.decode("utf-8", errors="replace")
447
+ except Exception:
448
+ pass
449
+
450
+ # Response headers
451
+ try:
452
+ if resp and getattr(resp, "headers", None):
453
+ rec["response_headers"] = dict(resp.headers)
454
+ except Exception:
455
+ pass
456
+
457
+ # Response body
458
+ body = getattr(resp, "body", None) if resp else None
459
+ if body is not None:
460
+ if isinstance(body, (bytes, bytearray)):
461
+ data: bytes = bytes(body)
462
+ elif isinstance(body, str):
463
+ data = body.encode("utf-8", errors="replace")
464
+ elif isinstance(body, (dict, list)):
465
+ # DrissionPage auto-parses JSON responses into Python objects
466
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
467
+ else:
468
+ data = str(body).encode("utf-8", errors="replace")
469
+ rec["size"] = len(data)
470
+
471
+ if out_dir is not None:
472
+ # Save every response as its own file
473
+ fname = _response_filename(counter, url, ct)
474
+ try:
475
+ (out_dir / fname).write_bytes(data)
476
+ rec["file"] = fname
477
+ except Exception as e:
478
+ rec["file_error"] = str(e)
479
+ else:
480
+ # Inline text bodies only (no out_dir → old compact behaviour)
481
+ is_text = any(t in ct.lower() for t in _TEXT_TYPES)
482
+ if is_text:
483
+ rec["body"] = data.decode("utf-8", errors="replace")
484
+
485
+ records.append(rec)
486
+
487
+ return records
221
488
 
222
489
 
223
490
  def _find_element(page, ref):
@@ -267,15 +534,65 @@ def cmd_open(args):
267
534
  "user_data_path": getattr(args, "profile", None),
268
535
  "port": getattr(args, "port", None),
269
536
  "sandbox": getattr(args, "sandbox", False),
537
+ "browser": getattr(args, "browser", None),
270
538
  }
271
539
  page = _get_page(session, create=True, options=options)
272
540
 
273
541
  url = getattr(args, "url", None)
542
+ capture = getattr(args, "capture", False)
543
+
544
+ _MEDIA_EXTS = {
545
+ ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".avif", ".bmp", ".ico",
546
+ ".mp3", ".mp4", ".ogg", ".ogv", ".wav", ".webm", ".aac", ".flac", ".avi", ".mov",
547
+ }
548
+
274
549
  if url:
550
+ if capture:
551
+ # Start listening BEFORE navigation so no packet is missed
552
+ page.listen.start()
553
+
275
554
  page.get(url)
276
555
 
556
+ if capture:
557
+ timestamp = time.strftime("%Y-%m-%dT%H-%M-%S")
558
+ capture_dir = Path.cwd() / f"capture-{timestamp}"
559
+ capture_dir.mkdir(parents=True, exist_ok=True)
560
+
561
+ # Save page snapshot HTML
562
+ html_file = capture_dir / "snapshot.html"
563
+ try:
564
+ html_file.write_text(page.html, encoding="utf-8")
565
+ except Exception as e:
566
+ print(f"[warn] could not save snapshot HTML: {e}", file=sys.stderr)
567
+
568
+ # Drain listener; each response body is written as its own file
569
+ records = _collect_traffic(page, out_dir=capture_dir)
570
+
571
+ # Save traffic manifest
572
+ traffic_file = capture_dir / "traffic.json"
573
+ try:
574
+ traffic_file.write_text(
575
+ json.dumps(records, indent=2, ensure_ascii=False),
576
+ encoding="utf-8",
577
+ )
578
+ except Exception as e:
579
+ print(f"[warn] could not save traffic manifest: {e}", file=sys.stderr)
580
+
581
+ n_media = sum(
582
+ 1 for r in records
583
+ if r.get("file") and Path(r["file"]).suffix.lower() in _MEDIA_EXTS
584
+ )
585
+ print(f"[capture] folder → {capture_dir}")
586
+ print(f"[capture] snapshot → snapshot.html")
587
+ print(f"[capture] traffic → traffic.json ({len(records)} requests)")
588
+ if n_media:
589
+ print(f"[capture] media → {n_media} files (images/audio/video)")
590
+ else:
591
+ if capture:
592
+ print("[warn] --capture requires a URL to be useful; listener not started.", file=sys.stderr)
593
+
277
594
  _inject_console_capture(page)
278
- print(_format_snapshot(page))
595
+ print(_format_snapshot(page, save=not capture))
279
596
 
280
597
 
281
598
  def cmd_goto(args):
@@ -1175,6 +1492,38 @@ def cmd_state_load(args):
1175
1492
  # --- Install skills ---
1176
1493
 
1177
1494
 
1495
+ def cmd_md(args):
1496
+ """Convert a Feishu document to Markdown with locally saved images."""
1497
+ from drissionpage_cli._feishu import convert
1498
+
1499
+ url = args.url
1500
+ parsed = urlparse(url)
1501
+ is_feishu = (
1502
+ parsed.netloc.endswith(".feishu.cn")
1503
+ and ("/wiki/" in parsed.path or "/docx/" in parsed.path)
1504
+ )
1505
+ if not is_feishu:
1506
+ print(f"Error: '{url}' is not a supported Feishu document.", file=sys.stderr)
1507
+ print("Supported: https://<company>.feishu.cn/wiki/<id> or /docx/<id>", file=sys.stderr)
1508
+ sys.exit(1)
1509
+
1510
+ session = _get_session_name(args)
1511
+ out_dir = Path(args.out_dir)
1512
+ out_dir.mkdir(parents=True, exist_ok=True)
1513
+
1514
+ try:
1515
+ page = _get_page(session)
1516
+ except RuntimeError:
1517
+ page = _get_page(session, create=True, options={"headless": False})
1518
+
1519
+ convert(
1520
+ page,
1521
+ url=url,
1522
+ out_dir=out_dir,
1523
+ save_html=getattr(args, "save_html", False),
1524
+ )
1525
+
1526
+
1178
1527
  def cmd_install(args):
1179
1528
  """Install skills for Claude Code or other agents."""
1180
1529
  if not getattr(args, "skills", False):
@@ -1231,6 +1580,11 @@ def build_parser():
1231
1580
  p.add_argument("--sandbox", action="store_true",
1232
1581
  help="Isolated session: random port + temporary profile, no persistent state")
1233
1582
  p.add_argument("--port", type=int, help=f"CDP debugging port (default: {DEFAULT_PORT})")
1583
+ p.add_argument("--capture", action="store_true",
1584
+ help="Capture full network traffic during page load; saves paired "
1585
+ "page-<ts>.html and page-<ts>.traffic.json to the snapshots dir")
1586
+ p.add_argument("--browser", choices=["chrome", "chromium", "edge"],
1587
+ help="Browser to use (default: auto-detect)")
1234
1588
  p.set_defaults(func=cmd_open)
1235
1589
 
1236
1590
  # goto
@@ -1507,6 +1861,13 @@ def build_parser():
1507
1861
  p.add_argument("--skills", action="store_true", help="Install Claude Code skills")
1508
1862
  p.set_defaults(func=cmd_install)
1509
1863
 
1864
+ # md - Convert Feishu document to Markdown with locally saved images
1865
+ p = subparsers.add_parser("md", help="Convert a Feishu document to Markdown")
1866
+ p.add_argument("url", help="Feishu document URL (*.feishu.cn/wiki/* or /docx/*)")
1867
+ p.add_argument("out_dir", nargs="?", default=".", help="Output directory (default: .)")
1868
+ p.add_argument("--save-html", action="store_true", help="Also save the raw SSR HTML")
1869
+ p.set_defaults(func=cmd_md)
1870
+
1510
1871
  return parser
1511
1872
 
1512
1873