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.
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/PKG-INFO +29 -4
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/README.md +28 -3
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/__init__.py +380 -19
- drissionpage_cli-0.2.0/drissionpage_cli/_compat.py +105 -0
- drissionpage_cli-0.2.0/drissionpage_cli/_feishu.py +1485 -0
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/SKILL.md +99 -0
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/PKG-INFO +29 -4
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/SOURCES.txt +2 -0
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/entry_points.txt +1 -0
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/pyproject.toml +2 -1
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/tests/test_unit.py +2 -1
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/LICENSE +0 -0
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/__main__.py +0 -0
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/dual-mode.md +0 -0
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/element-locators.md +0 -0
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/network-listening.md +0 -0
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/running-code.md +0 -0
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/screenshots-pdf.md +0 -0
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/session-management.md +0 -0
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/storage-state.md +0 -0
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/dependency_links.txt +0 -0
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/requires.txt +0 -0
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/top_level.txt +0 -0
- {drissionpage_cli-0.1.5 → drissionpage_cli-0.2.0}/setup.cfg +0 -0
- {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.
|
|
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
|
|
78
|
+
After installation both `drissionpage-cli` and the shortcut `dp-cli` are available globally:
|
|
79
79
|
|
|
80
80
|
```bash
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
52
|
+
After installation both `drissionpage-cli` and the shortcut `dp-cli` are available globally:
|
|
53
53
|
|
|
54
54
|
```bash
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
77
|
-
"""Return the
|
|
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
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
lines.append(f"[Snapshot]({snap_file})")
|
|
427
|
+
ct = (resp.headers.get("content-type") or "") if resp else ""
|
|
217
428
|
except Exception:
|
|
218
|
-
|
|
429
|
+
pass
|
|
219
430
|
|
|
220
|
-
|
|
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
|
|