yuque-cli 0.1.1__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 (39) hide show
  1. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/PKG-INFO +1 -1
  2. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/pyproject.toml +1 -1
  3. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/cli.py +155 -28
  4. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/client.py +89 -4
  5. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/output.py +42 -2
  6. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_cli.py +82 -32
  7. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_client.py +68 -1
  8. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/uv.lock +1 -1
  9. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/.claude/settings.local.json +0 -0
  10. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/.gitignore +0 -0
  11. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/.python-version +0 -0
  12. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/.vscode/extensions.json +0 -0
  13. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/.vscode/settings.json +0 -0
  14. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/AGENTS.md +0 -0
  15. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/CLAUDE.md +0 -0
  16. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/CONTEXT.md +0 -0
  17. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/README.md +0 -0
  18. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/docs/adr/0001-use-internal-web-api-with-cookie-auth.md +0 -0
  19. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/docs/adr/0002-cookie-acquisition-cdp-with-manual-fallback.md +0 -0
  20. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/docs/adr/0003-doc-update-fetch-then-merge.md +0 -0
  21. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/docs/internal-api.md +0 -0
  22. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/main.py +0 -0
  23. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/mise.toml +0 -0
  24. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/__init__.py +0 -0
  25. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/__main__.py +0 -0
  26. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/appdata.py +0 -0
  27. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/auth.py +0 -0
  28. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/config.py +0 -0
  29. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/errors.py +0 -0
  30. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/inputs.py +0 -0
  31. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/session.py +0 -0
  32. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/urls.py +0 -0
  33. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_appdata.py +0 -0
  34. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_auth.py +0 -0
  35. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_config.py +0 -0
  36. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_inputs.py +0 -0
  37. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_output.py +0 -0
  38. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_session.py +0 -0
  39. {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_urls.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yuque-cli
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: 针对语雀的命令行客户端:cookie 认证,驱动内部 web 接口,提供文档与评论的 CRUD
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: httpx>=0.27
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "yuque-cli"
3
- version = "0.1.1"
3
+ version = "0.2.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
@@ -99,8 +102,16 @@ def build_client(state: AppState) -> Client:
99
102
  return Client(http, sess, base_url=config.base_url(state.host))
100
103
 
101
104
 
105
+ def _pager(text: str) -> None:
106
+ """当 stdout 是终端时通过 less 分页显示,否则直接输出。"""
107
+ if sys.stdout.isatty():
108
+ subprocess.run(["less", "-FRX"], input=text, text=True)
109
+ else:
110
+ typer.echo(text)
111
+
112
+
102
113
  def _emit(state: AppState, data, human: str) -> None:
103
- typer.echo(output.dumps(data) if state.json else human)
114
+ _pager(output.dumps(data) if state.json else human)
104
115
 
105
116
 
106
117
  def _confirm(state: AppState, message: str) -> None:
@@ -125,8 +136,10 @@ def _resolve_repo_ref(
125
136
  if not has_url and not has_opts:
126
137
  raise YuqueError("请提供 URL 或指定 --space 和 --repo")
127
138
  if has_url:
139
+ assert url is not None
128
140
  return parse_repo_ref(url)
129
- return RepoRef(space=space, repo=repo) # type: ignore[arg-type]
141
+ assert space is not None and repo is not None
142
+ return RepoRef(space=space, repo=repo)
130
143
 
131
144
 
132
145
  def _resolve_doc_ref(
@@ -164,40 +177,100 @@ def _prompt_cookie() -> str:
164
177
  return typer.prompt("cookie")
165
178
 
166
179
 
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
- )
180
+ _CDP_URL = "chrome://inspect/#remote-debugging"
181
+
182
+
183
+ def _open_cdp_hint() -> None:
184
+ """在 Chrome 中打开远程调试说明页面。"""
185
+ import platform
186
+ if platform.system() == "Darwin":
187
+ subprocess.run(["open", "-a", "Google Chrome", _CDP_URL], check=False)
188
+ elif platform.system() == "Windows":
189
+ subprocess.run(["start", "", _CDP_URL], shell=True, check=False)
190
+ else:
191
+ subprocess.run(["google-chrome", _CDP_URL], check=False)
192
+
193
+
194
+ _CDP_DONE_MSG = "建议及时关闭 Chrome 远程调试端口,避免安全风险"
195
+
196
+
197
+ def _try_cdp_login(base: str, state: AppState) -> dict:
198
+ """尝试 CDP 获取 cookie;失败后打开浏览器并持续轮询直至成功。"""
199
+ # 首次尝试:可能已经开启
200
+ ws_url = auth.cdp_websocket_url(verbose=state.verbose)
201
+ if ws_url:
202
+ try:
203
+ cookies = auth.cookies_via_cdp(base, ws_url)
204
+ except auth.CdpUnavailable:
205
+ pass
206
+ else:
207
+ host = state.host or config.host()
208
+ typer.secho(f"已通过 CDP 从浏览器获取 {host} 登录态", fg="green", err=True)
209
+ typer.secho(_CDP_DONE_MSG, fg="yellow", err=True)
210
+ return cookies
211
+
212
+ # 打开浏览器引导开启远程调试
213
+ _open_cdp_hint()
214
+ typer.secho(
215
+ f"等待 Chrome 远程调试开启({_CDP_URL}),Ctrl+C 可中断...",
216
+ fg="yellow", err=True,
217
+ )
218
+
219
+ # 轮询直至成功
220
+ spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
221
+ tick = 0
222
+ try:
223
+ while True:
224
+ # 用 spinner 替代 sleep,视觉上不卡死
225
+ for _ in range(10):
226
+ char = spinner[tick % len(spinner)]
227
+ sys.stderr.write(f"\r {char} 等待 Chrome 远程调试...")
228
+ sys.stderr.flush()
229
+ time.sleep(0.2)
230
+ tick += 1
231
+ ws_url = auth.cdp_websocket_url(verbose=state.verbose)
232
+ if ws_url is None:
233
+ continue
234
+ try:
235
+ cookies = auth.cookies_via_cdp(base, ws_url)
236
+ sys.stderr.write("\r" + " " * 40 + "\r")
237
+ sys.stderr.flush()
238
+ host = state.host or config.host()
239
+ typer.secho(f"已通过 CDP 从浏览器获取 {host} 登录态", fg="green", err=True)
240
+ typer.secho(_CDP_DONE_MSG, fg="yellow", err=True)
241
+ return cookies
242
+ except auth.CdpUnavailable:
243
+ continue
244
+ except KeyboardInterrupt:
245
+ sys.stderr.write("\r" + " " * 40 + "\r")
246
+ sys.stderr.flush()
247
+ raise
174
248
 
175
249
 
176
250
  @auth_app.command("login")
177
251
  def auth_login(
178
252
  ctx: typer.Context,
179
253
  manual: Annotated[
180
- bool, typer.Option("--manual", help="跳过 CDP,直接手动粘贴 cookie")
254
+ bool, typer.Option("--manual", help="跳过 CDP,交互式粘贴 cookie")
181
255
  ] = False,
256
+ cookie: Annotated[
257
+ Optional[str], typer.Option("--cookie", help="直接提供 cookie 串完成登录")
258
+ ] = None,
182
259
  ) -> None:
183
- """登录:默认探测本地 CDP 自动获取登录态;未启用或失败时回退手动粘贴。"""
260
+ """登录:默认探测本地 CDP 自动获取登录态;也可用 --cookie 或 --manual 手动登录。"""
184
261
  with cli_errors():
185
262
  state: AppState = ctx.obj
186
263
  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)
264
+ if cookie is not None:
265
+ cookies = auth.cookies_via_manual(lambda: cookie)
266
+ elif manual:
191
267
  cookies = auth.cookies_via_manual(_prompt_cookie)
192
268
  else:
193
269
  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)
270
+ cookies = _try_cdp_login(base, state)
271
+ except KeyboardInterrupt:
272
+ typer.echo("", err=True)
273
+ raise typer.Exit(1)
201
274
  sess = session.Session(cookies)
202
275
  sess.require_session()
203
276
  session.save(sess, config.session_file())
@@ -236,6 +309,26 @@ def repo_list(ctx: typer.Context) -> None:
236
309
  _emit(state, repos, output.render_repos(repos))
237
310
 
238
311
 
312
+ @repo_app.command("get")
313
+ def repo_get(
314
+ ctx: typer.Context,
315
+ url: UrlArg = None,
316
+ space: SpaceOpt = None,
317
+ repo: RepoOpt = None,
318
+ ) -> None:
319
+ """获取指定知识库的信息(GET /api/books/{id}/overview)。"""
320
+ state: AppState = ctx.obj
321
+ with cli_errors():
322
+ ref = _resolve_repo_ref(url, space, repo)
323
+ client = build_client(state)
324
+ repo_id = client.resolve_repo_id(ref.space, ref.repo)
325
+ data = client.repo_overview(repo_id)
326
+ docs = client.repo_docs(repo_id)
327
+ if state.json:
328
+ data["docs"] = docs
329
+ _emit(state, data, output.render_repo_overview(data, docs))
330
+
331
+
239
332
  # -- doc --------------------------------------------------------------------
240
333
 
241
334
  UrlArg = Annotated[Optional[str], typer.Argument(help="目标 URL 或去 host 的路径形态")]
@@ -256,6 +349,7 @@ def doc_list(ctx: typer.Context, url: UrlArg) -> None:
256
349
  """列出知识库下的文档。"""
257
350
  state: AppState = ctx.obj
258
351
  with cli_errors():
352
+ assert url is not None
259
353
  ref = parse_repo_ref(url)
260
354
  client = build_client(state)
261
355
  repo_id = client.resolve_repo_id(ref.space, ref.repo)
@@ -265,15 +359,28 @@ def doc_list(ctx: typer.Context, url: UrlArg) -> None:
265
359
 
266
360
  @doc_app.command("get")
267
361
  def doc_get(ctx: typer.Context, url: UrlArg) -> None:
268
- """取文档正文:默认干净 Markdown;--json 出详情(含 Lake)。"""
362
+ """取文档正文:默认干净 Markdown;--json 出详情(含段落结构)。"""
269
363
  state: AppState = ctx.obj
270
364
  with cli_errors():
365
+ assert url is not None
271
366
  ref = parse_doc_ref(url)
272
367
  client = build_client(state)
273
368
  if state.json:
274
369
  app_data = client.doc_page_app_data(ref.space, ref.repo, ref.slug)
275
370
  detail = client.doc_detail(ref.slug, appdata_repo_id(app_data))
276
- typer.echo(output.dumps(detail))
371
+ doc_meta = app_data.get("doc", {})
372
+ book_meta = app_data.get("book", {})
373
+ lake_body = detail.get("content") or detail.get("body_asl") or detail.get("body", "")
374
+ combined = {
375
+ "doc": detail,
376
+ "doc_version_id": doc_meta.get("doc_version_id"),
377
+ "book": {
378
+ "id": book_meta.get("id") or appdata_repo_id(app_data),
379
+ "slug": ref.repo,
380
+ },
381
+ "paragraphs": _extract_paragraphs(lake_body) if lake_body else [],
382
+ }
383
+ typer.echo(output.dumps(combined))
277
384
  else:
278
385
  typer.echo(client.doc_markdown(ref.space, ref.repo, ref.slug))
279
386
 
@@ -348,6 +455,7 @@ def doc_delete(ctx: typer.Context, url: UrlArg) -> None:
348
455
  """删除文档(软删,可在回收站恢复)。"""
349
456
  state: AppState = ctx.obj
350
457
  with cli_errors():
458
+ assert url is not None
351
459
  ref = parse_doc_ref(url)
352
460
  client = build_client(state)
353
461
  app_data = client.doc_page_app_data(ref.space, ref.repo, ref.slug)
@@ -366,6 +474,7 @@ def comment_list(ctx: typer.Context, url: UrlArg) -> None:
366
474
  """列出文档的评论。"""
367
475
  state: AppState = ctx.obj
368
476
  with cli_errors():
477
+ assert url is not None
369
478
  ref = parse_doc_ref(url)
370
479
  client = build_client(state)
371
480
  _, doc_id = client.resolve_doc_ids(ref.space, ref.repo, ref.slug)
@@ -373,20 +482,38 @@ def comment_list(ctx: typer.Context, url: UrlArg) -> None:
373
482
  _emit(state, items, output.render_comments(items))
374
483
 
375
484
 
485
+ SelectionOpt = Annotated[
486
+ Optional[str],
487
+ typer.Option("--selection", "-s", help="划词评论的 selection JSON(如提供则为划词评论)"),
488
+ ]
489
+
490
+
376
491
  @comment_app.command("add")
377
492
  def comment_add(
378
- ctx: typer.Context, url: UrlArg, body: BodyOpt = None, file: FileOpt = None
493
+ ctx: typer.Context,
494
+ url: UrlArg,
495
+ body: BodyOpt = None,
496
+ file: FileOpt = None,
497
+ selection: SelectionOpt = None,
379
498
  ) -> None:
380
- """给文档添加评论。"""
499
+ """给文档添加评论。带 --selection 时为划词评论。"""
381
500
  state: AppState = ctx.obj
382
501
  with cli_errors():
502
+ assert url is not None
383
503
  ref = parse_doc_ref(url)
384
504
  text = _read_body(body, file)
385
505
  if not text:
386
506
  raise YuqueError("评论正文不能为空(用 --body 或 --file/-)")
387
507
  client = build_client(state)
388
508
  _, doc_id = client.resolve_doc_ids(ref.space, ref.repo, ref.slug)
389
- created = client.create_comment(doc_id=doc_id, body=text)
509
+ if selection is not None:
510
+ try:
511
+ sel = json.loads(selection)
512
+ except json.JSONDecodeError as exc:
513
+ raise YuqueError(f"--selection JSON 解析失败:{exc}") from exc
514
+ created = client.create_comment(doc_id=doc_id, body=text, selection=sel)
515
+ else:
516
+ created = client.create_comment(doc_id=doc_id, body=text)
390
517
  _emit(state, created, f"已评论(id={created.get('id', '')})")
391
518
 
392
519
 
@@ -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:
@@ -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}]
@@ -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):
@@ -361,6 +384,33 @@ class TestComment:
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", "add", "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", "add", "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": []}]
@@ -337,7 +337,7 @@ wheels = [
337
337
 
338
338
  [[package]]
339
339
  name = "yuque-cli"
340
- version = "0.1.1"
340
+ version = "0.2.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
File without changes
File without changes