@acedatacloud/skills 2026.720.3 → 2026.721.1
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.
|
|
3
|
+
"version": "2026.721.1",
|
|
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)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: facebook
|
|
3
|
+
description: Publish text, link or photo posts to your Facebook Page via the Facebook Pages API, and read your Page's recent posts. Use when the user wants to post to their Facebook Page, share a link, publish a photo, or review their own Page posts. Auth uses a Page access token (BYOC). 支持 Facebook 主页文本 / 链接 / 图片发帖。
|
|
4
|
+
when_to_use: |
|
|
5
|
+
Trigger when the user wants to publish a post (text, link or photo) to their
|
|
6
|
+
Facebook Page, cross-post an article, or review their own recent Page posts.
|
|
7
|
+
Publishing posts as their real Page — confirm the message + any link/photo
|
|
8
|
+
first. This posts to a Facebook Page, not a personal profile (the Graph API
|
|
9
|
+
cannot post to personal timelines).
|
|
10
|
+
connections: [facebook]
|
|
11
|
+
allowed_tools: [Bash]
|
|
12
|
+
license: Apache-2.0
|
|
13
|
+
metadata:
|
|
14
|
+
author: acedatacloud
|
|
15
|
+
version: "1.0"
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
Call the **Facebook Graph API** (`graph.facebook.com`) with `curl + jq`. The
|
|
19
|
+
connector injects `$FACEBOOK_ACCESS_TOKEN` (a **Page** access token with
|
|
20
|
+
`pages_manage_posts` + `pages_read_engagement`) and optionally
|
|
21
|
+
`$FACEBOOK_PAGE_ID`. Never echo them.
|
|
22
|
+
|
|
23
|
+
The Graph API publishes only to a **Facebook Page** you manage — it cannot post
|
|
24
|
+
to a personal profile timeline.
|
|
25
|
+
|
|
26
|
+
### Resolve the Page id
|
|
27
|
+
|
|
28
|
+
If `$FACEBOOK_PAGE_ID` is set, use it. Otherwise derive it from the token — try
|
|
29
|
+
the **Page-token** path first (`/me` is the Page), then fall back to the
|
|
30
|
+
**user-token** path (`/me/accounts` → first managed Page), so either token type
|
|
31
|
+
the connector accepts resolves cleanly:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
if [ -n "$FACEBOOK_PAGE_ID" ]; then
|
|
35
|
+
PAGE="$FACEBOOK_PAGE_ID"
|
|
36
|
+
else
|
|
37
|
+
# Page access token: /me IS the Page
|
|
38
|
+
PAGE=$(curl -sS "https://graph.facebook.com/v21.0/me?fields=id,name&access_token=$FACEBOOK_ACCESS_TOKEN" | jq -r 'select(.name).id // empty')
|
|
39
|
+
if [ -z "$PAGE" ]; then
|
|
40
|
+
# User access token: list the managed Pages, take the first
|
|
41
|
+
PAGE=$(curl -sS "https://graph.facebook.com/v21.0/me/accounts?access_token=$FACEBOOK_ACCESS_TOKEN" | jq -r '.data[0].id // empty')
|
|
42
|
+
fi
|
|
43
|
+
fi
|
|
44
|
+
echo "page_id=$PAGE"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Errors are JSON with `error.message` / `error.code` — show them verbatim.
|
|
48
|
+
`401` / `OAuthException` → token expired or missing scope; a null `PAGE` → the
|
|
49
|
+
token isn't a Page token / manages no Page (see Gotchas). Reconnect if needed.
|
|
50
|
+
|
|
51
|
+
> If the user token path is used, `/me/accounts` also returns a per-Page
|
|
52
|
+
> `access_token` in `.data[0].access_token`. Prefer that Page token for the
|
|
53
|
+
> publish call when the injected token is a user token; a Page's own token is
|
|
54
|
+
> what `pages_manage_posts` actually authorizes.
|
|
55
|
+
|
|
56
|
+
## Publish a text or link post
|
|
57
|
+
|
|
58
|
+
**Confirm the message (and link, if any) with the user first.** Post to
|
|
59
|
+
`/{page-id}/feed`:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Text-only post
|
|
63
|
+
curl -sS -X POST "https://graph.facebook.com/v21.0/$PAGE/feed" \
|
|
64
|
+
--data-urlencode "message=One endpoint → posters, cards, mockups. #AI #API" \
|
|
65
|
+
-d "access_token=$FACEBOOK_ACCESS_TOKEN" | jq .
|
|
66
|
+
# → {"id":"<PAGE_ID>_<POST_ID>"}
|
|
67
|
+
|
|
68
|
+
# Link post — Facebook renders a link preview card from the URL
|
|
69
|
+
curl -sS -X POST "https://graph.facebook.com/v21.0/$PAGE/feed" \
|
|
70
|
+
--data-urlencode "message=Read our new guide 👇" \
|
|
71
|
+
-d "link=https://acedata.cloud/blog/xxxx" \
|
|
72
|
+
-d "access_token=$FACEBOOK_ACCESS_TOKEN" | jq .
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Publish a photo post
|
|
76
|
+
|
|
77
|
+
Post to `/{page-id}/photos` with a **public image URL** (Facebook server-side
|
|
78
|
+
fetches it); `caption` is the post text:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
curl -sS -X POST "https://graph.facebook.com/v21.0/$PAGE/photos" \
|
|
82
|
+
-d "url=https://cdn.acedata.cloud/xxxx.jpg" \
|
|
83
|
+
--data-urlencode "caption=One endpoint → posters, cards, mockups. #AI #API" \
|
|
84
|
+
-d "access_token=$FACEBOOK_ACCESS_TOKEN" | jq .
|
|
85
|
+
# → {"id":"<PHOTO_ID>","post_id":"<PAGE_ID>_<POST_ID>"}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
To attach **multiple photos** to one post: upload each with `published=false` to
|
|
89
|
+
get its media id, then create the feed post with
|
|
90
|
+
`attached_media[0]={"media_fbid":"<ID1>"}` … for each.
|
|
91
|
+
|
|
92
|
+
## Get the post permalink
|
|
93
|
+
|
|
94
|
+
Use the returned `post_id` (or `id`) to fetch the live URL to hand back:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
curl -sS "https://graph.facebook.com/v21.0/<PAGE_ID>_<POST_ID>?fields=permalink_url&access_token=$FACEBOOK_ACCESS_TOKEN" | jq -r .permalink_url
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Read recent Page posts
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
curl -sS "https://graph.facebook.com/v21.0/$PAGE/posts?fields=id,message,created_time,permalink_url&limit=10&access_token=$FACEBOOK_ACCESS_TOKEN" | jq .
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Gotchas
|
|
107
|
+
|
|
108
|
+
- **Pages only, not profiles** — the Graph API cannot publish to a personal
|
|
109
|
+
timeline. If the user asks to post to their profile, explain this limitation.
|
|
110
|
+
- **Page token, not user token** — publishing needs a Page access token with
|
|
111
|
+
`pages_manage_posts`. If you only have a user token, resolve the Page token via
|
|
112
|
+
`/me/accounts` (see above). A `(#200)` permissions error means the token lacks
|
|
113
|
+
`pages_manage_posts` or the user isn't an admin of the Page.
|
|
114
|
+
- **Photo URL must be public** on a server Facebook can reach; local files won't
|
|
115
|
+
work. Host on cdn.acedata.cloud first.
|
|
116
|
+
- **Page Publishing Authorization (PPA):** some Pages must complete PPA before API
|
|
117
|
+
publishing works — surface the API error verbatim if it mentions PPA.
|
|
118
|
+
- **Token expiry:** a Page token derived from a short-lived user token expires
|
|
119
|
+
fast. If posting 401s soon after connecting, the user needs a long-lived Page
|
|
120
|
+
token — surface the `OAuthException` verbatim and suggest reconnecting.
|
|
121
|
+
- API version: keep the `vXX.0` path current if you hit a version/deprecation error.
|
|
122
|
+
|
|
123
|
+
## Record the output
|
|
124
|
+
|
|
125
|
+
After you successfully publish and obtain the live permalink, call the built-in
|
|
126
|
+
`publish_artifact` tool ONCE so the user can track it in **My Outputs**:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
publish_artifact(kind="post", channel="facebook", title="<title>", url="<the REAL permalink>", status="delivered")
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Use the real returned URL — never fabricate one. Call it once per published item,
|
|
133
|
+
only after delivery is confirmed; skip it (or use `status="failed"`) if publishing failed.
|
|
134
|
+
See `_shared/artifacts.md`.
|