@acedatacloud/skills 2026.726.2 → 2026.726.3

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.2",
3
+ "version": "2026.726.3",
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()