@acedatacloud/skills 2026.728.1 → 2026.728.2
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.
- package/package.json +1 -1
- package/skills/yuque/SKILL.md +84 -63
- package/skills/yuque/scripts/yuque.py +368 -95
- package/skills/yuque/tests/test_yuque.py +311 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.728.
|
|
3
|
+
"version": "2026.728.2",
|
|
4
4
|
"description": "Agent Skills for AceDataCloud AI services — music, image, video generation, LLM chat, web search. Compatible with Claude Code, GitHub Copilot, Gemini CLI, OpenAI Codex, and 30+ AI coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent-skills",
|
package/skills/yuque/SKILL.md
CHANGED
|
@@ -1,104 +1,125 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yuque
|
|
3
|
-
description: Read and write Yuque (语雀) documents
|
|
3
|
+
description: Read and write Yuque (语雀) documents on the user's own account — list knowledge bases (知识库), read a document, publish Markdown, and update or delete one. Works with either the user's browser login (free) or a personal access token. Use when the user wants to publish Markdown to 语雀, list their 语雀 knowledge bases, or read a 语雀 document.
|
|
4
4
|
when_to_use: |
|
|
5
5
|
Trigger for 语雀 / Yuque document management: verify the connected account,
|
|
6
6
|
list knowledge bases or the documents inside one, read a document, create a
|
|
7
|
-
Markdown document,
|
|
8
|
-
destructive actions require explicit confirmation.
|
|
7
|
+
Markdown document, and (token connections only) update or delete one.
|
|
8
|
+
Writes and destructive actions require explicit confirmation.
|
|
9
9
|
connections: [yuque]
|
|
10
10
|
allowed_tools: [Bash]
|
|
11
11
|
license: Apache-2.0
|
|
12
12
|
metadata:
|
|
13
13
|
author: acedatacloud
|
|
14
|
-
version: "
|
|
14
|
+
version: "2.0"
|
|
15
15
|
---
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
personal access token as `$YUQUE_TOKEN`. Never print it. The CLI uses the
|
|
19
|
-
official open API at `https://www.yuque.com/api/v2` with the documented
|
|
20
|
-
`X-Auth-Token` header.
|
|
17
|
+
# 语雀 — two connection modes
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
python3 "$SKILL_DIR/scripts/yuque.py" whoami
|
|
24
|
-
```
|
|
19
|
+
The connector injects exactly one credential, and the CLI picks its mode from it:
|
|
25
20
|
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
- **`$YUQUE_COOKIES`** — the user's own browser login jar, captured by the ACE
|
|
22
|
+
extension. **Free.** Drives 语雀's internal web API.
|
|
23
|
+
- **`$YUQUE_TOKEN`** — a 语雀 personal access token for the official open API.
|
|
24
|
+
**Requires a paid 语雀超级会员.**
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
`
|
|
31
|
-
`https://auth.acedata.cloud/user/connections`. Do not ask for their account
|
|
32
|
-
password or Cookie.
|
|
26
|
+
Both are **secret — never echo, print, log or return them.** Every command
|
|
27
|
+
reports the active mode back as `auth_mode`; read that instead of guessing.
|
|
33
28
|
|
|
34
|
-
|
|
29
|
+
| Command | cookie | token |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `whoami`, `repos`, `docs`, `doc` | ✅ | ✅ |
|
|
32
|
+
| `create` | ✅ | ✅ |
|
|
33
|
+
| `update`, `delete` | ❌ refused with a clear error | ✅ |
|
|
34
|
+
|
|
35
|
+
If the user asks to edit or delete on a cookie connection, tell them that needs
|
|
36
|
+
a personal token (created at `https://www.yuque.com/settings/tokens`, 超级会员
|
|
37
|
+
required) and offer to create a new document instead. Do not attempt a
|
|
38
|
+
workaround.
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
(`user/book`, as shown in the document URL) or by its numeric id. Always run
|
|
38
|
-
`repos` first — never guess a namespace.
|
|
40
|
+
## Script resolution
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
python3 "$SKILL_DIR/scripts/yuque.py" whoami
|
|
42
|
+
Bash calls do not share shell variables. Resolve the helper inside **every**
|
|
43
|
+
fenced Bash invocation before using it:
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
python3 "$
|
|
45
|
+
```sh
|
|
46
|
+
Y="${SKILL_DIR:-}/scripts/yuque.py"; [ -f "$Y" ] || Y=$(find /tmp -maxdepth 8 -path '*/skills/*/yuque/scripts/yuque.py' -print -quit 2>/dev/null)
|
|
47
|
+
[ -f "$Y" ] || { echo "yuque script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
|
|
48
|
+
python3 "$Y" whoami
|
|
48
49
|
```
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
On an auth error, ask the user to reconnect at
|
|
52
|
+
<https://auth.acedata.cloud/user/connections>. Never ask for their password,
|
|
53
|
+
and never ask them to paste a Cookie into the chat.
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
## Read
|
|
56
|
+
|
|
57
|
+
A knowledge base (`repo`) is addressed by its `user/book` namespace or its
|
|
58
|
+
numeric id. **Always run `repos` first — never guess a namespace or an id.**
|
|
59
|
+
Pass back exactly the `repo_id` that `repos` printed; on a cookie connection a
|
|
60
|
+
`user/book` namespace is resolved by matching the account's own knowledge
|
|
61
|
+
bases, so it fails for a base the account does not own, and an ambiguous slug
|
|
62
|
+
is refused rather than guessed.
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
Y="${SKILL_DIR:-}/scripts/yuque.py"; [ -f "$Y" ] || Y=$(find /tmp -maxdepth 8 -path '*/skills/*/yuque/scripts/yuque.py' -print -quit 2>/dev/null)
|
|
66
|
+
python3 "$Y" repos
|
|
67
|
+
python3 "$Y" docs REPO_ID --limit 20
|
|
68
|
+
python3 "$Y" doc REPO_ID DOC_ID
|
|
69
|
+
```
|
|
55
70
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
71
|
+
## Create
|
|
72
|
+
|
|
73
|
+
Prepare the complete Markdown in a file. 语雀 has no draft state — a document is
|
|
74
|
+
either private or public — so the CLI creates **private** documents by default
|
|
75
|
+
and only publishes publicly with an explicit `--public`.
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
Y="${SKILL_DIR:-}/scripts/yuque.py"; [ -f "$Y" ] || Y=$(find /tmp -maxdepth 8 -path '*/skills/*/yuque/scripts/yuque.py' -print -quit 2>/dev/null)
|
|
79
|
+
|
|
80
|
+
# The first call is always a dry run: it loads no credentials and calls no API.
|
|
81
|
+
python3 "$Y" create REPO_ID --title "标题" --content-file /tmp/article.md
|
|
60
82
|
|
|
61
83
|
# Create it privately after the user confirms.
|
|
62
|
-
python3 "$
|
|
63
|
-
--title "标题" --content-file /tmp/article.md --confirm
|
|
84
|
+
python3 "$Y" create REPO_ID --title "标题" --content-file /tmp/article.md --confirm
|
|
64
85
|
|
|
65
86
|
# Public publishing additionally requires --public.
|
|
66
|
-
python3 "$
|
|
67
|
-
--title "标题" --content-file /tmp/article.md --public --confirm
|
|
68
|
-
|
|
69
|
-
python3 "$SKILL_DIR/scripts/yuque.py" update REPO_NAMESPACE DOC_ID \
|
|
70
|
-
--title "新标题" --content-file /tmp/article.md --public --confirm
|
|
87
|
+
python3 "$Y" create REPO_ID --title "标题" --content-file /tmp/article.md --public --confirm
|
|
71
88
|
```
|
|
72
89
|
|
|
73
|
-
`--confirm` is
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
Note that `update` rewrites the whole document body. Read the current document
|
|
79
|
-
first if the user only wants part of it changed.
|
|
90
|
+
`--confirm` is honored **only as the final argument**. Before a public publish,
|
|
91
|
+
always show the user the target knowledge base, the title, the visibility and
|
|
92
|
+
the full content. Default to private unless they explicitly ask to publish
|
|
93
|
+
publicly.
|
|
80
94
|
|
|
81
|
-
##
|
|
95
|
+
## Update and delete (token connections only)
|
|
82
96
|
|
|
83
|
-
```
|
|
84
|
-
python3 "$
|
|
97
|
+
```sh
|
|
98
|
+
python3 "$Y" update REPO_NAMESPACE DOC_ID --title "新标题" --content-file /tmp/a.md --confirm
|
|
99
|
+
python3 "$Y" delete REPO_NAMESPACE DOC_ID --confirm
|
|
85
100
|
```
|
|
86
101
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
you do not create a duplicate.
|
|
102
|
+
`update` rewrites the whole document body — read the current document first if
|
|
103
|
+
the user only wants part of it changed.
|
|
90
104
|
|
|
91
105
|
## Gotchas
|
|
92
106
|
|
|
107
|
+
- Use the real returned `doc_id` and `url`; never invent either. A cookie
|
|
108
|
+
connection cannot always resolve a public URL, in which case `url` is `null`
|
|
109
|
+
— report the `doc_id` instead of guessing a link.
|
|
110
|
+
- If a write times out its outcome is **unknown** — run `docs` to check before
|
|
111
|
+
retrying, or you will create a duplicate.
|
|
93
112
|
- Images referenced by external URL are not re-hosted; 语雀 renders them from
|
|
94
|
-
the original host. If
|
|
113
|
+
the original host. If that host blocks hotlinking, upload the images in 语雀
|
|
95
114
|
manually first and reference the returned URLs.
|
|
96
|
-
- 语雀's
|
|
97
|
-
|
|
98
|
-
|
|
115
|
+
- 语雀's terms allow the API for normal reading and writing of 语雀 content;
|
|
116
|
+
abnormal automated behaviour can get the account blocked. Keep the volume
|
|
117
|
+
human-scale and never batch-publish.
|
|
99
118
|
|
|
100
119
|
## Record the output
|
|
101
120
|
|
|
102
|
-
After a confirmed public publish
|
|
103
|
-
once with `kind="article"`, `channel="yuque"`, the
|
|
104
|
-
`status="delivered"`.
|
|
121
|
+
After a confirmed **public** publish, if the response carries a non-null `url`,
|
|
122
|
+
call `publish_artifact` once with `kind="article"`, `channel="yuque"`, the
|
|
123
|
+
title, that URL, and `status="delivered"`. If `url` is `null`, **do not invent
|
|
124
|
+
one** — report the `doc_id` to the user and skip `publish_artifact`. Do not
|
|
125
|
+
record private documents or failed/unknown writes.
|
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Read and write Yuque (语雀) documents
|
|
2
|
+
"""Read and write Yuque (语雀) documents.
|
|
3
|
+
|
|
4
|
+
Two authentication modes, picked automatically from the environment:
|
|
5
|
+
|
|
6
|
+
* ``YUQUE_TOKEN`` — the official open API (``/api/v2`` + ``X-Auth-Token``).
|
|
7
|
+
Requires a paid 语雀超级会员.
|
|
8
|
+
* ``YUQUE_COOKIES`` — the user's own browser login jar (BYOC) driving the
|
|
9
|
+
internal web API (``/api/*``). Free, no membership.
|
|
10
|
+
|
|
11
|
+
Standard-library only (urllib), no third-party deps, so it runs in the bare
|
|
12
|
+
sandbox without an image change.
|
|
13
|
+
|
|
14
|
+
Writes are GATED: without a trailing ``--confirm`` they only dry-run.
|
|
15
|
+
``--confirm`` is honored ONLY as the last argument, so a title or body that
|
|
16
|
+
merely contains "--confirm" can never silently write.
|
|
17
|
+
"""
|
|
3
18
|
|
|
4
19
|
from __future__ import annotations
|
|
5
20
|
|
|
6
21
|
import argparse
|
|
22
|
+
import gzip
|
|
7
23
|
import json
|
|
8
24
|
import os
|
|
9
25
|
import pathlib
|
|
@@ -13,11 +29,21 @@ import urllib.error
|
|
|
13
29
|
import urllib.parse
|
|
14
30
|
import urllib.request
|
|
15
31
|
|
|
16
|
-
|
|
32
|
+
ORIGIN = "https://www.yuque.com"
|
|
33
|
+
API_BASE = f"{ORIGIN}/api/v2"
|
|
34
|
+
WEB_API = f"{ORIGIN}/api"
|
|
35
|
+
UA = (
|
|
36
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
37
|
+
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
|
38
|
+
)
|
|
17
39
|
GATED_COMMANDS = {"create", "update", "delete"}
|
|
40
|
+
# Commands the internal web API cannot serve safely; see SKILL.md.
|
|
41
|
+
TOKEN_ONLY_COMMANDS = {"update", "delete"}
|
|
18
42
|
MAX_CONTENT_BYTES = 10 * 1024 * 1024
|
|
19
43
|
MAX_ITEMS = 100
|
|
20
44
|
|
|
45
|
+
RECONNECT = "https://auth.acedata.cloud/user/connections"
|
|
46
|
+
|
|
21
47
|
|
|
22
48
|
def output(value) -> None:
|
|
23
49
|
print(json.dumps(value, ensure_ascii=False, indent=2, default=str))
|
|
@@ -34,110 +60,280 @@ def split_confirmation(argv: list[str]) -> tuple[list[str], bool]:
|
|
|
34
60
|
|
|
35
61
|
|
|
36
62
|
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
63
|
+
"""Refuse redirects outright.
|
|
64
|
+
|
|
65
|
+
``add_unredirected_header`` protects only the Cookie header — urllib copies
|
|
66
|
+
every *other* header onto a redirected request, so ``x-csrf-token`` would
|
|
67
|
+
follow a 30x to an arbitrary host. Refusing redirects closes that leak.
|
|
68
|
+
"""
|
|
69
|
+
|
|
37
70
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
38
71
|
return None
|
|
39
72
|
|
|
40
73
|
|
|
74
|
+
# ── Cookie jar (shared pattern across the cookie-BYOC skills) ──────────────
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_cookies(raw: str) -> list:
|
|
78
|
+
try:
|
|
79
|
+
jar = json.loads(raw)
|
|
80
|
+
except json.JSONDecodeError as error:
|
|
81
|
+
die(f"YUQUE_COOKIES is not valid JSON: {error}")
|
|
82
|
+
if not isinstance(jar, list):
|
|
83
|
+
die(f"YUQUE_COOKIES must be a JSON list of cookies, got {type(jar).__name__}")
|
|
84
|
+
return jar
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _domain_matches(host: str, domain: str) -> bool:
|
|
88
|
+
d = domain.lstrip(".").lower()
|
|
89
|
+
h = host.lower()
|
|
90
|
+
return not d or h == d or h.endswith("." + d)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cookie_header(jar: list, url: str) -> str:
|
|
94
|
+
host = urllib.parse.urlsplit(url).hostname or ""
|
|
95
|
+
host_in_scope = any(
|
|
96
|
+
c.get("domain") and _domain_matches(host, str(c["domain"])) for c in jar
|
|
97
|
+
)
|
|
98
|
+
parts = []
|
|
99
|
+
for c in jar:
|
|
100
|
+
name, value = c.get("name"), c.get("value")
|
|
101
|
+
if not name or value is None:
|
|
102
|
+
continue
|
|
103
|
+
domain = c.get("domain")
|
|
104
|
+
if domain:
|
|
105
|
+
if not _domain_matches(host, str(domain)):
|
|
106
|
+
continue
|
|
107
|
+
elif not host_in_scope:
|
|
108
|
+
continue
|
|
109
|
+
parts.append(f"{name}={value}")
|
|
110
|
+
return "; ".join(parts)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def csrf_token(jar: list) -> str:
|
|
114
|
+
"""Yuque's internal API takes its CSRF token from the yuque_ctoken cookie."""
|
|
115
|
+
for c in jar:
|
|
116
|
+
if c.get("name") == "yuque_ctoken" and c.get("value"):
|
|
117
|
+
return str(c["value"])
|
|
118
|
+
die(
|
|
119
|
+
"The 语雀 cookie jar has no yuque_ctoken cookie, so no write can be "
|
|
120
|
+
f"signed. Log in at {ORIGIN} and reconnect at {RECONNECT}."
|
|
121
|
+
)
|
|
122
|
+
return "" # unreachable; die() raises
|
|
123
|
+
|
|
124
|
+
|
|
41
125
|
class YuqueClient:
|
|
42
|
-
|
|
126
|
+
"""One client, two modes. ``mode`` drives the base URL and the auth headers."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, mode: str, *, token: str = "", cookies: list | None = None, opener=None) -> None:
|
|
129
|
+
self.mode = mode
|
|
43
130
|
self._token = token
|
|
131
|
+
self._cookies = cookies or []
|
|
132
|
+
self._csrf = csrf_token(self._cookies) if mode == "cookie" else ""
|
|
133
|
+
self._books_cache: list[dict] | None = None
|
|
134
|
+
self._book_meta: dict[int, dict] = {}
|
|
44
135
|
self._opener = opener or urllib.request.build_opener(NoRedirectHandler())
|
|
45
136
|
|
|
46
137
|
@classmethod
|
|
47
138
|
def from_environment(cls):
|
|
48
139
|
token = os.environ.get("YUQUE_TOKEN", "").strip()
|
|
49
|
-
if
|
|
50
|
-
|
|
51
|
-
"YUQUE_TOKEN
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
f"{API_BASE}{path}",
|
|
60
|
-
data=encoded,
|
|
61
|
-
method=method,
|
|
62
|
-
headers={
|
|
63
|
-
"Accept": "application/json",
|
|
64
|
-
"X-Auth-Token": self._token,
|
|
65
|
-
"Content-Type": "application/json",
|
|
66
|
-
"User-Agent": "AceDataCloud-Yuque-Skill/1.0",
|
|
67
|
-
},
|
|
140
|
+
if token:
|
|
141
|
+
if "\r" in token or "\n" in token:
|
|
142
|
+
die("YUQUE_TOKEN contains invalid characters.")
|
|
143
|
+
return cls("token", token=token)
|
|
144
|
+
raw = os.environ.get("YUQUE_COOKIES", "").strip()
|
|
145
|
+
if raw:
|
|
146
|
+
return cls("cookie", cookies=load_cookies(raw))
|
|
147
|
+
die(
|
|
148
|
+
"No 语雀 credential is available. Connect 语雀 with the browser "
|
|
149
|
+
f"extension (free) or a personal token at {RECONNECT}."
|
|
68
150
|
)
|
|
151
|
+
|
|
152
|
+
# -- transport ---------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def _open(self, request: urllib.request.Request, *, what: str, write: bool):
|
|
69
155
|
try:
|
|
70
156
|
with self._opener.open(request, timeout=30) as response:
|
|
71
157
|
raw = response.read()
|
|
158
|
+
if response.headers.get("Content-Encoding") == "gzip":
|
|
159
|
+
raw = gzip.decompress(raw)
|
|
160
|
+
return raw
|
|
72
161
|
except urllib.error.HTTPError as error:
|
|
73
162
|
if error.code in {301, 302, 303, 307, 308}:
|
|
74
|
-
die(f"Yuque API redirected {method} {path}; credentials were not forwarded.")
|
|
75
|
-
if error.code in {401, 403}:
|
|
76
163
|
die(
|
|
77
|
-
f"
|
|
78
|
-
"
|
|
164
|
+
f"语雀 redirected {what} — not followed, so no credential left "
|
|
165
|
+
f"yuque.com. You are most likely logged out; reconnect at {RECONNECT}."
|
|
166
|
+
+ (
|
|
167
|
+
" This was a WRITE: its outcome is UNKNOWN — list the "
|
|
168
|
+
"documents before retrying so you do not create a duplicate."
|
|
169
|
+
if write
|
|
170
|
+
else ""
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
if error.code in {401, 403}:
|
|
174
|
+
hint = (
|
|
175
|
+
"The token is invalid or lacks scope. Note the 语雀 open API "
|
|
176
|
+
"requires a paid 语雀超级会员."
|
|
177
|
+
if self.mode == "token"
|
|
178
|
+
else "The login session expired or the request was rejected by 语雀's "
|
|
179
|
+
"CSRF check."
|
|
79
180
|
)
|
|
181
|
+
die(f"语雀 HTTP {error.code} for {what}. {hint} Reconnect at {RECONNECT}.")
|
|
80
182
|
if error.code == 404:
|
|
81
|
-
die(f"
|
|
82
|
-
die(f"
|
|
83
|
-
except (urllib.error.URLError, OSError, socket.timeout):
|
|
183
|
+
die(f"语雀 404 for {what}; the knowledge base or document does not exist.")
|
|
184
|
+
die(f"语雀 HTTP {error.code} for {what}.")
|
|
185
|
+
except (urllib.error.URLError, OSError, socket.timeout) as error:
|
|
84
186
|
if write:
|
|
85
187
|
die(
|
|
86
|
-
f"
|
|
87
|
-
"List the documents before retrying so you do not
|
|
188
|
+
f"语雀 write {what} did not return a result ({error}); the outcome "
|
|
189
|
+
"is UNKNOWN. List the documents before retrying so you do not "
|
|
190
|
+
"create a duplicate."
|
|
88
191
|
)
|
|
89
|
-
die(f"Network error while calling
|
|
192
|
+
die(f"Network error while calling 语雀 {what}.")
|
|
193
|
+
|
|
194
|
+
def request(self, method: str, path: str, *, body=None, write: bool = False, expect_json: bool = True):
|
|
195
|
+
"""Call the API of whichever mode is active and return the ``data`` payload."""
|
|
196
|
+
what = f"{method} {path}"
|
|
197
|
+
if self.mode == "token":
|
|
198
|
+
url = f"{API_BASE}{path}"
|
|
199
|
+
headers = {
|
|
200
|
+
"Accept": "application/json",
|
|
201
|
+
"X-Auth-Token": self._token,
|
|
202
|
+
"Content-Type": "application/json",
|
|
203
|
+
"User-Agent": "AceDataCloud-Yuque-Skill/2.0",
|
|
204
|
+
}
|
|
205
|
+
else:
|
|
206
|
+
url = f"{WEB_API}{path}"
|
|
207
|
+
# Yuque's CSRF layer rejects writes without Origin/Referer — the
|
|
208
|
+
# upstream WechatSync adapter injects both for every /api/ call.
|
|
209
|
+
headers = {
|
|
210
|
+
"Accept": "application/json",
|
|
211
|
+
"Accept-Language": "zh-CN,zh;q=0.9",
|
|
212
|
+
"Content-Type": "application/json",
|
|
213
|
+
"User-Agent": UA,
|
|
214
|
+
"Origin": ORIGIN,
|
|
215
|
+
"Referer": f"{ORIGIN}/dashboard",
|
|
216
|
+
"x-csrf-token": self._csrf,
|
|
217
|
+
"x-requested-with": "XMLHttpRequest",
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
encoded = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
221
|
+
request = urllib.request.Request(url, data=encoded, method=method, headers=headers)
|
|
222
|
+
if self.mode == "cookie":
|
|
223
|
+
request.add_unredirected_header("Cookie", cookie_header(self._cookies, url))
|
|
224
|
+
|
|
225
|
+
raw = self._open(request, what=what, write=write)
|
|
90
226
|
if not expect_json or not raw:
|
|
91
227
|
return None
|
|
92
228
|
try:
|
|
93
229
|
payload = json.loads(raw)
|
|
94
230
|
except json.JSONDecodeError:
|
|
95
|
-
die(f"
|
|
231
|
+
die(f"语雀 returned invalid JSON for {what}.")
|
|
96
232
|
if not isinstance(payload, dict) or "data" not in payload:
|
|
97
|
-
die(f"
|
|
233
|
+
die(f"语雀 returned an unexpected envelope for {what}.")
|
|
98
234
|
return payload["data"]
|
|
99
235
|
|
|
236
|
+
# -- reads -------------------------------------------------------------
|
|
237
|
+
|
|
100
238
|
def user(self) -> dict:
|
|
101
|
-
|
|
239
|
+
path = "/user" if self.mode == "token" else "/mine"
|
|
240
|
+
value = self.request("GET", path)
|
|
102
241
|
if not isinstance(value, dict):
|
|
103
|
-
die("
|
|
242
|
+
die("语雀 returned malformed user data.")
|
|
104
243
|
return value
|
|
105
244
|
|
|
106
|
-
def repos(self, login: str) -> list[dict]:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
245
|
+
def repos(self, login: str | None = None) -> list[dict]:
|
|
246
|
+
if self.mode == "token":
|
|
247
|
+
quoted = urllib.parse.quote(str(login), safe="")
|
|
248
|
+
value = self.request("GET", f"/users/{quoted}/repos")
|
|
249
|
+
if not isinstance(value, list):
|
|
250
|
+
die("语雀 returned malformed repo data.")
|
|
251
|
+
return value
|
|
252
|
+
# Internal API: books are nested under the "common used" payload and
|
|
253
|
+
# carry the book id as target_id.
|
|
254
|
+
value = self.request("GET", "/mine/common_used")
|
|
255
|
+
books = (value or {}).get("books") if isinstance(value, dict) else None
|
|
256
|
+
if not isinstance(books, list):
|
|
257
|
+
die("语雀 returned malformed knowledge-base data.")
|
|
258
|
+
return books
|
|
112
259
|
|
|
113
260
|
def docs(self, repo: str) -> list[dict]:
|
|
114
|
-
|
|
115
|
-
|
|
261
|
+
if self.mode == "token":
|
|
262
|
+
quoted = urllib.parse.quote(str(repo), safe="")
|
|
263
|
+
value = self.request("GET", f"/repos/{quoted}/docs")
|
|
264
|
+
else:
|
|
265
|
+
value = self.request("GET", f"/books/{self._book_id(repo)}/docs")
|
|
116
266
|
if not isinstance(value, list):
|
|
117
|
-
die("
|
|
267
|
+
die("语雀 returned malformed document data.")
|
|
118
268
|
return value
|
|
119
269
|
|
|
120
|
-
def doc(self, repo: str, doc_id: str) -> dict:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
270
|
+
def doc(self, repo: str, doc_id: str) -> tuple[dict, str | None]:
|
|
271
|
+
book_id = None
|
|
272
|
+
if self.mode == "token":
|
|
273
|
+
repo_q = urllib.parse.quote(str(repo), safe="")
|
|
274
|
+
doc_q = urllib.parse.quote(str(doc_id), safe="")
|
|
275
|
+
value = self.request("GET", f"/repos/{repo_q}/docs/{doc_q}")
|
|
276
|
+
else:
|
|
277
|
+
book_id = self._book_id(repo)
|
|
278
|
+
query = urllib.parse.urlencode({"book_id": book_id, "mode": "markdown"})
|
|
279
|
+
doc_q = urllib.parse.quote(str(doc_id), safe="")
|
|
280
|
+
value = self.request("GET", f"/docs/{doc_q}?{query}")
|
|
124
281
|
if not isinstance(value, dict):
|
|
125
|
-
die("
|
|
126
|
-
return value
|
|
282
|
+
die("语雀 returned malformed document data.")
|
|
283
|
+
return value, self.doc_url(book_id, value)
|
|
284
|
+
|
|
285
|
+
# -- writes ------------------------------------------------------------
|
|
127
286
|
|
|
128
|
-
def create_doc(self, repo: str,
|
|
129
|
-
|
|
130
|
-
|
|
287
|
+
def create_doc(self, repo: str, args, content: str) -> tuple[dict, str | None]:
|
|
288
|
+
book_id = None
|
|
289
|
+
if self.mode == "token":
|
|
290
|
+
quoted = urllib.parse.quote(str(repo), safe="")
|
|
291
|
+
body = {
|
|
292
|
+
"title": args.title,
|
|
293
|
+
"body": content,
|
|
294
|
+
"format": "markdown",
|
|
295
|
+
"public": 1 if args.public else 0,
|
|
296
|
+
}
|
|
297
|
+
if args.slug:
|
|
298
|
+
body["slug"] = args.slug
|
|
299
|
+
value = self.request("POST", f"/repos/{quoted}/docs", body=body, write=True)
|
|
300
|
+
else:
|
|
301
|
+
# The internal API accepts markdown directly, so we skip the extra
|
|
302
|
+
# /api/docs/convert round trip, and insert_to_catalog puts the new
|
|
303
|
+
# doc in the book's 目录 (the plain create leaves it unlisted).
|
|
304
|
+
book_id = self._book_id(repo)
|
|
305
|
+
body = {
|
|
306
|
+
"book_id": book_id,
|
|
307
|
+
"title": args.title,
|
|
308
|
+
"format": "markdown",
|
|
309
|
+
"body": content,
|
|
310
|
+
"body_draft": content,
|
|
311
|
+
"public": 1 if args.public else 0,
|
|
312
|
+
"insert_to_catalog": True,
|
|
313
|
+
"action": "appendByDocs",
|
|
314
|
+
"target_uuid": "",
|
|
315
|
+
}
|
|
316
|
+
if args.slug:
|
|
317
|
+
body["slug"] = args.slug
|
|
318
|
+
value = self.request("POST", "/docs", body=body, write=True)
|
|
131
319
|
if not isinstance(value, dict) or not value.get("id"):
|
|
132
|
-
die("
|
|
133
|
-
return value
|
|
320
|
+
die("语雀 did not return a valid created document.")
|
|
321
|
+
return value, self.doc_url(book_id, value)
|
|
134
322
|
|
|
135
|
-
def update_doc(self, repo: str, doc_id: str,
|
|
323
|
+
def update_doc(self, repo: str, doc_id: str, args, content: str) -> dict:
|
|
136
324
|
repo_q = urllib.parse.quote(str(repo), safe="")
|
|
137
325
|
doc_q = urllib.parse.quote(str(doc_id), safe="")
|
|
326
|
+
body = {
|
|
327
|
+
"title": args.title,
|
|
328
|
+
"body": content,
|
|
329
|
+
"format": "markdown",
|
|
330
|
+
"public": 1 if args.public else 0,
|
|
331
|
+
}
|
|
332
|
+
if args.slug:
|
|
333
|
+
body["slug"] = args.slug
|
|
138
334
|
value = self.request("PUT", f"/repos/{repo_q}/docs/{doc_q}", body=body, write=True)
|
|
139
335
|
if not isinstance(value, dict) or not value.get("id"):
|
|
140
|
-
die("
|
|
336
|
+
die("语雀 did not return a valid updated document.")
|
|
141
337
|
return value
|
|
142
338
|
|
|
143
339
|
def delete_doc(self, repo: str, doc_id: str) -> None:
|
|
@@ -145,6 +341,73 @@ class YuqueClient:
|
|
|
145
341
|
doc_q = urllib.parse.quote(str(doc_id), safe="")
|
|
146
342
|
self.request("DELETE", f"/repos/{repo_q}/docs/{doc_q}", write=True, expect_json=False)
|
|
147
343
|
|
|
344
|
+
# -- helpers -----------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
def _book_id(self, repo: str) -> int:
|
|
347
|
+
"""Resolve a repo reference to a numeric book id for the internal API.
|
|
348
|
+
|
|
349
|
+
The internal endpoints are addressed by numeric book id only, so a
|
|
350
|
+
``user/book`` namespace has to be looked up against the account's own
|
|
351
|
+
knowledge bases first.
|
|
352
|
+
"""
|
|
353
|
+
text = str(repo).strip()
|
|
354
|
+
# isdigit() is Unicode-aware but int() is not, and non-ASCII digits
|
|
355
|
+
# would otherwise either crash or resolve to a different number.
|
|
356
|
+
if text.isascii() and text.isdigit():
|
|
357
|
+
return int(text)
|
|
358
|
+
wanted = text.rsplit("/", 1)[-1].lower()
|
|
359
|
+
if not wanted:
|
|
360
|
+
die("Empty knowledge base reference; run `repos` and pass a repo_id.")
|
|
361
|
+
matches = []
|
|
362
|
+
for book in self._books():
|
|
363
|
+
slug = str(book.get("slug") or "").lower()
|
|
364
|
+
name = str(book.get("name") or "").lower()
|
|
365
|
+
if wanted in {slug, name}:
|
|
366
|
+
raw = book.get("target_id") or book.get("id")
|
|
367
|
+
try:
|
|
368
|
+
matches.append((int(raw), book))
|
|
369
|
+
except (TypeError, ValueError):
|
|
370
|
+
continue
|
|
371
|
+
if len(matches) > 1:
|
|
372
|
+
die(
|
|
373
|
+
f"{repo!r} matches {len(matches)} knowledge bases "
|
|
374
|
+
f"({', '.join(str(m[0]) for m in matches)}). Pass the numeric "
|
|
375
|
+
"repo_id from `repos` instead — writing to the wrong base is silent."
|
|
376
|
+
)
|
|
377
|
+
if matches:
|
|
378
|
+
self._book_meta[matches[0][0]] = matches[0][1]
|
|
379
|
+
return matches[0][0]
|
|
380
|
+
die(
|
|
381
|
+
f"Could not resolve knowledge base {repo!r} to a book id. Run "
|
|
382
|
+
"`repos` and pass the numeric repo_id shown there."
|
|
383
|
+
)
|
|
384
|
+
return 0 # unreachable; die() raises
|
|
385
|
+
|
|
386
|
+
def _books(self) -> list[dict]:
|
|
387
|
+
"""Knowledge bases, fetched once — a doc loop must not re-query per item."""
|
|
388
|
+
if self._books_cache is None:
|
|
389
|
+
self._books_cache = self.repos()
|
|
390
|
+
return self._books_cache
|
|
391
|
+
|
|
392
|
+
def doc_url(self, book_id, doc: dict) -> str | None:
|
|
393
|
+
"""Public URL for a doc, using the book namespace when we know it."""
|
|
394
|
+
slug = doc.get("slug")
|
|
395
|
+
book = (doc.get("book") or {}) if isinstance(doc.get("book"), dict) else {}
|
|
396
|
+
namespace = book.get("namespace")
|
|
397
|
+
if not namespace and self.mode == "cookie":
|
|
398
|
+
try:
|
|
399
|
+
meta = self._book_meta.get(int(book_id)) or {}
|
|
400
|
+
except (TypeError, ValueError):
|
|
401
|
+
meta = {}
|
|
402
|
+
user = (meta.get("user") or {}) if isinstance(meta.get("user"), dict) else {}
|
|
403
|
+
login = user.get("login")
|
|
404
|
+
book_slug = meta.get("slug")
|
|
405
|
+
if login and book_slug:
|
|
406
|
+
namespace = f"{login}/{book_slug}"
|
|
407
|
+
if namespace and slug:
|
|
408
|
+
return f"{ORIGIN}/{namespace}/{slug}"
|
|
409
|
+
return None
|
|
410
|
+
|
|
148
411
|
|
|
149
412
|
def read_content(args) -> str:
|
|
150
413
|
if args.content_file:
|
|
@@ -163,9 +426,17 @@ def read_content(args) -> str:
|
|
|
163
426
|
|
|
164
427
|
|
|
165
428
|
def format_repo(repo: dict) -> dict:
|
|
429
|
+
# The internal API nests books under a "common used" record whose own `id`
|
|
430
|
+
# is NOT the book id — the book id is target_id. Prefer it, or `repos`
|
|
431
|
+
# would print an id that no other endpoint accepts.
|
|
432
|
+
repo_id = repo.get("target_id") or repo.get("id")
|
|
433
|
+
user = repo.get("user") if isinstance(repo.get("user"), dict) else {}
|
|
434
|
+
namespace = repo.get("namespace")
|
|
435
|
+
if not namespace and user.get("login") and repo.get("slug"):
|
|
436
|
+
namespace = f"{user['login']}/{repo['slug']}"
|
|
166
437
|
return {
|
|
167
|
-
"repo_id":
|
|
168
|
-
"namespace":
|
|
438
|
+
"repo_id": repo_id,
|
|
439
|
+
"namespace": namespace,
|
|
169
440
|
"name": repo.get("name"),
|
|
170
441
|
"slug": repo.get("slug"),
|
|
171
442
|
"type": repo.get("type"),
|
|
@@ -174,43 +445,31 @@ def format_repo(repo: dict) -> dict:
|
|
|
174
445
|
}
|
|
175
446
|
|
|
176
447
|
|
|
177
|
-
def format_doc(doc: dict, repo: str | None = None) -> dict:
|
|
448
|
+
def format_doc(doc: dict, repo: str | None = None, url: str | None = None) -> dict:
|
|
178
449
|
namespace = repo or (doc.get("book") or {}).get("namespace")
|
|
179
450
|
slug = doc.get("slug")
|
|
451
|
+
if url is None and namespace and slug and not str(namespace).isdigit():
|
|
452
|
+
url = f"{ORIGIN}/{namespace}/{slug}"
|
|
180
453
|
return {
|
|
181
454
|
"doc_id": doc.get("id"),
|
|
182
455
|
"title": doc.get("title"),
|
|
183
456
|
"slug": slug,
|
|
184
457
|
"public": doc.get("public"),
|
|
185
458
|
"status": doc.get("status"),
|
|
186
|
-
"url":
|
|
459
|
+
"url": url,
|
|
187
460
|
"word_count": doc.get("word_count"),
|
|
188
461
|
"created_at": doc.get("created_at"),
|
|
189
462
|
"updated_at": doc.get("updated_at"),
|
|
190
463
|
}
|
|
191
464
|
|
|
192
465
|
|
|
193
|
-
def build_body(args, content: str) -> dict:
|
|
194
|
-
# public: 0 = private, 1 = public. Yuque has no separate "draft" state, so a
|
|
195
|
-
# private doc is the closest equivalent and is the default.
|
|
196
|
-
body = {
|
|
197
|
-
"title": args.title,
|
|
198
|
-
"body": content,
|
|
199
|
-
"format": "markdown",
|
|
200
|
-
"public": 1 if args.public else 0,
|
|
201
|
-
}
|
|
202
|
-
if args.slug:
|
|
203
|
-
body["slug"] = args.slug
|
|
204
|
-
return body
|
|
205
|
-
|
|
206
|
-
|
|
207
466
|
def build_parser() -> argparse.ArgumentParser:
|
|
208
|
-
parser = argparse.ArgumentParser(prog="yuque.py", description="Yuque
|
|
467
|
+
parser = argparse.ArgumentParser(prog="yuque.py", description="Yuque CLI (token or cookie)")
|
|
209
468
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
210
|
-
sub.add_parser("whoami", help="show the
|
|
469
|
+
sub.add_parser("whoami", help="show the connected account and the active auth mode")
|
|
211
470
|
|
|
212
471
|
repos = sub.add_parser("repos", help="list the account's knowledge bases")
|
|
213
|
-
repos.add_argument("--login", help="user login; defaults to the
|
|
472
|
+
repos.add_argument("--login", help="user login; token mode only, defaults to the own account")
|
|
214
473
|
|
|
215
474
|
docs = sub.add_parser("docs", help="list documents in a knowledge base")
|
|
216
475
|
docs.add_argument("repo", help="repo namespace (user/book) or numeric repo id")
|
|
@@ -255,7 +514,7 @@ def dry_run(args, content: str | None = None) -> None:
|
|
|
255
514
|
)
|
|
256
515
|
else:
|
|
257
516
|
value["doc_id"] = args.doc_id
|
|
258
|
-
value["note"] = "Re-run with --confirm as the final argument to write to
|
|
517
|
+
value["note"] = "Re-run with --confirm as the final argument to write to 语雀."
|
|
259
518
|
output(value)
|
|
260
519
|
|
|
261
520
|
|
|
@@ -269,42 +528,56 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
269
528
|
return
|
|
270
529
|
|
|
271
530
|
client = YuqueClient.from_environment()
|
|
531
|
+
if client.mode == "cookie" and args.command in TOKEN_ONLY_COMMANDS:
|
|
532
|
+
die(
|
|
533
|
+
f"`{args.command}` is only available when 语雀 is connected with a "
|
|
534
|
+
"personal token (the open API). This connection uses login cookies. "
|
|
535
|
+
"Create a new document instead, or edit the existing one in 语雀."
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
mode = {"auth_mode": client.mode}
|
|
272
539
|
if args.command == "whoami":
|
|
273
540
|
user = client.user()
|
|
541
|
+
login = user.get("login")
|
|
274
542
|
output(
|
|
275
543
|
{
|
|
544
|
+
**mode,
|
|
276
545
|
"user_id": user.get("id"),
|
|
277
|
-
"login":
|
|
546
|
+
"login": login,
|
|
278
547
|
"name": user.get("name"),
|
|
279
|
-
"url": f"
|
|
548
|
+
"url": f"{ORIGIN}/{login}" if login else None,
|
|
280
549
|
"books_count": user.get("books_count"),
|
|
281
550
|
"public_books_count": user.get("public_books_count"),
|
|
282
551
|
}
|
|
283
552
|
)
|
|
284
553
|
elif args.command == "repos":
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
554
|
+
if client.mode == "token":
|
|
555
|
+
login = args.login or client.user().get("login")
|
|
556
|
+
if not login:
|
|
557
|
+
die("Could not resolve the account login; pass --login explicitly.")
|
|
558
|
+
items = client.repos(login)
|
|
559
|
+
else:
|
|
560
|
+
items = client.repos()
|
|
561
|
+
output({**mode, "repos": [format_repo(item) for item in items]})
|
|
289
562
|
elif args.command == "docs":
|
|
290
563
|
if not 1 <= args.limit <= MAX_ITEMS:
|
|
291
564
|
die(f"--limit must be between 1 and {MAX_ITEMS}.")
|
|
292
565
|
items = client.docs(args.repo)[: args.limit]
|
|
293
|
-
output({"count": len(items), "docs": [format_doc(item, args.repo) for item in items]})
|
|
566
|
+
output({**mode, "count": len(items), "docs": [format_doc(item, args.repo) for item in items]})
|
|
294
567
|
elif args.command == "doc":
|
|
295
|
-
doc = client.doc(args.repo, args.doc_id)
|
|
296
|
-
result = format_doc(doc, args.repo)
|
|
568
|
+
doc, url = client.doc(args.repo, args.doc_id)
|
|
569
|
+
result = format_doc(doc, args.repo, url)
|
|
297
570
|
result["body"] = doc.get("body")
|
|
298
|
-
output(result)
|
|
571
|
+
output({**mode, **result})
|
|
299
572
|
elif args.command == "create":
|
|
300
|
-
result = client.create_doc(args.repo,
|
|
301
|
-
output({"ok": True, **format_doc(result, args.repo), "public": bool(args.public)})
|
|
573
|
+
result, url = client.create_doc(args.repo, args, content or "")
|
|
574
|
+
output({**mode, "ok": True, **format_doc(result, args.repo, url), "public": bool(args.public)})
|
|
302
575
|
elif args.command == "update":
|
|
303
|
-
result = client.update_doc(args.repo, args.doc_id,
|
|
304
|
-
output({"ok": True, **format_doc(result, args.repo), "public": bool(args.public)})
|
|
576
|
+
result = client.update_doc(args.repo, args.doc_id, args, content or "")
|
|
577
|
+
output({**mode, "ok": True, **format_doc(result, args.repo), "public": bool(args.public)})
|
|
305
578
|
elif args.command == "delete":
|
|
306
579
|
client.delete_doc(args.repo, args.doc_id)
|
|
307
|
-
output({"ok": True, "repo": args.repo, "doc_id": args.doc_id, "deleted": True})
|
|
580
|
+
output({**mode, "ok": True, "repo": args.repo, "doc_id": args.doc_id, "deleted": True})
|
|
308
581
|
|
|
309
582
|
|
|
310
583
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import importlib.util
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import pathlib
|
|
8
|
+
import sys
|
|
9
|
+
import unittest
|
|
10
|
+
import unittest.mock
|
|
11
|
+
import urllib.error
|
|
12
|
+
|
|
13
|
+
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "yuque.py"
|
|
14
|
+
SPEC = importlib.util.spec_from_file_location("yuque_skill_script", SCRIPT)
|
|
15
|
+
yuque = importlib.util.module_from_spec(SPEC)
|
|
16
|
+
assert SPEC.loader is not None
|
|
17
|
+
sys.modules[SPEC.name] = yuque
|
|
18
|
+
SPEC.loader.exec_module(yuque)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
COOKIES = [
|
|
22
|
+
{"name": "yuque_ctoken", "value": "CTOKEN", "domain": ".yuque.com"},
|
|
23
|
+
{"name": "_yuque_session", "value": "SESSION", "domain": ".yuque.com"},
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run(argv, env):
|
|
28
|
+
"""Run main() with a patched environ and captured stdout."""
|
|
29
|
+
buffer = io.StringIO()
|
|
30
|
+
code = 0
|
|
31
|
+
with unittest.mock.patch.dict(yuque.os.environ, env, clear=True):
|
|
32
|
+
with contextlib.redirect_stdout(buffer):
|
|
33
|
+
try:
|
|
34
|
+
yuque.main(argv)
|
|
35
|
+
except SystemExit as exc: # die() path
|
|
36
|
+
code = exc.code
|
|
37
|
+
return code, json.loads(buffer.getvalue())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ModeSelection(unittest.TestCase):
|
|
41
|
+
def test_token_wins_when_both_credentials_are_present(self):
|
|
42
|
+
with unittest.mock.patch.dict(
|
|
43
|
+
yuque.os.environ,
|
|
44
|
+
{"YUQUE_TOKEN": "t", "YUQUE_COOKIES": json.dumps(COOKIES)},
|
|
45
|
+
clear=True,
|
|
46
|
+
):
|
|
47
|
+
self.assertEqual(yuque.YuqueClient.from_environment().mode, "token")
|
|
48
|
+
|
|
49
|
+
def test_cookie_is_the_fallback(self):
|
|
50
|
+
with unittest.mock.patch.dict(
|
|
51
|
+
yuque.os.environ, {"YUQUE_COOKIES": json.dumps(COOKIES)}, clear=True
|
|
52
|
+
):
|
|
53
|
+
self.assertEqual(yuque.YuqueClient.from_environment().mode, "cookie")
|
|
54
|
+
|
|
55
|
+
def test_no_credential_dies_with_a_reconnect_hint(self):
|
|
56
|
+
code, payload = run(["whoami"], {})
|
|
57
|
+
self.assertEqual(code, 1)
|
|
58
|
+
self.assertIn("connections", payload["error"])
|
|
59
|
+
|
|
60
|
+
def test_cookie_jar_without_ctoken_is_rejected(self):
|
|
61
|
+
jar = [c for c in COOKIES if c["name"] != "yuque_ctoken"]
|
|
62
|
+
code, payload = run(["whoami"], {"YUQUE_COOKIES": json.dumps(jar)})
|
|
63
|
+
self.assertEqual(code, 1)
|
|
64
|
+
self.assertIn("yuque_ctoken", payload["error"])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CookieScoping(unittest.TestCase):
|
|
68
|
+
def test_jar_is_not_sent_off_domain(self):
|
|
69
|
+
self.assertEqual(yuque.cookie_header(COOKIES, "https://evil.example/x"), "")
|
|
70
|
+
|
|
71
|
+
def test_jar_is_sent_to_yuque(self):
|
|
72
|
+
header = yuque.cookie_header(COOKIES, "https://www.yuque.com/api/mine")
|
|
73
|
+
self.assertIn("_yuque_session=SESSION", header)
|
|
74
|
+
|
|
75
|
+
def test_domainless_cookie_is_not_leaked_to_an_unrelated_host(self):
|
|
76
|
+
jar = COOKIES + [{"name": "loose", "value": "L"}]
|
|
77
|
+
self.assertEqual(yuque.cookie_header(jar, "https://evil.example/x"), "")
|
|
78
|
+
|
|
79
|
+
def test_a_lookalike_suffix_host_is_not_matched(self):
|
|
80
|
+
self.assertEqual(yuque.cookie_header(COOKIES, "https://notyuque.com/api"), "")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class WriteGating(unittest.TestCase):
|
|
84
|
+
def test_create_without_confirm_is_a_dry_run(self):
|
|
85
|
+
code, payload = run(
|
|
86
|
+
["create", "1", "--title", "T", "--content", "body"],
|
|
87
|
+
{"YUQUE_COOKIES": json.dumps(COOKIES)},
|
|
88
|
+
)
|
|
89
|
+
self.assertEqual(code, 0)
|
|
90
|
+
self.assertTrue(payload["dry_run"])
|
|
91
|
+
|
|
92
|
+
def test_confirm_is_only_honored_as_the_last_argument(self):
|
|
93
|
+
# A real --confirm argv element that is NOT last must not confirm.
|
|
94
|
+
argv = ["create", "1", "--confirm", "--title", "T", "--content", "b"]
|
|
95
|
+
self.assertEqual(yuque.split_confirmation(argv), (argv, False))
|
|
96
|
+
|
|
97
|
+
def test_confirm_as_the_last_argument_does_confirm(self):
|
|
98
|
+
clean, confirmed = yuque.split_confirmation(["create", "1", "--confirm"])
|
|
99
|
+
self.assertTrue(confirmed)
|
|
100
|
+
self.assertEqual(clean, ["create", "1"])
|
|
101
|
+
|
|
102
|
+
def test_a_body_containing_the_flag_is_not_a_confirmation(self):
|
|
103
|
+
code, payload = run(
|
|
104
|
+
["create", "1", "--title", "T", "--content", "please --confirm this"],
|
|
105
|
+
{"YUQUE_COOKIES": json.dumps(COOKIES)},
|
|
106
|
+
)
|
|
107
|
+
self.assertEqual(code, 0)
|
|
108
|
+
self.assertTrue(payload["dry_run"])
|
|
109
|
+
|
|
110
|
+
def test_create_defaults_to_private(self):
|
|
111
|
+
code, payload = run(
|
|
112
|
+
["create", "1", "--title", "T", "--content", "b"],
|
|
113
|
+
{"YUQUE_COOKIES": json.dumps(COOKIES)},
|
|
114
|
+
)
|
|
115
|
+
self.assertEqual(payload["visibility"], "private")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TokenOnlyCommands(unittest.TestCase):
|
|
119
|
+
def test_update_is_refused_on_a_cookie_connection(self):
|
|
120
|
+
code, payload = run(
|
|
121
|
+
["update", "1", "9", "--title", "T", "--content", "b", "--confirm"],
|
|
122
|
+
{"YUQUE_COOKIES": json.dumps(COOKIES)},
|
|
123
|
+
)
|
|
124
|
+
self.assertEqual(code, 1)
|
|
125
|
+
self.assertIn("personal token", payload["error"])
|
|
126
|
+
|
|
127
|
+
def test_delete_is_refused_on_a_cookie_connection(self):
|
|
128
|
+
code, payload = run(
|
|
129
|
+
["delete", "1", "9", "--confirm"], {"YUQUE_COOKIES": json.dumps(COOKIES)}
|
|
130
|
+
)
|
|
131
|
+
self.assertEqual(code, 1)
|
|
132
|
+
self.assertIn("personal token", payload["error"])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class RequestShape(unittest.TestCase):
|
|
136
|
+
"""Capture the outgoing request instead of calling 语雀."""
|
|
137
|
+
|
|
138
|
+
def _capture(self, mode_env, method, path, **kwargs):
|
|
139
|
+
seen = {}
|
|
140
|
+
|
|
141
|
+
class Opener:
|
|
142
|
+
def open(self, request, timeout=None):
|
|
143
|
+
seen["request"] = request
|
|
144
|
+
raise urllib.error.HTTPError(
|
|
145
|
+
request.full_url, 401, "Unauthorized", {}, None
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
with unittest.mock.patch.dict(yuque.os.environ, mode_env, clear=True):
|
|
149
|
+
client = yuque.YuqueClient.from_environment()
|
|
150
|
+
client._opener = Opener()
|
|
151
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
152
|
+
with self.assertRaises(SystemExit):
|
|
153
|
+
client.request(method, path, **kwargs)
|
|
154
|
+
return seen["request"]
|
|
155
|
+
|
|
156
|
+
def test_cookie_mode_sends_csrf_origin_and_referer(self):
|
|
157
|
+
request = self._capture({"YUQUE_COOKIES": json.dumps(COOKIES)}, "GET", "/mine")
|
|
158
|
+
self.assertEqual(request.full_url, "https://www.yuque.com/api/mine")
|
|
159
|
+
self.assertEqual(request.get_header("X-csrf-token"), "CTOKEN")
|
|
160
|
+
# 语雀's CSRF layer rejects writes without these two.
|
|
161
|
+
self.assertEqual(request.get_header("Origin"), "https://www.yuque.com")
|
|
162
|
+
self.assertTrue(request.get_header("Referer"))
|
|
163
|
+
|
|
164
|
+
def test_cookie_is_unredirected_so_it_is_dropped_on_a_30x(self):
|
|
165
|
+
request = self._capture({"YUQUE_COOKIES": json.dumps(COOKIES)}, "GET", "/mine")
|
|
166
|
+
self.assertIn("Cookie", request.unredirected_hdrs)
|
|
167
|
+
self.assertNotIn("Cookie", request.headers)
|
|
168
|
+
|
|
169
|
+
def test_token_mode_uses_the_open_api_and_auth_header(self):
|
|
170
|
+
request = self._capture({"YUQUE_TOKEN": "TK"}, "GET", "/user")
|
|
171
|
+
self.assertEqual(request.full_url, "https://www.yuque.com/api/v2/user")
|
|
172
|
+
self.assertEqual(request.get_header("X-auth-token"), "TK")
|
|
173
|
+
self.assertIsNone(request.get_header("X-csrf-token"))
|
|
174
|
+
|
|
175
|
+
def test_cookie_create_sends_markdown_without_a_convert_round_trip(self):
|
|
176
|
+
request = self._capture(
|
|
177
|
+
{"YUQUE_COOKIES": json.dumps(COOKIES)},
|
|
178
|
+
"POST",
|
|
179
|
+
"/docs",
|
|
180
|
+
body={"book_id": 1, "format": "markdown", "body": "# hi"},
|
|
181
|
+
write=True,
|
|
182
|
+
)
|
|
183
|
+
payload = json.loads(request.data)
|
|
184
|
+
self.assertEqual(payload["format"], "markdown")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class RedirectHandling(unittest.TestCase):
|
|
188
|
+
def test_redirects_are_refused(self):
|
|
189
|
+
handler = yuque.NoRedirectHandler()
|
|
190
|
+
self.assertIsNone(
|
|
191
|
+
handler.redirect_request(None, None, 302, "Found", {}, "https://evil.example")
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def test_the_no_redirect_handler_is_actually_installed(self):
|
|
195
|
+
# Cookie is unredirected, but x-csrf-token is NOT — urllib copies every
|
|
196
|
+
# other header onto a redirected request, so only refusing the redirect
|
|
197
|
+
# keeps the CSRF token off a foreign host.
|
|
198
|
+
with unittest.mock.patch.dict(
|
|
199
|
+
yuque.os.environ, {"YUQUE_COOKIES": json.dumps(COOKIES)}, clear=True
|
|
200
|
+
):
|
|
201
|
+
client = yuque.YuqueClient.from_environment()
|
|
202
|
+
self.assertTrue(
|
|
203
|
+
any(isinstance(h, yuque.NoRedirectHandler) for h in client._opener.handlers),
|
|
204
|
+
"default opener must install NoRedirectHandler",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def test_a_30x_on_a_write_reports_an_unknown_outcome(self):
|
|
208
|
+
class Opener:
|
|
209
|
+
def open(self, request, timeout=None):
|
|
210
|
+
raise urllib.error.HTTPError(
|
|
211
|
+
request.full_url, 302, "Found", {}, None
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
with unittest.mock.patch.dict(
|
|
215
|
+
yuque.os.environ, {"YUQUE_COOKIES": json.dumps(COOKIES)}, clear=True
|
|
216
|
+
):
|
|
217
|
+
client = yuque.YuqueClient.from_environment()
|
|
218
|
+
client._opener = Opener()
|
|
219
|
+
buffer = io.StringIO()
|
|
220
|
+
with contextlib.redirect_stdout(buffer):
|
|
221
|
+
with self.assertRaises(SystemExit):
|
|
222
|
+
client.request("POST", "/docs", body={}, write=True)
|
|
223
|
+
error = json.loads(buffer.getvalue())["error"]
|
|
224
|
+
self.assertIn("UNKNOWN", error)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class BookIdResolution(unittest.TestCase):
|
|
228
|
+
"""`_book_id` turns a repo reference into the numeric id the internal API needs."""
|
|
229
|
+
|
|
230
|
+
def _client(self, books):
|
|
231
|
+
with unittest.mock.patch.dict(
|
|
232
|
+
yuque.os.environ, {"YUQUE_COOKIES": json.dumps(COOKIES)}, clear=True
|
|
233
|
+
):
|
|
234
|
+
client = yuque.YuqueClient.from_environment()
|
|
235
|
+
client._books_cache = books
|
|
236
|
+
return client
|
|
237
|
+
|
|
238
|
+
def _expect_die(self, fn, *args):
|
|
239
|
+
buffer = io.StringIO()
|
|
240
|
+
with contextlib.redirect_stdout(buffer):
|
|
241
|
+
with self.assertRaises(SystemExit):
|
|
242
|
+
fn(*args)
|
|
243
|
+
return json.loads(buffer.getvalue())["error"]
|
|
244
|
+
|
|
245
|
+
def test_non_ascii_digits_do_not_crash(self):
|
|
246
|
+
# str.isdigit() is Unicode-aware but int() is not; a traceback would
|
|
247
|
+
# break the JSON-only output contract.
|
|
248
|
+
error = self._expect_die(self._client([])._book_id, "²")
|
|
249
|
+
self.assertIn("Could not resolve", error)
|
|
250
|
+
|
|
251
|
+
def test_a_malformed_book_id_is_skipped_not_fatal(self):
|
|
252
|
+
client = self._client(
|
|
253
|
+
[{"slug": "bad", "target_id": "not-a-number"}, {"slug": "blog", "target_id": 42}]
|
|
254
|
+
)
|
|
255
|
+
self.assertEqual(client._book_id("alice/blog"), 42)
|
|
256
|
+
|
|
257
|
+
def test_an_ambiguous_slug_is_refused_rather_than_guessed(self):
|
|
258
|
+
# Writing to the wrong knowledge base would be a silent wrong result.
|
|
259
|
+
client = self._client(
|
|
260
|
+
[{"slug": "blog", "target_id": 111}, {"slug": "blog", "target_id": 222}]
|
|
261
|
+
)
|
|
262
|
+
error = self._expect_die(client._book_id, "alice/blog")
|
|
263
|
+
self.assertIn("matches 2", error)
|
|
264
|
+
|
|
265
|
+
def test_books_are_fetched_once_per_run(self):
|
|
266
|
+
client = self._client(None)
|
|
267
|
+
calls = []
|
|
268
|
+
|
|
269
|
+
def fake_repos(login=None):
|
|
270
|
+
calls.append(1)
|
|
271
|
+
return [{"slug": "blog", "target_id": 7}]
|
|
272
|
+
|
|
273
|
+
client.repos = fake_repos
|
|
274
|
+
client._book_id("a/blog")
|
|
275
|
+
client._book_id("a/blog")
|
|
276
|
+
self.assertEqual(len(calls), 1)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class OutputShape(unittest.TestCase):
|
|
280
|
+
def test_repos_reports_target_id_as_the_repo_id(self):
|
|
281
|
+
# The common-used record's own `id` is not the book id.
|
|
282
|
+
formatted = yuque.format_repo({"id": 55555555, "target_id": 12345, "slug": "blog"})
|
|
283
|
+
self.assertEqual(formatted["repo_id"], 12345)
|
|
284
|
+
|
|
285
|
+
def test_repos_derives_a_namespace_from_the_owner_login(self):
|
|
286
|
+
formatted = yuque.format_repo(
|
|
287
|
+
{"target_id": 1, "slug": "blog", "user": {"login": "alice"}}
|
|
288
|
+
)
|
|
289
|
+
self.assertEqual(formatted["namespace"], "alice/blog")
|
|
290
|
+
|
|
291
|
+
def test_cookie_mode_create_returns_a_real_url(self):
|
|
292
|
+
# SKILL.md requires a real URL for publish_artifact; a null would
|
|
293
|
+
# invite the model to invent one.
|
|
294
|
+
client = self._cookie_client()
|
|
295
|
+
client._book_meta[9] = {"slug": "blog", "user": {"login": "alice"}}
|
|
296
|
+
url = client.doc_url(9, {"id": 1, "slug": "abc123"})
|
|
297
|
+
self.assertEqual(url, "https://www.yuque.com/alice/blog/abc123")
|
|
298
|
+
|
|
299
|
+
def test_doc_url_is_none_rather_than_wrong_when_the_book_is_unknown(self):
|
|
300
|
+
client = self._cookie_client()
|
|
301
|
+
self.assertIsNone(client.doc_url(9, {"id": 1, "slug": "abc123"}))
|
|
302
|
+
|
|
303
|
+
def _cookie_client(self):
|
|
304
|
+
with unittest.mock.patch.dict(
|
|
305
|
+
yuque.os.environ, {"YUQUE_COOKIES": json.dumps(COOKIES)}, clear=True
|
|
306
|
+
):
|
|
307
|
+
return yuque.YuqueClient.from_environment()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__":
|
|
311
|
+
unittest.main()
|