@acedatacloud/skills 2026.726.1 → 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 +1 -1
- package/skills/kuaishou/SKILL.md +92 -0
- package/skills/kuaishou/scripts/kuaishou.py +376 -0
- package/skills/kuaishou/tests/test_kuaishou.py +245 -0
- package/skills/reddit/SKILL.md +6 -1
- package/skills/reddit/scripts/reddit.py +3 -3
- package/skills/reddit/tests/test_reddit.py +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.726.
|
|
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()
|
package/skills/reddit/SKILL.md
CHANGED
|
@@ -65,10 +65,15 @@ R="${SKILL_DIR:-}/scripts/reddit.py"; [ -f "$R" ] || R=$(find /tmp -maxdepth 8 -
|
|
|
65
65
|
[ -f "$R" ] || { echo "reddit script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
|
|
66
66
|
|
|
67
67
|
python3 "$R" search --query "suno api" --time week --limit 10
|
|
68
|
-
python3 "$R" search --query "image generation api" --subreddit SideProject
|
|
68
|
+
python3 "$R" search --query "image generation api" --subreddit SideProject
|
|
69
69
|
python3 "$R" subreddit-info --subreddit SideProject
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
`--sort` defaults to `relevance`. **Reddit's `new` sort ignores keyword relevance**
|
|
73
|
+
— it returns recent posts that often do not contain the query terms at all. Use
|
|
74
|
+
`--sort new` only to scan a specific subreddit's recent activity, never to find
|
|
75
|
+
threads by keyword.
|
|
76
|
+
|
|
72
77
|
## Reply to a thread — GATED
|
|
73
78
|
|
|
74
79
|
`--parent` accepts a fullname (`t3_…` post, `t1_…` comment) or a reddit.com
|
|
@@ -474,7 +474,7 @@ class RedditClient:
|
|
|
474
474
|
return {"ok": True, "posted": True, "id": post.get("id"), "name": post.get("name"), "url": post_url}
|
|
475
475
|
|
|
476
476
|
|
|
477
|
-
def search(self, *, query: str, subreddit: str = "", sort: str = "
|
|
477
|
+
def search(self, *, query: str, subreddit: str = "", sort: str = "relevance", limit: int = 10, time_filter: str = "month") -> list[dict]:
|
|
478
478
|
suffix = ".json" if self.mode == "cookie" else ""
|
|
479
479
|
path = f"/r/{subreddit}/search{suffix}" if subreddit else f"/search{suffix}"
|
|
480
480
|
params = {"q": query, "sort": sort, "limit": limit, "t": time_filter, "raw_json": 1, "type": "link"}
|
|
@@ -677,8 +677,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
677
677
|
search = commands.add_parser("search", help="search public posts to find threads worth replying to")
|
|
678
678
|
search.add_argument("--query", "-q", required=True)
|
|
679
679
|
search.add_argument("--subreddit", "-r", default="")
|
|
680
|
-
search.add_argument("--sort", choices=["
|
|
681
|
-
search.add_argument("--time", dest="time_filter", choices=["hour", "day", "week", "month", "year", "all"], default="
|
|
680
|
+
search.add_argument("--sort", choices=["relevance", "new", "top", "comments"], default="relevance")
|
|
681
|
+
search.add_argument("--time", dest="time_filter", choices=["hour", "day", "week", "month", "year", "all"], default="month")
|
|
682
682
|
search.add_argument("--limit", type=positive_limit, default=10)
|
|
683
683
|
|
|
684
684
|
about = commands.add_parser("subreddit-info", help="read a subreddit's rules and posting requirements")
|
|
@@ -512,6 +512,20 @@ class RedditSkillTests(unittest.TestCase):
|
|
|
512
512
|
self.assertEqual("https://www.reddit.com/r/SunoAI/comments/p1/looking/", formatted["url"])
|
|
513
513
|
self.assertEqual(500, len(formatted["selftext_excerpt"]))
|
|
514
514
|
|
|
515
|
+
def test_search_defaults_to_relevance_because_new_ignores_keywords(self):
|
|
516
|
+
"""Reddit's `new` sort returns recent posts that need not match the query."""
|
|
517
|
+
client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
|
|
518
|
+
with patch.object(client, "request", return_value={"data": {"children": []}}) as request:
|
|
519
|
+
client.search(query="suno api")
|
|
520
|
+
self.assertEqual("relevance", request.call_args.kwargs["query"]["sort"])
|
|
521
|
+
|
|
522
|
+
stream = io.StringIO()
|
|
523
|
+
with patch.dict(os.environ, {"REDDIT_COOKIES": COOKIE_JAR}, clear=True), patch.object(
|
|
524
|
+
reddit.RedditClient, "search", return_value=[]
|
|
525
|
+
) as search, redirect_stdout(stream):
|
|
526
|
+
reddit.main(["search", "--query", "suno api"])
|
|
527
|
+
self.assertEqual("relevance", search.call_args.kwargs["sort"])
|
|
528
|
+
|
|
515
529
|
def test_search_is_a_read_and_rejects_malformed_shape(self):
|
|
516
530
|
client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
|
|
517
531
|
stream = io.StringIO()
|