@acedatacloud/skills 2026.726.2 → 2026.726.4
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.726.
|
|
3
|
+
"version": "2026.726.4",
|
|
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: kuaishou
|
|
3
|
+
description: Publish video to the user's own 快手 (Kuaishou) account and read their works via the Kuaishou open platform. Use when the user mentions 快手, Kuaishou, 发快手, 发布到快手, 我的快手作品, or wants to post a generated video to their Kuaishou account.
|
|
4
|
+
when_to_use: |
|
|
5
|
+
Trigger for anything on the user's own 快手 account through the official
|
|
6
|
+
open platform: publishing a video (e.g. one generated by the video
|
|
7
|
+
skills), listing their works with play/like/comment counts, inspecting
|
|
8
|
+
one work, or checking their account. Publishing is a real, immediately
|
|
9
|
+
public post — always confirm the caption AND that the video is the
|
|
10
|
+
user's own original work before running with --confirm.
|
|
11
|
+
connections: [kuaishou]
|
|
12
|
+
allowed_tools: [Bash]
|
|
13
|
+
license: Apache-2.0
|
|
14
|
+
metadata:
|
|
15
|
+
author: acedatacloud
|
|
16
|
+
version: "1.0"
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
All calls go through `scripts/kuaishou.py` (stdlib-only). The user's OAuth token
|
|
20
|
+
arrives as `$KUAISHOU_TOKEN` and the app id as `$KUAISHOU_APP_ID` (injected from
|
|
21
|
+
the connector's `metadata.platform_env`); the script reads both and fails fast
|
|
22
|
+
with a clear message if either is missing — never print either.
|
|
23
|
+
|
|
24
|
+
Resolve the script first (`$SKILL_DIR` may point at a different skill loaded the
|
|
25
|
+
same turn), and repeat this preamble in every Bash block:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
KS="$SKILL_DIR/scripts/kuaishou.py"; [ -f "$KS" ] || KS=$(find /tmp -maxdepth 8 -path '*/skills/*/scripts/kuaishou.py' 2>/dev/null | head -1)
|
|
29
|
+
[ -f "$KS" ] || { echo "kuaishou script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Read
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
python3 "$KS" whoami # nickname, avatar, fans, follows, city
|
|
36
|
+
python3 "$KS" works --limit 20 # works + play/like/comment counts
|
|
37
|
+
python3 "$KS" works --limit 20 --cursor <photo_id> # next page
|
|
38
|
+
python3 "$KS" work <photo-id> # one work
|
|
39
|
+
python3 "$KS" counts # public / friend / private / total
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Publish a video
|
|
43
|
+
|
|
44
|
+
**This posts publicly to the user's real account — there is no draft mode.**
|
|
45
|
+
Before publishing, confirm with the user in chat:
|
|
46
|
+
|
|
47
|
+
1. the caption (required by 快手), and
|
|
48
|
+
2. that the video is **their own original work** — 快手 bans accounts that
|
|
49
|
+
publish non-original video, and the ban lands on *the user's* account.
|
|
50
|
+
|
|
51
|
+
Without a trailing `--confirm` the command only dry-runs and publishes nothing.
|
|
52
|
+
`--confirm` must be the **last** argument.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# 1) Dry run — show the user exactly what will be posted
|
|
56
|
+
python3 "$KS" publish --caption "标题" --video-url "https://cdn.acedata.cloud/....mp4" \
|
|
57
|
+
--cover-url "https://cdn.acedata.cloud/....jpg"
|
|
58
|
+
|
|
59
|
+
# 2) After they confirm in chat
|
|
60
|
+
python3 "$KS" publish --caption "标题" --video-url "https://cdn.acedata.cloud/....mp4" \
|
|
61
|
+
--cover-url "https://cdn.acedata.cloud/....jpg" --confirm
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
A cover image is **required** (`--cover-url` or `--cover-file`, < 10MB). Local
|
|
65
|
+
files work too via `--video-file` / `--cover-file`. Optional: `--stereo-type`
|
|
66
|
+
for 360/180 panoramic video, `--product-id` to attach a 小黄车 product.
|
|
67
|
+
|
|
68
|
+
Remote `--*-url` inputs must live on **`cdn.acedata.cloud`** (or Kuaishou's own
|
|
69
|
+
image hosts). Anything else is rejected — upload it to our CDN first, or pass a
|
|
70
|
+
local path. This blocks a hostile URL from turning the upload step into an SSRF.
|
|
71
|
+
|
|
72
|
+
After a successful publish, record the deliverable once (see
|
|
73
|
+
`_shared/artifacts.md`):
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
publish_artifact(kind="video", channel="kuaishou", title="<caption>",
|
|
77
|
+
url="<the url returned by the script>", status="delivered")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Gotchas
|
|
81
|
+
|
|
82
|
+
- **Upload success ≠ playable video.** The upload step only confirms bytes
|
|
83
|
+
arrived; a malformed file fails later at transcode with `视频发布失败`
|
|
84
|
+
(120003) or `视频未上传成功` (120002). Report those verbatim.
|
|
85
|
+
- Rate limits: **1000 calls/day per user**, 100k/day per app. `100100402` means
|
|
86
|
+
the limit was hit *or* the account is banned.
|
|
87
|
+
- `ACCESS_DENIED` = the authorization is missing that endpoint's scope →
|
|
88
|
+
reconnect 快手 and grant it. Token expiry (`100200108`) also means reconnect.
|
|
89
|
+
- Kuaishou answers HTTP 200 even on failure — the script already checks
|
|
90
|
+
`result != 1`, so trust its `error` field rather than the exit status alone.
|
|
91
|
+
- Connections need re-authorizing roughly every 180 days: the refresh token does
|
|
92
|
+
not extend on refresh. If the user hits an auth error, have them reconnect.
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""kuaishou — publish video to & read works from the user's 快手 account.
|
|
3
|
+
|
|
4
|
+
Standard-library only (urllib), no third-party deps. The connector injects the
|
|
5
|
+
user's OAuth token as ``KUAISHOU_TOKEN``; ``KUAISHOU_APP_ID`` carries the app id
|
|
6
|
+
that every open-platform call also requires.
|
|
7
|
+
|
|
8
|
+
Publishing is three stateful calls (start_upload → upload → publish), which is
|
|
9
|
+
why this is a script and not raw curl. ``publish`` is GATED: without a trailing
|
|
10
|
+
``--confirm`` it only dry-runs. ``--confirm`` is honored ONLY as the last
|
|
11
|
+
argument, so a caption that merely contains "--confirm" can never self-authorize.
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
python3 kuaishou.py whoami
|
|
15
|
+
python3 kuaishou.py works --limit 20
|
|
16
|
+
python3 kuaishou.py work <photo-id>
|
|
17
|
+
python3 kuaishou.py counts
|
|
18
|
+
python3 kuaishou.py publish --video-url URL --cover-url URL --caption T --confirm
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import ipaddress
|
|
25
|
+
import json
|
|
26
|
+
import mimetypes
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import socket
|
|
30
|
+
import sys
|
|
31
|
+
import tempfile
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.parse
|
|
34
|
+
import urllib.request
|
|
35
|
+
|
|
36
|
+
API = "https://open.kuaishou.com"
|
|
37
|
+
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
|
38
|
+
MAX_COVER_BYTES = 10 * 1024 * 1024
|
|
39
|
+
MAX_VIDEO_BYTES = 500 * 1024 * 1024
|
|
40
|
+
CHUNK = 8 * 1024 * 1024 # Kuaishou recommends fragments <= 10MB.
|
|
41
|
+
DIRECT_UPLOAD_MAX = 10 * 1024 * 1024
|
|
42
|
+
|
|
43
|
+
_RAW = sys.argv[1:]
|
|
44
|
+
CONFIRM = bool(_RAW) and _RAW[-1] == "--confirm"
|
|
45
|
+
ARGV = _RAW[:-1] if CONFIRM else list(_RAW)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def out(obj) -> None:
|
|
49
|
+
print(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def die(msg: str, code: int = 1) -> None:
|
|
53
|
+
out({"error": msg})
|
|
54
|
+
sys.exit(code)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _creds():
|
|
58
|
+
token = os.environ.get("KUAISHOU_TOKEN", "").strip()
|
|
59
|
+
app_id = os.environ.get("KUAISHOU_APP_ID", "").strip()
|
|
60
|
+
if not token:
|
|
61
|
+
die("KUAISHOU_TOKEN is not set — connect 快手 first")
|
|
62
|
+
if not app_id:
|
|
63
|
+
die("KUAISHOU_APP_ID is not set — the connector must inject the app id")
|
|
64
|
+
return token, app_id
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
68
|
+
# A 30x could otherwise reach an internal host _assert_public_url() never saw.
|
|
69
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
70
|
+
raise RuntimeError(f"media redirect blocked ({code}) -> {newurl[:80]}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_MEDIA_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
MEDIA_HOST_ALLOWLIST = ("cdn.acedata.cloud", "acedata.cloud", "kwimgs.com", "yximgs.com")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _assert_public_url(url: str) -> None:
|
|
80
|
+
parts = urllib.parse.urlsplit(url)
|
|
81
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
82
|
+
raise RuntimeError(f"unsupported media URL: {url[:80]}")
|
|
83
|
+
host = parts.hostname.lower()
|
|
84
|
+
# Allowlist, not just an IP check: a bare IP check is defeated by DNS
|
|
85
|
+
# rebinding (pass on the first resolve, point at 169.254.169.254 on the
|
|
86
|
+
# second). Media we publish comes from our own CDN anyway.
|
|
87
|
+
if not any(host == h or host.endswith("." + h) for h in MEDIA_HOST_ALLOWLIST):
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
f"media host not allowed: {host} — upload the file to cdn.acedata.cloud first, or pass a local path"
|
|
90
|
+
)
|
|
91
|
+
try:
|
|
92
|
+
addrs = socket.getaddrinfo(host, None)
|
|
93
|
+
except OSError as e:
|
|
94
|
+
raise RuntimeError(f"cannot resolve {host}: {e}")
|
|
95
|
+
for info in addrs:
|
|
96
|
+
ip = ipaddress.ip_address(info[4][0])
|
|
97
|
+
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast or ip.is_unspecified:
|
|
98
|
+
raise RuntimeError(f"blocked non-public media host: {host}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _download_to(url: str, limit: int, dest) -> int:
|
|
102
|
+
"""Stream to *dest* so a 500MB video never sits in memory."""
|
|
103
|
+
_assert_public_url(url)
|
|
104
|
+
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
105
|
+
total = 0
|
|
106
|
+
with _MEDIA_OPENER.open(req, timeout=300) as r:
|
|
107
|
+
while True:
|
|
108
|
+
buf = r.read(1024 * 1024)
|
|
109
|
+
if not buf:
|
|
110
|
+
break
|
|
111
|
+
total += len(buf)
|
|
112
|
+
if total > limit:
|
|
113
|
+
raise RuntimeError(f"media exceeds {limit} bytes")
|
|
114
|
+
dest.write(buf)
|
|
115
|
+
dest.flush()
|
|
116
|
+
return total
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _download(url: str, limit: int) -> bytes:
|
|
120
|
+
_assert_public_url(url)
|
|
121
|
+
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
122
|
+
with _MEDIA_OPENER.open(req, timeout=120) as r:
|
|
123
|
+
data = r.read(limit + 1)
|
|
124
|
+
if len(data) > limit:
|
|
125
|
+
raise RuntimeError(f"media exceeds {limit} bytes")
|
|
126
|
+
return data
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _read_media(url: str | None, path: str | None, limit: int, what: str) -> bytes:
|
|
130
|
+
"""Small media (covers) only — video streams to disk via ``_download_to``."""
|
|
131
|
+
if url:
|
|
132
|
+
return _download(url, limit)
|
|
133
|
+
if path:
|
|
134
|
+
with open(path, "rb") as fh:
|
|
135
|
+
data = fh.read(limit + 1)
|
|
136
|
+
if len(data) > limit:
|
|
137
|
+
raise RuntimeError(f"{what} exceeds {limit} bytes")
|
|
138
|
+
return data
|
|
139
|
+
raise RuntimeError(f"no {what} provided")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _redact(text: str) -> str:
|
|
143
|
+
"""Strip credentials before anything upstream-echoed reaches stdout."""
|
|
144
|
+
for name in ("access_token", "upload_token", "app_secret", "refresh_token"):
|
|
145
|
+
text = re.sub(rf"({name}[\"'=:\s]+)[^\s\"'&,}}]+", r"\1<redacted>", text)
|
|
146
|
+
for var in ("KUAISHOU_TOKEN", "KUAISHOU_APP_ID"):
|
|
147
|
+
secret = os.environ.get(var, "")
|
|
148
|
+
if len(secret) >= 6:
|
|
149
|
+
text = text.replace(secret, "<redacted>")
|
|
150
|
+
return text
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _check(payload: dict, what: str) -> dict:
|
|
154
|
+
# Kuaishou answers HTTP 200 even on failure; ``result`` is the real status.
|
|
155
|
+
if payload.get("result") != 1:
|
|
156
|
+
raise RuntimeError(_redact(f"{what} failed: {json.dumps(payload, ensure_ascii=False)}"))
|
|
157
|
+
return payload
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _api(path: str, params: dict, method: str = "GET", body: bytes | None = None, ctype: str | None = None) -> dict:
|
|
161
|
+
url = f"{API}{path}?{urllib.parse.urlencode(params)}"
|
|
162
|
+
headers = {"User-Agent": UA}
|
|
163
|
+
if ctype:
|
|
164
|
+
headers["Content-Type"] = ctype
|
|
165
|
+
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
166
|
+
try:
|
|
167
|
+
with urllib.request.urlopen(req, timeout=120) as r:
|
|
168
|
+
return json.loads(r.read().decode("utf-8", "replace"))
|
|
169
|
+
except urllib.error.HTTPError as e:
|
|
170
|
+
detail = e.read().decode("utf-8", "replace")[:400]
|
|
171
|
+
raise RuntimeError(_redact(f"HTTP {e.code} from {path}: {detail}"))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _multipart(fields: dict, files: dict) -> tuple[bytes, str]:
|
|
175
|
+
boundary = "----AceDataKuaishou" + os.urandom(8).hex()
|
|
176
|
+
buf = bytearray()
|
|
177
|
+
for name, value in fields.items():
|
|
178
|
+
if value is None:
|
|
179
|
+
continue
|
|
180
|
+
buf += f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n".encode()
|
|
181
|
+
for name, (filename, blob) in files.items():
|
|
182
|
+
ctype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
183
|
+
buf += (
|
|
184
|
+
f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"\r\n"
|
|
185
|
+
f"Content-Type: {ctype}\r\n\r\n"
|
|
186
|
+
).encode()
|
|
187
|
+
buf += blob + b"\r\n"
|
|
188
|
+
buf += f"--{boundary}--\r\n".encode()
|
|
189
|
+
return bytes(buf), f"multipart/form-data; boundary={boundary}"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _upload_binary(endpoint: str, upload_token: str, path: str, size: int) -> None:
|
|
193
|
+
"""Step 2 — direct for small files, fragmented otherwise. Reads from disk
|
|
194
|
+
one chunk at a time so peak memory stays at one fragment."""
|
|
195
|
+
# The gateway is plain http per the docs and the host is issued fresh by
|
|
196
|
+
# start_upload — never hardcode it.
|
|
197
|
+
base = f"http://{endpoint}"
|
|
198
|
+
if size <= DIRECT_UPLOAD_MAX:
|
|
199
|
+
with open(path, "rb") as fh:
|
|
200
|
+
blob = fh.read()
|
|
201
|
+
url = f"{base}/api/upload?{urllib.parse.urlencode({'upload_token': upload_token})}"
|
|
202
|
+
req = urllib.request.Request(url, data=blob, headers={"Content-Type": "video/mp4"}, method="POST")
|
|
203
|
+
with urllib.request.urlopen(req, timeout=600) as r:
|
|
204
|
+
_check(json.loads(r.read().decode("utf-8", "replace")), "upload")
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
count = 0
|
|
208
|
+
with open(path, "rb") as fh:
|
|
209
|
+
while True:
|
|
210
|
+
part = fh.read(CHUNK)
|
|
211
|
+
if not part:
|
|
212
|
+
break
|
|
213
|
+
q = urllib.parse.urlencode({"upload_token": upload_token, "fragment_id": count}) # starts at 0
|
|
214
|
+
req = urllib.request.Request(
|
|
215
|
+
f"{base}/api/upload/fragment?{q}", data=part, headers={"Content-Type": "video/mp4"}, method="POST"
|
|
216
|
+
)
|
|
217
|
+
with urllib.request.urlopen(req, timeout=600) as r:
|
|
218
|
+
_check(json.loads(r.read().decode("utf-8", "replace")), f"fragment {count}")
|
|
219
|
+
count += 1
|
|
220
|
+
q = urllib.parse.urlencode({"upload_token": upload_token, "fragment_count": count})
|
|
221
|
+
req = urllib.request.Request(f"{base}/api/upload/complete?{q}", data=b"", method="POST")
|
|
222
|
+
with urllib.request.urlopen(req, timeout=300) as r:
|
|
223
|
+
_check(json.loads(r.read().decode("utf-8", "replace")), "complete")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def cmd_whoami() -> None:
|
|
227
|
+
token, app_id = _creds()
|
|
228
|
+
data = _check(_api("/openapi/user_info", {"app_id": app_id, "access_token": token}), "user_info")
|
|
229
|
+
out(data.get("user_info") or {})
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def cmd_works(limit: int, cursor: str | None) -> None:
|
|
233
|
+
token, app_id = _creds()
|
|
234
|
+
params = {"app_id": app_id, "access_token": token, "count": max(1, min(limit, 200))}
|
|
235
|
+
if cursor:
|
|
236
|
+
params["cursor"] = cursor
|
|
237
|
+
data = _check(_api("/openapi/photo/list", params), "photo/list")
|
|
238
|
+
out({"video_list": data.get("video_list") or []})
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def cmd_work(photo_id: str) -> None:
|
|
242
|
+
token, app_id = _creds()
|
|
243
|
+
data = _check(
|
|
244
|
+
_api("/openapi/photo/info", {"app_id": app_id, "access_token": token, "photo_id": photo_id}), "photo/info"
|
|
245
|
+
)
|
|
246
|
+
out(data.get("video_info") or {})
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def cmd_counts() -> None:
|
|
250
|
+
token, app_id = _creds()
|
|
251
|
+
data = _check(_api("/openapi/photo/count", {"app_id": app_id, "access_token": token}), "photo/count")
|
|
252
|
+
data.pop("result", None)
|
|
253
|
+
out(data)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def cmd_publish(args) -> None:
|
|
257
|
+
token, app_id = _creds()
|
|
258
|
+
if not args.caption:
|
|
259
|
+
die("--caption is required (快手 requires a title)")
|
|
260
|
+
if not (args.cover_url or args.cover_file):
|
|
261
|
+
die("--cover-url or --cover-file is required (快手 requires a cover image)")
|
|
262
|
+
if not (args.video_url or args.video_file):
|
|
263
|
+
die("--video-url or --video-file is required")
|
|
264
|
+
|
|
265
|
+
if not CONFIRM:
|
|
266
|
+
out(
|
|
267
|
+
{
|
|
268
|
+
"dry_run": True,
|
|
269
|
+
"caption": args.caption,
|
|
270
|
+
"video": args.video_url or args.video_file,
|
|
271
|
+
"cover": args.cover_url or args.cover_file,
|
|
272
|
+
"note": (
|
|
273
|
+
"Nothing was published. Confirm the content is the user's own original work — "
|
|
274
|
+
"快手 bans accounts that publish non-original video. Re-run with --confirm as the LAST argument."
|
|
275
|
+
),
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
tmp = None
|
|
281
|
+
try:
|
|
282
|
+
cover = _read_media(args.cover_url, args.cover_file, MAX_COVER_BYTES, "cover")
|
|
283
|
+
if args.video_url:
|
|
284
|
+
tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
|
|
285
|
+
size = _download_to(args.video_url, MAX_VIDEO_BYTES, tmp)
|
|
286
|
+
tmp.close()
|
|
287
|
+
video_path = tmp.name
|
|
288
|
+
else:
|
|
289
|
+
video_path = args.video_file
|
|
290
|
+
size = os.path.getsize(video_path)
|
|
291
|
+
if size > MAX_VIDEO_BYTES:
|
|
292
|
+
raise RuntimeError(f"video exceeds {MAX_VIDEO_BYTES} bytes")
|
|
293
|
+
|
|
294
|
+
started = _check(
|
|
295
|
+
_api("/openapi/photo/start_upload", {"app_id": app_id, "access_token": token}, method="POST"),
|
|
296
|
+
"start_upload",
|
|
297
|
+
)
|
|
298
|
+
upload_token = started["upload_token"]
|
|
299
|
+
_upload_binary(started["endpoint"], upload_token, video_path, size)
|
|
300
|
+
|
|
301
|
+
fields = {"caption": args.caption}
|
|
302
|
+
if args.stereo_type:
|
|
303
|
+
fields["stereo_type"] = args.stereo_type
|
|
304
|
+
if args.product_id:
|
|
305
|
+
fields["merchant_product_id"] = args.product_id
|
|
306
|
+
body, ctype = _multipart(fields, {"cover": ("cover.jpg", cover)})
|
|
307
|
+
published = _check(
|
|
308
|
+
_api(
|
|
309
|
+
"/openapi/photo/publish",
|
|
310
|
+
{"app_id": app_id, "access_token": token, "upload_token": upload_token},
|
|
311
|
+
method="POST",
|
|
312
|
+
body=body,
|
|
313
|
+
ctype=ctype,
|
|
314
|
+
),
|
|
315
|
+
"photo/publish",
|
|
316
|
+
)
|
|
317
|
+
except (RuntimeError, OSError) as e:
|
|
318
|
+
die(_redact(str(e)))
|
|
319
|
+
finally:
|
|
320
|
+
if tmp is not None:
|
|
321
|
+
try:
|
|
322
|
+
os.unlink(tmp.name)
|
|
323
|
+
except OSError:
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
info = published.get("video_info") or {}
|
|
327
|
+
photo_id = info.get("photo_id")
|
|
328
|
+
out(
|
|
329
|
+
{
|
|
330
|
+
"published": True,
|
|
331
|
+
"photo_id": photo_id,
|
|
332
|
+
"caption": info.get("caption"),
|
|
333
|
+
"url": f"https://www.kuaishou.com/short-video/{photo_id}" if photo_id else None,
|
|
334
|
+
"pending": info.get("pending"),
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def main() -> None:
|
|
340
|
+
p = argparse.ArgumentParser(prog="kuaishou", description="快手 open-platform client")
|
|
341
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
342
|
+
|
|
343
|
+
sub.add_parser("whoami")
|
|
344
|
+
sub.add_parser("counts")
|
|
345
|
+
|
|
346
|
+
w = sub.add_parser("works")
|
|
347
|
+
w.add_argument("--limit", type=int, default=20)
|
|
348
|
+
w.add_argument("--cursor")
|
|
349
|
+
|
|
350
|
+
one = sub.add_parser("work")
|
|
351
|
+
one.add_argument("photo_id")
|
|
352
|
+
|
|
353
|
+
pub = sub.add_parser("publish")
|
|
354
|
+
pub.add_argument("--caption", required=False)
|
|
355
|
+
pub.add_argument("--video-url")
|
|
356
|
+
pub.add_argument("--video-file")
|
|
357
|
+
pub.add_argument("--cover-url")
|
|
358
|
+
pub.add_argument("--cover-file")
|
|
359
|
+
pub.add_argument("--stereo-type", choices=["NOT_SPHERICAL_VIDEO", "SPHERICAL_VIDEO_360", "SPHERICAL_VIDEO_180"])
|
|
360
|
+
pub.add_argument("--product-id")
|
|
361
|
+
|
|
362
|
+
args = p.parse_args(ARGV)
|
|
363
|
+
if args.cmd == "whoami":
|
|
364
|
+
cmd_whoami()
|
|
365
|
+
elif args.cmd == "counts":
|
|
366
|
+
cmd_counts()
|
|
367
|
+
elif args.cmd == "works":
|
|
368
|
+
cmd_works(args.limit, args.cursor)
|
|
369
|
+
elif args.cmd == "work":
|
|
370
|
+
cmd_work(args.photo_id)
|
|
371
|
+
elif args.cmd == "publish":
|
|
372
|
+
cmd_publish(args)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
if __name__ == "__main__":
|
|
376
|
+
main()
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Guards for the kuaishou skill script — no network, stdlib only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import importlib.util
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import unittest
|
|
14
|
+
import unittest.mock
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "kuaishou.py"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load(argv):
|
|
21
|
+
"""Import the script fresh with a given argv so module-level CONFIRM re-evaluates."""
|
|
22
|
+
old = sys.argv
|
|
23
|
+
sys.argv = ["kuaishou.py", *argv]
|
|
24
|
+
try:
|
|
25
|
+
spec = importlib.util.spec_from_file_location(f"ks_{len(argv)}_{abs(hash(tuple(argv)))}", SCRIPT)
|
|
26
|
+
mod = importlib.util.module_from_spec(spec)
|
|
27
|
+
spec.loader.exec_module(mod)
|
|
28
|
+
return mod
|
|
29
|
+
finally:
|
|
30
|
+
sys.argv = old
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _run(args, env=None):
|
|
34
|
+
e = {**os.environ, "KUAISHOU_TOKEN": "tok", "KUAISHOU_APP_ID": "aid", **(env or {})}
|
|
35
|
+
return subprocess.run([sys.executable, str(SCRIPT), *args], capture_output=True, text=True, env=e)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestConfirmGate(unittest.TestCase):
|
|
39
|
+
def test_confirm_only_honored_as_last_argument(self):
|
|
40
|
+
self.assertTrue(_load(["publish", "--caption", "t", "--confirm"]).CONFIRM)
|
|
41
|
+
# A caption that merely contains the flag must NOT self-authorize.
|
|
42
|
+
mod = _load(["publish", "--caption", "--confirm", "--video-url", "u"])
|
|
43
|
+
self.assertFalse(mod.CONFIRM)
|
|
44
|
+
|
|
45
|
+
def test_publish_without_confirm_is_a_dry_run(self):
|
|
46
|
+
r = _run(
|
|
47
|
+
[
|
|
48
|
+
"publish",
|
|
49
|
+
"--caption",
|
|
50
|
+
"标题",
|
|
51
|
+
"--video-url",
|
|
52
|
+
"https://cdn.acedata.cloud/a.mp4",
|
|
53
|
+
"--cover-url",
|
|
54
|
+
"https://cdn.acedata.cloud/a.jpg",
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
self.assertEqual(r.returncode, 0)
|
|
58
|
+
self.assertTrue(json.loads(r.stdout)["dry_run"])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestRequiredInputs(unittest.TestCase):
|
|
62
|
+
def test_missing_token_fails_clearly(self):
|
|
63
|
+
r = _run(["whoami"], env={"KUAISHOU_TOKEN": ""})
|
|
64
|
+
self.assertEqual(r.returncode, 1)
|
|
65
|
+
self.assertIn("KUAISHOU_TOKEN", json.loads(r.stdout)["error"])
|
|
66
|
+
|
|
67
|
+
def test_missing_app_id_fails_clearly(self):
|
|
68
|
+
r = _run(["whoami"], env={"KUAISHOU_APP_ID": ""})
|
|
69
|
+
self.assertEqual(r.returncode, 1)
|
|
70
|
+
self.assertIn("KUAISHOU_APP_ID", json.loads(r.stdout)["error"])
|
|
71
|
+
|
|
72
|
+
def test_cover_is_required(self):
|
|
73
|
+
r = _run(["publish", "--caption", "t", "--video-url", "https://cdn.acedata.cloud/a.mp4"])
|
|
74
|
+
self.assertEqual(r.returncode, 1)
|
|
75
|
+
self.assertIn("cover", json.loads(r.stdout)["error"])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestUpstreamSemantics(unittest.TestCase):
|
|
79
|
+
def test_result_not_one_is_a_failure_despite_http_200(self):
|
|
80
|
+
mod = _load(["whoami"])
|
|
81
|
+
with self.assertRaises(RuntimeError):
|
|
82
|
+
mod._check({"result": 100200108, "error_msg": "expired"}, "user_info")
|
|
83
|
+
self.assertEqual(mod._check({"result": 1, "x": 2}, "ok")["x"], 2)
|
|
84
|
+
|
|
85
|
+
def test_private_media_hosts_are_blocked(self):
|
|
86
|
+
mod = _load(["whoami"])
|
|
87
|
+
for url in (
|
|
88
|
+
"http://127.0.0.1/a.mp4",
|
|
89
|
+
"http://localhost/a.mp4",
|
|
90
|
+
"file:///etc/passwd",
|
|
91
|
+
"http://2130706433/a.mp4", # decimal
|
|
92
|
+
"http://0x7f000001/a.mp4", # hex
|
|
93
|
+
"http://[::1]/a.mp4", # IPv6 loopback
|
|
94
|
+
"http://169.254.169.254/latest/meta-data/", # cloud metadata
|
|
95
|
+
# Allowlisted-looking but not actually ours (DNS rebinding vector).
|
|
96
|
+
"https://evil.example/a.mp4",
|
|
97
|
+
"https://cdn.acedata.cloud.evil.example/a.mp4",
|
|
98
|
+
):
|
|
99
|
+
with self.assertRaises(RuntimeError, msg=url):
|
|
100
|
+
mod._assert_public_url(url)
|
|
101
|
+
|
|
102
|
+
def test_own_cdn_is_allowed(self):
|
|
103
|
+
mod = _load(["whoami"])
|
|
104
|
+
# Mocked so the suite stays offline-safe.
|
|
105
|
+
with unittest.mock.patch.object(
|
|
106
|
+
mod.socket, "getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]
|
|
107
|
+
):
|
|
108
|
+
mod._assert_public_url("https://cdn.acedata.cloud/x.mp4") # must not raise
|
|
109
|
+
|
|
110
|
+
def test_credentials_are_redacted_from_errors(self):
|
|
111
|
+
mod = _load(["whoami"])
|
|
112
|
+
leaked = '{"error_msg":"bad","access_token":"secret-abc","upload_token":"up-xyz"}'
|
|
113
|
+
red = mod._redact(leaked)
|
|
114
|
+
self.assertNotIn("secret-abc", red)
|
|
115
|
+
self.assertNotIn("up-xyz", red)
|
|
116
|
+
with self.assertRaises(RuntimeError) as cm:
|
|
117
|
+
mod._check({"result": 2, "access_token": "secret-abc"}, "publish")
|
|
118
|
+
self.assertNotIn("secret-abc", str(cm.exception))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class _FakeResp:
|
|
122
|
+
def __init__(self, payload):
|
|
123
|
+
self._b = json.dumps(payload).encode()
|
|
124
|
+
|
|
125
|
+
def read(self, *a):
|
|
126
|
+
return self._b
|
|
127
|
+
|
|
128
|
+
def __enter__(self):
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
def __exit__(self, *a):
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TestHttpContract(unittest.TestCase):
|
|
136
|
+
"""Assert the actual requests, so a wrong param/verb can't pass silently."""
|
|
137
|
+
|
|
138
|
+
def _capture(self, mod, payload=None):
|
|
139
|
+
calls = []
|
|
140
|
+
|
|
141
|
+
def fake(req, timeout=None):
|
|
142
|
+
calls.append(req)
|
|
143
|
+
return _FakeResp(payload if payload is not None else {"result": 1})
|
|
144
|
+
|
|
145
|
+
return calls, fake
|
|
146
|
+
|
|
147
|
+
def test_direct_upload_posts_raw_bytes_with_token_in_query(self):
|
|
148
|
+
mod = _load(["whoami"])
|
|
149
|
+
calls, fake = self._capture(mod)
|
|
150
|
+
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as fh:
|
|
151
|
+
fh.write(b"v" * 1024)
|
|
152
|
+
path = fh.name
|
|
153
|
+
try:
|
|
154
|
+
with unittest.mock.patch.object(mod.urllib.request, "urlopen", fake):
|
|
155
|
+
mod._upload_binary("up.example.com", "utok", path, 1024)
|
|
156
|
+
finally:
|
|
157
|
+
os.unlink(path)
|
|
158
|
+
self.assertEqual(len(calls), 1)
|
|
159
|
+
req = calls[0]
|
|
160
|
+
self.assertEqual(req.get_method(), "POST")
|
|
161
|
+
self.assertTrue(req.full_url.startswith("http://up.example.com/api/upload?"))
|
|
162
|
+
self.assertIn("upload_token=utok", req.full_url)
|
|
163
|
+
self.assertEqual(req.data, b"v" * 1024) # raw body, not multipart
|
|
164
|
+
|
|
165
|
+
def test_fragmented_upload_ids_start_at_zero_then_completes(self):
|
|
166
|
+
mod = _load(["whoami"])
|
|
167
|
+
calls, fake = self._capture(mod)
|
|
168
|
+
size = mod.CHUNK * 2 + 7
|
|
169
|
+
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as fh:
|
|
170
|
+
fh.write(b"v" * size)
|
|
171
|
+
path = fh.name
|
|
172
|
+
try:
|
|
173
|
+
with unittest.mock.patch.object(mod.urllib.request, "urlopen", fake):
|
|
174
|
+
mod._upload_binary("up.example.com", "utok", path, size)
|
|
175
|
+
finally:
|
|
176
|
+
os.unlink(path)
|
|
177
|
+
urls = [c.full_url for c in calls]
|
|
178
|
+
self.assertEqual(len(urls), 4) # 3 fragments + complete
|
|
179
|
+
self.assertIn("fragment_id=0", urls[0])
|
|
180
|
+
self.assertIn("fragment_id=2", urls[2])
|
|
181
|
+
self.assertIn("fragment_count=3", urls[3])
|
|
182
|
+
self.assertTrue(urls[3].startswith("http://up.example.com/api/upload/complete?"))
|
|
183
|
+
# EVERY fragment carries at most one chunk, never the whole remainder.
|
|
184
|
+
frags = [c for c in calls if "/api/upload/fragment?" in c.full_url]
|
|
185
|
+
self.assertEqual(len(frags), 3)
|
|
186
|
+
for i, f in enumerate(frags):
|
|
187
|
+
self.assertLessEqual(len(f.data), mod.CHUNK, f"fragment {i} too big")
|
|
188
|
+
self.assertEqual(sum(len(f.data) for f in frags), size)
|
|
189
|
+
|
|
190
|
+
def test_publish_sends_upload_token_in_query_and_cover_in_multipart(self):
|
|
191
|
+
mod = _load(["whoami"])
|
|
192
|
+
calls = []
|
|
193
|
+
|
|
194
|
+
def fake(req, timeout=None):
|
|
195
|
+
calls.append(req)
|
|
196
|
+
if "start_upload" in req.full_url:
|
|
197
|
+
return _FakeResp({"result": 1, "upload_token": "utok", "endpoint": "up.example.com"})
|
|
198
|
+
if "photo/publish" in req.full_url:
|
|
199
|
+
return _FakeResp({"result": 1, "video_info": {"photo_id": "pid", "caption": "标题"}})
|
|
200
|
+
return _FakeResp({"result": 1})
|
|
201
|
+
|
|
202
|
+
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as v:
|
|
203
|
+
v.write(b"v" * 512)
|
|
204
|
+
vpath = v.name
|
|
205
|
+
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as c:
|
|
206
|
+
c.write(b"c" * 32)
|
|
207
|
+
cpath = c.name
|
|
208
|
+
try:
|
|
209
|
+
with unittest.mock.patch.object(mod.urllib.request, "urlopen", fake), unittest.mock.patch.dict(
|
|
210
|
+
os.environ, {"KUAISHOU_TOKEN": "tok", "KUAISHOU_APP_ID": "aid"}
|
|
211
|
+
):
|
|
212
|
+
args = mod.argparse.Namespace(
|
|
213
|
+
caption="标题",
|
|
214
|
+
video_url=None,
|
|
215
|
+
video_file=vpath,
|
|
216
|
+
cover_url=None,
|
|
217
|
+
cover_file=cpath,
|
|
218
|
+
stereo_type=None,
|
|
219
|
+
product_id=None,
|
|
220
|
+
)
|
|
221
|
+
mod.CONFIRM = True
|
|
222
|
+
buf = io.StringIO()
|
|
223
|
+
with contextlib.redirect_stdout(buf):
|
|
224
|
+
mod.cmd_publish(args)
|
|
225
|
+
result = json.loads(buf.getvalue())
|
|
226
|
+
finally:
|
|
227
|
+
os.unlink(vpath)
|
|
228
|
+
os.unlink(cpath)
|
|
229
|
+
|
|
230
|
+
self.assertTrue(result["published"])
|
|
231
|
+
self.assertEqual(result["photo_id"], "pid")
|
|
232
|
+
pub = [c for c in calls if "photo/publish" in c.full_url][0]
|
|
233
|
+
self.assertEqual(pub.get_method(), "POST")
|
|
234
|
+
# upload_token rides the query string; cover/caption ride the body.
|
|
235
|
+
self.assertIn("upload_token=utok", pub.full_url)
|
|
236
|
+
self.assertIn("access_token=tok", pub.full_url)
|
|
237
|
+
self.assertTrue(pub.headers["Content-type"].startswith("multipart/form-data; boundary="))
|
|
238
|
+
self.assertIn(b'name="cover"; filename="cover.jpg"', pub.data)
|
|
239
|
+
self.assertIn("标题".encode(), pub.data)
|
|
240
|
+
start = [c for c in calls if "start_upload" in c.full_url][0]
|
|
241
|
+
self.assertEqual(start.get_method(), "POST")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__":
|
|
245
|
+
unittest.main()
|
|
@@ -9,11 +9,11 @@ when_to_use: |
|
|
|
9
9
|
This skill is for self-hosted WordPress (Application Password auth),
|
|
10
10
|
not WordPress.com.
|
|
11
11
|
connections: [wordpress]
|
|
12
|
-
allowed_tools: [Bash]
|
|
12
|
+
allowed_tools: [Bash, publish_artifact]
|
|
13
13
|
license: Apache-2.0
|
|
14
14
|
metadata:
|
|
15
15
|
author: acedatacloud
|
|
16
|
-
version: "1.
|
|
16
|
+
version: "1.1"
|
|
17
17
|
---
|
|
18
18
|
|
|
19
19
|
Drive the **WordPress REST API** (`/wp-json/wp/v2`) with `curl + jq`.
|
|
@@ -36,27 +36,54 @@ API="$SITE/wp-json/wp/v2"
|
|
|
36
36
|
WP=(curl -sS --fail-with-body -u "$WORDPRESS_USERNAME:$WORDPRESS_APP_PASSWORD")
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
> **Don't add `-L`/`--location` to these calls.** A redirect usually means the
|
|
40
|
+
> site URL is wrong. curl strips the `Authorization` header when a redirect
|
|
41
|
+
> crosses host or scheme (e.g. `http`→`https`, apex→`www`) — exactly the cases
|
|
42
|
+
> that occur here — so the followed request runs **unauthenticated** and can
|
|
43
|
+
> return the site's HTML page with HTTP 200: it looks like success but wrote
|
|
44
|
+
> nothing. Fix the URL instead (see the `/wp-json` gotcha below).
|
|
45
|
+
|
|
46
|
+
|
|
39
47
|
Errors come back as `{"code": "...", "message": "...", "data": {"status": 401}}` —
|
|
40
48
|
show `message` verbatim. Common codes:
|
|
41
49
|
|
|
42
50
|
| HTTP | Meaning | What to tell the user |
|
|
43
51
|
|------|---------|-----------------------|
|
|
44
52
|
| 401 | `incorrect_password` / bad Basic auth | Application Password wrong or revoked → regenerate it and reconnect the WordPress connector |
|
|
53
|
+
| 401 | `rest_not_logged_in` on a plain-`http://` site | Application Passwords are disabled without HTTPS → the user must enable HTTPS |
|
|
45
54
|
| 403 | `rest_cannot_create` / insufficient role | The user's role can't publish; needs Author/Editor/Admin, or Application Passwords are disabled on the site |
|
|
46
55
|
| 404 | `rest_no_route` | REST API disabled or a security plugin blocks `/wp-json` → the user must re-enable it |
|
|
47
56
|
| 400 | `rest_invalid_param` | Bad field (e.g. unknown category id) → fix and retry |
|
|
57
|
+
| 500 | `rest_upload_sideload_error` | `wp-content/uploads` isn't writable by the web server → the user must fix directory permissions |
|
|
58
|
+
|
|
59
|
+
> **`content` is HTML, not Markdown.** Raw Markdown renders literally. `pandoc` is
|
|
60
|
+
> **not installed** — convert with Python's `markdown` package (preinstalled in the
|
|
61
|
+
> sandbox). If the import ever fails, `pip install markdown` first, then:
|
|
62
|
+
>
|
|
63
|
+
> ```bash
|
|
64
|
+
> HTML=$(python3 -c "
|
|
65
|
+
> import sys, markdown
|
|
66
|
+
> print(markdown.markdown(sys.stdin.read(), extensions=['fenced_code','tables']))
|
|
67
|
+
> " <<'MD'
|
|
68
|
+
> ## 标题
|
|
69
|
+
>
|
|
70
|
+
> 正文 **粗体**
|
|
71
|
+
> MD
|
|
72
|
+
> )
|
|
73
|
+
> ```
|
|
48
74
|
|
|
49
|
-
> **`content` is HTML, not Markdown.** Convert Markdown to HTML first
|
|
50
|
-
> (`pandoc -f markdown -t html`, or a simple converter). Raw Markdown renders literally.
|
|
51
75
|
|
|
52
76
|
## Step 0 — verify the connection first
|
|
53
77
|
|
|
54
78
|
```bash
|
|
55
|
-
"${WP[@]}" "$API/users/me" | jq '{id, name, slug, roles
|
|
79
|
+
"${WP[@]}" "$API/users/me?context=edit" | jq '{id, name, slug, roles}'
|
|
56
80
|
```
|
|
57
81
|
|
|
58
|
-
|
|
59
|
-
|
|
82
|
+
`context=edit` is required — without it WP omits `roles`/`capabilities` entirely,
|
|
83
|
+
so you cannot tell whether the account may publish. A 200 with `roles` containing
|
|
84
|
+
`administrator`, `editor` or `author` confirms the site URL, username, Application
|
|
85
|
+
Password **and** publish permission. If this fails, stop and surface the error —
|
|
86
|
+
don't attempt writes.
|
|
60
87
|
|
|
61
88
|
## Publish or draft a post
|
|
62
89
|
|
|
@@ -87,6 +114,62 @@ jq -n --arg t "标题" --arg c "<p>正文</p>" --arg e "一句话摘要" \
|
|
|
87
114
|
- Update a post: `POST $API/posts/<id>` with any subset of fields (WP REST uses
|
|
88
115
|
POST, not PUT, for updates).
|
|
89
116
|
- Delete (trash) a post: `"${WP[@]}" -X DELETE "$API/posts/<id>"`.
|
|
117
|
+
- Schedule a post: `{"status":"future","date_gmt":"2030-01-01T00:00:00"}` (UTC,
|
|
118
|
+
no trailing `Z`).
|
|
119
|
+
|
|
120
|
+
## SEO fields that actually matter
|
|
121
|
+
|
|
122
|
+
WordPress core emits `<link rel="canonical">` on its own but ships **no meta
|
|
123
|
+
description tag at all** — that only appears if the site runs an SEO plugin
|
|
124
|
+
(Yoast, Rank Math, SEOPress) or a theme that renders one. Those consume the
|
|
125
|
+
post's `excerpt`, so setting `excerpt` is what makes a good description possible;
|
|
126
|
+
it does nothing on a bare core install. For an SEO post always set:
|
|
127
|
+
|
|
128
|
+
| Field | Why |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `slug` | The permalink. Set it explicitly to a short ASCII keyword phrase — otherwise a CJK title becomes a percent-encoded URL. |
|
|
131
|
+
| `excerpt` | Source for the SEO plugin's meta description and for list-page summaries. One sentence. |
|
|
132
|
+
| `categories` / `tags` | Internal linking + topic clustering. |
|
|
133
|
+
| `featured_media` | Social/OG card image. |
|
|
134
|
+
| media `alt_text` | Image SEO + accessibility. Set it on the media object (below). |
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
jq -n --arg s "claude-api-guide" --argjson c 3 --argjson m 9 \
|
|
139
|
+
'{title:"如何稳定调用 Claude API:完整对接指南",
|
|
140
|
+
slug:$s,
|
|
141
|
+
content:"<h2>小标题</h2><p>正文</p>",
|
|
142
|
+
excerpt:"一句话摘要,用于 meta description。",
|
|
143
|
+
status:"publish", categories:[$c], featured_media:$m}' \
|
|
144
|
+
| "${WP[@]}" -X POST "$API/posts" -H "Content-Type: application/json" -d @- \
|
|
145
|
+
| jq '{id, status, slug, link}'
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Before publishing: check for a duplicate
|
|
149
|
+
|
|
150
|
+
**WordPress will NOT reject a duplicate slug — it silently appends `-2`,** so an
|
|
151
|
+
unattended/scheduled run that reposts the same article creates an endless trail of
|
|
152
|
+
near-identical URLs that compete with each other in search. Always pre-check:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
SLUG="claude-api-guide"
|
|
156
|
+
# Fail CLOSED at BOTH steps: a failed request, or a 200 that isn't a JSON array
|
|
157
|
+
# (HTML from a redirect/permalink issue, or an error object), must abort — never
|
|
158
|
+
# fall through and create the duplicate this check exists to prevent.
|
|
159
|
+
if ! LOOKUP=$("${WP[@]}" "$API/posts?slug=$SLUG&status=publish,draft,future&_fields=id,link"); then
|
|
160
|
+
echo "duplicate check failed (request error) — aborting" >&2; exit 1
|
|
161
|
+
fi
|
|
162
|
+
if ! EXISTING=$(printf '%s' "$LOOKUP" | jq -er 'if type=="array" then (.[0].id // "") else error("not a JSON array") end'); then
|
|
163
|
+
echo "duplicate check failed (unexpected response) — aborting" >&2; exit 1
|
|
164
|
+
fi
|
|
165
|
+
if [ -n "$EXISTING" ]; then
|
|
166
|
+
echo "already exists as post $EXISTING — update it instead of creating a new one"
|
|
167
|
+
# update: "${WP[@]}" -X POST "$API/posts/$EXISTING" ...
|
|
168
|
+
fi
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Prefer **updating** the existing post over creating a near-duplicate. This matters
|
|
172
|
+
most in Scheduled Tasks, where nobody is watching the output.
|
|
90
173
|
|
|
91
174
|
## List / read posts
|
|
92
175
|
|
|
@@ -125,22 +208,40 @@ MEDIA_ID=$("${WP[@]}" -X POST "$API/media" \
|
|
|
125
208
|
-H "Content-Type: image/png" \
|
|
126
209
|
--data-binary @"$FILE" | jq -r '.id')
|
|
127
210
|
echo "media id=$MEDIA_ID"
|
|
211
|
+
|
|
212
|
+
# Set alt text (image SEO + accessibility) — a separate call on the media object.
|
|
213
|
+
jq -n --arg a "Claude API 架构图" '{alt_text:$a}' \
|
|
214
|
+
| "${WP[@]}" -X POST "$API/media/$MEDIA_ID" \
|
|
215
|
+
-H "Content-Type: application/json" -d @- | jq '{id, alt_text}'
|
|
216
|
+
|
|
128
217
|
# Attach as the post's featured image:
|
|
129
218
|
# add "featured_media": <MEDIA_ID> to the post body.
|
|
130
219
|
```
|
|
131
220
|
|
|
132
221
|
## Gotchas
|
|
133
222
|
|
|
134
|
-
- **HTTPS
|
|
135
|
-
|
|
223
|
+
- **HTTPS is effectively required for Application Passwords.** WP gates them on
|
|
224
|
+
`wp_is_application_passwords_available()`, which is false on a plain-`http://`
|
|
225
|
+
production site → authenticated calls fail with `rest_not_logged_in` (401). (The
|
|
226
|
+
gate is filterable and is bypassed when `WP_ENVIRONMENT_TYPE` is `local`, so a
|
|
227
|
+
dev box may still work — but any real site the user connects must serve HTTPS.)
|
|
228
|
+
- **`$WORDPRESS_SITE_URL` must exactly match the site's configured address.** If
|
|
229
|
+
it differs (missing/extra `www`, `http` vs `https`), WP answers `/wp-json/...`
|
|
230
|
+
with a **301** to the canonical host. Fix the stored site URL in the connector
|
|
231
|
+
rather than papering over it with `-L`.
|
|
232
|
+
|
|
136
233
|
- **A security plugin / host may block `/wp-json`** (Wordfence, "disable REST
|
|
137
234
|
API" plugins, some managed hosts). Symptom: 404 `rest_no_route` or an HTML
|
|
138
235
|
login page instead of JSON. The user must allow REST API access.
|
|
236
|
+
- **Pretty permalinks may be off.** If `/wp-json/wp/v2/...` returns the site's
|
|
237
|
+
HTML instead of JSON, the site is on plain permalinks — use the always-available
|
|
238
|
+
query form instead: `$SITE/?rest_route=/wp/v2/posts`.
|
|
139
239
|
- **The Application Password contains spaces** (e.g. `abcd efgh ijkl mnop`).
|
|
140
240
|
Keep them — `curl -u` handles the spaces fine; don't strip them.
|
|
141
241
|
- **Never publish silently.** Even if the user says "post it", prefer creating a
|
|
142
242
|
draft and returning the `wp-admin` edit link unless they explicitly asked to
|
|
143
|
-
go live.
|
|
243
|
+
go live. (In an unattended Scheduled Task the user has pre-authorized the run,
|
|
244
|
+
so publishing directly is expected there — but still run the duplicate check.)
|
|
144
245
|
|
|
145
246
|
|
|
146
247
|
## Record the output
|