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.
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/PKG-INFO +1 -1
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/pyproject.toml +1 -1
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/cli.py +155 -28
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/client.py +89 -4
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/output.py +42 -2
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_cli.py +82 -32
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_client.py +68 -1
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/uv.lock +1 -1
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/.claude/settings.local.json +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/.gitignore +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/.python-version +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/.vscode/extensions.json +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/.vscode/settings.json +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/AGENTS.md +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/CLAUDE.md +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/CONTEXT.md +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/README.md +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/docs/adr/0001-use-internal-web-api-with-cookie-auth.md +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/docs/adr/0002-cookie-acquisition-cdp-with-manual-fallback.md +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/docs/adr/0003-doc-update-fetch-then-merge.md +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/docs/internal-api.md +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/main.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/mise.toml +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/__init__.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/__main__.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/appdata.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/auth.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/config.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/errors.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/inputs.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/session.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/src/yuque_cli/urls.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_appdata.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_auth.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_config.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_inputs.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_output.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_session.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-0.2.0}/test/test_urls.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 =
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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("<", "<").replace(">", ">").replace("&", "&")
|
|
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
|
|
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}/
|
|
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
|
|
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("<", "<").replace(">", ">").replace("&", "&")
|
|
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
|
-
|
|
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":
|
|
36
|
-
"title": "
|
|
37
|
-
"target": {"id":
|
|
35
|
+
"id": 100001,
|
|
36
|
+
"title": "Team A",
|
|
37
|
+
"target": {"id": 200001, "login": "user_a", "name": "Team A"},
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
|
-
"id":
|
|
41
|
-
"title": "
|
|
42
|
-
"target": {"id":
|
|
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
|
|
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
|
-
|
|
171
|
-
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
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 "
|
|
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 "
|
|
377
|
-
assert "
|
|
378
|
-
assert "
|
|
379
|
-
assert "
|
|
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": "
|
|
386
|
-
assert '"login": "
|
|
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":
|
|
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 <world></p>'
|
|
245
|
+
paras = _extract_paragraphs(html)
|
|
246
|
+
assert paras == [{"id": "x", "text": "hello <world>", "spans": []}]
|
|
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
|
{yuque_cli-0.1.1 → yuque_cli-0.2.0}/docs/adr/0002-cookie-acquisition-cdp-with-manual-fallback.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|