yuque-cli 0.1.1__tar.gz → 1.0.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 (40) hide show
  1. yuque_cli-1.0.0/.claude/settings.json +15 -0
  2. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/PKG-INFO +3 -3
  3. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/README.md +2 -2
  4. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/pyproject.toml +1 -1
  5. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/cli.py +169 -41
  6. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/client.py +89 -4
  7. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/output.py +42 -2
  8. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/urls.py +21 -5
  9. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_cli.py +85 -35
  10. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_client.py +68 -1
  11. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_urls.py +33 -9
  12. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/uv.lock +1 -1
  13. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/.claude/settings.local.json +0 -0
  14. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/.gitignore +0 -0
  15. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/.python-version +0 -0
  16. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/.vscode/extensions.json +0 -0
  17. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/.vscode/settings.json +0 -0
  18. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/AGENTS.md +0 -0
  19. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/CLAUDE.md +0 -0
  20. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/CONTEXT.md +0 -0
  21. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/docs/adr/0001-use-internal-web-api-with-cookie-auth.md +0 -0
  22. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/docs/adr/0002-cookie-acquisition-cdp-with-manual-fallback.md +0 -0
  23. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/docs/adr/0003-doc-update-fetch-then-merge.md +0 -0
  24. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/docs/internal-api.md +0 -0
  25. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/main.py +0 -0
  26. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/mise.toml +0 -0
  27. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/__init__.py +0 -0
  28. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/__main__.py +0 -0
  29. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/appdata.py +0 -0
  30. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/auth.py +0 -0
  31. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/config.py +0 -0
  32. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/errors.py +0 -0
  33. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/inputs.py +0 -0
  34. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/session.py +0 -0
  35. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_appdata.py +0 -0
  36. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_auth.py +0 -0
  37. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_config.py +0 -0
  38. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_inputs.py +0 -0
  39. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_output.py +0 -0
  40. {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_session.py +0 -0
@@ -0,0 +1,15 @@
1
+ {
2
+ "hooks": {
3
+ "Stop": [
4
+ {
5
+ "matcher": "EDIT|Write",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "if git diff --name-only HEAD 2>/dev/null | grep -q '^src/\\|^pyproject.toml'; then current=$(grep '^version = ' pyproject.toml | head -1 | sed 's/version = \"\\(.*\\)\"/\\1/'); IFS='.' read -r major minor patch <<< \"$current\"; new=\"$major.$minor.$((patch + 1))\"; sed -i '' \"s/^version = \\\".*\\\"/version = \\\"$new\\\"/\" pyproject.toml && echo \"[版本升级] $current → $new (patch)\"; fi"
10
+ }
11
+ ]
12
+ }
13
+ ]
14
+ }
15
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yuque-cli
3
- Version: 0.1.1
3
+ Version: 1.0.0
4
4
  Summary: 针对语雀的命令行客户端:cookie 认证,驱动内部 web 接口,提供文档与评论的 CRUD
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: httpx>=0.27
@@ -101,12 +101,12 @@ yuque -y doc delete team/handbook/old-draft
101
101
  | 命令 | 说明 |
102
102
  | --- | --- |
103
103
  | `comment list <space>/<repo>/<slug>` | 列出评论 |
104
- | `comment add <space>/<repo>/<slug> [-b/--body \| -F/--file]` | 添加评论 |
104
+ | `comment create <space>/<repo>/<slug> [-b/--body \| -F/--file]` | 添加评论 |
105
105
  | `comment delete <comment_id>` | 删除评论 |
106
106
 
107
107
  ## 正文输入
108
108
 
109
- `doc create` / `comment add` 正文来源(`--body` 与 `--file` 互斥):
109
+ `doc create` / `comment create` 正文来源(`--body` 与 `--file` 互斥):
110
110
 
111
111
  1. `-b/--body` 内联字符串
112
112
  2. `-F/--file` 从文件读(`-F -` 读 stdin)
@@ -91,12 +91,12 @@ yuque -y doc delete team/handbook/old-draft
91
91
  | 命令 | 说明 |
92
92
  | --- | --- |
93
93
  | `comment list <space>/<repo>/<slug>` | 列出评论 |
94
- | `comment add <space>/<repo>/<slug> [-b/--body \| -F/--file]` | 添加评论 |
94
+ | `comment create <space>/<repo>/<slug> [-b/--body \| -F/--file]` | 添加评论 |
95
95
  | `comment delete <comment_id>` | 删除评论 |
96
96
 
97
97
  ## 正文输入
98
98
 
99
- `doc create` / `comment add` 正文来源(`--body` 与 `--file` 互斥):
99
+ `doc create` / `comment create` 正文来源(`--body` 与 `--file` 互斥):
100
100
 
101
101
  1. `-b/--body` 内联字符串
102
102
  2. `-F/--file` 从文件读(`-F -` 读 stdin)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "yuque-cli"
3
- version = "0.1.1"
3
+ version = "1.0.0"
4
4
  description = "针对语雀的命令行客户端:cookie 认证,驱动内部 web 接口,提供文档与评论的 CRUD"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
4
+ import subprocess
3
5
  import sys
6
+ import time
4
7
  from contextlib import contextmanager
5
8
  from dataclasses import dataclass
6
9
  from typing import Annotated, Any, Callable, Optional
@@ -12,7 +15,7 @@ from . import __version__, auth, config, output, session
12
15
  from .appdata import AppDataError
13
16
  from .appdata import repo_id as appdata_repo_id
14
17
  from .appdata import doc_id as appdata_doc_id
15
- from .client import Client
18
+ from .client import Client, _extract_paragraphs
16
19
  from .errors import AuthExpired, YuqueError
17
20
  from .inputs import BodyInputError, resolve_body
18
21
  from .session import SessionError
@@ -83,7 +86,7 @@ def cli_errors():
83
86
  raise typer.Exit(1)
84
87
 
85
88
 
86
- def build_client(state: AppState) -> Client:
89
+ def build_client(state: AppState, host_hint: str | None = None) -> Client:
87
90
  sess = session.load(config.session_file())
88
91
  hooks: dict[str, list[Callable[..., Any]]] = {}
89
92
  if state.verbose:
@@ -96,11 +99,20 @@ def build_client(state: AppState) -> Client:
96
99
  # trust_env=False:默认直连,绕开 *_proxy 环境变量(如本地 Clash 的 socks5 代理)——
97
100
  # 避免请求被本地代理拦截,也免去 SOCKS 需 socksio 的崩溃。如需经代理访问,移除此参数并装 httpx[socks]。
98
101
  http = httpx.Client(timeout=30.0, event_hooks=hooks, trust_env=False)
99
- return Client(http, sess, base_url=config.base_url(state.host))
102
+ # --host 显式指定优先;否则从 URL 提取的 host 作为 fallback
103
+ return Client(http, sess, base_url=config.base_url(state.host or host_hint))
104
+
105
+
106
+ def _pager(text: str) -> None:
107
+ """当 stdout 是终端时通过 less 分页显示,否则直接输出。"""
108
+ if sys.stdout.isatty():
109
+ subprocess.run(["less", "-FRX"], input=text, text=True)
110
+ else:
111
+ typer.echo(text)
100
112
 
101
113
 
102
114
  def _emit(state: AppState, data, human: str) -> None:
103
- typer.echo(output.dumps(data) if state.json else human)
115
+ _pager(output.dumps(data) if state.json else human)
104
116
 
105
117
 
106
118
  def _confirm(state: AppState, message: str) -> None:
@@ -125,8 +137,10 @@ def _resolve_repo_ref(
125
137
  if not has_url and not has_opts:
126
138
  raise YuqueError("请提供 URL 或指定 --space 和 --repo")
127
139
  if has_url:
140
+ assert url is not None
128
141
  return parse_repo_ref(url)
129
- return RepoRef(space=space, repo=repo) # type: ignore[arg-type]
142
+ assert space is not None and repo is not None
143
+ return RepoRef(space=space, repo=repo)
130
144
 
131
145
 
132
146
  def _resolve_doc_ref(
@@ -164,40 +178,100 @@ def _prompt_cookie() -> str:
164
178
  return typer.prompt("cookie")
165
179
 
166
180
 
167
- _CDP_HINT = (
168
- f"未检测到 Chrome 远程调试(DevToolsActivePort / 127.0.0.1:{auth.CDP_PORT})。\n"
169
- "如需自动化登录:完全退出 Chrome,再带远程调试启动并登录目标语雀站点,例如 macOS:\n"
170
- f' "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" '
171
- f"--remote-debugging-port={auth.CDP_PORT}\n"
172
- "随后重试 yuque auth login。现在可手动粘贴 cookie 完成登录。"
173
- )
181
+ _CDP_URL = "chrome://inspect/#remote-debugging"
182
+
183
+
184
+ def _open_cdp_hint() -> None:
185
+ """在 Chrome 中打开远程调试说明页面。"""
186
+ import platform
187
+ if platform.system() == "Darwin":
188
+ subprocess.run(["open", "-a", "Google Chrome", _CDP_URL], check=False)
189
+ elif platform.system() == "Windows":
190
+ subprocess.run(["start", "", _CDP_URL], shell=True, check=False)
191
+ else:
192
+ subprocess.run(["google-chrome", _CDP_URL], check=False)
193
+
194
+
195
+ _CDP_DONE_MSG = "建议及时关闭 Chrome 远程调试端口,避免安全风险"
196
+
197
+
198
+ def _try_cdp_login(base: str, state: AppState) -> dict:
199
+ """尝试 CDP 获取 cookie;失败后打开浏览器并持续轮询直至成功。"""
200
+ # 首次尝试:可能已经开启
201
+ ws_url = auth.cdp_websocket_url(verbose=state.verbose)
202
+ if ws_url:
203
+ try:
204
+ cookies = auth.cookies_via_cdp(base, ws_url)
205
+ except auth.CdpUnavailable:
206
+ pass
207
+ else:
208
+ host = state.host or config.host()
209
+ typer.secho(f"已通过 CDP 从浏览器获取 {host} 登录态", fg="green", err=True)
210
+ typer.secho(_CDP_DONE_MSG, fg="yellow", err=True)
211
+ return cookies
212
+
213
+ # 打开浏览器引导开启远程调试
214
+ _open_cdp_hint()
215
+ typer.secho(
216
+ f"等待 Chrome 远程调试开启({_CDP_URL}),Ctrl+C 可中断...",
217
+ fg="yellow", err=True,
218
+ )
219
+
220
+ # 轮询直至成功
221
+ spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
222
+ tick = 0
223
+ try:
224
+ while True:
225
+ # 用 spinner 替代 sleep,视觉上不卡死
226
+ for _ in range(10):
227
+ char = spinner[tick % len(spinner)]
228
+ sys.stderr.write(f"\r {char} 等待 Chrome 远程调试...")
229
+ sys.stderr.flush()
230
+ time.sleep(0.2)
231
+ tick += 1
232
+ ws_url = auth.cdp_websocket_url(verbose=state.verbose)
233
+ if ws_url is None:
234
+ continue
235
+ try:
236
+ cookies = auth.cookies_via_cdp(base, ws_url)
237
+ sys.stderr.write("\r" + " " * 40 + "\r")
238
+ sys.stderr.flush()
239
+ host = state.host or config.host()
240
+ typer.secho(f"已通过 CDP 从浏览器获取 {host} 登录态", fg="green", err=True)
241
+ typer.secho(_CDP_DONE_MSG, fg="yellow", err=True)
242
+ return cookies
243
+ except auth.CdpUnavailable:
244
+ continue
245
+ except KeyboardInterrupt:
246
+ sys.stderr.write("\r" + " " * 40 + "\r")
247
+ sys.stderr.flush()
248
+ raise
174
249
 
175
250
 
176
251
  @auth_app.command("login")
177
252
  def auth_login(
178
253
  ctx: typer.Context,
179
254
  manual: Annotated[
180
- bool, typer.Option("--manual", help="跳过 CDP,直接手动粘贴 cookie")
255
+ bool, typer.Option("--manual", help="跳过 CDP,交互式粘贴 cookie")
181
256
  ] = False,
257
+ cookie: Annotated[
258
+ Optional[str], typer.Option("--cookie", help="直接提供 cookie 串完成登录")
259
+ ] = None,
182
260
  ) -> None:
183
- """登录:默认探测本地 CDP 自动获取登录态;未启用或失败时回退手动粘贴。"""
261
+ """登录:默认探测本地 CDP 自动获取登录态;也可用 --cookie 或 --manual 手动登录。"""
184
262
  with cli_errors():
185
263
  state: AppState = ctx.obj
186
264
  base = config.base_url(state.host)
187
- ws_url = None if manual else auth.cdp_websocket_url(verbose=state.verbose)
188
- if ws_url is None:
189
- if not manual:
190
- typer.secho(_CDP_HINT, fg="yellow", err=True)
265
+ if cookie is not None:
266
+ cookies = auth.cookies_via_manual(lambda: cookie)
267
+ elif manual:
191
268
  cookies = auth.cookies_via_manual(_prompt_cookie)
192
269
  else:
193
270
  try:
194
- cookies = auth.cookies_via_cdp(base, ws_url)
195
- typer.secho(
196
- f"已通过 CDP 从浏览器获取 {state.host or config.host()} 登录态", fg="green", err=True
197
- )
198
- except auth.CdpUnavailable as exc:
199
- typer.secho(f"CDP 已连接但获取失败:{exc}", fg="yellow", err=True)
200
- cookies = auth.cookies_via_manual(_prompt_cookie)
271
+ cookies = _try_cdp_login(base, state)
272
+ except KeyboardInterrupt:
273
+ typer.echo("", err=True)
274
+ raise typer.Exit(1)
201
275
  sess = session.Session(cookies)
202
276
  sess.require_session()
203
277
  session.save(sess, config.session_file())
@@ -236,6 +310,26 @@ def repo_list(ctx: typer.Context) -> None:
236
310
  _emit(state, repos, output.render_repos(repos))
237
311
 
238
312
 
313
+ @repo_app.command("get")
314
+ def repo_get(
315
+ ctx: typer.Context,
316
+ url: UrlArg = None,
317
+ space: SpaceOpt = None,
318
+ repo: RepoOpt = None,
319
+ ) -> None:
320
+ """获取指定知识库的信息(GET /api/books/{id}/overview)。"""
321
+ state: AppState = ctx.obj
322
+ with cli_errors():
323
+ ref = _resolve_repo_ref(url, space, repo)
324
+ client = build_client(state, host_hint=ref.host)
325
+ repo_id = client.resolve_repo_id(ref.space, ref.repo)
326
+ data = client.repo_overview(repo_id)
327
+ docs = client.repo_docs(repo_id)
328
+ if state.json:
329
+ data["docs"] = docs
330
+ _emit(state, data, output.render_repo_overview(data, docs))
331
+
332
+
239
333
  # -- doc --------------------------------------------------------------------
240
334
 
241
335
  UrlArg = Annotated[Optional[str], typer.Argument(help="目标 URL 或去 host 的路径形态")]
@@ -256,8 +350,9 @@ def doc_list(ctx: typer.Context, url: UrlArg) -> None:
256
350
  """列出知识库下的文档。"""
257
351
  state: AppState = ctx.obj
258
352
  with cli_errors():
353
+ assert url is not None
259
354
  ref = parse_repo_ref(url)
260
- client = build_client(state)
355
+ client = build_client(state, host_hint=ref.host)
261
356
  repo_id = client.resolve_repo_id(ref.space, ref.repo)
262
357
  docs = client.repo_docs(repo_id)
263
358
  _emit(state, docs, output.render_docs(docs))
@@ -265,15 +360,28 @@ def doc_list(ctx: typer.Context, url: UrlArg) -> None:
265
360
 
266
361
  @doc_app.command("get")
267
362
  def doc_get(ctx: typer.Context, url: UrlArg) -> None:
268
- """取文档正文:默认干净 Markdown;--json 出详情(含 Lake)。"""
363
+ """取文档正文:默认干净 Markdown;--json 出详情(含段落结构)。"""
269
364
  state: AppState = ctx.obj
270
365
  with cli_errors():
366
+ assert url is not None
271
367
  ref = parse_doc_ref(url)
272
- client = build_client(state)
368
+ client = build_client(state, host_hint=ref.host)
273
369
  if state.json:
274
370
  app_data = client.doc_page_app_data(ref.space, ref.repo, ref.slug)
275
371
  detail = client.doc_detail(ref.slug, appdata_repo_id(app_data))
276
- typer.echo(output.dumps(detail))
372
+ doc_meta = app_data.get("doc", {})
373
+ book_meta = app_data.get("book", {})
374
+ lake_body = detail.get("content") or detail.get("body_asl") or detail.get("body", "")
375
+ combined = {
376
+ "doc": detail,
377
+ "doc_version_id": doc_meta.get("doc_version_id"),
378
+ "book": {
379
+ "id": book_meta.get("id") or appdata_repo_id(app_data),
380
+ "slug": ref.repo,
381
+ },
382
+ "paragraphs": _extract_paragraphs(lake_body) if lake_body else [],
383
+ }
384
+ typer.echo(output.dumps(combined))
277
385
  else:
278
386
  typer.echo(client.doc_markdown(ref.space, ref.repo, ref.slug))
279
387
 
@@ -297,13 +405,13 @@ def doc_create(
297
405
  raise YuqueError("--title 为必填项")
298
406
  ref = _resolve_repo_ref(url, space, repo)
299
407
  text = _read_body(body, file)
300
- client = build_client(state)
408
+ client = build_client(state, host_hint=ref.host)
301
409
  repo_id = client.resolve_repo_id(ref.space, ref.repo)
302
410
  pub = None if public is None else (1 if public else 0)
303
411
  created = client.create_doc(
304
412
  repo_id=repo_id, title=title, body=text, slug=slug, public=pub
305
413
  )
306
- url_out = f"{config.base_url(state.host)}/{ref.space}/{ref.repo}/{created.get('slug', '')}"
414
+ url_out = f"{config.base_url(state.host or ref.host)}/{ref.space}/{ref.repo}/{created.get('slug', '')}"
307
415
  _emit(state, created, f"已创建:{url_out}")
308
416
 
309
417
 
@@ -326,7 +434,7 @@ def doc_update(
326
434
  "没有要更新的字段(至少给 --title / 正文 / --public|--private 之一)"
327
435
  )
328
436
  ref = _resolve_doc_ref(url, space, repo)
329
- client = build_client(state)
437
+ client = build_client(state, host_hint=ref.host)
330
438
  app_data = client.doc_page_app_data(ref.space, ref.repo, ref.slug)
331
439
  doc_id = appdata_doc_id(app_data)
332
440
  meta = app_data.get("doc", {})
@@ -339,7 +447,7 @@ def doc_update(
339
447
  updated = client.update_doc(
340
448
  doc_id=doc_id, title=new_title, body=new_body, public=new_public
341
449
  )
342
- url_out = f"{config.base_url(state.host)}/{ref.space}/{ref.repo}/{ref.slug}"
450
+ url_out = f"{config.base_url(state.host or ref.host)}/{ref.space}/{ref.repo}/{ref.slug}"
343
451
  _emit(state, updated, f"已更新:{url_out}")
344
452
 
345
453
 
@@ -348,8 +456,9 @@ def doc_delete(ctx: typer.Context, url: UrlArg) -> None:
348
456
  """删除文档(软删,可在回收站恢复)。"""
349
457
  state: AppState = ctx.obj
350
458
  with cli_errors():
459
+ assert url is not None
351
460
  ref = parse_doc_ref(url)
352
- client = build_client(state)
461
+ client = build_client(state, host_hint=ref.host)
353
462
  app_data = client.doc_page_app_data(ref.space, ref.repo, ref.slug)
354
463
  doc_id = appdata_doc_id(app_data)
355
464
  title = app_data.get("doc", {}).get("title", ref.slug)
@@ -366,27 +475,46 @@ def comment_list(ctx: typer.Context, url: UrlArg) -> None:
366
475
  """列出文档的评论。"""
367
476
  state: AppState = ctx.obj
368
477
  with cli_errors():
478
+ assert url is not None
369
479
  ref = parse_doc_ref(url)
370
- client = build_client(state)
480
+ client = build_client(state, host_hint=ref.host)
371
481
  _, doc_id = client.resolve_doc_ids(ref.space, ref.repo, ref.slug)
372
482
  items = client.comments(doc_id)
373
483
  _emit(state, items, output.render_comments(items))
374
484
 
375
485
 
376
- @comment_app.command("add")
377
- def comment_add(
378
- ctx: typer.Context, url: UrlArg, body: BodyOpt = None, file: FileOpt = None
486
+ SelectionOpt = Annotated[
487
+ Optional[str],
488
+ typer.Option("--selection", "-s", help="划词评论的 selection JSON(如提供则为划词评论)"),
489
+ ]
490
+
491
+
492
+ @comment_app.command("create")
493
+ def comment_create(
494
+ ctx: typer.Context,
495
+ url: UrlArg,
496
+ body: BodyOpt = None,
497
+ file: FileOpt = None,
498
+ selection: SelectionOpt = None,
379
499
  ) -> None:
380
- """给文档添加评论。"""
500
+ """给文档添加评论。带 --selection 时为划词评论。"""
381
501
  state: AppState = ctx.obj
382
502
  with cli_errors():
503
+ assert url is not None
383
504
  ref = parse_doc_ref(url)
384
505
  text = _read_body(body, file)
385
506
  if not text:
386
507
  raise YuqueError("评论正文不能为空(用 --body 或 --file/-)")
387
- client = build_client(state)
508
+ client = build_client(state, host_hint=ref.host)
388
509
  _, doc_id = client.resolve_doc_ids(ref.space, ref.repo, ref.slug)
389
- created = client.create_comment(doc_id=doc_id, body=text)
510
+ if selection is not None:
511
+ try:
512
+ sel = json.loads(selection)
513
+ except json.JSONDecodeError as exc:
514
+ raise YuqueError(f"--selection JSON 解析失败:{exc}") from exc
515
+ created = client.create_comment(doc_id=doc_id, body=text, selection=sel)
516
+ else:
517
+ created = client.create_comment(doc_id=doc_id, body=text)
390
518
  _emit(state, created, f"已评论(id={created.get('id', '')})")
391
519
 
392
520
 
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import secrets
3
4
  from typing import Any
4
5
 
5
6
  import httpx
@@ -14,6 +15,63 @@ USER_AGENT = (
14
15
  )
15
16
 
16
17
 
18
+ def _random_id() -> str:
19
+ return "u" + secrets.token_hex(4)
20
+
21
+
22
+ def _random_hash() -> str:
23
+ return secrets.token_hex(32)
24
+
25
+
26
+ def _build_body_asl(text: str) -> tuple[str, str, str]:
27
+ pid = _random_id()
28
+ sid = _random_id()
29
+ h = _random_hash()
30
+ body_asl = (
31
+ '<!doctype lake>'
32
+ '<meta name="doc-version" content="1" />'
33
+ '<meta name="viewport" content="adapt" />'
34
+ f'<p data-lake-id="{pid}" id="{pid}">'
35
+ f'<span data-lake-id="{sid}" id="{sid}">{text}</span>'
36
+ f'</p>'
37
+ f'<!{h}>'
38
+ )
39
+ return body_asl, pid, sid
40
+
41
+
42
+ def _build_body(text: str, pid: str, sid: str) -> str:
43
+ return (
44
+ '<div class="lake-content" typography="traditional">'
45
+ f'<p id="{pid}" class="ne-p">'
46
+ f'<span class="ne-text">{text}</span>'
47
+ f'</p>'
48
+ f'</div>'
49
+ )
50
+
51
+
52
+ def _html_to_text(html: str) -> str:
53
+ import re
54
+
55
+ text = re.sub(r"<[^>]+>", "", html or "")
56
+ text = text.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
57
+ return text
58
+
59
+
60
+ def _extract_paragraphs(html: str) -> list[dict]:
61
+ import re
62
+
63
+ paras: list[dict] = []
64
+ for pm in re.finditer(r'<p[^>]*\s+id="([^"]*)"[^>]*>(.*?)</p>', html, re.DOTALL):
65
+ pid = pm.group(1)
66
+ raw = pm.group(2)
67
+ text = _html_to_text(raw)
68
+ spans: list[dict] = []
69
+ for sm in re.finditer(r'<span[^>]*\s+id="([^"]*)"[^>]*>(.*?)</span>', raw, re.DOTALL):
70
+ spans.append({"id": sm.group(1), "text": _html_to_text(sm.group(2))})
71
+ paras.append({"id": pid, "text": text, "spans": spans})
72
+ return paras
73
+
74
+
17
75
  class Client:
18
76
  """语雀内部 web API 客户端。注入 httpx.Client 与 Session 以便测试。"""
19
77
 
@@ -92,11 +150,27 @@ class Client:
92
150
  """用户所属的组织/团队列表(GET /api/mine/group_quick_links)。"""
93
151
  return self._data(self._request("GET", f"{self.base_url}/api/mine/group_quick_links"))
94
152
 
95
- def repo_docs(self, repo_id: int) -> list[dict]:
153
+ def repo_overview(self, repo_id: int) -> dict:
96
154
  return self._data(
97
- self._request("GET", f"{self.base_url}/api/books/{repo_id}/docs")
155
+ self._request("GET", f"{self.base_url}/api/books/{repo_id}/overview")
98
156
  )
99
157
 
158
+ def repo_docs(self, repo_id: int) -> list[dict]:
159
+ all_docs: list[dict] = []
160
+ offset = 0
161
+ while True:
162
+ page = self._data(
163
+ self._request(
164
+ "GET", f"{self.base_url}/api/docs",
165
+ params={"book_id": repo_id, "offset": offset}
166
+ )
167
+ )
168
+ if not page:
169
+ break
170
+ all_docs.extend(page)
171
+ offset += len(page)
172
+ return all_docs
173
+
100
174
  # -- 文档 ---------------------------------------------------------------
101
175
 
102
176
  def doc_markdown(self, space: str, repo: str, slug: str) -> str:
@@ -173,8 +247,19 @@ class Client:
173
247
  )
174
248
  )
175
249
 
176
- def create_comment(self, *, doc_id: int, body: str) -> dict:
177
- payload = {"commentable_id": doc_id, "commentable_type": "Doc", "body": body}
250
+ def create_comment(self, *, doc_id: int, body: str, selection: dict | None = None) -> dict:
251
+ payload: dict[str, Any] = {
252
+ "commentable_id": doc_id,
253
+ "commentable_type": "Doc",
254
+ "body": body,
255
+ }
256
+ if selection is not None:
257
+ body_asl, pid, sid = _build_body_asl(body)
258
+ payload["type"] = "WORDING_COMMENT"
259
+ payload["format"] = "lake"
260
+ payload["body_asl"] = body_asl
261
+ payload["body"] = _build_body(body, pid, sid)
262
+ payload["selection"] = selection
178
263
  return self._data(
179
264
  self._request(
180
265
  "POST", f"{self.base_url}/api/comments", json_body=payload, write=True
@@ -63,16 +63,56 @@ def render_docs(docs: list[dict]) -> str:
63
63
  return table(["TITLE", "SLUG", "ID"], rows)
64
64
 
65
65
 
66
+ def _strip_html(html: str) -> str:
67
+ """提取 HTML 中的纯文本。"""
68
+ import re
69
+
70
+ text = re.sub(r"<[^>]+>", "", html or "")
71
+ text = text.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
72
+ return text.strip()
73
+
74
+
66
75
  def render_comments(comments: list[dict]) -> str:
67
76
  rows = [
68
77
  [
69
78
  str(c.get("id", "")),
70
79
  str((c.get("user") or {}).get("name", "")),
71
- _first_line(c.get("body", "")),
80
+ "划词" if c.get("type") == "WORDING_COMMENT" else "正文",
81
+ _first_line(
82
+ _strip_html(c.get("body", ""))
83
+ if c.get("type") == "WORDING_COMMENT"
84
+ else (c.get("body", "") or "")
85
+ ),
72
86
  ]
73
87
  for c in comments
74
88
  ]
75
- return table(["ID", "AUTHOR", "BODY"], rows)
89
+ return table(["ID", "AUTHOR", "TYPE", "BODY"], rows)
90
+
91
+
92
+ def render_repo_overview(data: dict, docs: list[dict] | None = None) -> str:
93
+ """通用渲染:按 key 逐行展示,遇到 dict 取其 name/login/id,其它值直接展示。"""
94
+ _SKIP_KEYS = {"enableCustomBody", "enableCatalog", "order", "layout", "customIndex", "enableUserFeed"}
95
+ lines: list[str] = []
96
+ for key, val in data.items():
97
+ if key in _SKIP_KEYS:
98
+ continue
99
+ if isinstance(val, dict):
100
+ val = val.get("name") or val.get("login") or val.get("id") or json.dumps(val, ensure_ascii=False)
101
+ elif isinstance(val, list):
102
+ if val and isinstance(val[0], dict):
103
+ val = ", ".join(v.get("name") or str(v) for v in val)
104
+ else:
105
+ val = ", ".join(str(v) for v in val)
106
+ elif isinstance(val, bool):
107
+ val = "yes" if val else "no"
108
+ elif val is None:
109
+ val = ""
110
+ lines.append(f"{key}: {val}")
111
+ if docs:
112
+ lines.append("")
113
+ lines.append(f"文档数量: {len(docs)}")
114
+ lines.append(render_docs(docs))
115
+ return "\n".join(lines)
76
116
 
77
117
 
78
118
  def render_spaces(spaces: list[dict]) -> str:
@@ -12,6 +12,7 @@ class UrlParseError(ValueError):
12
12
  class RepoRef:
13
13
  space: str
14
14
  repo: str
15
+ host: str | None = None
15
16
 
16
17
 
17
18
  @dataclass(frozen=True)
@@ -19,10 +20,11 @@ class DocRef:
19
20
  space: str
20
21
  repo: str
21
22
  slug: str
23
+ host: str | None = None
22
24
 
23
25
  @property
24
26
  def repo_ref(self) -> RepoRef:
25
- return RepoRef(self.space, self.repo)
27
+ return RepoRef(self.space, self.repo, host=self.host)
26
28
 
27
29
 
28
30
  def _segments(raw: str) -> list[str]:
@@ -42,14 +44,28 @@ def _segments(raw: str) -> list[str]:
42
44
  return [seg for seg in path.split("/") if seg]
43
45
 
44
46
 
47
+ def extract_host(raw: str) -> str | None:
48
+ """从完整 URL 或带 host 的路径中提取 host;无法识别时返回 None。"""
49
+ s = raw.strip()
50
+ if not s:
51
+ return None
52
+ if "://" in s:
53
+ return urlsplit(s).hostname
54
+ # 无 scheme:首段含 "." 即视为 host
55
+ parts = s.split("/")
56
+ if parts and "." in parts[0]:
57
+ return parts[0]
58
+ return None
59
+
60
+
45
61
  def parse_doc_ref(raw: str) -> DocRef:
46
62
  segs = _segments(raw)
47
- if len(segs) != 3:
63
+ if len(segs) < 3:
48
64
  raise UrlParseError(
49
65
  f"文档地址应形如 {{space}}/{{repo}}/{{slug}}(三段),得到:{raw!r}"
50
66
  )
51
- space, repo, slug = segs
52
- return DocRef(space=space, repo=repo, slug=slug)
67
+ space, repo, slug = segs[:3]
68
+ return DocRef(space=space, repo=repo, slug=slug, host=extract_host(raw))
53
69
 
54
70
 
55
71
  def parse_repo_ref(raw: str) -> RepoRef:
@@ -60,4 +76,4 @@ def parse_repo_ref(raw: str) -> RepoRef:
60
76
  )
61
77
  if len(segs) > 3:
62
78
  raise UrlParseError(f"地址段数过多,无法识别为知识库:{raw!r}")
63
- return RepoRef(space=segs[0], repo=segs[1])
79
+ return RepoRef(space=segs[0], repo=segs[1], host=extract_host(raw))
@@ -32,14 +32,14 @@ class FakeClient:
32
32
  self._rec("groups")
33
33
  return [
34
34
  {
35
- "id": 5969528134,
36
- "title": "跨境支付技术中心",
37
- "target": {"id": 42434150, "login": "pmw77l", "name": "跨境支付技术中心"},
35
+ "id": 100001,
36
+ "title": "Team A",
37
+ "target": {"id": 200001, "login": "user_a", "name": "Team A"},
38
38
  },
39
39
  {
40
- "id": 5973455253,
41
- "title": "PayKKa",
42
- "target": {"id": 59898319, "login": "gg87x1", "name": "PayKKa Global"},
40
+ "id": 100002,
41
+ "title": "Project B",
42
+ "target": {"id": 200002, "login": "user_b", "name": "Project B"},
43
43
  },
44
44
  ]
45
45
 
@@ -47,6 +47,10 @@ class FakeClient:
47
47
  self._rec("resolve_repo_id", space, repo)
48
48
  return 77
49
49
 
50
+ def repo_overview(self, repo_id):
51
+ self._rec("repo_overview", repo_id)
52
+ return {"name": "B", "slug": "bk", "id": 77, "description": "desc", "public": 1, "items_count": 2}
53
+
50
54
  def repo_docs(self, repo_id):
51
55
  self._rec("repo_docs", repo_id)
52
56
  return [{"title": "D", "slug": "d", "id": 88}]
@@ -95,7 +99,7 @@ class FakeClient:
95
99
  @pytest.fixture
96
100
  def fake(monkeypatch):
97
101
  fc = FakeClient()
98
- monkeypatch.setattr(cli, "build_client", lambda state: fc)
102
+ monkeypatch.setattr(cli, "build_client", lambda state, host_hint=None: fc)
99
103
  return fc
100
104
 
101
105
 
@@ -158,29 +162,18 @@ class TestAuth:
158
162
  assert "CDP" in result.output
159
163
  assert (tmp_path / "session.json").exists()
160
164
 
161
- def test_login_cdp_disabled_shows_hint_then_manual(self, monkeypatch, tmp_path):
165
+ def test_login_with_cookie_option(self, monkeypatch, tmp_path):
162
166
  monkeypatch.setenv("YUQUE_CONFIG_DIR", str(tmp_path))
163
- monkeypatch.setattr(cli.auth, "cdp_websocket_url", lambda **kw: None)
164
- monkeypatch.setattr(cli, "_prompt_cookie", lambda: f"{SESSION_COOKIE}=x")
165
- result = runner.invoke(cli.app, ["auth", "login"])
166
- assert result.exit_code == 0
167
- assert "--remote-debugging-port" in result.output
168
- assert (tmp_path / "session.json").exists()
169
167
 
170
- def test_login_cdp_connected_but_fails_falls_back_to_manual(
171
- self, monkeypatch, tmp_path
172
- ):
173
- monkeypatch.setenv("YUQUE_CONFIG_DIR", str(tmp_path))
174
- monkeypatch.setattr(cli.auth, "cdp_websocket_url", lambda **kw: "ws://b")
175
-
176
- def boom(base, ws):
177
- raise cli.auth.CdpUnavailable("无目标站点登录态")
168
+ def no_prompt(): # pragma: no cover - must not be called
169
+ raise AssertionError("should not prompt when --cookie is used")
178
170
 
179
- monkeypatch.setattr(cli.auth, "cookies_via_cdp", boom)
180
- monkeypatch.setattr(cli, "_prompt_cookie", lambda: f"{SESSION_COOKIE}=x")
181
- result = runner.invoke(cli.app, ["auth", "login"])
171
+ monkeypatch.setattr(cli, "_prompt_cookie", no_prompt)
172
+ result = runner.invoke(
173
+ cli.app, ["auth", "login", "--cookie", f"{SESSION_COOKIE}=x; ct=t"]
174
+ )
182
175
  assert result.exit_code == 0
183
- assert "获取失败" in result.output
176
+ assert "已登录" in result.output
184
177
  assert (tmp_path / "session.json").exists()
185
178
 
186
179
  def test_logout_removes_session(self, monkeypatch, tmp_path):
@@ -206,6 +199,33 @@ class TestRepo:
206
199
  result = runner.invoke(cli.app, ["--json", "repo", "list"])
207
200
  assert '"slug": "bk"' in result.output
208
201
 
202
+ def test_get_human(self, fake):
203
+ result = runner.invoke(cli.app, ["repo", "get", "lg/bk"])
204
+ assert result.exit_code == 0
205
+ assert "name: B" in result.output
206
+ assert "slug: bk" in result.output
207
+ assert "D" in result.output
208
+ assert calls(fake, "resolve_repo_id") == [("resolve_repo_id", "lg", "bk")]
209
+ assert calls(fake, "repo_overview") == [("repo_overview", 77)]
210
+ assert calls(fake, "repo_docs") == [("repo_docs", 77)]
211
+
212
+ def test_get_json(self, fake):
213
+ result = runner.invoke(cli.app, ["--json", "repo", "get", "lg/bk"])
214
+ assert result.exit_code == 0
215
+ assert '"name": "B"' in result.output
216
+ assert '"slug": "bk"' in result.output
217
+ assert '"docs"' in result.output
218
+ assert '"title": "D"' in result.output
219
+
220
+ def test_get_with_space_book_opts(self, fake):
221
+ result = runner.invoke(cli.app, ["repo", "get", "--space", "lg", "--repo", "bk"])
222
+ assert result.exit_code == 0
223
+ assert "name: B" in result.output
224
+ assert "D" in result.output
225
+ assert calls(fake, "resolve_repo_id") == [("resolve_repo_id", "lg", "bk")]
226
+ assert calls(fake, "repo_overview") == [("repo_overview", 77)]
227
+ assert calls(fake, "repo_docs") == [("repo_docs", 77)]
228
+
209
229
 
210
230
  class TestDoc:
211
231
  def test_list(self, fake):
@@ -222,6 +242,9 @@ class TestDoc:
222
242
  def test_get_json_detail(self, fake):
223
243
  result = runner.invoke(cli.app, ["--json", "doc", "get", "lg/bk/sl"])
224
244
  assert '"body": "<lake>"' in result.output
245
+ assert '"doc_version_id"' in result.output
246
+ assert '"book"' in result.output
247
+ assert '"paragraphs"' in result.output
225
248
  assert calls(fake, "doc_detail") == [("doc_detail", "sl", 77)]
226
249
 
227
250
  def test_create_inline_body(self, fake):
@@ -351,16 +374,43 @@ class TestComment:
351
374
 
352
375
  def test_add(self, fake):
353
376
  result = runner.invoke(
354
- cli.app, ["comment", "add", "lg/bk/sl", "--body", "nice"]
377
+ cli.app, ["comment", "create", "lg/bk/sl", "--body", "nice"]
355
378
  )
356
379
  assert "已评论" in result.output
357
380
  assert calls(fake, "create_comment")[0][1] == {"doc_id": 88, "body": "nice"}
358
381
 
359
382
  def test_add_empty_body_errors(self, fake):
360
- result = runner.invoke(cli.app, ["comment", "add", "lg/bk/sl"])
383
+ result = runner.invoke(cli.app, ["comment", "create", "lg/bk/sl"])
361
384
  assert result.exit_code == 1
362
385
  assert "评论正文不能为空" in result.output
363
386
 
387
+ def test_add_with_selection(self, fake):
388
+ sel = '{"paragraph_id":"u-pid","text":"hello","text_offset":0,"selection_range":{"start":{"id":"u-eid","text":"hello","offset":0,"paragraphId":"u-pid","paragraphOffset":0},"end":{"id":"u-eid","text":"hello","offset":3,"paragraphId":"u-pid","paragraphOffset":3}},"doc_version_id":123}'
389
+ result = runner.invoke(
390
+ cli.app, ["comment", "create", "lg/bk/sl", "--body", "nice", "--selection", sel]
391
+ )
392
+ assert "已评论" in result.output
393
+ kw = calls(fake, "create_comment")[0][1]
394
+ assert kw["doc_id"] == 88
395
+ assert kw["body"] == "nice"
396
+ assert kw["selection"] == {
397
+ "paragraph_id": "u-pid",
398
+ "text": "hello",
399
+ "text_offset": 0,
400
+ "selection_range": {
401
+ "start": {"id": "u-eid", "text": "hello", "offset": 0, "paragraphId": "u-pid", "paragraphOffset": 0},
402
+ "end": {"id": "u-eid", "text": "hello", "offset": 3, "paragraphId": "u-pid", "paragraphOffset": 3},
403
+ },
404
+ "doc_version_id": 123,
405
+ }
406
+
407
+ def test_add_with_selection_invalid_json_errors(self, fake):
408
+ result = runner.invoke(
409
+ cli.app, ["comment", "create", "lg/bk/sl", "--body", "nice", "--selection", "not json"]
410
+ )
411
+ assert result.exit_code == 1
412
+ assert "--selection JSON 解析失败" in result.output
413
+
364
414
  def test_delete_with_yes(self, fake):
365
415
  result = runner.invoke(cli.app, ["--yes", "comment", "delete", "5"])
366
416
  assert "已删除评论" in result.output
@@ -373,16 +423,16 @@ class TestSpace:
373
423
  assert result.exit_code == 0
374
424
  assert "Me" in result.output
375
425
  assert "me" in result.output
376
- assert "跨境支付技术中心" in result.output
377
- assert "PayKKa" in result.output
378
- assert "pmw77l" in result.output
379
- assert "gg87x1" in result.output
426
+ assert "Team A" in result.output
427
+ assert "Project B" in result.output
428
+ assert "user_a" in result.output
429
+ assert "user_b" in result.output
380
430
 
381
431
  def test_list_json(self, fake):
382
432
  result = runner.invoke(cli.app, ["--json", "space", "list"])
383
433
  assert result.exit_code == 0
384
434
  assert '"login": "me"' in result.output
385
- assert '"login": "pmw77l"' in result.output
386
- assert '"login": "gg87x1"' in result.output
435
+ assert '"login": "user_a"' in result.output
436
+ assert '"login": "user_b"' in result.output
387
437
  assert '"id": 1' in result.output
388
- assert '"id": 42434150' in result.output
438
+ assert '"id": 200001' in result.output
@@ -6,7 +6,7 @@ from urllib.parse import quote
6
6
  import httpx
7
7
  import pytest
8
8
 
9
- from yuque_cli.client import USER_AGENT, Client
9
+ from yuque_cli.client import USER_AGENT, Client, _extract_paragraphs
10
10
  from yuque_cli.errors import ApiError, AuthExpired
11
11
  from yuque_cli.session import CSRF_COOKIE, SESSION_COOKIE, Session
12
12
 
@@ -150,6 +150,40 @@ class TestWriteEndpoints:
150
150
  make_client(handler).create_comment(doc_id=7, body="nice")
151
151
  assert seen["body"] == {"commentable_id": 7, "commentable_type": "Doc", "body": "nice"}
152
152
 
153
+ def test_create_comment_with_selection(self):
154
+ seen = {}
155
+
156
+ def handler(req: httpx.Request) -> httpx.Response:
157
+ seen["body"] = json.loads(req.content)
158
+ return httpx.Response(200, json={"data": {"id": 11}})
159
+
160
+ sel = {
161
+ "paragraph_id": "u-pid",
162
+ "text_offset": 0,
163
+ "text": "hello",
164
+ "selection_range": {
165
+ "start": {"id": "u-eid", "text": "hello", "offset": 0, "paragraphId": "u-pid", "paragraphOffset": 0},
166
+ "end": {"id": "u-eid", "text": "hello", "offset": 3, "paragraphId": "u-pid", "paragraphOffset": 3},
167
+ },
168
+ "doc_version_id": 123,
169
+ }
170
+ make_client(handler).create_comment(doc_id=7, body="nice", selection=sel)
171
+
172
+ payload = seen["body"]
173
+ assert payload["commentable_id"] == 7
174
+ assert payload["commentable_type"] == "Doc"
175
+ assert payload["type"] == "WORDING_COMMENT"
176
+ assert payload["format"] == "lake"
177
+ assert payload["selection"] == sel
178
+ assert "body_asl" in payload
179
+ assert "<!doctype lake>" in payload["body_asl"]
180
+ assert "nice" in payload["body_asl"]
181
+ assert '<p data-lake-id="' in payload["body_asl"]
182
+ assert '<span data-lake-id="' in payload["body_asl"]
183
+ assert payload["body"] != "nice" # body is HTML not plain text
184
+ assert '<div class="lake-content"' in payload["body"]
185
+ assert "nice" in payload["body"]
186
+
153
187
  def test_delete_comment(self):
154
188
  def handler(req: httpx.Request) -> httpx.Response:
155
189
  assert req.method == "DELETE"
@@ -177,3 +211,36 @@ class TestIdResolution:
177
211
  return httpx.Response(200, text=html)
178
212
 
179
213
  assert make_client(handler).resolve_doc_ids("lg", "bk", "sl") == (77, 88)
214
+
215
+
216
+ class TestExtractParagraphs:
217
+ def test_extracts_ids_and_text(self):
218
+ html = '<div class="lake-content"><p id="u73ba16c1" class="ne-p"><span class="ne-text">发布时发布后</span></p></div>'
219
+ paras = _extract_paragraphs(html)
220
+ assert paras == [{"id": "u73ba16c1", "text": "发布时发布后", "spans": []}]
221
+
222
+ def test_extracts_spans_with_ids(self):
223
+ html = '<p id="p1"><span data-lake-id="s1" id="s1">hello</span></p>'
224
+ paras = _extract_paragraphs(html)
225
+ assert paras == [{"id": "p1", "text": "hello", "spans": [{"id": "s1", "text": "hello"}]}]
226
+
227
+ def test_skips_spans_without_ids(self):
228
+ html = '<p id="p1"><span style="color:red">no id span</span></p>'
229
+ paras = _extract_paragraphs(html)
230
+ assert paras[0]["spans"] == []
231
+
232
+ def test_multiple_paragraphs(self):
233
+ html = '<p id="a1">第一段<crap/></p>中间文本<p id="b2">二段</p>'
234
+ paras = _extract_paragraphs(html)
235
+ assert paras == [
236
+ {"id": "a1", "text": "第一段", "spans": []},
237
+ {"id": "b2", "text": "二段", "spans": []},
238
+ ]
239
+
240
+ def test_empty_no_paragraphs(self):
241
+ assert _extract_paragraphs("<div>no p tags</div>") == []
242
+
243
+ def test_strips_tags_and_entities(self):
244
+ html = '<p id="x">hello &lt;world&gt;</p>'
245
+ paras = _extract_paragraphs(html)
246
+ assert paras == [{"id": "x", "text": "hello <world>", "spans": []}]
@@ -4,6 +4,7 @@ from yuque_cli.urls import (
4
4
  RepoRef,
5
5
  DocRef,
6
6
  UrlParseError,
7
+ extract_host,
7
8
  parse_repo_ref,
8
9
  parse_doc_ref,
9
10
  )
@@ -12,15 +13,15 @@ from yuque_cli.urls import (
12
13
  class TestParseDocRef:
13
14
  def test_full_https_url(self):
14
15
  ref = parse_doc_ref("https://www.yuque.com/abc/team-book/my-slug")
15
- assert ref == DocRef(space="abc", repo="team-book", slug="my-slug")
16
+ assert ref == DocRef(space="abc", repo="team-book", slug="my-slug", host="www.yuque.com")
16
17
 
17
18
  def test_http_scheme(self):
18
19
  ref = parse_doc_ref("http://www.yuque.com/abc/book/slug")
19
- assert ref == DocRef("abc", "book", "slug")
20
+ assert ref == DocRef("abc", "book", "slug", host="www.yuque.com")
20
21
 
21
22
  def test_host_without_scheme(self):
22
23
  ref = parse_doc_ref("www.yuque.com/abc/book/slug")
23
- assert ref == DocRef("abc", "book", "slug")
24
+ assert ref == DocRef("abc", "book", "slug", host="www.yuque.com")
24
25
 
25
26
  def test_path_only(self):
26
27
  ref = parse_doc_ref("abc/book/slug")
@@ -34,7 +35,7 @@ class TestParseDocRef:
34
35
 
35
36
  def test_strips_query_and_fragment(self):
36
37
  ref = parse_doc_ref("https://www.yuque.com/abc/book/slug?from=x#h1")
37
- assert ref == DocRef("abc", "book", "slug")
38
+ assert ref == DocRef("abc", "book", "slug", host="www.yuque.com")
38
39
 
39
40
  def test_strips_surrounding_whitespace(self):
40
41
  assert parse_doc_ref(" abc/book/slug ") == DocRef("abc", "book", "slug")
@@ -51,9 +52,12 @@ class TestParseDocRef:
51
52
  with pytest.raises(UrlParseError):
52
53
  parse_doc_ref("")
53
54
 
54
- def test_too_many_segments_rejected(self):
55
- with pytest.raises(UrlParseError):
56
- parse_doc_ref("abc/book/slug/extra")
55
+ def test_extra_segments_use_first_three(self):
56
+ """多余的路径段(如 /edit)会被忽略,只取前三段。"""
57
+ assert parse_doc_ref("abc/book/slug/edit") == DocRef("abc", "book", "slug")
58
+ assert parse_doc_ref(
59
+ "https://www.yuque.com/abc/book/slug/edit"
60
+ ) == DocRef("abc", "book", "slug", host="www.yuque.com")
57
61
 
58
62
  def test_repo_ref_property(self):
59
63
  ref = parse_doc_ref("abc/book/slug")
@@ -63,7 +67,7 @@ class TestParseDocRef:
63
67
  class TestParseRepoRef:
64
68
  def test_full_https_url(self):
65
69
  assert parse_repo_ref("https://www.yuque.com/abc/team-book") == RepoRef(
66
- "abc", "team-book"
70
+ "abc", "team-book", host="www.yuque.com"
67
71
  )
68
72
 
69
73
  def test_path_only(self):
@@ -73,7 +77,7 @@ class TestParseRepoRef:
73
77
  assert parse_repo_ref("abc/team-book/some-slug") == RepoRef("abc", "team-book")
74
78
 
75
79
  def test_host_stripped_and_trailing_slash(self):
76
- assert parse_repo_ref("www.yuque.com/abc/book/") == RepoRef("abc", "book")
80
+ assert parse_repo_ref("www.yuque.com/abc/book/") == RepoRef("abc", "book", host="www.yuque.com")
77
81
 
78
82
  def test_space_only_is_rejected(self):
79
83
  with pytest.raises(UrlParseError):
@@ -82,3 +86,23 @@ class TestParseRepoRef:
82
86
  def test_empty_is_rejected(self):
83
87
  with pytest.raises(UrlParseError):
84
88
  parse_repo_ref(" ")
89
+
90
+
91
+ class TestExtractHost:
92
+ def test_full_url(self):
93
+ assert extract_host("https://www.yuque.com/abc/book/slug") == "www.yuque.com"
94
+
95
+ def test_custom_domain(self):
96
+ assert extract_host("https://jlpay.yuque.com/pmw77l/iybc48/doc") == "jlpay.yuque.com"
97
+
98
+ def test_no_scheme_with_host(self):
99
+ assert extract_host("jlpay.yuque.com/abc/book/slug") == "jlpay.yuque.com"
100
+
101
+ def test_path_only(self):
102
+ assert extract_host("abc/book/slug") is None
103
+
104
+ def test_empty(self):
105
+ assert extract_host("") is None
106
+
107
+ def test_whitespace(self):
108
+ assert extract_host(" https://foo.com/a/b ") == "foo.com"
@@ -337,7 +337,7 @@ wheels = [
337
337
 
338
338
  [[package]]
339
339
  name = "yuque-cli"
340
- version = "0.1.1"
340
+ version = "1.0.0"
341
341
  source = { editable = "." }
342
342
  dependencies = [
343
343
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes