yuque-cli 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- yuque_cli/__main__.py +4 -0
- yuque_cli/appdata.py +39 -0
- yuque_cli/auth.py +153 -0
- yuque_cli/cli.py +355 -0
- yuque_cli/client.py +199 -0
- yuque_cli/config.py +31 -0
- yuque_cli/errors.py +17 -0
- yuque_cli/inputs.py +35 -0
- yuque_cli/output.py +63 -0
- yuque_cli/session.py +69 -0
- yuque_cli/urls.py +63 -0
- yuque_cli-0.1.0.dist-info/METADATA +190 -0
- yuque_cli-0.1.0.dist-info/RECORD +16 -0
- yuque_cli-0.1.0.dist-info/WHEEL +4 -0
- yuque_cli-0.1.0.dist-info/entry_points.txt +2 -0
yuque_cli/__init__.py
ADDED
yuque_cli/__main__.py
ADDED
yuque_cli/appdata.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from urllib.parse import unquote
|
|
6
|
+
|
|
7
|
+
# 匹配 window.appData = JSON.parse(decodeURIComponent("...")) 中被 percent-encode 的串。
|
|
8
|
+
# 编码后的内容不含裸引号(" 会变成 %22),故 [^"]* 足以圈定整段。
|
|
9
|
+
_APP_DATA_RE = re.compile(
|
|
10
|
+
r'window\.appData\s*=\s*JSON\.parse\(\s*decodeURIComponent\(\s*"([^"]*)"\s*\)\s*\)'
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AppDataError(ValueError):
|
|
15
|
+
"""页面 HTML 中找不到或无法解析 window.appData。"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_app_data(html: str) -> dict:
|
|
19
|
+
m = _APP_DATA_RE.search(html)
|
|
20
|
+
if not m:
|
|
21
|
+
raise AppDataError("页面中未找到 window.appData(可能未登录或页面结构变化)")
|
|
22
|
+
try:
|
|
23
|
+
return json.loads(unquote(m.group(1)))
|
|
24
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
25
|
+
raise AppDataError(f"window.appData 解码失败:{exc}") from exc
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def book_id(app_data: dict) -> int:
|
|
29
|
+
try:
|
|
30
|
+
return int(app_data["book"]["id"])
|
|
31
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
32
|
+
raise AppDataError("appData 中缺少 book.id") from exc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def doc_id(app_data: dict) -> int:
|
|
36
|
+
try:
|
|
37
|
+
return int(app_data["doc"]["id"])
|
|
38
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
39
|
+
raise AppDataError("appData 中缺少 doc.id(该页面可能不是文档页)") from exc
|
yuque_cli/auth.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable, Optional
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .session import SESSION_COOKIE, parse_cookie_string
|
|
13
|
+
|
|
14
|
+
CDP_PORT = 9222
|
|
15
|
+
# 本地回环不应走代理;这些 host 在 CDP 连接时强制直连。
|
|
16
|
+
_NO_PROXY_HOSTS = ["127.0.0.1", "localhost", "::1"]
|
|
17
|
+
_WS_TIMEOUT = 5.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CdpUnavailable(Exception):
|
|
21
|
+
"""无法通过 Chrome 远程调试(CDP)抓到目标域名的会话 cookie。"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# -- 浏览器级 WebSocket 端点发现 -------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_devtools_port_files() -> list[Path]:
|
|
28
|
+
home = Path.home()
|
|
29
|
+
candidates = [
|
|
30
|
+
home / "Library/Application Support/Google/Chrome/DevToolsActivePort",
|
|
31
|
+
home / "Library/Application Support/Chromium/DevToolsActivePort",
|
|
32
|
+
home / ".config/google-chrome/DevToolsActivePort",
|
|
33
|
+
home / ".config/chromium/DevToolsActivePort",
|
|
34
|
+
]
|
|
35
|
+
local_appdata = os.environ.get("LOCALAPPDATA")
|
|
36
|
+
if local_appdata:
|
|
37
|
+
candidates.append(
|
|
38
|
+
Path(local_appdata) / "Google/Chrome/User Data/DevToolsActivePort"
|
|
39
|
+
)
|
|
40
|
+
return candidates
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_devtools_active_port(text: str) -> tuple[int, str]:
|
|
44
|
+
lines = text.splitlines()
|
|
45
|
+
if not lines or not lines[0].strip().isdigit():
|
|
46
|
+
raise CdpUnavailable("DevToolsActivePort 内容异常(缺少端口号)")
|
|
47
|
+
port = int(lines[0].strip())
|
|
48
|
+
ws_path = lines[1].strip() if len(lines) > 1 else ""
|
|
49
|
+
return port, ws_path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _ws_url_from_devtools_file(*, verbose: bool = False) -> Optional[str]:
|
|
53
|
+
"""从 Chrome 的 DevToolsActivePort 文件取浏览器级 WS 地址。
|
|
54
|
+
|
|
55
|
+
Chrome 136+ 在默认 profile 下禁用了 /json HTTP 接口(返回 404),但该文件仍记录
|
|
56
|
+
端口与浏览器级 WS 路径,故这是最可靠的发现方式。
|
|
57
|
+
"""
|
|
58
|
+
for path in default_devtools_port_files():
|
|
59
|
+
try:
|
|
60
|
+
if not path.is_file():
|
|
61
|
+
continue
|
|
62
|
+
port, ws_path = parse_devtools_active_port(path.read_text())
|
|
63
|
+
if ws_path:
|
|
64
|
+
return f"ws://127.0.0.1:{port}{ws_path}"
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
if verbose:
|
|
67
|
+
print(f"读取 {path} 失败:{exc!r}", file=sys.stderr)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _ws_url_from_http(port: int, *, verbose: bool = False) -> Optional[str]:
|
|
72
|
+
"""退而求其次:经 /json/version 取 WS 地址(部分配置可用)。
|
|
73
|
+
|
|
74
|
+
用 trust_env=False 绕开 *_proxy 环境变量——本地回环不该走代理。
|
|
75
|
+
"""
|
|
76
|
+
url = f"http://127.0.0.1:{port}/json/version"
|
|
77
|
+
try:
|
|
78
|
+
resp = httpx.get(url, timeout=1.0, trust_env=False)
|
|
79
|
+
resp.raise_for_status()
|
|
80
|
+
return resp.json().get("webSocketDebuggerUrl")
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
if verbose:
|
|
83
|
+
print(f"CDP HTTP 探测失败({url}):{exc!r}", file=sys.stderr)
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cdp_websocket_url(port: int = CDP_PORT, *, verbose: bool = False) -> Optional[str]:
|
|
88
|
+
"""发现本地 Chrome 的浏览器级 WebSocket 调试地址;未启用远程调试则 None。"""
|
|
89
|
+
return _ws_url_from_devtools_file(verbose=verbose) or _ws_url_from_http(
|
|
90
|
+
port, verbose=verbose
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# -- cookie 过滤 ------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _domain_matches(cookie_domain: str, host: str) -> bool:
|
|
98
|
+
d = cookie_domain.lstrip(".")
|
|
99
|
+
return host == d or host.endswith("." + d)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cookies_for_host(cdp_cookies: list[dict], host: str) -> dict[str, str]:
|
|
103
|
+
out: dict[str, str] = {}
|
|
104
|
+
for cookie in cdp_cookies:
|
|
105
|
+
if _domain_matches(str(cookie.get("domain", "")), host):
|
|
106
|
+
out[cookie["name"]] = cookie["value"]
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# -- WebSocket 取 cookie ----------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _storage_get_cookies(ws_url: str) -> list[dict]:
|
|
114
|
+
from websocket import create_connection
|
|
115
|
+
|
|
116
|
+
# suppress_origin:Chrome 111+ 默认拒绝带 Origin 的 WS(除非 --remote-allow-origins);
|
|
117
|
+
# 不发 Origin 头即可连上,免去用户重启 Chrome 加该 flag。
|
|
118
|
+
ws = create_connection(
|
|
119
|
+
ws_url,
|
|
120
|
+
timeout=_WS_TIMEOUT,
|
|
121
|
+
http_no_proxy=_NO_PROXY_HOSTS,
|
|
122
|
+
suppress_origin=True,
|
|
123
|
+
)
|
|
124
|
+
try:
|
|
125
|
+
ws.send(json.dumps({"id": 1, "method": "Storage.getCookies"}))
|
|
126
|
+
for _ in range(50):
|
|
127
|
+
data = json.loads(ws.recv())
|
|
128
|
+
if data.get("id") == 1:
|
|
129
|
+
return data.get("result", {}).get("cookies", [])
|
|
130
|
+
raise CdpUnavailable("CDP 未在预期内返回 cookie")
|
|
131
|
+
finally:
|
|
132
|
+
ws.close()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cookies_via_cdp(base_url: str, ws_url: str) -> dict[str, str]:
|
|
136
|
+
"""经浏览器级 WS 调 Storage.getCookies,过滤出目标域名 cookie 并校验登录态。"""
|
|
137
|
+
host = urlsplit(base_url).hostname or base_url
|
|
138
|
+
try:
|
|
139
|
+
raw = _storage_get_cookies(ws_url)
|
|
140
|
+
except CdpUnavailable:
|
|
141
|
+
raise
|
|
142
|
+
except Exception as exc: # WS 连接/收发失败、端口已死等
|
|
143
|
+
raise CdpUnavailable(f"CDP 抓取失败:{exc}") from exc
|
|
144
|
+
cookies = cookies_for_host(raw, host)
|
|
145
|
+
if SESSION_COOKIE not in cookies:
|
|
146
|
+
raise CdpUnavailable(
|
|
147
|
+
f"CDP 未取到 {host} 的 {SESSION_COOKIE}(请确认已在该 Chrome 登录该域名)"
|
|
148
|
+
)
|
|
149
|
+
return cookies
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cookies_via_manual(prompt_fn: Callable[[], str]) -> dict[str, str]:
|
|
153
|
+
return parse_cookie_string(prompt_fn())
|
yuque_cli/cli.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Annotated, Any, Callable, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from . import __version__, auth, config, output, session
|
|
12
|
+
from .appdata import AppDataError
|
|
13
|
+
from .appdata import book_id as appdata_book_id
|
|
14
|
+
from .appdata import doc_id as appdata_doc_id
|
|
15
|
+
from .client import Client
|
|
16
|
+
from .errors import AuthExpired, YuqueError
|
|
17
|
+
from .inputs import BodyInputError, resolve_body
|
|
18
|
+
from .session import SessionError
|
|
19
|
+
from .urls import UrlParseError, parse_book_ref, parse_doc_ref
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class AppState:
|
|
24
|
+
json: bool = False
|
|
25
|
+
yes: bool = False
|
|
26
|
+
verbose: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False, help="企业语雀命令行客户端")
|
|
30
|
+
auth_app = typer.Typer(no_args_is_help=True, help="登录态管理")
|
|
31
|
+
book_app = typer.Typer(no_args_is_help=True, help="知识库(book)")
|
|
32
|
+
doc_app = typer.Typer(no_args_is_help=True, help="文档(doc)")
|
|
33
|
+
comment_app = typer.Typer(no_args_is_help=True, help="评论(comment)")
|
|
34
|
+
app.add_typer(auth_app, name="auth")
|
|
35
|
+
app.add_typer(book_app, name="book")
|
|
36
|
+
app.add_typer(doc_app, name="doc")
|
|
37
|
+
app.add_typer(comment_app, name="comment")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _version_callback(value: bool) -> None:
|
|
41
|
+
if value:
|
|
42
|
+
typer.echo(__version__)
|
|
43
|
+
raise typer.Exit()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.callback()
|
|
47
|
+
def main(
|
|
48
|
+
ctx: typer.Context,
|
|
49
|
+
json_out: Annotated[bool, typer.Option("--json", help="输出原始 JSON")] = False,
|
|
50
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="跳过所有确认")] = False,
|
|
51
|
+
verbose: Annotated[
|
|
52
|
+
bool, typer.Option("--verbose", "-v", help="打印内部 HTTP 请求")
|
|
53
|
+
] = False,
|
|
54
|
+
version: Annotated[
|
|
55
|
+
Optional[bool],
|
|
56
|
+
typer.Option(
|
|
57
|
+
"--version", "-V", callback=_version_callback, is_eager=True, help="显示版本"
|
|
58
|
+
),
|
|
59
|
+
] = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
ctx.obj = AppState(json=json_out, yes=yes, verbose=verbose)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# -- 公共辅助 ---------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@contextmanager
|
|
68
|
+
def cli_errors():
|
|
69
|
+
try:
|
|
70
|
+
yield
|
|
71
|
+
except AuthExpired as exc:
|
|
72
|
+
typer.secho(str(exc), fg="red", err=True)
|
|
73
|
+
raise typer.Exit(2)
|
|
74
|
+
except (YuqueError, SessionError, UrlParseError, AppDataError, BodyInputError) as exc:
|
|
75
|
+
typer.secho(str(exc), fg="red", err=True)
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_client(state: AppState) -> Client:
|
|
80
|
+
sess = session.load(config.session_file())
|
|
81
|
+
hooks: dict[str, list[Callable[..., Any]]] = {}
|
|
82
|
+
if state.verbose:
|
|
83
|
+
|
|
84
|
+
def _log(request: httpx.Request) -> None:
|
|
85
|
+
typer.secho(f"→ {request.method} {request.url}", fg="bright_black", err=True)
|
|
86
|
+
|
|
87
|
+
hook: Callable[..., Any] = _log
|
|
88
|
+
hooks["request"] = [hook]
|
|
89
|
+
# trust_env=False:默认直连,绕开 *_proxy 环境变量(如本地 Clash 的 socks5 代理)——
|
|
90
|
+
# 避免请求被本地代理拦截,也免去 SOCKS 需 socksio 的崩溃。如需经代理访问,移除此参数并装 httpx[socks]。
|
|
91
|
+
http = httpx.Client(timeout=30.0, event_hooks=hooks, trust_env=False)
|
|
92
|
+
return Client(http, sess, base_url=config.base_url())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _emit(state: AppState, data, human: str) -> None:
|
|
96
|
+
typer.echo(output.dumps(data) if state.json else human)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _confirm(state: AppState, message: str) -> None:
|
|
100
|
+
if state.yes:
|
|
101
|
+
return
|
|
102
|
+
if not sys.stdin.isatty():
|
|
103
|
+
raise YuqueError("需要确认但当前为非交互环境;如确定请加 --yes")
|
|
104
|
+
if not typer.confirm(message):
|
|
105
|
+
raise typer.Abort()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _read_body(body: Optional[str], file: Optional[str]) -> Optional[str]:
|
|
109
|
+
return resolve_body(
|
|
110
|
+
body, file, stdin=sys.stdin, stdin_isatty=sys.stdin.isatty()
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# -- auth -------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _prompt_cookie() -> str:
|
|
118
|
+
typer.echo(
|
|
119
|
+
"请粘贴目标域名的完整 cookie 串"
|
|
120
|
+
"(浏览器 DevTools → Application → Cookies,或 document.cookie):",
|
|
121
|
+
err=True,
|
|
122
|
+
)
|
|
123
|
+
return typer.prompt("cookie")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
_CDP_HINT = (
|
|
127
|
+
f"未检测到 Chrome 远程调试(DevToolsActivePort / 127.0.0.1:{auth.CDP_PORT})。\n"
|
|
128
|
+
"如需自动化登录:完全退出 Chrome,再带远程调试启动并登录目标语雀站点,例如 macOS:\n"
|
|
129
|
+
f' "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" '
|
|
130
|
+
f"--remote-debugging-port={auth.CDP_PORT}\n"
|
|
131
|
+
"随后重试 yuque auth login。现在可手动粘贴 cookie 完成登录。"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@auth_app.command("login")
|
|
136
|
+
def auth_login(
|
|
137
|
+
ctx: typer.Context,
|
|
138
|
+
manual: Annotated[
|
|
139
|
+
bool, typer.Option("--manual", help="跳过 CDP,直接手动粘贴 cookie")
|
|
140
|
+
] = False,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""登录:默认探测本地 CDP 自动获取登录态;未启用或失败时回退手动粘贴。"""
|
|
143
|
+
with cli_errors():
|
|
144
|
+
state: AppState = ctx.obj
|
|
145
|
+
base = config.base_url()
|
|
146
|
+
ws_url = None if manual else auth.cdp_websocket_url(verbose=state.verbose)
|
|
147
|
+
if ws_url is None:
|
|
148
|
+
if not manual:
|
|
149
|
+
typer.secho(_CDP_HINT, fg="yellow", err=True)
|
|
150
|
+
cookies = auth.cookies_via_manual(_prompt_cookie)
|
|
151
|
+
else:
|
|
152
|
+
try:
|
|
153
|
+
cookies = auth.cookies_via_cdp(base, ws_url)
|
|
154
|
+
typer.secho(
|
|
155
|
+
f"已通过 CDP 从浏览器获取 {config.host()} 登录态", fg="green", err=True
|
|
156
|
+
)
|
|
157
|
+
except auth.CdpUnavailable as exc:
|
|
158
|
+
typer.secho(f"CDP 已连接但获取失败:{exc}", fg="yellow", err=True)
|
|
159
|
+
cookies = auth.cookies_via_manual(_prompt_cookie)
|
|
160
|
+
sess = session.Session(cookies)
|
|
161
|
+
sess.require_session()
|
|
162
|
+
session.save(sess, config.session_file())
|
|
163
|
+
typer.echo(f"已登录,会话保存在 {config.session_file()}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@auth_app.command("status")
|
|
167
|
+
def auth_status(ctx: typer.Context) -> None:
|
|
168
|
+
"""显示当前登录身份(GET /api/mine)。"""
|
|
169
|
+
state: AppState = ctx.obj
|
|
170
|
+
with cli_errors():
|
|
171
|
+
user = build_client(state).mine()
|
|
172
|
+
_emit(state, user, output.render_user(user))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@auth_app.command("logout")
|
|
176
|
+
def auth_logout(ctx: typer.Context) -> None:
|
|
177
|
+
"""清除本地保存的会话。"""
|
|
178
|
+
path = config.session_file()
|
|
179
|
+
if path.exists():
|
|
180
|
+
path.unlink()
|
|
181
|
+
typer.echo("已退出登录(已清除本地会话)")
|
|
182
|
+
else:
|
|
183
|
+
typer.echo("当前未登录")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# -- book -------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@book_app.command("list")
|
|
190
|
+
def book_list(ctx: typer.Context) -> None:
|
|
191
|
+
"""列出我的知识库(GET /api/mine/books)。"""
|
|
192
|
+
state: AppState = ctx.obj
|
|
193
|
+
with cli_errors():
|
|
194
|
+
books = build_client(state).mine_books()
|
|
195
|
+
_emit(state, books, output.render_books(books))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# -- doc --------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
UrlArg = Annotated[str, typer.Argument(help="目标 URL 或去 host 的路径形态")]
|
|
201
|
+
BodyOpt = Annotated[Optional[str], typer.Option("--body", "-b", help="内联正文")]
|
|
202
|
+
FileOpt = Annotated[
|
|
203
|
+
Optional[str], typer.Option("--file", "-F", help="从文件读正文(- 表示 stdin)")
|
|
204
|
+
]
|
|
205
|
+
PublicOpt = Annotated[
|
|
206
|
+
Optional[bool],
|
|
207
|
+
typer.Option("--public/--private", help="可见性;不给则继承知识库默认/保持原值"),
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@doc_app.command("list")
|
|
212
|
+
def doc_list(ctx: typer.Context, url: UrlArg) -> None:
|
|
213
|
+
"""列出知识库下的文档。"""
|
|
214
|
+
state: AppState = ctx.obj
|
|
215
|
+
with cli_errors():
|
|
216
|
+
ref = parse_book_ref(url)
|
|
217
|
+
client = build_client(state)
|
|
218
|
+
book_id = client.resolve_book_id(ref.login, ref.book)
|
|
219
|
+
docs = client.book_docs(book_id)
|
|
220
|
+
_emit(state, docs, output.render_docs(docs))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@doc_app.command("get")
|
|
224
|
+
def doc_get(ctx: typer.Context, url: UrlArg) -> None:
|
|
225
|
+
"""取文档正文:默认干净 Markdown;--json 出详情(含 Lake)。"""
|
|
226
|
+
state: AppState = ctx.obj
|
|
227
|
+
with cli_errors():
|
|
228
|
+
ref = parse_doc_ref(url)
|
|
229
|
+
client = build_client(state)
|
|
230
|
+
if state.json:
|
|
231
|
+
app_data = client.doc_page_app_data(ref.login, ref.book, ref.slug)
|
|
232
|
+
detail = client.doc_detail(ref.slug, appdata_book_id(app_data))
|
|
233
|
+
typer.echo(output.dumps(detail))
|
|
234
|
+
else:
|
|
235
|
+
typer.echo(client.doc_markdown(ref.login, ref.book, ref.slug))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@doc_app.command("create")
|
|
239
|
+
def doc_create(
|
|
240
|
+
ctx: typer.Context,
|
|
241
|
+
url: UrlArg,
|
|
242
|
+
title: Annotated[str, typer.Option("--title", "-t", help="文档标题")],
|
|
243
|
+
slug: Annotated[Optional[str], typer.Option("--slug", help="文档 slug(省略则自动生成)")] = None,
|
|
244
|
+
public: PublicOpt = None,
|
|
245
|
+
body: BodyOpt = None,
|
|
246
|
+
file: FileOpt = None,
|
|
247
|
+
) -> None:
|
|
248
|
+
"""在知识库中创建文档。"""
|
|
249
|
+
state: AppState = ctx.obj
|
|
250
|
+
with cli_errors():
|
|
251
|
+
ref = parse_book_ref(url)
|
|
252
|
+
text = _read_body(body, file)
|
|
253
|
+
client = build_client(state)
|
|
254
|
+
book_id = client.resolve_book_id(ref.login, ref.book)
|
|
255
|
+
pub = None if public is None else (1 if public else 0)
|
|
256
|
+
created = client.create_doc(
|
|
257
|
+
book_id=book_id, title=title, body=text, slug=slug, public=pub
|
|
258
|
+
)
|
|
259
|
+
url_out = f"{config.base_url()}/{ref.login}/{ref.book}/{created.get('slug', '')}"
|
|
260
|
+
_emit(state, created, f"已创建:{url_out}")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@doc_app.command("update")
|
|
264
|
+
def doc_update(
|
|
265
|
+
ctx: typer.Context,
|
|
266
|
+
url: UrlArg,
|
|
267
|
+
title: Annotated[Optional[str], typer.Option("--title", "-t", help="新标题")] = None,
|
|
268
|
+
public: PublicOpt = None,
|
|
269
|
+
body: BodyOpt = None,
|
|
270
|
+
file: FileOpt = None,
|
|
271
|
+
) -> None:
|
|
272
|
+
"""更新文档(先取后合并:未给的字段保持原值)。"""
|
|
273
|
+
state: AppState = ctx.obj
|
|
274
|
+
with cli_errors():
|
|
275
|
+
if title is None and public is None and body is None and file is None:
|
|
276
|
+
raise YuqueError(
|
|
277
|
+
"没有要更新的字段(至少给 --title / 正文 / --public|--private 之一)"
|
|
278
|
+
)
|
|
279
|
+
ref = parse_doc_ref(url)
|
|
280
|
+
client = build_client(state)
|
|
281
|
+
app_data = client.doc_page_app_data(ref.login, ref.book, ref.slug)
|
|
282
|
+
doc_id = appdata_doc_id(app_data)
|
|
283
|
+
meta = app_data.get("doc", {})
|
|
284
|
+
new_title = title if title is not None else meta.get("title", "")
|
|
285
|
+
if body is not None or file is not None:
|
|
286
|
+
new_body = _read_body(body, file) or ""
|
|
287
|
+
else:
|
|
288
|
+
new_body = client.doc_markdown(ref.login, ref.book, ref.slug)
|
|
289
|
+
new_public = (1 if public else 0) if public is not None else meta.get("public")
|
|
290
|
+
updated = client.update_doc(
|
|
291
|
+
doc_id=doc_id, title=new_title, body=new_body, public=new_public
|
|
292
|
+
)
|
|
293
|
+
url_out = f"{config.base_url()}/{ref.login}/{ref.book}/{ref.slug}"
|
|
294
|
+
_emit(state, updated, f"已更新:{url_out}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@doc_app.command("delete")
|
|
298
|
+
def doc_delete(ctx: typer.Context, url: UrlArg) -> None:
|
|
299
|
+
"""删除文档(软删,可在回收站恢复)。"""
|
|
300
|
+
state: AppState = ctx.obj
|
|
301
|
+
with cli_errors():
|
|
302
|
+
ref = parse_doc_ref(url)
|
|
303
|
+
client = build_client(state)
|
|
304
|
+
app_data = client.doc_page_app_data(ref.login, ref.book, ref.slug)
|
|
305
|
+
doc_id = appdata_doc_id(app_data)
|
|
306
|
+
title = app_data.get("doc", {}).get("title", ref.slug)
|
|
307
|
+
_confirm(state, f"确认删除文档「{title}」?(软删,可在回收站恢复)")
|
|
308
|
+
result = client.delete_doc(doc_id)
|
|
309
|
+
_emit(state, result, "已删除(可在回收站恢复)")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# -- comment ----------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@comment_app.command("list")
|
|
316
|
+
def comment_list(ctx: typer.Context, url: UrlArg) -> None:
|
|
317
|
+
"""列出文档的评论。"""
|
|
318
|
+
state: AppState = ctx.obj
|
|
319
|
+
with cli_errors():
|
|
320
|
+
ref = parse_doc_ref(url)
|
|
321
|
+
client = build_client(state)
|
|
322
|
+
_, doc_id = client.resolve_doc_ids(ref.login, ref.book, ref.slug)
|
|
323
|
+
items = client.comments(doc_id)
|
|
324
|
+
_emit(state, items, output.render_comments(items))
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@comment_app.command("add")
|
|
328
|
+
def comment_add(
|
|
329
|
+
ctx: typer.Context, url: UrlArg, body: BodyOpt = None, file: FileOpt = None
|
|
330
|
+
) -> None:
|
|
331
|
+
"""给文档添加评论。"""
|
|
332
|
+
state: AppState = ctx.obj
|
|
333
|
+
with cli_errors():
|
|
334
|
+
ref = parse_doc_ref(url)
|
|
335
|
+
text = _read_body(body, file)
|
|
336
|
+
if not text:
|
|
337
|
+
raise YuqueError("评论正文不能为空(用 --body 或 --file/-)")
|
|
338
|
+
client = build_client(state)
|
|
339
|
+
_, doc_id = client.resolve_doc_ids(ref.login, ref.book, ref.slug)
|
|
340
|
+
created = client.create_comment(doc_id=doc_id, body=text)
|
|
341
|
+
_emit(state, created, f"已评论(id={created.get('id', '')})")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@comment_app.command("delete")
|
|
345
|
+
def comment_delete(
|
|
346
|
+
ctx: typer.Context,
|
|
347
|
+
comment_id: Annotated[int, typer.Argument(help="评论 id(从 comment list 获取)")],
|
|
348
|
+
) -> None:
|
|
349
|
+
"""删除一条评论。"""
|
|
350
|
+
state: AppState = ctx.obj
|
|
351
|
+
with cli_errors():
|
|
352
|
+
client = build_client(state)
|
|
353
|
+
_confirm(state, f"确认删除评论 {comment_id}?")
|
|
354
|
+
result = client.delete_comment(comment_id)
|
|
355
|
+
_emit(state, result, "已删除评论")
|
yuque_cli/client.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from . import appdata
|
|
8
|
+
from .errors import ApiError, AuthExpired
|
|
9
|
+
from .session import Session
|
|
10
|
+
|
|
11
|
+
USER_AGENT = (
|
|
12
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
13
|
+
"(KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Client:
|
|
18
|
+
"""语雀内部 web API 客户端。注入 httpx.Client 与 Session 以便测试。"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
http: httpx.Client,
|
|
23
|
+
session: Session,
|
|
24
|
+
*,
|
|
25
|
+
base_url: str,
|
|
26
|
+
user_agent: str = USER_AGENT,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.http = http
|
|
29
|
+
self.session = session
|
|
30
|
+
self.base_url = base_url.rstrip("/")
|
|
31
|
+
self.user_agent = user_agent
|
|
32
|
+
|
|
33
|
+
# -- 底层请求 -----------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def _headers(self, *, write: bool, referer: str | None) -> dict[str, str]:
|
|
36
|
+
headers = {
|
|
37
|
+
"Cookie": self.session.cookie_header,
|
|
38
|
+
"User-Agent": self.user_agent,
|
|
39
|
+
"Referer": referer or f"{self.base_url}/",
|
|
40
|
+
"x-requested-with": "XMLHttpRequest",
|
|
41
|
+
}
|
|
42
|
+
if write:
|
|
43
|
+
token = self.session.csrf_token
|
|
44
|
+
if not token:
|
|
45
|
+
raise ApiError("写操作缺少 CSRF token(yuque_ctoken);请重新登录")
|
|
46
|
+
headers["Content-Type"] = "application/json"
|
|
47
|
+
headers["x-csrf-token"] = token
|
|
48
|
+
return headers
|
|
49
|
+
|
|
50
|
+
def _request(
|
|
51
|
+
self,
|
|
52
|
+
method: str,
|
|
53
|
+
url: str,
|
|
54
|
+
*,
|
|
55
|
+
params: dict[str, Any] | None = None,
|
|
56
|
+
json_body: Any | None = None,
|
|
57
|
+
write: bool = False,
|
|
58
|
+
referer: str | None = None,
|
|
59
|
+
) -> httpx.Response:
|
|
60
|
+
resp = self.http.request(
|
|
61
|
+
method,
|
|
62
|
+
url,
|
|
63
|
+
params=params,
|
|
64
|
+
json=json_body,
|
|
65
|
+
headers=self._headers(write=write, referer=referer),
|
|
66
|
+
)
|
|
67
|
+
if resp.status_code == 401:
|
|
68
|
+
raise AuthExpired("登录态已失效(401);请重新运行 yuque auth login")
|
|
69
|
+
if resp.status_code >= 400:
|
|
70
|
+
raise ApiError(
|
|
71
|
+
f"请求失败 {resp.status_code} {method} {url}:{resp.text[:200]}",
|
|
72
|
+
status_code=resp.status_code,
|
|
73
|
+
)
|
|
74
|
+
return resp
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _data(resp: httpx.Response) -> Any:
|
|
78
|
+
payload = resp.json()
|
|
79
|
+
if isinstance(payload, dict) and "data" in payload:
|
|
80
|
+
return payload["data"]
|
|
81
|
+
return payload
|
|
82
|
+
|
|
83
|
+
# -- 用户 / 知识库 ------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def mine(self) -> dict:
|
|
86
|
+
return self._data(self._request("GET", f"{self.base_url}/api/mine"))
|
|
87
|
+
|
|
88
|
+
def mine_books(self) -> list[dict]:
|
|
89
|
+
return self._data(self._request("GET", f"{self.base_url}/api/mine/books"))
|
|
90
|
+
|
|
91
|
+
def book_docs(self, book_id: int) -> list[dict]:
|
|
92
|
+
return self._data(
|
|
93
|
+
self._request("GET", f"{self.base_url}/api/books/{book_id}/docs")
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# -- 文档 ---------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def doc_markdown(self, login: str, book: str, slug: str) -> str:
|
|
99
|
+
url = f"{self.base_url}/{login}/{book}/{slug}/markdown"
|
|
100
|
+
return self._request(
|
|
101
|
+
"GET",
|
|
102
|
+
url,
|
|
103
|
+
params={"plain": "true", "linebreak": "false", "anchor": "false"},
|
|
104
|
+
referer=f"{self.base_url}/{login}/{book}/{slug}",
|
|
105
|
+
).text
|
|
106
|
+
|
|
107
|
+
def doc_detail(self, slug_or_id: str | int, book_id: int) -> dict:
|
|
108
|
+
return self._data(
|
|
109
|
+
self._request(
|
|
110
|
+
"GET",
|
|
111
|
+
f"{self.base_url}/api/docs/{slug_or_id}",
|
|
112
|
+
params={"book_id": book_id},
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def create_doc(
|
|
117
|
+
self,
|
|
118
|
+
*,
|
|
119
|
+
book_id: int,
|
|
120
|
+
title: str,
|
|
121
|
+
body: str | None,
|
|
122
|
+
slug: str | None = None,
|
|
123
|
+
public: int | None = None,
|
|
124
|
+
) -> dict:
|
|
125
|
+
payload: dict[str, Any] = {
|
|
126
|
+
"book_id": book_id,
|
|
127
|
+
"title": title,
|
|
128
|
+
"format": "markdown",
|
|
129
|
+
"body": body or "",
|
|
130
|
+
}
|
|
131
|
+
if slug is not None:
|
|
132
|
+
payload["slug"] = slug
|
|
133
|
+
if public is not None:
|
|
134
|
+
payload["public"] = public
|
|
135
|
+
return self._data(
|
|
136
|
+
self._request("POST", f"{self.base_url}/api/docs", json_body=payload, write=True)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def update_doc(
|
|
140
|
+
self,
|
|
141
|
+
*,
|
|
142
|
+
doc_id: int,
|
|
143
|
+
title: str,
|
|
144
|
+
body: str,
|
|
145
|
+
public: int | None = None,
|
|
146
|
+
) -> dict:
|
|
147
|
+
payload: dict[str, Any] = {"title": title, "body": body, "format": "markdown"}
|
|
148
|
+
if public is not None:
|
|
149
|
+
payload["public"] = public
|
|
150
|
+
return self._data(
|
|
151
|
+
self._request(
|
|
152
|
+
"PUT", f"{self.base_url}/api/docs/{doc_id}", json_body=payload, write=True
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def delete_doc(self, doc_id: int) -> dict:
|
|
157
|
+
return self._data(
|
|
158
|
+
self._request("DELETE", f"{self.base_url}/api/docs/{doc_id}", write=True)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# -- 评论 ---------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def comments(self, doc_id: int) -> list[dict]:
|
|
164
|
+
return self._data(
|
|
165
|
+
self._request(
|
|
166
|
+
"GET",
|
|
167
|
+
f"{self.base_url}/api/comments",
|
|
168
|
+
params={"commentable_id": doc_id, "commentable_type": "Doc"},
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def create_comment(self, *, doc_id: int, body: str) -> dict:
|
|
173
|
+
payload = {"commentable_id": doc_id, "commentable_type": "Doc", "body": body}
|
|
174
|
+
return self._data(
|
|
175
|
+
self._request(
|
|
176
|
+
"POST", f"{self.base_url}/api/comments", json_body=payload, write=True
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def delete_comment(self, comment_id: int) -> dict:
|
|
181
|
+
return self._data(
|
|
182
|
+
self._request(
|
|
183
|
+
"DELETE", f"{self.base_url}/api/comments/{comment_id}", write=True
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# -- URL → id 解析(抓 appData) ----------------------------------------
|
|
188
|
+
|
|
189
|
+
def doc_page_app_data(self, login: str, book: str, slug: str) -> dict:
|
|
190
|
+
html = self._request("GET", f"{self.base_url}/{login}/{book}/{slug}").text
|
|
191
|
+
return appdata.parse_app_data(html)
|
|
192
|
+
|
|
193
|
+
def resolve_book_id(self, login: str, book: str) -> int:
|
|
194
|
+
html = self._request("GET", f"{self.base_url}/{login}/{book}").text
|
|
195
|
+
return appdata.book_id(appdata.parse_app_data(html))
|
|
196
|
+
|
|
197
|
+
def resolve_doc_ids(self, login: str, book: str, slug: str) -> tuple[int, int]:
|
|
198
|
+
data = self.doc_page_app_data(login, book, slug)
|
|
199
|
+
return appdata.book_id(data), appdata.doc_id(data)
|
yuque_cli/config.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
DEFAULT_HOST = "www.yuque.com"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def host() -> str:
|
|
10
|
+
return os.environ.get("YUQUE_HOST") or DEFAULT_HOST
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def base_url() -> str:
|
|
14
|
+
return f"https://{host()}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def config_dir() -> Path:
|
|
18
|
+
override = os.environ.get("YUQUE_CONFIG_DIR")
|
|
19
|
+
if override:
|
|
20
|
+
return Path(override)
|
|
21
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
22
|
+
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
23
|
+
return base / "yuque-cli"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def session_file() -> Path:
|
|
27
|
+
return config_dir() / "session.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def no_color() -> bool:
|
|
31
|
+
return bool(os.environ.get("NO_COLOR"))
|
yuque_cli/errors.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class YuqueError(Exception):
|
|
5
|
+
"""yuque-cli 的运行期错误基类。"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthExpired(YuqueError):
|
|
9
|
+
"""登录态失效(HTTP 401)。"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ApiError(YuqueError):
|
|
13
|
+
"""内部接口返回非预期状态码。"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.status_code = status_code
|
yuque_cli/inputs.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TextIO
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BodyInputError(ValueError):
|
|
8
|
+
"""正文输入来源无效(互斥冲突或文件不可读)。"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_body(
|
|
12
|
+
body: str | None,
|
|
13
|
+
file: str | None,
|
|
14
|
+
*,
|
|
15
|
+
stdin: TextIO,
|
|
16
|
+
stdin_isatty: bool,
|
|
17
|
+
) -> str | None:
|
|
18
|
+
"""按 --body > --file(-=stdin) > 管道 stdin 的优先级解析正文。
|
|
19
|
+
|
|
20
|
+
在 TTY 下且未给任何来源时返回 None(由调用方决定是否必填)。
|
|
21
|
+
"""
|
|
22
|
+
if body is not None and file is not None:
|
|
23
|
+
raise BodyInputError("--body 与 --file 互斥,只能给其一")
|
|
24
|
+
if body is not None:
|
|
25
|
+
return body
|
|
26
|
+
if file is not None:
|
|
27
|
+
if file == "-":
|
|
28
|
+
return stdin.read()
|
|
29
|
+
try:
|
|
30
|
+
return Path(file).read_text(encoding="utf-8")
|
|
31
|
+
except OSError as exc:
|
|
32
|
+
raise BodyInputError(f"无法读取文件 {file}:{exc}") from exc
|
|
33
|
+
if not stdin_isatty:
|
|
34
|
+
return stdin.read()
|
|
35
|
+
return None
|
yuque_cli/output.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def dumps(data: Any) -> str:
|
|
8
|
+
return json.dumps(data, ensure_ascii=False, indent=2)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def table(headers: list[str], rows: list[list[str]]) -> str:
|
|
12
|
+
widths = [len(h) for h in headers]
|
|
13
|
+
for row in rows:
|
|
14
|
+
for i, cell in enumerate(row):
|
|
15
|
+
widths[i] = max(widths[i], len(cell))
|
|
16
|
+
lines = [" ".join(h.ljust(widths[i]) for i, h in enumerate(headers)).rstrip()]
|
|
17
|
+
for row in rows:
|
|
18
|
+
lines.append(" ".join(row[i].ljust(widths[i]) for i in range(len(headers))).rstrip())
|
|
19
|
+
return "\n".join(lines)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _first_line(text: str, limit: int = 60) -> str:
|
|
23
|
+
line = (text or "").splitlines()[0] if text else ""
|
|
24
|
+
return line if len(line) <= limit else line[: limit - 1] + "…"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render_user(u: dict) -> str:
|
|
28
|
+
fields = ["login", "name", "id", "email", "organization_login"]
|
|
29
|
+
return "\n".join(f"{k}: {u[k]}" for k in fields if k in u)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render_books(books: list[dict]) -> str:
|
|
33
|
+
rows = [
|
|
34
|
+
[
|
|
35
|
+
str(b.get("name", "")),
|
|
36
|
+
str(b.get("slug", "")),
|
|
37
|
+
str(b.get("items_count", "")),
|
|
38
|
+
"yes" if b.get("public") else "no",
|
|
39
|
+
str(b.get("id", "")),
|
|
40
|
+
]
|
|
41
|
+
for b in books
|
|
42
|
+
]
|
|
43
|
+
return table(["NAME", "SLUG", "DOCS", "PUBLIC", "ID"], rows)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def render_docs(docs: list[dict]) -> str:
|
|
47
|
+
rows = [
|
|
48
|
+
[str(d.get("title", "")), str(d.get("slug", "")), str(d.get("id", ""))]
|
|
49
|
+
for d in docs
|
|
50
|
+
]
|
|
51
|
+
return table(["TITLE", "SLUG", "ID"], rows)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def render_comments(comments: list[dict]) -> str:
|
|
55
|
+
rows = [
|
|
56
|
+
[
|
|
57
|
+
str(c.get("id", "")),
|
|
58
|
+
str((c.get("user") or {}).get("name", "")),
|
|
59
|
+
_first_line(c.get("body", "")),
|
|
60
|
+
]
|
|
61
|
+
for c in comments
|
|
62
|
+
]
|
|
63
|
+
return table(["ID", "AUTHOR", "BODY"], rows)
|
yuque_cli/session.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
SESSION_COOKIE = "_yuque_session"
|
|
8
|
+
CSRF_COOKIE = "yuque_ctoken"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SessionError(ValueError):
|
|
12
|
+
"""会话缺失或无效(未登录、cookie 不含登录态等)。"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_cookie_string(raw: str) -> dict[str, str]:
|
|
16
|
+
out: dict[str, str] = {}
|
|
17
|
+
for part in raw.split(";"):
|
|
18
|
+
part = part.strip()
|
|
19
|
+
if not part or "=" not in part:
|
|
20
|
+
continue
|
|
21
|
+
key, value = part.split("=", 1)
|
|
22
|
+
key = key.strip()
|
|
23
|
+
if key:
|
|
24
|
+
out[key] = value.strip()
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Session:
|
|
30
|
+
cookies: dict[str, str]
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def cookie_header(self) -> str:
|
|
34
|
+
return "; ".join(f"{k}={v}" for k, v in self.cookies.items())
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def csrf_token(self) -> str | None:
|
|
38
|
+
return self.cookies.get(CSRF_COOKIE)
|
|
39
|
+
|
|
40
|
+
def require_session(self) -> None:
|
|
41
|
+
if SESSION_COOKIE not in self.cookies:
|
|
42
|
+
raise SessionError(
|
|
43
|
+
f"cookie 中缺少 {SESSION_COOKIE}(登录态);请重新运行 yuque auth login"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def from_cookie_string(raw: str) -> Session:
|
|
48
|
+
session = Session(parse_cookie_string(raw))
|
|
49
|
+
session.require_session()
|
|
50
|
+
return session
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save(session: Session, path: Path) -> None:
|
|
54
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
path.write_text(
|
|
56
|
+
json.dumps({"cookies": session.cookies}, ensure_ascii=False),
|
|
57
|
+
encoding="utf-8",
|
|
58
|
+
)
|
|
59
|
+
path.chmod(0o600)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load(path: Path) -> Session:
|
|
63
|
+
if not path.exists():
|
|
64
|
+
raise SessionError("尚未登录;请先运行 yuque auth login")
|
|
65
|
+
try:
|
|
66
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
67
|
+
return Session(dict(data["cookies"]))
|
|
68
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
69
|
+
raise SessionError(f"会话文件损坏:{path}({exc})") from exc
|
yuque_cli/urls.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class UrlParseError(ValueError):
|
|
8
|
+
"""输入无法解析成知识库(book)或文档(doc)地址。"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class BookRef:
|
|
13
|
+
login: str
|
|
14
|
+
book: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class DocRef:
|
|
19
|
+
login: str
|
|
20
|
+
book: str
|
|
21
|
+
slug: str
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def book_ref(self) -> BookRef:
|
|
25
|
+
return BookRef(self.login, self.book)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _segments(raw: str) -> list[str]:
|
|
29
|
+
s = raw.strip()
|
|
30
|
+
if not s:
|
|
31
|
+
return []
|
|
32
|
+
s = s.split("#", 1)[0].split("?", 1)[0]
|
|
33
|
+
if "://" in s:
|
|
34
|
+
path = urlsplit(s).path
|
|
35
|
+
else:
|
|
36
|
+
# 无 scheme 时可能是 "host/seg/..." 也可能是纯 "seg/seg..."。
|
|
37
|
+
# 语雀 login 不含点,故首段含 "." 即视为 host 并剥掉。
|
|
38
|
+
parts = s.split("/")
|
|
39
|
+
if "." in parts[0]:
|
|
40
|
+
parts = parts[1:]
|
|
41
|
+
path = "/".join(parts)
|
|
42
|
+
return [seg for seg in path.split("/") if seg]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_doc_ref(raw: str) -> DocRef:
|
|
46
|
+
segs = _segments(raw)
|
|
47
|
+
if len(segs) != 3:
|
|
48
|
+
raise UrlParseError(
|
|
49
|
+
f"文档地址应形如 {{login}}/{{book}}/{{slug}}(三段),得到:{raw!r}"
|
|
50
|
+
)
|
|
51
|
+
login, book, slug = segs
|
|
52
|
+
return DocRef(login=login, book=book, slug=slug)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_book_ref(raw: str) -> BookRef:
|
|
56
|
+
segs = _segments(raw)
|
|
57
|
+
if len(segs) < 2:
|
|
58
|
+
raise UrlParseError(
|
|
59
|
+
f"知识库地址应形如 {{login}}/{{book}}(两段),得到:{raw!r}"
|
|
60
|
+
)
|
|
61
|
+
if len(segs) > 3:
|
|
62
|
+
raise UrlParseError(f"地址段数过多,无法识别为知识库:{raw!r}")
|
|
63
|
+
return BookRef(login=segs[0], book=segs[1])
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yuque-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 针对企业语雀实例的命令行客户端:cookie 认证,驱动内部 web 接口,提供文档与评论的 CRUD
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: httpx>=0.27
|
|
7
|
+
Requires-Dist: typer>=0.12
|
|
8
|
+
Requires-Dist: websocket-client>=1.9.0
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# yuque-cli
|
|
12
|
+
|
|
13
|
+
面向语雀(含企业版)的通用命令行客户端:以浏览器 cookie 认证,驱动语雀内部 web 接口,提供文档(doc)与评论(comment)的增删改查。默认连接 `www.yuque.com`,企业实例用环境变量 `YUQUE_HOST` 指定。
|
|
14
|
+
|
|
15
|
+
> 术语(知识库/文档/评论/login/namespace/slug)见 [CONTEXT.md](./CONTEXT.md);关键设计取舍见 [docs/adr](./docs/adr)。
|
|
16
|
+
|
|
17
|
+
## 特性
|
|
18
|
+
|
|
19
|
+
- **复用浏览器登录态**:默认探测本地 Chrome 远程调试(CDP)自动抓取 cookie,无需手动复制;失败可回退手动粘贴。
|
|
20
|
+
- **干净的 Markdown 输出**:`doc get` 默认输出纯 Markdown,便于管道与落盘;`--json` 输出含 Lake 正文的完整详情。
|
|
21
|
+
- **先取后合并的更新**:`doc update` 只改你指定的字段,其余保持原值。
|
|
22
|
+
- **脚本友好**:全局 `--json` 输出原始 JSON,`-y` 跳过确认,正文支持 `--body`/`--file`/管道 stdin。
|
|
23
|
+
|
|
24
|
+
## 安装
|
|
25
|
+
|
|
26
|
+
需要 Python ≥ 3.10 与 [uv](https://docs.astral.sh/uv/)。
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv sync # 安装依赖
|
|
30
|
+
uv run yuque --help # 在项目内运行
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
或作为工具安装,得到全局 `yuque` 命令:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv tool install .
|
|
37
|
+
yuque --help
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 快速开始
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
yuque auth login # 登录(自动 CDP,失败回退手动粘贴)
|
|
44
|
+
yuque auth status # 查看当前身份
|
|
45
|
+
yuque book list # 列出我的知识库
|
|
46
|
+
yuque doc list <login>/<book> # 列出知识库下的文档
|
|
47
|
+
yuque doc get <login>/<book>/<slug> # 取文档正文(Markdown)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`<login>/<book>/<slug>` 既可写成去掉 host 的路径形态,也可直接粘贴完整浏览器 URL(见 [URL 形态](#url-形态))。
|
|
51
|
+
|
|
52
|
+
## 认证
|
|
53
|
+
|
|
54
|
+
### 自动(CDP)
|
|
55
|
+
|
|
56
|
+
`yuque auth login` 默认尝试从**你当前的 Chrome**(已登录目标语雀站点)直接抓取 cookie。前提是 Chrome 以远程调试端口启动:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# 完全退出 Chrome 后,带远程调试重新启动(默认 profile 即可),再登录你的语雀站点
|
|
60
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
随后 `yuque auth login` 会自动连接、抓取目标站点的全部 cookie(含 `_yuque_session` 与 CSRF `yuque_ctoken`)并保存会话。
|
|
64
|
+
|
|
65
|
+
### 手动
|
|
66
|
+
|
|
67
|
+
未检测到远程调试、或自动抓取失败时,会提示粘贴 cookie;也可直接 `yuque auth login --manual` 跳过 CDP。cookie 取自浏览器 DevTools → Application → Cookies,或控制台 `document.cookie`。
|
|
68
|
+
|
|
69
|
+
### 会话与退出
|
|
70
|
+
|
|
71
|
+
- 会话保存在 `~/.config/yuque-cli/session.json`(权限 `0600`)。
|
|
72
|
+
- `yuque auth logout` 清除本地会话。
|
|
73
|
+
|
|
74
|
+
### 代理说明
|
|
75
|
+
|
|
76
|
+
对语雀的 API 请求**默认直连,不读取 `*_proxy` 环境变量**(`trust_env=False`),避免请求被本地代理(如 Clash)拦截,也免去 SOCKS 代理触发 `socksio` 缺失的报错。如果你的网络必须经代理才能访问目标实例,需要移除该参数并引入 `httpx[socks]` 依赖。
|
|
77
|
+
|
|
78
|
+
## 命令参考
|
|
79
|
+
|
|
80
|
+
### 全局选项
|
|
81
|
+
|
|
82
|
+
置于子命令之前,如 `yuque --json book list`。
|
|
83
|
+
|
|
84
|
+
| 选项 | 说明 |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `--json` | 输出原始 JSON(便于脚本解析) |
|
|
87
|
+
| `-y`, `--yes` | 跳过所有确认(删除等危险操作) |
|
|
88
|
+
| `-v`, `--verbose` | 打印内部 HTTP 请求到 stderr |
|
|
89
|
+
| `-V`, `--version` | 显示版本 |
|
|
90
|
+
|
|
91
|
+
### auth — 登录态管理
|
|
92
|
+
|
|
93
|
+
| 命令 | 说明 |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| `auth login [--manual]` | 登录;默认探测 CDP 自动获取,`--manual` 直接手动粘贴 |
|
|
96
|
+
| `auth status` | 显示当前登录身份(`GET /api/mine`) |
|
|
97
|
+
| `auth logout` | 清除本地会话 |
|
|
98
|
+
|
|
99
|
+
### book — 知识库
|
|
100
|
+
|
|
101
|
+
| 命令 | 说明 |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| `book list` | 列出我的知识库(`GET /api/mine/books`) |
|
|
104
|
+
|
|
105
|
+
### doc — 文档
|
|
106
|
+
|
|
107
|
+
| 命令 | 说明 |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| `doc list <login>/<book>` | 列出知识库下的文档 |
|
|
110
|
+
| `doc get <login>/<book>/<slug>` | 取正文;默认干净 Markdown,`--json` 出详情(含 Lake) |
|
|
111
|
+
| `doc create <login>/<book> -t <标题> [选项]` | 在知识库中创建文档 |
|
|
112
|
+
| `doc update <login>/<book>/<slug> [选项]` | 更新文档(先取后合并,未给的字段保持原值) |
|
|
113
|
+
| `doc delete <login>/<book>/<slug>` | 删除文档(软删,可在回收站恢复) |
|
|
114
|
+
|
|
115
|
+
`doc create` 选项:`-t/--title`(必填)、`--slug`(省略则自动生成)、`--public/--private`、`-b/--body`、`-F/--file`。
|
|
116
|
+
|
|
117
|
+
`doc update` 选项:`-t/--title`、`--public/--private`、`-b/--body`、`-F/--file`;至少给其一,否则报错。
|
|
118
|
+
|
|
119
|
+
可见性 `--public/--private`:不给则创建时继承知识库默认、更新时保持原值。
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
# 创建(内联正文)
|
|
123
|
+
yuque doc create your-team/handbook -t "上手指南" --body "# 标题\n正文"
|
|
124
|
+
|
|
125
|
+
# 从文件创建,并设为私有
|
|
126
|
+
yuque doc create your-team/handbook -t "周报" -F report.md --private
|
|
127
|
+
|
|
128
|
+
# 仅改标题(正文与可见性保持不变)
|
|
129
|
+
yuque doc update your-team/handbook/getting-started -t "新标题"
|
|
130
|
+
|
|
131
|
+
# 删除(非交互环境需 -y)
|
|
132
|
+
yuque -y doc delete your-team/handbook/old-draft
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### comment — 评论
|
|
136
|
+
|
|
137
|
+
| 命令 | 说明 |
|
|
138
|
+
| --- | --- |
|
|
139
|
+
| `comment list <login>/<book>/<slug>` | 列出文档评论 |
|
|
140
|
+
| `comment add <login>/<book>/<slug> [-b/--body \| -F/--file]` | 添加评论(正文不能为空) |
|
|
141
|
+
| `comment delete <comment_id>` | 删除一条评论(id 取自 `comment list`) |
|
|
142
|
+
|
|
143
|
+
## URL 形态
|
|
144
|
+
|
|
145
|
+
凡接受文档/知识库地址的命令,既可用**完整浏览器 URL**,也可用**去掉 host 的路径形态**,二者等价:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
https://<host>/<login>/<book>/<slug> ←→ <login>/<book>/<slug> (文档)
|
|
149
|
+
https://<host>/<login>/<book> ←→ <login>/<book> (知识库)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
数字 id 不属于用户语汇,仅内部解析时使用。
|
|
153
|
+
|
|
154
|
+
## 正文输入
|
|
155
|
+
|
|
156
|
+
`doc create` / `comment add` 的正文按以下优先级取值(`--body` 与 `--file` 互斥):
|
|
157
|
+
|
|
158
|
+
1. `-b/--body`(内联字符串)
|
|
159
|
+
2. `-F/--file`(从文件读;`-F -` 表示读 stdin)
|
|
160
|
+
3. 管道 stdin(直接 `... | yuque doc create ...`)
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
echo "# 来自管道的正文" | yuque doc create your-team/handbook -t "标题"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`doc update` 略有不同:只有显式给 `-b/--body` 或 `-F/--file`(含 `-F -` 读 stdin)才会改正文;**裸管道不会被读取**——不给正文来源即表示保持原正文不变。
|
|
167
|
+
|
|
168
|
+
## 环境变量
|
|
169
|
+
|
|
170
|
+
| 变量 | 作用 | 默认 |
|
|
171
|
+
| --- | --- | --- |
|
|
172
|
+
| `YUQUE_HOST` | 目标语雀 host | `www.yuque.com` |
|
|
173
|
+
| `YUQUE_CONFIG_DIR` | 配置/会话目录 | `$XDG_CONFIG_HOME/yuque-cli` 或 `~/.config/yuque-cli` |
|
|
174
|
+
| `XDG_CONFIG_HOME` | 配置根目录 | `~/.config` |
|
|
175
|
+
| `NO_COLOR` | 设置后禁用彩色输出 | — |
|
|
176
|
+
|
|
177
|
+
## 开发
|
|
178
|
+
|
|
179
|
+
本项目遵循 TDD,并用 [mise](https://mise.jdx.dev/) 管理工具链(锁定 uv)与常用任务。
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
mise run deps # 安装依赖(uv sync)
|
|
183
|
+
mise run test # 运行测试(pytest)
|
|
184
|
+
mise run lint # ruff check && ruff format
|
|
185
|
+
mise run check # ty 类型检查
|
|
186
|
+
mise run cli -- --help # 运行 CLI(-- 之后为 yuque 的参数)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
- 源码:`src/yuque_cli/`;测试:`test/`。
|
|
190
|
+
- 设计文档:术语表 [CONTEXT.md](./CONTEXT.md)、决策记录 [docs/adr](./docs/adr)、内部接口速查 [docs/internal-api.md](./docs/internal-api.md)。
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
yuque_cli/__init__.py,sha256=E7Y2P7b8OYtteFYpw4lSixsqfYPPORhyw3S2sV0O7fA,74
|
|
2
|
+
yuque_cli/__main__.py,sha256=Qd-f8z2Q2vpiEP2x6PBFsJrpACWDVxFKQk820MhFmHo,59
|
|
3
|
+
yuque_cli/appdata.py,sha256=OCpcFbn1_tpLa8xP9knKx724tWu93BedIijqaGBOJM4,1328
|
|
4
|
+
yuque_cli/auth.py,sha256=PGp_y_M-fmATK447w3NyJ2HL6jOkkcUxsdQLXJ-wKjw,5393
|
|
5
|
+
yuque_cli/cli.py,sha256=Y5QGMY2sEGZ00DhUKUaVSUcw0Svp8R_UNrGhvA_tdkc,13007
|
|
6
|
+
yuque_cli/client.py,sha256=LSvSUAtcThzlZ_YSrHjIe5PUQt9ndj5tAGLKYSO5gk0,6534
|
|
7
|
+
yuque_cli/config.py,sha256=yoeFuC26KjaxGwDr3TrTCQvtqimIZ9NRXvhNmJm0yj0,633
|
|
8
|
+
yuque_cli/errors.py,sha256=P0PJ6ZrdxQUStDVEE9drygFzTGm76CoTlI7rYJptoU0,425
|
|
9
|
+
yuque_cli/inputs.py,sha256=YhyRxZLuVZ17v1ZIYruBSh9-1ayY35brdpfdH0a5Aro,1004
|
|
10
|
+
yuque_cli/output.py,sha256=rsPjt_JGZMONcSFEysSE8tFdqBiAD-q26ti31RLps30,1828
|
|
11
|
+
yuque_cli/session.py,sha256=Aw2Xgbs6Oh3a4zvRb1PMzDONor1-CA3LtOayMk64qgo,1921
|
|
12
|
+
yuque_cli/urls.py,sha256=iiMW2nI_k4ycJxOt_DlV0_Q7XHx94nZfnY1ParjH9UM,1678
|
|
13
|
+
yuque_cli-0.1.0.dist-info/METADATA,sha256=1Kt63WVNkneeJF2TYIx4SXzgC7Fu317Qqm88870wUs0,7340
|
|
14
|
+
yuque_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
15
|
+
yuque_cli-0.1.0.dist-info/entry_points.txt,sha256=wAmlUjwsHdCdBpdv0baZSO1v_vVbpe4940swfNb7zIM,44
|
|
16
|
+
yuque_cli-0.1.0.dist-info/RECORD,,
|