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.
Files changed (25) hide show
  1. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/PKG-INFO +1 -1
  2. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/__init__.py +117 -8
  3. drissionpage_cli-0.2.0/drissionpage_cli/_compat.py +105 -0
  4. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/_feishu.py +266 -13
  5. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/PKG-INFO +1 -1
  6. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/SOURCES.txt +1 -0
  7. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/pyproject.toml +1 -1
  8. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/LICENSE +0 -0
  9. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/README.md +0 -0
  10. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/__main__.py +0 -0
  11. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/SKILL.md +0 -0
  12. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/dual-mode.md +0 -0
  13. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/element-locators.md +0 -0
  14. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/network-listening.md +0 -0
  15. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/running-code.md +0 -0
  16. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/screenshots-pdf.md +0 -0
  17. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/session-management.md +0 -0
  18. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli/skills/drissionpage-cli/references/storage-state.md +0 -0
  19. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/dependency_links.txt +0 -0
  20. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/entry_points.txt +0 -0
  21. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/requires.txt +0 -0
  22. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/drissionpage_cli.egg-info/top_level.txt +0 -0
  23. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/setup.cfg +0 -0
  24. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/tests/test_integration.py +0 -0
  25. {drissionpage_cli-0.1.8 → drissionpage_cli-0.2.0}/tests/test_unit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drissionpage-cli
3
- Version: 0.1.8
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
@@ -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 _cli_profile_path():
79
- """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"
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 at ~/.drissionpage-cli/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
- profile_path = (options and options.get("user_data_path")) or str(_cli_profile_path())
164
- _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)
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
- # Detect old-format "DOC" (wikcn prefix) — uses a completely different structure
118
- if '"type":"DOC"' in ssr_html or '"type": "DOC"' in ssr_html:
119
- # Best-effort title extraction from old format
120
- m = re.search(r'window\.SERVER_DATA\s*=\s*Object\(\{"meta":\{"title":"([^"]+)"', ssr_html)
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drissionpage-cli
3
- Version: 0.1.8
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
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  drissionpage_cli/__init__.py
5
5
  drissionpage_cli/__main__.py
6
+ drissionpage_cli/_compat.py
6
7
  drissionpage_cli/_feishu.py
7
8
  drissionpage_cli.egg-info/PKG-INFO
8
9
  drissionpage_cli.egg-info/SOURCES.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "drissionpage-cli"
7
- version = "0.1.8"
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"}