@acedatacloud/skills 2026.720.3 → 2026.721.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acedatacloud/skills",
3
- "version": "2026.720.3",
3
+ "version": "2026.721.0",
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",
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: cnblogs
3
+ description: Publish, update and read CNBlogs (博客园) posts with a personal access token through the API used by the official vscode-cnb client. Use when the user wants to publish Markdown to 博客园, save a CNBlogs draft, edit or delete a post, or list posts or categories.
4
+ when_to_use: |
5
+ Trigger for 博客园 / CNBlogs blog management: verify the connected account,
6
+ list categories or recent posts, create a Markdown draft, publish or update
7
+ a post, or delete a post. Public writes and destructive actions require
8
+ explicit confirmation.
9
+ connections: [cnblogs]
10
+ allowed_tools: [Bash]
11
+ license: Apache-2.0
12
+ metadata:
13
+ author: acedatacloud
14
+ version: "1.0"
15
+ ---
16
+
17
+ Use the bundled standard-library CLI. The connector injects the user's 博客园
18
+ personal access token as `$CNBLOGS_TOKEN`. Never print it. The CLI follows the
19
+ current official `cnblogs/vscode-cnb` client contract at
20
+ `https://write.cnblogs.com/api` (`Authorization: Bearer` plus
21
+ `Authorization-Type: pat`).
22
+
23
+ ```bash
24
+ python3 "$SKILL_DIR/scripts/cnblogs.py" whoami
25
+ ```
26
+
27
+ If authentication fails, ask the user to create a PAT at
28
+ `https://account.cnblogs.com/settings/tokens` and reconnect. Do not ask for
29
+ their account password or Cookie.
30
+
31
+ ## Read the blog
32
+
33
+ ```bash
34
+ # Verify the token and inspect the account's post template.
35
+ python3 "$SKILL_DIR/scripts/cnblogs.py" whoami
36
+
37
+ # Categories and recent posts.
38
+ python3 "$SKILL_DIR/scripts/cnblogs.py" categories
39
+ python3 "$SKILL_DIR/scripts/cnblogs.py" posts --limit 20
40
+ python3 "$SKILL_DIR/scripts/cnblogs.py" post POST_ID
41
+ ```
42
+
43
+ ## Create a draft or publish
44
+
45
+ Prepare the complete Markdown in a file. Category values are numeric IDs from
46
+ the `categories` command; tags are names.
47
+
48
+ ```bash
49
+ # First call is always a dry run and does not load credentials or call the API.
50
+ python3 "$SKILL_DIR/scripts/cnblogs.py" create \
51
+ --title "标题" --content-file /tmp/article.md \
52
+ --category-ids "123,456" --tags "agent,api"
53
+
54
+ # Save as a private draft after the user confirms.
55
+ python3 "$SKILL_DIR/scripts/cnblogs.py" create \
56
+ --title "标题" --content-file /tmp/article.md \
57
+ --category-ids "123,456" --tags "agent,api" --confirm
58
+
59
+ # Public publishing additionally requires --publish.
60
+ python3 "$SKILL_DIR/scripts/cnblogs.py" create \
61
+ --title "标题" --content-file /tmp/article.md \
62
+ --category-ids "123,456" --tags "agent,api" --publish --confirm
63
+ ```
64
+
65
+ `--confirm` is valid only as the final argument. Always show the title,
66
+ categories, tags, visibility and full content to the user before a public
67
+ publish. Default to a draft unless the user explicitly requests publication.
68
+
69
+ ## Update and delete
70
+
71
+ Updating requires an explicit visibility choice so an existing post is not
72
+ silently unpublished. All commands below dry-run without trailing `--confirm`.
73
+
74
+ ```bash
75
+ python3 "$SKILL_DIR/scripts/cnblogs.py" update POST_ID \
76
+ --title "新标题" --content-file /tmp/article.md --publish --confirm
77
+
78
+ python3 "$SKILL_DIR/scripts/cnblogs.py" update POST_ID \
79
+ --title "新标题" --content-file /tmp/article.md --draft --confirm
80
+
81
+ python3 "$SKILL_DIR/scripts/cnblogs.py" delete POST_ID --confirm
82
+ ```
83
+
84
+ Use the real returned `post_id`, URL, or media URL. Do not retry a timed-out
85
+ write automatically because its outcome may be unknown; list recent posts or
86
+ inspect the post first.
87
+
88
+ ## Record the output
89
+
90
+ After a confirmed public publish returns a real URL, call `publish_artifact`
91
+ once with `kind="article"`, `channel="cnblogs"`, the title, returned URL, and
92
+ `status="delivered"`. Do not record drafts or failed/unknown writes.
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env python3
2
+ """Read and write CNBlogs posts through its official-client JSON API."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import pathlib
10
+ import socket
11
+ import sys
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+
16
+ API_BASE = "https://write.cnblogs.com/api"
17
+ GATED_COMMANDS = {"create", "update", "delete"}
18
+ MAX_CONTENT_BYTES = 10 * 1024 * 1024
19
+ MAX_POSTS = 100
20
+
21
+
22
+ def output(value) -> None:
23
+ print(json.dumps(value, ensure_ascii=False, indent=2, default=str))
24
+
25
+
26
+ def die(message: str, code: int = 1) -> None:
27
+ output({"error": message})
28
+ raise SystemExit(code)
29
+
30
+
31
+ def split_confirmation(argv: list[str]) -> tuple[list[str], bool]:
32
+ confirmed = bool(argv) and argv[-1] == "--confirm"
33
+ return (argv[:-1] if confirmed else list(argv), confirmed)
34
+
35
+
36
+ class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
37
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
38
+ return None
39
+
40
+
41
+ class CNBlogsClient:
42
+ def __init__(self, token: str, opener=None) -> None:
43
+ self._token = token
44
+ self._opener = opener or urllib.request.build_opener(NoRedirectHandler())
45
+
46
+ @classmethod
47
+ def from_environment(cls):
48
+ token = os.environ.get("CNBLOGS_TOKEN", "").strip()
49
+ if not token:
50
+ die(
51
+ "CNBLOGS_TOKEN is not set. Reconnect 博客园 at "
52
+ "https://auth.acedata.cloud/user/connections."
53
+ )
54
+ return cls(token)
55
+
56
+ def request(self, method: str, path: str, *, body=None, write: bool = False, expect_json: bool = True):
57
+ encoded = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
58
+ request = urllib.request.Request(
59
+ f"{API_BASE}{path}",
60
+ data=encoded,
61
+ method=method,
62
+ headers={
63
+ "Accept": "application/json",
64
+ "Authorization": f"Bearer {self._token}",
65
+ "Authorization-Type": "pat",
66
+ "Content-Type": "application/json",
67
+ "User-Agent": "AceDataCloud-CNBlogs-Skill/1.0",
68
+ },
69
+ )
70
+ try:
71
+ with self._opener.open(request, timeout=30) as response:
72
+ raw = response.read()
73
+ except urllib.error.HTTPError as error:
74
+ if error.code in {301, 302, 303, 307, 308}:
75
+ die(f"CNBlogs API redirected {method} {path}; credentials were not forwarded.")
76
+ suffix = " Reconnect with a valid PAT." if error.code in {401, 403} else ""
77
+ die(f"CNBlogs API HTTP {error.code} for {method} {path}.{suffix}")
78
+ except (urllib.error.URLError, OSError, socket.timeout):
79
+ if write:
80
+ die(
81
+ f"CNBlogs write {method} {path} did not return a result; outcome is unknown. "
82
+ "Inspect the post list before retrying."
83
+ )
84
+ die(f"Network error while calling CNBlogs {method} {path}.")
85
+ if not expect_json or not raw:
86
+ return None
87
+ try:
88
+ return json.loads(raw)
89
+ except json.JSONDecodeError:
90
+ die(f"CNBlogs returned invalid JSON for {method} {path}.")
91
+
92
+ def template(self) -> dict:
93
+ value = self.request("GET", "/posts/-1")
94
+ post = value.get("blogPost") if isinstance(value, dict) else None
95
+ if not isinstance(post, dict):
96
+ die("CNBlogs returned malformed post template data.")
97
+ return post
98
+
99
+ def categories(self) -> list[dict]:
100
+ value = self.request("GET", "/v2/blog-category-types/1/categories")
101
+ if not isinstance(value, list):
102
+ die("CNBlogs returned malformed category data.")
103
+ return value
104
+
105
+ def posts(self, limit: int) -> list[dict]:
106
+ query = urllib.parse.urlencode({"t": 1, "p": 1, "s": limit})
107
+ value = self.request("GET", f"/posts/list?{query}")
108
+ posts = value.get("postList") if isinstance(value, dict) else None
109
+ if not isinstance(posts, list):
110
+ die("CNBlogs returned malformed post-list data.")
111
+ return posts
112
+
113
+ def post(self, post_id: int) -> dict:
114
+ value = self.request("GET", f"/posts/{post_id}")
115
+ post = value.get("blogPost") if isinstance(value, dict) else None
116
+ if not isinstance(post, dict):
117
+ die("CNBlogs returned malformed post data.")
118
+ return post
119
+
120
+ def save(self, post: dict) -> dict:
121
+ value = self.request("POST", "/posts", body=post, write=True)
122
+ if not isinstance(value, dict) or not isinstance(value.get("id"), int) or value["id"] <= 0:
123
+ die("CNBlogs did not return a valid saved post.")
124
+ return value
125
+
126
+ def delete(self, post_id: int) -> None:
127
+ self.request("DELETE", f"/posts/{post_id}", write=True, expect_json=False)
128
+
129
+
130
+ def read_content(args) -> str:
131
+ if args.content_file:
132
+ path = pathlib.Path(args.content_file)
133
+ try:
134
+ if path.stat().st_size > MAX_CONTENT_BYTES:
135
+ die("Content file exceeds the 10 MiB safety limit.")
136
+ return path.read_text(encoding="utf-8")
137
+ except OSError as error:
138
+ die(f"Cannot read --content-file: {error}")
139
+ if args.content is not None:
140
+ if len(args.content.encode("utf-8")) > MAX_CONTENT_BYTES:
141
+ die("Content exceeds the 10 MiB safety limit.")
142
+ return args.content
143
+ die("Provide --content-file <path.md> or --content <markdown>.")
144
+
145
+
146
+ def csv_strings(raw: str | None) -> list[str]:
147
+ return [item.strip() for item in (raw or "").split(",") if item.strip()]
148
+
149
+
150
+ def csv_ids(raw: str | None) -> list[int]:
151
+ try:
152
+ return [int(item) for item in csv_strings(raw)]
153
+ except ValueError:
154
+ die("--category-ids must be a comma-separated list of integers.")
155
+
156
+
157
+ def apply_post_fields(post: dict, args, content: str) -> dict:
158
+ post.update(
159
+ {
160
+ "title": args.title,
161
+ "postBody": content,
162
+ "isMarkdown": True,
163
+ "isPublished": args.publish,
164
+ "isDraft": not args.publish,
165
+ }
166
+ )
167
+ if args.category_ids is not None:
168
+ post["categoryIds"] = csv_ids(args.category_ids)
169
+ if args.tags is not None:
170
+ post["tags"] = csv_strings(args.tags)
171
+ return post
172
+
173
+
174
+ def format_post(post: dict) -> dict:
175
+ return {
176
+ "post_id": post.get("id"),
177
+ "title": post.get("title"),
178
+ "url": post.get("url"),
179
+ "published": post.get("isPublished"),
180
+ "draft": post.get("isDraft"),
181
+ "category_ids": post.get("categoryIds") or [],
182
+ "tags": post.get("tags") or [],
183
+ "created_at": post.get("datePublished"),
184
+ "updated_at": post.get("dateUpdated"),
185
+ }
186
+
187
+
188
+ def positive_int(value: str) -> int:
189
+ try:
190
+ parsed = int(value)
191
+ except ValueError as error:
192
+ raise argparse.ArgumentTypeError("must be a positive integer") from error
193
+ if parsed <= 0:
194
+ raise argparse.ArgumentTypeError("must be a positive integer")
195
+ return parsed
196
+
197
+
198
+ def build_parser() -> argparse.ArgumentParser:
199
+ parser = argparse.ArgumentParser(description="CNBlogs PAT API CLI")
200
+ sub = parser.add_subparsers(dest="command", required=True)
201
+ sub.add_parser("whoami")
202
+ sub.add_parser("categories")
203
+ posts = sub.add_parser("posts")
204
+ posts.add_argument("--limit", type=int, default=20)
205
+ one = sub.add_parser("post")
206
+ one.add_argument("post_id", type=positive_int)
207
+
208
+ for command in ("create", "update"):
209
+ write = sub.add_parser(command)
210
+ if command == "update":
211
+ write.add_argument("post_id", type=positive_int)
212
+ write.add_argument("--title", required=True)
213
+ write.add_argument("--content")
214
+ write.add_argument("--content-file")
215
+ write.add_argument("--category-ids")
216
+ write.add_argument("--tags")
217
+ visibility = write.add_mutually_exclusive_group(required=command == "update")
218
+ visibility.add_argument("--publish", action="store_true")
219
+ if command == "update":
220
+ visibility.add_argument("--draft", action="store_true")
221
+
222
+ delete = sub.add_parser("delete")
223
+ delete.add_argument("post_id", type=positive_int)
224
+ return parser
225
+
226
+
227
+ def dry_run(args, content: str | None = None) -> None:
228
+ value = {"dry_run": True, "command": args.command, "platform": "cnblogs"}
229
+ if args.command in {"create", "update"}:
230
+ value.update(
231
+ {
232
+ "post_id": getattr(args, "post_id", None),
233
+ "title": args.title,
234
+ "visibility": "published" if args.publish else "draft",
235
+ "category_ids": csv_ids(args.category_ids) if args.category_ids is not None else None,
236
+ "tags": csv_strings(args.tags) if args.tags is not None else None,
237
+ "content_characters": len(content or ""),
238
+ }
239
+ )
240
+ else:
241
+ value["post_id"] = args.post_id
242
+ value["note"] = "Re-run with --confirm as the final argument to write to CNBlogs."
243
+ output(value)
244
+
245
+
246
+ def main(argv: list[str] | None = None) -> None:
247
+ raw = list(sys.argv[1:] if argv is None else argv)
248
+ clean_argv, confirmed = split_confirmation(raw)
249
+ args = build_parser().parse_args(clean_argv)
250
+ content = read_content(args) if args.command in {"create", "update"} else None
251
+ if args.command in GATED_COMMANDS and not confirmed:
252
+ dry_run(args, content)
253
+ return
254
+
255
+ client = CNBlogsClient.from_environment()
256
+ if args.command == "whoami":
257
+ template = client.template()
258
+ output({"author": template.get("author"), "blog_id": template.get("blogId"), "template": format_post(template)})
259
+ elif args.command == "categories":
260
+ output({"categories": client.categories()})
261
+ elif args.command == "posts":
262
+ if not 1 <= args.limit <= MAX_POSTS:
263
+ die(f"--limit must be between 1 and {MAX_POSTS}.")
264
+ output({"posts": [format_post(item) for item in client.posts(args.limit)]})
265
+ elif args.command == "post":
266
+ output(format_post(client.post(args.post_id)))
267
+ elif args.command == "create":
268
+ result = client.save(apply_post_fields(client.template(), args, content or ""))
269
+ output(
270
+ {
271
+ "ok": True,
272
+ **format_post(result),
273
+ "published": args.publish,
274
+ "draft": not args.publish,
275
+ "edit_url": f"https://i.cnblogs.com/posts/edit;postId={result['id']}",
276
+ }
277
+ )
278
+ elif args.command == "update":
279
+ result = client.save(apply_post_fields(client.post(args.post_id), args, content or ""))
280
+ output({"ok": True, **format_post(result), "published": args.publish, "draft": not args.publish})
281
+ elif args.command == "delete":
282
+ client.delete(args.post_id)
283
+ output({"ok": True, "post_id": args.post_id, "deleted": True})
284
+
285
+
286
+ if __name__ == "__main__":
287
+ main()
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import io
5
+ import json
6
+ import os
7
+ import pathlib
8
+ import sys
9
+ import urllib.error
10
+ from contextlib import redirect_stdout
11
+ from unittest.mock import MagicMock, patch
12
+
13
+ import pytest
14
+
15
+ SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "cnblogs.py"
16
+ SPEC = importlib.util.spec_from_file_location("cnblogs_skill_script", SCRIPT)
17
+ cnblogs = importlib.util.module_from_spec(SPEC)
18
+ assert SPEC.loader is not None
19
+ sys.modules[SPEC.name] = cnblogs
20
+ SPEC.loader.exec_module(cnblogs)
21
+
22
+
23
+ class FakeResponse:
24
+ def __init__(self, payload=None, *, raw=None):
25
+ self.payload = raw if raw is not None else (b"" if payload is None else json.dumps(payload).encode())
26
+
27
+ def __enter__(self):
28
+ return self
29
+
30
+ def __exit__(self, *_args):
31
+ return False
32
+
33
+ def read(self):
34
+ return self.payload
35
+
36
+
37
+ def make_client(*responses):
38
+ opener = MagicMock()
39
+ opener.open.side_effect = [FakeResponse(item) for item in responses]
40
+ return cnblogs.CNBlogsClient("secret-token", opener=opener), opener
41
+
42
+
43
+ def test_request_uses_official_pat_contract():
44
+ client, opener = make_client({"postList": []})
45
+ assert client.posts(20) == []
46
+ request = opener.open.call_args.args[0]
47
+ assert request.full_url == "https://write.cnblogs.com/api/posts/list?t=1&p=1&s=20"
48
+ assert request.get_header("Authorization") == "Bearer secret-token"
49
+ assert request.get_header("Authorization-type") == "pat"
50
+
51
+
52
+ def test_create_uses_server_template_and_returns_real_api_url():
53
+ template = {"blogPost": {"id": -1, "blogId": 7, "isAllowComments": True}}
54
+ saved = {"id": 123, "title": "Hello", "url": "https://www.cnblogs.com/alice/p/123"}
55
+ client, opener = make_client(template, saved)
56
+ args = cnblogs.build_parser().parse_args(
57
+ ["create", "--title", "Hello", "--content", "# Body", "--category-ids", "12,34", "--tags", "api, agent"]
58
+ )
59
+
60
+ result = client.save(cnblogs.apply_post_fields(client.template(), args, "# Body"))
61
+
62
+ assert result["url"] == "https://www.cnblogs.com/alice/p/123"
63
+ request = opener.open.call_args_list[1].args[0]
64
+ payload = json.loads(request.data)
65
+ assert payload["blogId"] == 7
66
+ assert payload["postBody"] == "# Body"
67
+ assert payload["isMarkdown"] is True
68
+ assert payload["categoryIds"] == [12, 34]
69
+ assert payload["tags"] == ["api", "agent"]
70
+ assert payload["isPublished"] is False
71
+ assert payload["isDraft"] is True
72
+
73
+
74
+ def test_dry_run_never_loads_credentials_or_calls_network():
75
+ stream = io.StringIO()
76
+ with patch.dict(os.environ, {}, clear=True), patch.object(
77
+ cnblogs.CNBlogsClient, "from_environment"
78
+ ) as from_environment, redirect_stdout(stream):
79
+ cnblogs.main(["create", "--title", "Hello", "--content", "Body"])
80
+
81
+ value = json.loads(stream.getvalue())
82
+ assert value["dry_run"] is True
83
+ assert value["visibility"] == "draft"
84
+ from_environment.assert_not_called()
85
+
86
+
87
+ def test_confirm_is_recognized_only_as_final_argument():
88
+ args, confirmed = cnblogs.split_confirmation(["create", "--content", "--confirm", "--title", "Hello"])
89
+ assert confirmed is False
90
+ assert "--confirm" in args
91
+ args, confirmed = cnblogs.split_confirmation(["delete", "123", "--confirm"])
92
+ assert confirmed is True
93
+ assert "--confirm" not in args
94
+
95
+
96
+ def test_successful_create_update_and_delete_commands():
97
+ template = {"blogPost": {"id": -1}}
98
+ created = {"id": 123, "title": "New", "url": "https://www.cnblogs.com/a/p/123"}
99
+ client, _ = make_client(template, created)
100
+ stream = io.StringIO()
101
+ with patch.object(cnblogs.CNBlogsClient, "from_environment", return_value=client), redirect_stdout(stream):
102
+ cnblogs.main(["create", "--title", "New", "--content", "Body", "--confirm"])
103
+ create_output = json.loads(stream.getvalue())
104
+ assert create_output["url"] == created["url"]
105
+ assert create_output["published"] is False
106
+ assert create_output["draft"] is True
107
+
108
+ current = {"blogPost": {"id": 123, "title": "Old", "categoryIds": [9], "tags": ["keep"]}}
109
+ updated = {"id": 123, "title": "Updated", "url": "https://www.cnblogs.com/a/p/123"}
110
+ client, opener = make_client(current, updated)
111
+ stream = io.StringIO()
112
+ with patch.object(cnblogs.CNBlogsClient, "from_environment", return_value=client), redirect_stdout(stream):
113
+ cnblogs.main(["update", "123", "--title", "Updated", "--content", "Body", "--publish", "--confirm"])
114
+ update_output = json.loads(stream.getvalue())
115
+ assert update_output["published"] is True
116
+ assert update_output["draft"] is False
117
+ update_payload = json.loads(opener.open.call_args_list[1].args[0].data)
118
+ assert update_payload["categoryIds"] == [9]
119
+ assert update_payload["tags"] == ["keep"]
120
+
121
+ opener = MagicMock()
122
+ opener.open.return_value = FakeResponse(raw=b"successful response with no JSON contract")
123
+ client = cnblogs.CNBlogsClient("secret-token", opener=opener)
124
+ stream = io.StringIO()
125
+ with patch.object(cnblogs.CNBlogsClient, "from_environment", return_value=client), redirect_stdout(stream):
126
+ cnblogs.main(["delete", "123", "--confirm"])
127
+ assert json.loads(stream.getvalue()) == {"ok": True, "post_id": 123, "deleted": True}
128
+
129
+
130
+ @pytest.mark.parametrize(("method", "path"), [("POST", "/posts"), ("DELETE", "/posts/123")])
131
+ def test_write_network_failure_reports_unknown_outcome(method, path):
132
+ opener = MagicMock()
133
+ opener.open.side_effect = urllib.error.URLError("secret-token")
134
+ client = cnblogs.CNBlogsClient("secret-token", opener=opener)
135
+ stream = io.StringIO()
136
+ with pytest.raises(SystemExit), redirect_stdout(stream):
137
+ client.request(method, path, body={} if method == "POST" else None, write=True)
138
+ assert "outcome is unknown" in stream.getvalue()
139
+ assert "secret-token" not in stream.getvalue()
140
+
141
+
142
+ def test_rejects_malformed_responses_and_oversized_content(tmp_path):
143
+ client, _ = make_client({"blogPost": None})
144
+ with pytest.raises(SystemExit), redirect_stdout(io.StringIO()):
145
+ client.template()
146
+
147
+ large = tmp_path / "large.md"
148
+ large.write_bytes(b"x" * (cnblogs.MAX_CONTENT_BYTES + 1))
149
+ args = cnblogs.build_parser().parse_args(["create", "--title", "T", "--content-file", str(large)])
150
+ with pytest.raises(SystemExit), redirect_stdout(io.StringIO()):
151
+ cnblogs.read_content(args)