drissionpage-cli 0.1.8__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.8 → drissionpage_cli-0.2.0}/PKG-INFO +1 -1
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/__init__.py +117 -8
- drissionpage_cli-0.2.0/drissionpage_cli/_compat.py +105 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/_feishu.py +266 -13
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/PKG-INFO +1 -1
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/SOURCES.txt +1 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/pyproject.toml +1 -1
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/LICENSE +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/README.md +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/__main__.py +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/SKILL.md +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/dual-mode.md +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/element-locators.md +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/network-listening.md +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/running-code.md +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/screenshots-pdf.md +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/session-management.md +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/storage-state.md +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/dependency_links.txt +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/entry_points.txt +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/requires.txt +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/top_level.txt +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/setup.cfg +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/tests/test_integration.py +0 -0
- {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/tests/test_unit.py +0 -0
|
@@ -10,6 +10,7 @@ import argparse
|
|
|
10
10
|
import json
|
|
11
11
|
import os
|
|
12
12
|
import re
|
|
13
|
+
import shutil
|
|
13
14
|
import signal
|
|
14
15
|
import sys
|
|
15
16
|
import time
|
|
@@ -23,6 +24,10 @@ try:
|
|
|
23
24
|
except _PackageNotFoundError:
|
|
24
25
|
__version__ = "unknown"
|
|
25
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
|
+
|
|
26
31
|
# Session storage directory — home-based so profile and state persist across
|
|
27
32
|
# different working directories.
|
|
28
33
|
CLI_DIR = Path(os.environ.get("DRISSIONPAGE_CLI_DIR", str(Path.home() / ".drissionpage-cli")))
|
|
@@ -31,6 +36,55 @@ SESSIONS_FILE = CLI_DIR / "sessions.json"
|
|
|
31
36
|
# Default CDP port — drissionpage-cli owns this port exclusively
|
|
32
37
|
DEFAULT_PORT = int(os.environ.get("DRISSIONPAGE_CLI_PORT", "9222"))
|
|
33
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
|
+
|
|
34
88
|
|
|
35
89
|
def ensure_cli_dir():
|
|
36
90
|
CLI_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -69,17 +123,55 @@ def _kill_session(sessions, session_name):
|
|
|
69
123
|
try:
|
|
70
124
|
os.kill(pid, signal.SIGTERM)
|
|
71
125
|
time.sleep(0.5)
|
|
72
|
-
except (ProcessLookupError, PermissionError):
|
|
126
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
73
127
|
pass
|
|
74
128
|
del sessions[session_name]
|
|
75
129
|
_save_sessions(sessions)
|
|
76
130
|
|
|
77
131
|
|
|
78
|
-
def
|
|
79
|
-
"""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"
|
|
80
162
|
return CLI_DIR / "profile"
|
|
81
163
|
|
|
82
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
|
+
|
|
83
175
|
def _get_page(session_name, create=False, options=None):
|
|
84
176
|
"""Get or create a ChromiumPage for the given session.
|
|
85
177
|
|
|
@@ -104,6 +196,7 @@ def _get_page(session_name, create=False, options=None):
|
|
|
104
196
|
co.headless(info.get("headless", False))
|
|
105
197
|
try:
|
|
106
198
|
page = ChromiumPage(addr_or_opts=co)
|
|
199
|
+
_apply_chrome146_fix(page)
|
|
107
200
|
if not create:
|
|
108
201
|
return page
|
|
109
202
|
# create=True but session is alive — close old one first
|
|
@@ -141,14 +234,25 @@ def _get_page(session_name, create=False, options=None):
|
|
|
141
234
|
explicit_port = options and options.get("port")
|
|
142
235
|
is_headless = options.get("headless", False) if options else False
|
|
143
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
|
+
|
|
144
245
|
co = ChromiumOptions()
|
|
145
246
|
|
|
247
|
+
if browser_path:
|
|
248
|
+
co.set_browser_path(browser_path)
|
|
249
|
+
|
|
146
250
|
if sandbox:
|
|
147
251
|
# Isolated profile: random port + temporary user data dir managed by DrissionPage.
|
|
148
252
|
# State is not persisted — the temp dir is cleaned up when Chrome exits.
|
|
149
253
|
co.auto_port()
|
|
150
254
|
else:
|
|
151
|
-
# Persistent profile: fixed port + CLI-managed profile
|
|
255
|
+
# Persistent profile: fixed port + CLI-managed profile.
|
|
152
256
|
# Works in both headed and headless mode — Chrome 136+ only blocks remote debugging
|
|
153
257
|
# on the OS-default profile path, not on custom paths.
|
|
154
258
|
port = explicit_port or DEFAULT_PORT
|
|
@@ -160,15 +264,16 @@ def _get_page(session_name, create=False, options=None):
|
|
|
160
264
|
)
|
|
161
265
|
co.set_local_port(port)
|
|
162
266
|
|
|
163
|
-
|
|
164
|
-
|
|
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)
|
|
165
272
|
co.set_user_data_path(profile_path)
|
|
166
273
|
|
|
167
274
|
if options:
|
|
168
275
|
if options.get("headless") is not None:
|
|
169
276
|
co.headless(options["headless"])
|
|
170
|
-
if options.get("browser_path"):
|
|
171
|
-
co.set_browser_path(options["browser_path"])
|
|
172
277
|
if options.get("proxy"):
|
|
173
278
|
co.set_proxy(options["proxy"])
|
|
174
279
|
if options.get("user_agent"):
|
|
@@ -178,6 +283,7 @@ def _get_page(session_name, create=False, options=None):
|
|
|
178
283
|
co.set_argument(arg)
|
|
179
284
|
|
|
180
285
|
page = ChromiumPage(addr_or_opts=co)
|
|
286
|
+
_apply_chrome146_fix(page)
|
|
181
287
|
|
|
182
288
|
# Record session info
|
|
183
289
|
sessions[session_name] = {
|
|
@@ -428,6 +534,7 @@ def cmd_open(args):
|
|
|
428
534
|
"user_data_path": getattr(args, "profile", None),
|
|
429
535
|
"port": getattr(args, "port", None),
|
|
430
536
|
"sandbox": getattr(args, "sandbox", False),
|
|
537
|
+
"browser": getattr(args, "browser", None),
|
|
431
538
|
}
|
|
432
539
|
page = _get_page(session, create=True, options=options)
|
|
433
540
|
|
|
@@ -1476,6 +1583,8 @@ def build_parser():
|
|
|
1476
1583
|
p.add_argument("--capture", action="store_true",
|
|
1477
1584
|
help="Capture full network traffic during page load; saves paired "
|
|
1478
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)")
|
|
1479
1588
|
p.set_defaults(func=cmd_open)
|
|
1480
1589
|
|
|
1481
1590
|
# goto
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Runtime patches for DrissionPage compatibility with newer Chrome versions.
|
|
2
|
+
|
|
3
|
+
DrissionPage-cli does not own the DrissionPage codebase. Rather than forking
|
|
4
|
+
or modifying installed files via post-install scripts, we apply targeted
|
|
5
|
+
monkey-patches at import time. Each patch:
|
|
6
|
+
|
|
7
|
+
- wraps (not replaces) the original method so upstream changes pass through,
|
|
8
|
+
- is guarded by a version or feature check when possible,
|
|
9
|
+
- is documented with the upstream issue link so it can be removed once fixed.
|
|
10
|
+
|
|
11
|
+
Call ``apply_patches()`` once, early in the process (e.g. at package import).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
_PATCHES_APPLIED = False
|
|
15
|
+
|
|
16
|
+
# ── Chrome 147+ newtab-footer target ─────────────────────────────────────
|
|
17
|
+
#
|
|
18
|
+
# Chrome 147 on Windows exposes ``chrome://newtab-footer/`` as a separate
|
|
19
|
+
# CDP ``page`` target. DrissionPage's ``_connect_browser`` can pick it as
|
|
20
|
+
# the default tab, causing all subsequent page content to render inside the
|
|
21
|
+
# footer's tiny area (~100 px at the bottom of the window).
|
|
22
|
+
#
|
|
23
|
+
# Fix: wrap ``_connect_browser`` so that when it would auto-select a target,
|
|
24
|
+
# we do the selection ourselves (filtering out the footer) and hand the
|
|
25
|
+
# correct target_id to the original method, which then skips its own
|
|
26
|
+
# selection entirely.
|
|
27
|
+
|
|
28
|
+
_NEWTAB_FOOTER_URLS = frozenset((
|
|
29
|
+
'chrome://newtab-footer/',
|
|
30
|
+
'chrome://newtab-footer',
|
|
31
|
+
))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _patch_connect_browser():
|
|
35
|
+
from DrissionPage._pages.chromium_base import ChromiumBase
|
|
36
|
+
|
|
37
|
+
_original = ChromiumBase._connect_browser
|
|
38
|
+
|
|
39
|
+
def _patched(self, target_id=None):
|
|
40
|
+
if target_id is None:
|
|
41
|
+
target_id = _select_target(self)
|
|
42
|
+
return _original(self, target_id)
|
|
43
|
+
|
|
44
|
+
ChromiumBase._connect_browser = _patched
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _select_target(page_obj):
|
|
48
|
+
"""Replicate DrissionPage's default target selection with extra filtering.
|
|
49
|
+
|
|
50
|
+
Returns the target_id of the best user-facing tab, skipping known
|
|
51
|
+
Chrome-internal sub-pages.
|
|
52
|
+
"""
|
|
53
|
+
browser = page_obj.browser
|
|
54
|
+
|
|
55
|
+
if browser._ws_only:
|
|
56
|
+
tabs = page_obj._run_cdp('Target.getTargets')['targetInfos']
|
|
57
|
+
_id = 'targetId'
|
|
58
|
+
else:
|
|
59
|
+
tabs = browser._driver.get(
|
|
60
|
+
f'http://{browser.address}/json').json()
|
|
61
|
+
_id = 'id'
|
|
62
|
+
|
|
63
|
+
tabs = [
|
|
64
|
+
(i[_id], i['url']) for i in tabs
|
|
65
|
+
if i['type'] in ('page', 'webview')
|
|
66
|
+
and not i['url'].startswith('devtools://')
|
|
67
|
+
and i['url'] not in _NEWTAB_FOOTER_URLS
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
if not tabs:
|
|
71
|
+
# Nothing matched after filtering; fall back to letting the
|
|
72
|
+
# original method handle it (it will re-fetch and pick whatever
|
|
73
|
+
# is available).
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
target_id = None
|
|
77
|
+
dialog = None
|
|
78
|
+
|
|
79
|
+
if len(tabs) > 1:
|
|
80
|
+
for k, t in enumerate(tabs):
|
|
81
|
+
if t[1] == 'chrome://privacy-sandbox-dialog/notice':
|
|
82
|
+
dialog = k
|
|
83
|
+
elif target_id is None:
|
|
84
|
+
target_id = t[0]
|
|
85
|
+
if target_id and dialog is not None:
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
if dialog is not None:
|
|
89
|
+
from DrissionPage._pages.chromium_base import close_privacy_dialog
|
|
90
|
+
close_privacy_dialog(page_obj, tabs[dialog][0])
|
|
91
|
+
else:
|
|
92
|
+
target_id = tabs[0][0]
|
|
93
|
+
|
|
94
|
+
return target_id
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Public entry point ────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def apply_patches():
|
|
100
|
+
"""Apply all runtime patches (idempotent)."""
|
|
101
|
+
global _PATCHES_APPLIED
|
|
102
|
+
if _PATCHES_APPLIED:
|
|
103
|
+
return
|
|
104
|
+
_PATCHES_APPLIED = True
|
|
105
|
+
_patch_connect_browser()
|
|
@@ -82,6 +82,19 @@ _ATTACH_CT = _IMG_CT | frozenset([
|
|
|
82
82
|
|
|
83
83
|
# ── helpers ────────────────────────────────────────────────────────────────────
|
|
84
84
|
|
|
85
|
+
_B36 = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
86
|
+
|
|
87
|
+
def _int_to_base36(n: int) -> str:
|
|
88
|
+
"""Convert a non-negative integer to a base-36 string (Etherpad encoding)."""
|
|
89
|
+
if n == 0:
|
|
90
|
+
return "0"
|
|
91
|
+
digits = []
|
|
92
|
+
while n:
|
|
93
|
+
digits.append(_B36[n % 36])
|
|
94
|
+
n //= 36
|
|
95
|
+
return "".join(reversed(digits))
|
|
96
|
+
|
|
97
|
+
|
|
85
98
|
def _strip_zero_width(text: str) -> str:
|
|
86
99
|
return _INVISIBLE_RE.sub("", text)
|
|
87
100
|
|
|
@@ -114,17 +127,10 @@ def _extract_block_map(ssr_html: str) -> tuple:
|
|
|
114
127
|
Returns (block_map: dict, title: str).
|
|
115
128
|
block_map is empty if not found.
|
|
116
129
|
"""
|
|
117
|
-
#
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
title = _strip_zero_width(m.group(1)) if m else ""
|
|
122
|
-
raise RuntimeError(
|
|
123
|
-
"This document uses the old Feishu 'DOC' format (wikcn… URL prefix), "
|
|
124
|
-
"which stores content differently from the modern 'docx' block format. "
|
|
125
|
-
"Old-format DOC conversion is not yet supported. "
|
|
126
|
-
f"Document: {title or '(unknown)'}"
|
|
127
|
-
)
|
|
130
|
+
# Old-format DOC is now handled by convert() before calling this function,
|
|
131
|
+
# but guard here as well in case _extract_block_map is called directly.
|
|
132
|
+
if _is_old_doc(ssr_html):
|
|
133
|
+
return {}, ""
|
|
128
134
|
|
|
129
135
|
# There are two clientVars occurrences; we want the one with block_map data.
|
|
130
136
|
idx = ssr_html.find('clientVars: Object({"data":{"block_map":')
|
|
@@ -160,12 +166,219 @@ def _extract_block_map(ssr_html: str) -> tuple:
|
|
|
160
166
|
return block_map, _strip_zero_width(title)
|
|
161
167
|
|
|
162
168
|
|
|
169
|
+
# ── old-format DOC (wikcn / "type":"DOC") ─────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
def _is_old_doc(ssr_html: str) -> bool:
|
|
172
|
+
"""Return True if the SSR HTML is for an old-format Feishu DOC."""
|
|
173
|
+
return '"type":"DOC"' in ssr_html or '"type": "DOC"' in ssr_html
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _extract_old_doc_data(ssr_html: str) -> tuple:
|
|
177
|
+
"""
|
|
178
|
+
Extract Etherpad-format document data from old-format Feishu DOC SSR HTML.
|
|
179
|
+
|
|
180
|
+
Old DOCs store content as a single attributed text string (Etherpad OT
|
|
181
|
+
format) rather than the modern block_map. The data lives in::
|
|
182
|
+
|
|
183
|
+
window.DATA.clientVars.data.collab_client_vars
|
|
184
|
+
|
|
185
|
+
Returns ``(title, text, attribs_str, nta_b36, resources)`` where
|
|
186
|
+
*nta_b36* is the ``numToAttrib`` dict re-keyed with base-36 strings
|
|
187
|
+
so it can be fed directly to :func:`_parse_attribs`.
|
|
188
|
+
"""
|
|
189
|
+
idx = ssr_html.find('clientVars: Object({"type":"DOC"')
|
|
190
|
+
if idx < 0:
|
|
191
|
+
idx = ssr_html.find('clientVars: Object({"type": "DOC"')
|
|
192
|
+
if idx < 0:
|
|
193
|
+
raise RuntimeError("Could not locate old-format DOC clientVars in SSR HTML")
|
|
194
|
+
|
|
195
|
+
obj_start = ssr_html.find("Object(", idx) + len("Object(")
|
|
196
|
+
depth = 0
|
|
197
|
+
obj_end = obj_start
|
|
198
|
+
for i, c in enumerate(ssr_html[obj_start:], obj_start):
|
|
199
|
+
if c == "{":
|
|
200
|
+
depth += 1
|
|
201
|
+
elif c == "}":
|
|
202
|
+
depth -= 1
|
|
203
|
+
if depth == 0:
|
|
204
|
+
obj_end = i + 1
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
data = json.loads(ssr_html[obj_start:obj_end])
|
|
208
|
+
ccv = data.get("data", {}).get("collab_client_vars", {})
|
|
209
|
+
|
|
210
|
+
iat = ccv.get("initialAttributedText", {})
|
|
211
|
+
text = iat.get("text", "")
|
|
212
|
+
attribs_str = iat.get("attribs", "")
|
|
213
|
+
nta = (ccv.get("apool") or {}).get("numToAttrib") or {}
|
|
214
|
+
resources = ccv.get("resources") or {}
|
|
215
|
+
|
|
216
|
+
# Re-key numToAttrib: Feishu stores decimal string keys ("10", "11", …)
|
|
217
|
+
# but attribs references them as base-36 ("a", "b", …).
|
|
218
|
+
nta_b36 = {}
|
|
219
|
+
for k, v in nta.items():
|
|
220
|
+
try:
|
|
221
|
+
nta_b36[_int_to_base36(int(k))] = v
|
|
222
|
+
except (ValueError, TypeError):
|
|
223
|
+
nta_b36[k] = v
|
|
224
|
+
|
|
225
|
+
# Title: prefer SERVER_DATA meta, fall back to ccv.title
|
|
226
|
+
m = re.search(
|
|
227
|
+
r'window\.SERVER_DATA\s*=\s*Object\(\{"meta":\{"title":"([^"]+)"',
|
|
228
|
+
ssr_html,
|
|
229
|
+
)
|
|
230
|
+
title = _strip_zero_width(m.group(1)) if m else ccv.get("title", "")
|
|
231
|
+
|
|
232
|
+
return title, text, attribs_str, nta_b36, resources
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _old_doc_to_markdown(
|
|
236
|
+
text: str,
|
|
237
|
+
attribs_str: str,
|
|
238
|
+
nta_b36: dict,
|
|
239
|
+
resources: dict,
|
|
240
|
+
) -> str:
|
|
241
|
+
"""
|
|
242
|
+
Convert old-format Feishu DOC Etherpad data to Markdown.
|
|
243
|
+
|
|
244
|
+
Handles headings (h1-h9), numbered/bulleted lists, inline links
|
|
245
|
+
(``url-*`` and ``mention-link_*``), and embedded block placeholders
|
|
246
|
+
(mind-notes, etc.).
|
|
247
|
+
"""
|
|
248
|
+
segments = _parse_attribs(attribs_str, nta_b36)
|
|
249
|
+
|
|
250
|
+
# ── walk segments and split into lines ────────────────────────────────
|
|
251
|
+
# Each line: (line_attrs from its lmkr segment, [(text, inline_attrs)…])
|
|
252
|
+
lines: list[tuple[dict, list]] = []
|
|
253
|
+
cur_attrs: dict = {}
|
|
254
|
+
cur_spans: list = []
|
|
255
|
+
pos = 0
|
|
256
|
+
|
|
257
|
+
for count, attrs in segments:
|
|
258
|
+
chunk = text[pos : pos + count]
|
|
259
|
+
pos += count
|
|
260
|
+
if not chunk:
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
# Line-marker character (*) — carries line-level attrs, skip the char
|
|
264
|
+
if attrs.get("lmkr"):
|
|
265
|
+
cur_attrs = dict(attrs)
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
# Split at embedded newlines
|
|
269
|
+
while "\n" in chunk:
|
|
270
|
+
nl = chunk.index("\n")
|
|
271
|
+
before = chunk[:nl]
|
|
272
|
+
if before:
|
|
273
|
+
cur_spans.append((before, attrs))
|
|
274
|
+
lines.append((dict(cur_attrs), list(cur_spans)))
|
|
275
|
+
cur_attrs = {}
|
|
276
|
+
cur_spans = []
|
|
277
|
+
chunk = chunk[nl + 1 :]
|
|
278
|
+
|
|
279
|
+
if chunk:
|
|
280
|
+
cur_spans.append((chunk, attrs))
|
|
281
|
+
|
|
282
|
+
if cur_spans:
|
|
283
|
+
lines.append((dict(cur_attrs), list(cur_spans)))
|
|
284
|
+
|
|
285
|
+
# ── render each line to Markdown ──────────────────────────────────────
|
|
286
|
+
md_parts: list[str] = []
|
|
287
|
+
|
|
288
|
+
for line_attrs, spans in lines:
|
|
289
|
+
heading = line_attrs.get("heading", "")
|
|
290
|
+
list_type = line_attrs.get("list", "")
|
|
291
|
+
start_num = line_attrs.get("start", "1")
|
|
292
|
+
|
|
293
|
+
inline = _render_old_doc_spans(spans)
|
|
294
|
+
|
|
295
|
+
# Blank line
|
|
296
|
+
if not inline.strip():
|
|
297
|
+
# Collapse consecutive blanks
|
|
298
|
+
if not md_parts or md_parts[-1] != "":
|
|
299
|
+
md_parts.append("")
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
# Line-level prefix (heading wins over list)
|
|
303
|
+
if heading:
|
|
304
|
+
lvl = int(heading[1:]) if heading[1:].isdigit() else 1
|
|
305
|
+
md_parts.append("#" * min(lvl, 6) + " " + inline)
|
|
306
|
+
elif list_type.startswith("number"):
|
|
307
|
+
depth = int(list_type[-1]) - 1 if list_type[-1].isdigit() else 0
|
|
308
|
+
md_parts.append(" " * depth + f"{start_num}. " + inline)
|
|
309
|
+
elif list_type.startswith("indent") or list_type.startswith("bullet"):
|
|
310
|
+
depth = int(list_type[-1]) - 1 if list_type[-1].isdigit() else 0
|
|
311
|
+
md_parts.append(" " * depth + "- " + inline)
|
|
312
|
+
else:
|
|
313
|
+
md_parts.append(inline)
|
|
314
|
+
|
|
315
|
+
return "\n".join(md_parts)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _render_old_doc_spans(spans: list) -> str:
|
|
319
|
+
"""Render a list of ``(text, attrs)`` spans to inline Markdown."""
|
|
320
|
+
parts: list[str] = []
|
|
321
|
+
|
|
322
|
+
for raw_text, attrs in spans:
|
|
323
|
+
chunk = raw_text
|
|
324
|
+
|
|
325
|
+
# ── embedded block placeholder (mindnote / diagram) ───────────
|
|
326
|
+
meta_json = attrs.get("dataMetaBlockProps")
|
|
327
|
+
if meta_json:
|
|
328
|
+
try:
|
|
329
|
+
meta = json.loads(meta_json)
|
|
330
|
+
engine = meta.get("engineType", "block")
|
|
331
|
+
token = meta.get("token", "")
|
|
332
|
+
chunk = f"*[Embedded {engine}: {token}]*"
|
|
333
|
+
except (json.JSONDecodeError, TypeError):
|
|
334
|
+
chunk = "*[embedded block]*"
|
|
335
|
+
parts.append(chunk)
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
# ── find link URL ─────────────────────────────────────────────
|
|
339
|
+
link_url = None
|
|
340
|
+
for k, v in attrs.items():
|
|
341
|
+
if k.startswith("url-"):
|
|
342
|
+
link_url = _url_unquote(v)
|
|
343
|
+
break
|
|
344
|
+
if k.startswith("mention-link_"):
|
|
345
|
+
link_url = _url_unquote(k[len("mention-link_") :])
|
|
346
|
+
break
|
|
347
|
+
|
|
348
|
+
# ── inline formatting ─────────────────────────────────────────
|
|
349
|
+
bold = attrs.get("bold") == "true"
|
|
350
|
+
italic = attrs.get("italic") == "true"
|
|
351
|
+
code = attrs.get("code") == "true"
|
|
352
|
+
strike = attrs.get("strikethrough") == "true"
|
|
353
|
+
|
|
354
|
+
if code:
|
|
355
|
+
chunk = f"`{chunk}`"
|
|
356
|
+
elif bold and italic:
|
|
357
|
+
chunk = f"***{chunk}***"
|
|
358
|
+
elif bold:
|
|
359
|
+
chunk = f"**{chunk}**"
|
|
360
|
+
elif italic:
|
|
361
|
+
chunk = f"*{chunk}*"
|
|
362
|
+
|
|
363
|
+
if strike:
|
|
364
|
+
chunk = f"~~{chunk}~~"
|
|
365
|
+
|
|
366
|
+
if link_url:
|
|
367
|
+
chunk = f"[{chunk}]({link_url})"
|
|
368
|
+
|
|
369
|
+
parts.append(chunk)
|
|
370
|
+
|
|
371
|
+
return "".join(parts)
|
|
372
|
+
|
|
373
|
+
|
|
163
374
|
# ── attributed text (Etherpad/Changeset format) ────────────────────────────────
|
|
164
375
|
|
|
165
376
|
def _parse_attribs(attribs_str: str, num_to_attrib: dict) -> list:
|
|
166
377
|
"""
|
|
167
378
|
Parse an Etherpad changeset attrib string into segments.
|
|
168
379
|
Counts are base-36 encoded (e.g. '+h' = 17 chars, '+11' = 37 chars).
|
|
380
|
+
``|L`` before ``+N`` means "N chars include L newlines" — we skip the L
|
|
381
|
+
since the newlines are already in the text.
|
|
169
382
|
Returns list of (char_count, {attr_key: attr_value}).
|
|
170
383
|
"""
|
|
171
384
|
segments = []
|
|
@@ -176,16 +389,22 @@ def _parse_attribs(attribs_str: str, num_to_attrib: dict) -> list:
|
|
|
176
389
|
if c == "*":
|
|
177
390
|
i += 1
|
|
178
391
|
j = i
|
|
179
|
-
while j < len(attribs_str) and attribs_str[j] not in "
|
|
392
|
+
while j < len(attribs_str) and attribs_str[j] not in "+*|":
|
|
180
393
|
j += 1
|
|
181
394
|
pair = num_to_attrib.get(attribs_str[i:j])
|
|
182
395
|
if pair and len(pair) == 2 and pair[1] not in ("", "false", None):
|
|
183
396
|
current_attrs[pair[0]] = pair[1]
|
|
184
397
|
i = j
|
|
398
|
+
elif c == "|":
|
|
399
|
+
# |L prefix: skip the newline count L; the following +N has the
|
|
400
|
+
# total character count we need.
|
|
401
|
+
i += 1
|
|
402
|
+
while i < len(attribs_str) and attribs_str[i] not in "+*|":
|
|
403
|
+
i += 1
|
|
185
404
|
elif c == "+":
|
|
186
405
|
i += 1
|
|
187
406
|
j = i
|
|
188
|
-
while j < len(attribs_str) and attribs_str[j] not in "
|
|
407
|
+
while j < len(attribs_str) and attribs_str[j] not in "+*|":
|
|
189
408
|
j += 1
|
|
190
409
|
raw = attribs_str[i:j]
|
|
191
410
|
count = int(raw, 36) if raw else 0
|
|
@@ -975,6 +1194,40 @@ def convert(page, url: str, out_dir: Path, save_html: bool = False) -> Path:
|
|
|
975
1194
|
"Make sure you are logged in and the document is accessible."
|
|
976
1195
|
)
|
|
977
1196
|
|
|
1197
|
+
# ── old-format DOC: completely different conversion path ────────────
|
|
1198
|
+
if _is_old_doc(ssr_html):
|
|
1199
|
+
print("[feishu2md] old-format DOC detected, using legacy converter…")
|
|
1200
|
+
title, doc_text, doc_attribs, doc_nta, doc_res = (
|
|
1201
|
+
_extract_old_doc_data(ssr_html)
|
|
1202
|
+
)
|
|
1203
|
+
safe = _safe_title(title)
|
|
1204
|
+
print(f"[feishu2md] document: {title}")
|
|
1205
|
+
|
|
1206
|
+
doc_dir = out_dir / safe
|
|
1207
|
+
doc_dir.mkdir(parents=True, exist_ok=True)
|
|
1208
|
+
|
|
1209
|
+
body = _old_doc_to_markdown(doc_text, doc_attribs, doc_nta, doc_res)
|
|
1210
|
+
|
|
1211
|
+
# Skip the first line of the body if it duplicates the title
|
|
1212
|
+
body_lines = body.split("\n")
|
|
1213
|
+
if body_lines and body_lines[0].strip() == title.strip():
|
|
1214
|
+
body = "\n".join(body_lines[1:]).lstrip("\n")
|
|
1215
|
+
|
|
1216
|
+
md = f"# {_strip_zero_width(title)}\n\n{body}"
|
|
1217
|
+
md = re.sub(r"\n{3,}", "\n\n", md)
|
|
1218
|
+
print(f" {len(md):,} chars")
|
|
1219
|
+
|
|
1220
|
+
md_path = doc_dir / f"{safe}.md"
|
|
1221
|
+
md_path.write_text(md, encoding="utf-8")
|
|
1222
|
+
print(f"[feishu2md] done → {md_path}")
|
|
1223
|
+
|
|
1224
|
+
if save_html:
|
|
1225
|
+
html_path = doc_dir / f"{safe}_ssr.html"
|
|
1226
|
+
shutil.copy2(tmp_dir / ssr_file, html_path)
|
|
1227
|
+
print(f"[feishu2md] html → {html_path}")
|
|
1228
|
+
|
|
1229
|
+
return md_path
|
|
1230
|
+
|
|
978
1231
|
# ── extract block_map ─────────────────────────────────────────────────
|
|
979
1232
|
print("[feishu2md] extracting block map…")
|
|
980
1233
|
block_map, title = _extract_block_map(ssr_html)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "drissionpage-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "DrissionPage CLI - Token-efficient browser automation for coding agents"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "Apache-2.0"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|