@acedatacloud/skills 2026.726.0 → 2026.726.2
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/oschina/SKILL.md +85 -0
- package/skills/oschina/scripts/oschina.py +287 -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/skills/segmentfault/SKILL.md +85 -0
- package/skills/segmentfault/scripts/segmentfault.py +302 -0
- package/skills/yuque/SKILL.md +104 -0
- package/skills/yuque/scripts/yuque.py +311 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.726.
|
|
3
|
+
"version": "2026.726.2",
|
|
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,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: oschina
|
|
3
|
+
description: Read the connected 开源中国 / OSChina (my.oschina.net) account and create Markdown blog drafts with the user's own login cookies (BYOC). Use when the user mentions 开源中国 / OSChina, wants to save a 开源中国 draft, or asks who their connected 开源中国 account is.
|
|
4
|
+
when_to_use: |
|
|
5
|
+
Trigger for the user's 开源中国 (my.oschina.net) account driven by their own
|
|
6
|
+
login cookie: show the connected account, or turn Markdown into a 开源中国
|
|
7
|
+
blog draft. 开源中国 exposes no publish API, so this skill stops at a draft
|
|
8
|
+
and hands the user the editor URL. Writes are gated behind explicit
|
|
9
|
+
confirmation.
|
|
10
|
+
connections: [oschina]
|
|
11
|
+
allowed_tools: [Bash]
|
|
12
|
+
license: Apache-2.0
|
|
13
|
+
metadata:
|
|
14
|
+
author: acedatacloud
|
|
15
|
+
version: "1.0"
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# oschina — read & draft on 开源中国 via your own cookies
|
|
19
|
+
|
|
20
|
+
Drives the user's **real** 开源中国 account through the same
|
|
21
|
+
`apiv1.oschina.net` endpoints the site uses, authenticated by the login cookie
|
|
22
|
+
they captured with the ACE extension. No browser, no third-party deps — just
|
|
23
|
+
`urllib`.
|
|
24
|
+
|
|
25
|
+
The connector injects the cookie jar as a JSON env var `$OSCHINA_COOKIES`.
|
|
26
|
+
Never print it.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python3 "$SKILL_DIR/scripts/oschina.py" whoami
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
If `$SKILL_DIR` points at a different skill loaded in the same turn, resolve
|
|
33
|
+
this skill's directory explicitly before running the commands below.
|
|
34
|
+
|
|
35
|
+
## Important: draft only
|
|
36
|
+
|
|
37
|
+
**开源中国 has no public publish API.** Every open-source client for this
|
|
38
|
+
platform (including the upstream adapter this skill is modelled on) stops at
|
|
39
|
+
draft creation. This skill does the same: it saves the Markdown as a draft and
|
|
40
|
+
returns the editor URL. Tell the user plainly that they must open that URL and
|
|
41
|
+
click publish themselves — do not claim the article was published.
|
|
42
|
+
|
|
43
|
+
## Verify the connection first
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
python3 "$SKILL_DIR/scripts/oschina.py" whoami
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If this fails with an auth error, the cookie has expired. Ask the user to
|
|
50
|
+
reconnect at `https://auth.acedata.cloud/user/connections` rather than retrying.
|
|
51
|
+
|
|
52
|
+
## Create a draft — GATED
|
|
53
|
+
|
|
54
|
+
Prepare the complete Markdown in a file. The first call is always a dry run and
|
|
55
|
+
does not write anything.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Dry run — shows exactly what would be written.
|
|
59
|
+
python3 "$SKILL_DIR/scripts/oschina.py" draft \
|
|
60
|
+
--title "标题" --content-file /tmp/article.md
|
|
61
|
+
|
|
62
|
+
# Actually create the draft after the user confirms.
|
|
63
|
+
python3 "$SKILL_DIR/scripts/oschina.py" draft \
|
|
64
|
+
--title "标题" --content-file /tmp/article.md --confirm
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`--confirm` is valid only as the final argument. Show the title and full
|
|
68
|
+
content to the user before writing.
|
|
69
|
+
|
|
70
|
+
## Gotchas
|
|
71
|
+
|
|
72
|
+
- Content is sent as Markdown (`contentType: 1`). Do not pre-render it to HTML.
|
|
73
|
+
- Images referenced by external URL are not re-hosted. If the source host
|
|
74
|
+
blocks hotlinking they will not render; mention this when the article has
|
|
75
|
+
images.
|
|
76
|
+
- `--catalog` takes a 开源中国 catalog id; the default `0` uses the account's
|
|
77
|
+
default catalog. If the API rejects the catalog, ask the user for the right
|
|
78
|
+
id rather than guessing.
|
|
79
|
+
- Do not retry a timed-out write automatically — the outcome may be unknown and
|
|
80
|
+
a retry can create a duplicate draft.
|
|
81
|
+
|
|
82
|
+
## Record the output
|
|
83
|
+
|
|
84
|
+
This skill only produces drafts, so do **not** call `publish_artifact`. Report
|
|
85
|
+
the returned `draft_id` and `edit_url` to the user instead.
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
oschina — read & publish on 开源中国 (my.oschina.net) with the user's own login
|
|
4
|
+
cookies (BYOC). Standard-library only (urllib), no third-party deps, so it runs
|
|
5
|
+
in the bare sandbox without an image change.
|
|
6
|
+
|
|
7
|
+
The connector injects the user's cookie jar as a JSON env var ``OSCHINA_COOKIES``
|
|
8
|
+
— a list of ``{name, value, domain, ...}`` dicts captured by the ACE extension.
|
|
9
|
+
|
|
10
|
+
Read commands run directly. ``publish`` is GATED: without a trailing
|
|
11
|
+
``--confirm`` it only dry-runs. ``--confirm`` is honored ONLY as the last
|
|
12
|
+
argument, so a title/content that merely contains "--confirm" can never silently
|
|
13
|
+
go live.
|
|
14
|
+
|
|
15
|
+
NOTE: 开源中国 exposes no public "publish" API — only draft creation. This CLI
|
|
16
|
+
creates a draft and returns its editor URL; the user finishes publishing in the
|
|
17
|
+
开源中国 editor.
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
python3 oschina.py whoami
|
|
21
|
+
python3 oschina.py draft --title T --content-file a.md --confirm
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import gzip
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import urllib.error
|
|
32
|
+
import urllib.parse
|
|
33
|
+
import urllib.request
|
|
34
|
+
|
|
35
|
+
UA = (
|
|
36
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
37
|
+
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
|
38
|
+
)
|
|
39
|
+
PLATFORM = "oschina"
|
|
40
|
+
API = "https://apiv1.oschina.net/oschinapi"
|
|
41
|
+
ORIGIN = "https://my.oschina.net"
|
|
42
|
+
MAX_CONTENT_BYTES = 10 * 1024 * 1024
|
|
43
|
+
|
|
44
|
+
_RAW = sys.argv[1:]
|
|
45
|
+
CONFIRM = bool(_RAW) and _RAW[-1] == "--confirm"
|
|
46
|
+
ARGV = _RAW[:-1] if CONFIRM else list(_RAW)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
50
|
+
"""Refuse redirects outright.
|
|
51
|
+
|
|
52
|
+
add_unredirected_header only protects the Cookie; urllib copies every other
|
|
53
|
+
header onto the redirected request, so a 30x to a foreign host could hand
|
|
54
|
+
over any auth header we add later.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_OPENER = urllib.request.build_opener(_NoRedirect())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def out(obj) -> None:
|
|
65
|
+
print(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def die(msg: str, code: int = 1) -> None:
|
|
69
|
+
out({"error": msg})
|
|
70
|
+
sys.exit(code)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── Cookie jar (shared pattern across the cookie-BYOC skills) ────────
|
|
74
|
+
|
|
75
|
+
def load_cookies() -> list:
|
|
76
|
+
env = f"{PLATFORM.upper()}_COOKIES"
|
|
77
|
+
raw = os.environ.get(env)
|
|
78
|
+
if not raw:
|
|
79
|
+
die(f"{env} is not set — connect 开源中国 at "
|
|
80
|
+
f"https://auth.acedata.cloud/user/connections, then retry.")
|
|
81
|
+
try:
|
|
82
|
+
jar = json.loads(raw)
|
|
83
|
+
except json.JSONDecodeError as e:
|
|
84
|
+
die(f"{env} is not valid JSON: {e}")
|
|
85
|
+
if not isinstance(jar, list):
|
|
86
|
+
die(f"{env} must be a JSON list of cookies, got {type(jar).__name__}")
|
|
87
|
+
return jar
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _domain_matches(host: str, domain: str) -> bool:
|
|
91
|
+
d = domain.lstrip(".").lower()
|
|
92
|
+
h = host.lower()
|
|
93
|
+
return not d or h == d or h.endswith("." + d)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cookie_header(jar: list, url: str) -> str:
|
|
97
|
+
host = urllib.parse.urlsplit(url).hostname or ""
|
|
98
|
+
host_in_scope = any(
|
|
99
|
+
c.get("domain") and _domain_matches(host, str(c["domain"])) for c in jar
|
|
100
|
+
)
|
|
101
|
+
parts = []
|
|
102
|
+
for c in jar:
|
|
103
|
+
name, value = c.get("name"), c.get("value")
|
|
104
|
+
if not name or value is None:
|
|
105
|
+
continue
|
|
106
|
+
domain = c.get("domain")
|
|
107
|
+
if domain:
|
|
108
|
+
if not _domain_matches(host, str(domain)):
|
|
109
|
+
continue
|
|
110
|
+
elif not host_in_scope:
|
|
111
|
+
continue
|
|
112
|
+
parts.append(f"{name}={value}")
|
|
113
|
+
return "; ".join(parts)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def request(method: str, url: str, jar: list, *, headers=None, body=None, write: bool = False):
|
|
117
|
+
hdrs = {
|
|
118
|
+
"User-Agent": UA,
|
|
119
|
+
"Accept": "*/*",
|
|
120
|
+
"Accept-Language": "zh-CN,zh;q=0.9",
|
|
121
|
+
"Origin": ORIGIN,
|
|
122
|
+
"Referer": f"{ORIGIN}/",
|
|
123
|
+
}
|
|
124
|
+
if headers:
|
|
125
|
+
hdrs.update(headers)
|
|
126
|
+
data = None
|
|
127
|
+
if body is not None:
|
|
128
|
+
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
129
|
+
hdrs.setdefault("Content-Type", "application/json")
|
|
130
|
+
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
|
|
131
|
+
# Unredirected → the cookie is not re-sent if the API 30x-redirects to a
|
|
132
|
+
# different host (e.g. a login page), so the jar never leaks off-site.
|
|
133
|
+
req.add_unredirected_header("Cookie", cookie_header(jar, url))
|
|
134
|
+
try:
|
|
135
|
+
with _OPENER.open(req, timeout=30) as resp:
|
|
136
|
+
raw = resp.read()
|
|
137
|
+
if resp.headers.get("Content-Encoding") == "gzip":
|
|
138
|
+
raw = gzip.decompress(raw)
|
|
139
|
+
return resp.status, raw.decode("utf-8", "replace")
|
|
140
|
+
except urllib.error.HTTPError as e:
|
|
141
|
+
if e.code in (301, 302, 303, 307, 308):
|
|
142
|
+
die(f"开源中国 redirected {method} {url} — not followed, so no "
|
|
143
|
+
f"credential left oschina.net. You are most likely logged out; "
|
|
144
|
+
f"reconnect at https://auth.acedata.cloud/user/connections."
|
|
145
|
+
+ (" This was a WRITE: its outcome is UNKNOWN — check your "
|
|
146
|
+
"drafts before retrying." if write else ""))
|
|
147
|
+
raw = e.read()
|
|
148
|
+
try:
|
|
149
|
+
if e.headers.get("Content-Encoding") == "gzip":
|
|
150
|
+
raw = gzip.decompress(raw)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
return e.code, raw.decode("utf-8", "replace")
|
|
154
|
+
except urllib.error.URLError as e:
|
|
155
|
+
if write:
|
|
156
|
+
die(f"开源中国 write {method} {url} did not return a result "
|
|
157
|
+
f"({e.reason}); the outcome is UNKNOWN. Check your 开源中国 "
|
|
158
|
+
f"drafts before retrying so you do not create a duplicate.")
|
|
159
|
+
die(f"network error reaching {url}: {e.reason}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def api_call(method: str, path: str, jar: list, *, body=None, write: bool = False):
|
|
163
|
+
"""Unwrap the {success, message, code, result} envelope, dying on failure.
|
|
164
|
+
开源中国 returns HTTP 200 even for logical errors."""
|
|
165
|
+
url = f"{API}{path}"
|
|
166
|
+
status, text = request(method, url, jar, body=body, write=write)
|
|
167
|
+
try:
|
|
168
|
+
env = json.loads(text)
|
|
169
|
+
except json.JSONDecodeError:
|
|
170
|
+
if write:
|
|
171
|
+
die(f"开源中国 write to {path} returned a non-JSON response ({status}); "
|
|
172
|
+
f"the outcome is UNKNOWN. Check your drafts before retrying. "
|
|
173
|
+
f"Body: {text[:200]}")
|
|
174
|
+
die(f"non-JSON response ({status}) from {path}: {text[:300]}")
|
|
175
|
+
if not isinstance(env, dict):
|
|
176
|
+
die(f"unexpected response from {path}: {text[:300]}")
|
|
177
|
+
if not env.get("success"):
|
|
178
|
+
msg = str(env.get("message") or "")
|
|
179
|
+
code = env.get("code")
|
|
180
|
+
if code in (40001, 401, 403) or "未登录" in msg or "登录" in msg:
|
|
181
|
+
die(f"auth failed (code={code}: {msg}) — cookie likely expired. "
|
|
182
|
+
f"Reconnect at https://auth.acedata.cloud/user/connections.")
|
|
183
|
+
die(f"开源中国 API error on {path} (code={code}): {msg}")
|
|
184
|
+
return env.get("result")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ── commands ────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
def os_me(jar) -> dict:
|
|
190
|
+
me = api_call("GET", "/user/myDetails", jar)
|
|
191
|
+
if not isinstance(me, dict) or not me.get("userId"):
|
|
192
|
+
die("could not read 开源中国 profile (cookie expired?)")
|
|
193
|
+
return me
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cmd_whoami(jar, _args):
|
|
197
|
+
me = os_me(jar)
|
|
198
|
+
vo = me.get("userVo") or {}
|
|
199
|
+
uid = me.get("userId")
|
|
200
|
+
out({
|
|
201
|
+
"user_id": str(uid),
|
|
202
|
+
"name": vo.get("name"),
|
|
203
|
+
"url": f"{ORIGIN}/u/{uid}",
|
|
204
|
+
"avatar": vo.get("portraitUrl"),
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def read_content(args) -> str:
|
|
209
|
+
content = args.content
|
|
210
|
+
if args.content_file:
|
|
211
|
+
try:
|
|
212
|
+
with open(args.content_file, encoding="utf-8") as f:
|
|
213
|
+
content = f.read()
|
|
214
|
+
except OSError as e:
|
|
215
|
+
die(f"cannot read --content-file: {e}")
|
|
216
|
+
if content is None:
|
|
217
|
+
die("provide --content-file <path.md> or --content <markdown>")
|
|
218
|
+
if len(content.encode("utf-8")) > MAX_CONTENT_BYTES:
|
|
219
|
+
die("content exceeds the 10 MiB safety limit")
|
|
220
|
+
return content
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def cmd_draft(jar, args):
|
|
224
|
+
if not args.title:
|
|
225
|
+
die("--title is required")
|
|
226
|
+
content = read_content(args)
|
|
227
|
+
|
|
228
|
+
if not CONFIRM:
|
|
229
|
+
out({
|
|
230
|
+
"dry_run": True, "command": "draft", "platform": PLATFORM,
|
|
231
|
+
"title": args.title, "catalog": args.catalog,
|
|
232
|
+
"private": not args.public,
|
|
233
|
+
"content_characters": len(content),
|
|
234
|
+
"note": "开源中国 content is Markdown. Re-run with --confirm as the LAST "
|
|
235
|
+
"argument to actually create the draft. 开源中国 exposes no "
|
|
236
|
+
"publish API — finish publishing in the 开源中国 editor.",
|
|
237
|
+
})
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
me = os_me(jar)
|
|
241
|
+
uid = me.get("userId")
|
|
242
|
+
result = api_call("POST", "/api/draft/save_draft", jar, write=True, body={
|
|
243
|
+
"title": args.title,
|
|
244
|
+
"user": int(uid),
|
|
245
|
+
"content": content,
|
|
246
|
+
"contentType": 1, # 1 = Markdown, 2 = HTML
|
|
247
|
+
"catalog": args.catalog,
|
|
248
|
+
"originUrl": "",
|
|
249
|
+
"privacy": not args.public,
|
|
250
|
+
"disableComment": False,
|
|
251
|
+
})
|
|
252
|
+
draft_id = result.get("id") if isinstance(result, dict) else None
|
|
253
|
+
if not draft_id:
|
|
254
|
+
die(f"draft creation failed: {str(result)[:300]}")
|
|
255
|
+
out({
|
|
256
|
+
"ok": True,
|
|
257
|
+
"draft_only": True,
|
|
258
|
+
"draft_id": str(draft_id),
|
|
259
|
+
"edit_url": f"{ORIGIN}/u/{uid}/blog/write/draft/{draft_id}",
|
|
260
|
+
"note": "Draft saved. 开源中国 has no publish API — open edit_url to publish.",
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
COMMANDS = {
|
|
265
|
+
"whoami": cmd_whoami,
|
|
266
|
+
"draft": cmd_draft,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def main() -> None:
|
|
271
|
+
p = argparse.ArgumentParser(prog="oschina.py", description="开源中国 cookie CLI")
|
|
272
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
273
|
+
sub.add_parser("whoami", help="show the logged-in account")
|
|
274
|
+
sp = sub.add_parser("draft", help="create a draft article (GATED by trailing --confirm)")
|
|
275
|
+
sp.add_argument("--title")
|
|
276
|
+
sp.add_argument("--content", help="Markdown content inline")
|
|
277
|
+
sp.add_argument("--content-file", help="path to a Markdown file")
|
|
278
|
+
sp.add_argument("--catalog", type=int, default=0, help="开源中国 catalog id (0 = default)")
|
|
279
|
+
sp.add_argument("--public", action="store_true",
|
|
280
|
+
help="mark the draft non-private; it still needs publishing in the editor")
|
|
281
|
+
args = p.parse_args(ARGV)
|
|
282
|
+
jar = load_cookies()
|
|
283
|
+
COMMANDS[args.command](jar, args)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
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()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: segmentfault
|
|
3
|
+
description: Read the connected SegmentFault 思否 (segmentfault.com) account and create Markdown article drafts with the user's own login cookies (BYOC). Use when the user mentions SegmentFault / 思否, wants to save a 思否 draft, or asks who their connected 思否 account is.
|
|
4
|
+
when_to_use: |
|
|
5
|
+
Trigger for the user's SegmentFault 思否 account driven by their own login
|
|
6
|
+
cookie: show the connected account, or turn Markdown into a 思否 article
|
|
7
|
+
draft. The write API creates a draft, so this skill stops there and hands the
|
|
8
|
+
user the editor URL. Writes are gated behind explicit confirmation.
|
|
9
|
+
connections: [segmentfault]
|
|
10
|
+
allowed_tools: [Bash]
|
|
11
|
+
license: Apache-2.0
|
|
12
|
+
metadata:
|
|
13
|
+
author: acedatacloud
|
|
14
|
+
version: "1.0"
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# segmentfault — read & draft on 思否 via your own cookies
|
|
18
|
+
|
|
19
|
+
Drives the user's **real** SegmentFault account through the same
|
|
20
|
+
`segmentfault.com/gateway` endpoints the site uses, authenticated by the login
|
|
21
|
+
cookie they captured with the ACE extension. No browser, no third-party deps —
|
|
22
|
+
just `urllib`.
|
|
23
|
+
|
|
24
|
+
The connector injects the cookie jar as a JSON env var `$SEGMENTFAULT_COOKIES`.
|
|
25
|
+
Never print it.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
python3 "$SKILL_DIR/scripts/segmentfault.py" whoami
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
If `$SKILL_DIR` points at a different skill loaded in the same turn, resolve
|
|
32
|
+
this skill's directory explicitly before running the commands below.
|
|
33
|
+
|
|
34
|
+
## Important: draft only
|
|
35
|
+
|
|
36
|
+
SegmentFault's write endpoint (`/gateway/draft`) creates a **draft**. This skill
|
|
37
|
+
returns the draft's editor URL and does not publish. Tell the user plainly that
|
|
38
|
+
they must open that URL and publish themselves — do not claim the article went
|
|
39
|
+
live.
|
|
40
|
+
|
|
41
|
+
## Verify the connection first
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
python3 "$SKILL_DIR/scripts/segmentfault.py" whoami
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
If this fails with an auth error, the cookie has expired. Ask the user to
|
|
48
|
+
reconnect at `https://auth.acedata.cloud/user/connections` rather than retrying.
|
|
49
|
+
|
|
50
|
+
## Create a draft — GATED
|
|
51
|
+
|
|
52
|
+
Prepare the complete Markdown in a file. The first call is always a dry run and
|
|
53
|
+
does not write anything.
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Dry run — shows exactly what would be written.
|
|
57
|
+
python3 "$SKILL_DIR/scripts/segmentfault.py" draft \
|
|
58
|
+
--title "标题" --content-file /tmp/article.md --tags "python,api"
|
|
59
|
+
|
|
60
|
+
# Actually create the draft after the user confirms.
|
|
61
|
+
python3 "$SKILL_DIR/scripts/segmentfault.py" draft \
|
|
62
|
+
--title "标题" --content-file /tmp/article.md --tags "python,api" --confirm
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`--confirm` is valid only as the final argument. Show the title, tags and full
|
|
66
|
+
content to the user before writing.
|
|
67
|
+
|
|
68
|
+
## Gotchas
|
|
69
|
+
|
|
70
|
+
- The write API needs a per-session token scraped from the `/write` page. If
|
|
71
|
+
the CLI reports it could not find that token, the cookie is stale — reconnect
|
|
72
|
+
rather than retrying.
|
|
73
|
+
- A restricted account (禁言 / 锁定) is reported as an explicit error. Do not
|
|
74
|
+
retry; tell the user their account is restricted.
|
|
75
|
+
- Content is sent as Markdown. Do not pre-render it to HTML.
|
|
76
|
+
- Images referenced by external URL are not re-hosted. If the source host
|
|
77
|
+
blocks hotlinking they will not render; mention this when the article has
|
|
78
|
+
images.
|
|
79
|
+
- Do not retry a timed-out write automatically — the outcome may be unknown and
|
|
80
|
+
a retry can create a duplicate draft.
|
|
81
|
+
|
|
82
|
+
## Record the output
|
|
83
|
+
|
|
84
|
+
This skill only produces drafts, so do **not** call `publish_artifact`. Report
|
|
85
|
+
the returned `draft_id` and `edit_url` to the user instead.
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
segmentfault — read & publish on SegmentFault 思否 (segmentfault.com) with the
|
|
4
|
+
user's own login cookies (BYOC). Standard-library only (urllib), no third-party
|
|
5
|
+
deps, so it runs in the bare sandbox without an image change.
|
|
6
|
+
|
|
7
|
+
The connector injects the user's cookie jar as a JSON env var
|
|
8
|
+
``SEGMENTFAULT_COOKIES`` — a list of ``{name, value, domain, ...}`` dicts
|
|
9
|
+
captured by the ACE extension.
|
|
10
|
+
|
|
11
|
+
Read commands run directly. ``draft`` is GATED: without a trailing ``--confirm``
|
|
12
|
+
it only dry-runs. ``--confirm`` is honored ONLY as the last argument, so a
|
|
13
|
+
title/content that merely contains "--confirm" can never silently go live.
|
|
14
|
+
|
|
15
|
+
NOTE: SegmentFault's write API creates a DRAFT. This CLI returns the draft's
|
|
16
|
+
editor URL; the user finishes publishing in the SegmentFault editor.
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
python3 segmentfault.py whoami
|
|
20
|
+
python3 segmentfault.py draft --title T --content-file a.md --confirm
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import gzip
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
import urllib.error
|
|
32
|
+
import urllib.parse
|
|
33
|
+
import urllib.request
|
|
34
|
+
|
|
35
|
+
UA = (
|
|
36
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
37
|
+
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
|
38
|
+
)
|
|
39
|
+
PLATFORM = "segmentfault"
|
|
40
|
+
BASE = "https://segmentfault.com"
|
|
41
|
+
MAX_CONTENT_BYTES = 10 * 1024 * 1024
|
|
42
|
+
|
|
43
|
+
# Bounded so a hostile/huge page cannot cause catastrophic backtracking.
|
|
44
|
+
_TOKEN_RE = re.compile(r'serverData"\s*:\s*\{\s*"Token"\s*:\s*"([^"]{1,500})"')
|
|
45
|
+
|
|
46
|
+
_RAW = sys.argv[1:]
|
|
47
|
+
CONFIRM = bool(_RAW) and _RAW[-1] == "--confirm"
|
|
48
|
+
ARGV = _RAW[:-1] if CONFIRM else list(_RAW)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
52
|
+
"""Refuse redirects outright.
|
|
53
|
+
|
|
54
|
+
add_unredirected_header only protects the Cookie; urllib still copies every
|
|
55
|
+
other header (including the per-session ``token``) onto the redirected
|
|
56
|
+
request, so a 30x to an attacker host would hand over the write token.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_OPENER = urllib.request.build_opener(_NoRedirect())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def out(obj) -> None:
|
|
67
|
+
print(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def die(msg: str, code: int = 1) -> None:
|
|
71
|
+
out({"error": msg})
|
|
72
|
+
sys.exit(code)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── Cookie jar (shared pattern across the cookie-BYOC skills) ────────
|
|
76
|
+
|
|
77
|
+
def load_cookies() -> list:
|
|
78
|
+
env = f"{PLATFORM.upper()}_COOKIES"
|
|
79
|
+
raw = os.environ.get(env)
|
|
80
|
+
if not raw:
|
|
81
|
+
die(f"{env} is not set — connect SegmentFault at "
|
|
82
|
+
f"https://auth.acedata.cloud/user/connections, then retry.")
|
|
83
|
+
try:
|
|
84
|
+
jar = json.loads(raw)
|
|
85
|
+
except json.JSONDecodeError as e:
|
|
86
|
+
die(f"{env} is not valid JSON: {e}")
|
|
87
|
+
if not isinstance(jar, list):
|
|
88
|
+
die(f"{env} must be a JSON list of cookies, got {type(jar).__name__}")
|
|
89
|
+
return jar
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _domain_matches(host: str, domain: str) -> bool:
|
|
93
|
+
d = domain.lstrip(".").lower()
|
|
94
|
+
h = host.lower()
|
|
95
|
+
return not d or h == d or h.endswith("." + d)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def cookie_header(jar: list, url: str) -> str:
|
|
99
|
+
host = urllib.parse.urlsplit(url).hostname or ""
|
|
100
|
+
host_in_scope = any(
|
|
101
|
+
c.get("domain") and _domain_matches(host, str(c["domain"])) for c in jar
|
|
102
|
+
)
|
|
103
|
+
parts = []
|
|
104
|
+
for c in jar:
|
|
105
|
+
name, value = c.get("name"), c.get("value")
|
|
106
|
+
if not name or value is None:
|
|
107
|
+
continue
|
|
108
|
+
domain = c.get("domain")
|
|
109
|
+
if domain:
|
|
110
|
+
if not _domain_matches(host, str(domain)):
|
|
111
|
+
continue
|
|
112
|
+
elif not host_in_scope:
|
|
113
|
+
continue
|
|
114
|
+
parts.append(f"{name}={value}")
|
|
115
|
+
return "; ".join(parts)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def request(method: str, url: str, jar: list, *, headers=None, body=None, write: bool = False):
|
|
119
|
+
hdrs = {
|
|
120
|
+
"User-Agent": UA,
|
|
121
|
+
"Accept": "*/*",
|
|
122
|
+
"Accept-Language": "zh-CN,zh;q=0.9",
|
|
123
|
+
"Origin": BASE,
|
|
124
|
+
"Referer": f"{BASE}/",
|
|
125
|
+
}
|
|
126
|
+
if headers:
|
|
127
|
+
hdrs.update(headers)
|
|
128
|
+
data = None
|
|
129
|
+
if body is not None:
|
|
130
|
+
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
131
|
+
hdrs.setdefault("Content-Type", "application/json")
|
|
132
|
+
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
|
|
133
|
+
# Unredirected → the cookie is not re-sent if the API 30x-redirects to a
|
|
134
|
+
# different host (e.g. a login page), so the jar never leaks off-site.
|
|
135
|
+
req.add_unredirected_header("Cookie", cookie_header(jar, url))
|
|
136
|
+
try:
|
|
137
|
+
with _OPENER.open(req, timeout=30) as resp:
|
|
138
|
+
raw = resp.read()
|
|
139
|
+
if resp.headers.get("Content-Encoding") == "gzip":
|
|
140
|
+
raw = gzip.decompress(raw)
|
|
141
|
+
return resp.status, raw.decode("utf-8", "replace")
|
|
142
|
+
except urllib.error.HTTPError as e:
|
|
143
|
+
if e.code in (301, 302, 303, 307, 308):
|
|
144
|
+
die(f"SegmentFault redirected {method} {url} — not followed, so no "
|
|
145
|
+
f"credential left segmentfault.com. You are most likely logged "
|
|
146
|
+
f"out; reconnect at https://auth.acedata.cloud/user/connections."
|
|
147
|
+
+ (" This was a WRITE: its outcome is UNKNOWN — check your "
|
|
148
|
+
"drafts before retrying." if write else ""))
|
|
149
|
+
raw = e.read()
|
|
150
|
+
try:
|
|
151
|
+
if e.headers.get("Content-Encoding") == "gzip":
|
|
152
|
+
raw = gzip.decompress(raw)
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
return e.code, raw.decode("utf-8", "replace")
|
|
156
|
+
except urllib.error.URLError as e:
|
|
157
|
+
if write:
|
|
158
|
+
die(f"SegmentFault write {method} {url} did not return a result "
|
|
159
|
+
f"({e.reason}); the outcome is UNKNOWN. Check your SegmentFault "
|
|
160
|
+
f"drafts before retrying so you do not create a duplicate.")
|
|
161
|
+
die(f"network error reaching {url}: {e.reason}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _auth_died(status: int, text: str) -> None:
|
|
165
|
+
if status in (401, 403) or text.strip('"') == "Unauthorized":
|
|
166
|
+
die("auth failed — cookie expired or invalid. Reconnect at "
|
|
167
|
+
"https://auth.acedata.cloud/user/connections.")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def session_token(jar: list) -> str:
|
|
171
|
+
"""The write API needs a per-session token embedded in the /write page."""
|
|
172
|
+
status, html = request("GET", f"{BASE}/write", jar)
|
|
173
|
+
_auth_died(status, html)
|
|
174
|
+
m = _TOKEN_RE.search(html)
|
|
175
|
+
if not m:
|
|
176
|
+
die("could not find the SegmentFault session token on /write — you are "
|
|
177
|
+
"probably logged out. Reconnect at "
|
|
178
|
+
"https://auth.acedata.cloud/user/connections.")
|
|
179
|
+
return m.group(1)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── commands ────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
def cmd_whoami(jar, _args):
|
|
185
|
+
status, html = request("GET", f"{BASE}/user/settings", jar)
|
|
186
|
+
_auth_died(status, html)
|
|
187
|
+
if status != 200:
|
|
188
|
+
die(f"unexpected status {status} reading the profile")
|
|
189
|
+
name = re.search(r'name="name"[^>]{0,300}?value="([^"]{1,200})"', html)
|
|
190
|
+
avatar = re.search(
|
|
191
|
+
r'src="(https://avatar-static\.segmentfault\.com/[^"]{1,300})"', html
|
|
192
|
+
)
|
|
193
|
+
if not name and "登录" in html:
|
|
194
|
+
die("not logged in — cookie expired. Reconnect at "
|
|
195
|
+
"https://auth.acedata.cloud/user/connections.")
|
|
196
|
+
out({
|
|
197
|
+
"platform": PLATFORM,
|
|
198
|
+
"name": name.group(1) if name else None,
|
|
199
|
+
"avatar": avatar.group(1) if avatar else None,
|
|
200
|
+
"settings_url": f"{BASE}/user/settings",
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def read_content(args) -> str:
|
|
205
|
+
content = args.content
|
|
206
|
+
if args.content_file:
|
|
207
|
+
try:
|
|
208
|
+
with open(args.content_file, encoding="utf-8") as f:
|
|
209
|
+
content = f.read()
|
|
210
|
+
except OSError as e:
|
|
211
|
+
die(f"cannot read --content-file: {e}")
|
|
212
|
+
if content is None:
|
|
213
|
+
die("provide --content-file <path.md> or --content <markdown>")
|
|
214
|
+
if len(content.encode("utf-8")) > MAX_CONTENT_BYTES:
|
|
215
|
+
die("content exceeds the 10 MiB safety limit")
|
|
216
|
+
return content
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _extract_draft_id(text: str):
|
|
220
|
+
"""SegmentFault answers either [0, {...}] / [1, "error"] or a bare object."""
|
|
221
|
+
try:
|
|
222
|
+
res = json.loads(text)
|
|
223
|
+
except json.JSONDecodeError:
|
|
224
|
+
return None, f"non-JSON response: {text[:200]}"
|
|
225
|
+
if isinstance(res, list):
|
|
226
|
+
if res and res[0] == 1:
|
|
227
|
+
return None, str(res[1] if len(res) > 1 else "unknown error")
|
|
228
|
+
if len(res) > 1 and isinstance(res[1], dict) and res[1].get("id"):
|
|
229
|
+
return res[1]["id"], None
|
|
230
|
+
return None, f"unexpected list response: {text[:200]}"
|
|
231
|
+
if isinstance(res, dict):
|
|
232
|
+
if res.get("id"):
|
|
233
|
+
return res["id"], None
|
|
234
|
+
msg = res.get("message") or res.get("msg") or res.get("error") or res.get("errMsg")
|
|
235
|
+
return None, str(msg or text[:200])
|
|
236
|
+
return None, f"unexpected response: {text[:200]}"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def cmd_draft(jar, args):
|
|
240
|
+
if not args.title:
|
|
241
|
+
die("--title is required")
|
|
242
|
+
content = read_content(args)
|
|
243
|
+
tags = [t.strip() for t in (args.tags or "").split(",") if t.strip()]
|
|
244
|
+
|
|
245
|
+
if not CONFIRM:
|
|
246
|
+
out({
|
|
247
|
+
"dry_run": True, "command": "draft", "platform": PLATFORM,
|
|
248
|
+
"title": args.title, "tags": tags,
|
|
249
|
+
"content_characters": len(content),
|
|
250
|
+
"note": "SegmentFault content is Markdown. Re-run with --confirm as "
|
|
251
|
+
"the LAST argument to actually create the draft. The write "
|
|
252
|
+
"API creates a DRAFT — finish publishing in the SegmentFault "
|
|
253
|
+
"editor.",
|
|
254
|
+
})
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
token = session_token(jar)
|
|
258
|
+
status, text = request("POST", f"{BASE}/gateway/draft", jar,
|
|
259
|
+
headers={"token": token}, write=True,
|
|
260
|
+
body={"title": args.title, "tags": tags,
|
|
261
|
+
"text": content, "object_id": "",
|
|
262
|
+
"type": "article"})
|
|
263
|
+
_auth_died(status, text)
|
|
264
|
+
for marker in ("禁言", "锁定"):
|
|
265
|
+
if marker in text:
|
|
266
|
+
die(f"SegmentFault rejected the write ({marker}); the account is "
|
|
267
|
+
f"restricted.")
|
|
268
|
+
draft_id, err = _extract_draft_id(text)
|
|
269
|
+
if not draft_id:
|
|
270
|
+
die(f"draft creation failed (status {status}): {err}")
|
|
271
|
+
out({
|
|
272
|
+
"ok": True,
|
|
273
|
+
"draft_only": True,
|
|
274
|
+
"draft_id": str(draft_id),
|
|
275
|
+
"edit_url": f"{BASE}/write?draftId={draft_id}",
|
|
276
|
+
"note": "Draft saved. Open edit_url to review and publish.",
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
COMMANDS = {
|
|
281
|
+
"whoami": cmd_whoami,
|
|
282
|
+
"draft": cmd_draft,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def main() -> None:
|
|
287
|
+
p = argparse.ArgumentParser(prog="segmentfault.py",
|
|
288
|
+
description="SegmentFault 思否 cookie CLI")
|
|
289
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
290
|
+
sub.add_parser("whoami", help="show the logged-in account")
|
|
291
|
+
sp = sub.add_parser("draft", help="create a draft article (GATED by trailing --confirm)")
|
|
292
|
+
sp.add_argument("--title")
|
|
293
|
+
sp.add_argument("--content", help="Markdown content inline")
|
|
294
|
+
sp.add_argument("--content-file", help="path to a Markdown file")
|
|
295
|
+
sp.add_argument("--tags", help="comma-separated tag names")
|
|
296
|
+
args = p.parse_args(ARGV)
|
|
297
|
+
jar = load_cookies()
|
|
298
|
+
COMMANDS[args.command](jar, args)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
main()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: yuque
|
|
3
|
+
description: Read and write Yuque (语雀) documents with the user's own personal access token through the official open API at https://www.yuque.com/api/v2. Use when the user wants to publish Markdown to 语雀, list their knowledge bases (知识库), read or update a 语雀 document, or delete one.
|
|
4
|
+
when_to_use: |
|
|
5
|
+
Trigger for 语雀 / Yuque document management: verify the connected account,
|
|
6
|
+
list knowledge bases or the documents inside one, read a document, create a
|
|
7
|
+
Markdown document, update an existing one, or delete one. Writes and
|
|
8
|
+
destructive actions require explicit confirmation.
|
|
9
|
+
connections: [yuque]
|
|
10
|
+
allowed_tools: [Bash]
|
|
11
|
+
license: Apache-2.0
|
|
12
|
+
metadata:
|
|
13
|
+
author: acedatacloud
|
|
14
|
+
version: "1.0"
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
Use the bundled standard-library CLI. The connector injects the user's 语雀
|
|
18
|
+
personal access token as `$YUQUE_TOKEN`. Never print it. The CLI uses the
|
|
19
|
+
official open API at `https://www.yuque.com/api/v2` with the documented
|
|
20
|
+
`X-Auth-Token` header.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
python3 "$SKILL_DIR/scripts/yuque.py" whoami
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
If `$SKILL_DIR` points at a different skill loaded in the same turn, resolve
|
|
27
|
+
this skill's directory explicitly before running the commands below.
|
|
28
|
+
|
|
29
|
+
If authentication fails, ask the user to create a token at
|
|
30
|
+
`https://www.yuque.com/settings/tokens` and reconnect at
|
|
31
|
+
`https://auth.acedata.cloud/user/connections`. Do not ask for their account
|
|
32
|
+
password or Cookie.
|
|
33
|
+
|
|
34
|
+
## Read
|
|
35
|
+
|
|
36
|
+
A `repo` is a 语雀 knowledge base, addressed either by its `namespace`
|
|
37
|
+
(`user/book`, as shown in the document URL) or by its numeric id. Always run
|
|
38
|
+
`repos` first — never guess a namespace.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Verify the token and see the account.
|
|
42
|
+
python3 "$SKILL_DIR/scripts/yuque.py" whoami
|
|
43
|
+
|
|
44
|
+
# List knowledge bases, then the documents in one.
|
|
45
|
+
python3 "$SKILL_DIR/scripts/yuque.py" repos
|
|
46
|
+
python3 "$SKILL_DIR/scripts/yuque.py" docs germey/blog --limit 20
|
|
47
|
+
python3 "$SKILL_DIR/scripts/yuque.py" doc germey/blog DOC_ID
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Create and update
|
|
51
|
+
|
|
52
|
+
Prepare the complete Markdown in a file. 语雀 has no separate draft state — a
|
|
53
|
+
document is either private or public — so the CLI creates **private** documents
|
|
54
|
+
by default and only publishes publicly with an explicit `--public`.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# First call is always a dry run and does not load credentials or call the API.
|
|
58
|
+
python3 "$SKILL_DIR/scripts/yuque.py" create germey/blog \
|
|
59
|
+
--title "标题" --content-file /tmp/article.md
|
|
60
|
+
|
|
61
|
+
# Create it privately after the user confirms.
|
|
62
|
+
python3 "$SKILL_DIR/scripts/yuque.py" create germey/blog \
|
|
63
|
+
--title "标题" --content-file /tmp/article.md --confirm
|
|
64
|
+
|
|
65
|
+
# Public publishing additionally requires --public.
|
|
66
|
+
python3 "$SKILL_DIR/scripts/yuque.py" create germey/blog \
|
|
67
|
+
--title "标题" --content-file /tmp/article.md --public --confirm
|
|
68
|
+
|
|
69
|
+
python3 "$SKILL_DIR/scripts/yuque.py" update germey/blog DOC_ID \
|
|
70
|
+
--title "新标题" --content-file /tmp/article.md --public --confirm
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`--confirm` is valid only as the final argument. Always show the target
|
|
74
|
+
knowledge base, title, visibility and full content to the user before a public
|
|
75
|
+
publish. Default to a private document unless the user explicitly asks to
|
|
76
|
+
publish publicly.
|
|
77
|
+
|
|
78
|
+
Note that `update` rewrites the whole document body. Read the current document
|
|
79
|
+
first if the user only wants part of it changed.
|
|
80
|
+
|
|
81
|
+
## Delete
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
python3 "$SKILL_DIR/scripts/yuque.py" delete germey/blog DOC_ID --confirm
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Use the real returned `doc_id` and URL. Do not retry a timed-out write
|
|
88
|
+
automatically because its outcome may be unknown; list the documents first so
|
|
89
|
+
you do not create a duplicate.
|
|
90
|
+
|
|
91
|
+
## Gotchas
|
|
92
|
+
|
|
93
|
+
- Images referenced by external URL are not re-hosted; 语雀 renders them from
|
|
94
|
+
the original host. If the source blocks hotlinking, upload the images to 语雀
|
|
95
|
+
manually first and reference the returned URLs.
|
|
96
|
+
- 语雀's own terms state the open API is for normal reading and writing of 语雀
|
|
97
|
+
content; abnormal automated behaviour can get the account blocked. Keep the
|
|
98
|
+
volume human-scale.
|
|
99
|
+
|
|
100
|
+
## Record the output
|
|
101
|
+
|
|
102
|
+
After a confirmed public publish returns a real URL, call `publish_artifact`
|
|
103
|
+
once with `kind="article"`, `channel="yuque"`, the title, returned URL, and
|
|
104
|
+
`status="delivered"`. Do not record private documents or failed/unknown writes.
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read and write Yuque (语雀) documents through its official open API."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
import socket
|
|
11
|
+
import sys
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
API_BASE = "https://www.yuque.com/api/v2"
|
|
17
|
+
GATED_COMMANDS = {"create", "update", "delete"}
|
|
18
|
+
MAX_CONTENT_BYTES = 10 * 1024 * 1024
|
|
19
|
+
MAX_ITEMS = 100
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def output(value) -> None:
|
|
23
|
+
print(json.dumps(value, ensure_ascii=False, indent=2, default=str))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def die(message: str, code: int = 1) -> None:
|
|
27
|
+
output({"error": message})
|
|
28
|
+
raise SystemExit(code)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def split_confirmation(argv: list[str]) -> tuple[list[str], bool]:
|
|
32
|
+
confirmed = bool(argv) and argv[-1] == "--confirm"
|
|
33
|
+
return (argv[:-1] if confirmed else list(argv), confirmed)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
37
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class YuqueClient:
|
|
42
|
+
def __init__(self, token: str, opener=None) -> None:
|
|
43
|
+
self._token = token
|
|
44
|
+
self._opener = opener or urllib.request.build_opener(NoRedirectHandler())
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_environment(cls):
|
|
48
|
+
token = os.environ.get("YUQUE_TOKEN", "").strip()
|
|
49
|
+
if not token:
|
|
50
|
+
die(
|
|
51
|
+
"YUQUE_TOKEN is not set. Reconnect 语雀 at "
|
|
52
|
+
"https://auth.acedata.cloud/user/connections."
|
|
53
|
+
)
|
|
54
|
+
return cls(token)
|
|
55
|
+
|
|
56
|
+
def request(self, method: str, path: str, *, body=None, write: bool = False, expect_json: bool = True):
|
|
57
|
+
encoded = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
58
|
+
request = urllib.request.Request(
|
|
59
|
+
f"{API_BASE}{path}",
|
|
60
|
+
data=encoded,
|
|
61
|
+
method=method,
|
|
62
|
+
headers={
|
|
63
|
+
"Accept": "application/json",
|
|
64
|
+
"X-Auth-Token": self._token,
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
"User-Agent": "AceDataCloud-Yuque-Skill/1.0",
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
try:
|
|
70
|
+
with self._opener.open(request, timeout=30) as response:
|
|
71
|
+
raw = response.read()
|
|
72
|
+
except urllib.error.HTTPError as error:
|
|
73
|
+
if error.code in {301, 302, 303, 307, 308}:
|
|
74
|
+
die(f"Yuque API redirected {method} {path}; credentials were not forwarded.")
|
|
75
|
+
if error.code in {401, 403}:
|
|
76
|
+
die(
|
|
77
|
+
f"Yuque API HTTP {error.code} for {method} {path}. The token is invalid or "
|
|
78
|
+
"lacks scope. Reconnect with a token from https://www.yuque.com/settings/tokens."
|
|
79
|
+
)
|
|
80
|
+
if error.code == 404:
|
|
81
|
+
die(f"Yuque API 404 for {method} {path}; the repo or document does not exist.")
|
|
82
|
+
die(f"Yuque API HTTP {error.code} for {method} {path}.")
|
|
83
|
+
except (urllib.error.URLError, OSError, socket.timeout):
|
|
84
|
+
if write:
|
|
85
|
+
die(
|
|
86
|
+
f"Yuque write {method} {path} did not return a result; outcome is unknown. "
|
|
87
|
+
"List the documents before retrying so you do not create a duplicate."
|
|
88
|
+
)
|
|
89
|
+
die(f"Network error while calling Yuque {method} {path}.")
|
|
90
|
+
if not expect_json or not raw:
|
|
91
|
+
return None
|
|
92
|
+
try:
|
|
93
|
+
payload = json.loads(raw)
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
die(f"Yuque returned invalid JSON for {method} {path}.")
|
|
96
|
+
if not isinstance(payload, dict) or "data" not in payload:
|
|
97
|
+
die(f"Yuque returned an unexpected envelope for {method} {path}.")
|
|
98
|
+
return payload["data"]
|
|
99
|
+
|
|
100
|
+
def user(self) -> dict:
|
|
101
|
+
value = self.request("GET", "/user")
|
|
102
|
+
if not isinstance(value, dict):
|
|
103
|
+
die("Yuque returned malformed user data.")
|
|
104
|
+
return value
|
|
105
|
+
|
|
106
|
+
def repos(self, login: str) -> list[dict]:
|
|
107
|
+
quoted = urllib.parse.quote(str(login), safe="")
|
|
108
|
+
value = self.request("GET", f"/users/{quoted}/repos")
|
|
109
|
+
if not isinstance(value, list):
|
|
110
|
+
die("Yuque returned malformed repo data.")
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
def docs(self, repo: str) -> list[dict]:
|
|
114
|
+
quoted = urllib.parse.quote(str(repo), safe="")
|
|
115
|
+
value = self.request("GET", f"/repos/{quoted}/docs")
|
|
116
|
+
if not isinstance(value, list):
|
|
117
|
+
die("Yuque returned malformed document data.")
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
def doc(self, repo: str, doc_id: str) -> dict:
|
|
121
|
+
repo_q = urllib.parse.quote(str(repo), safe="")
|
|
122
|
+
doc_q = urllib.parse.quote(str(doc_id), safe="")
|
|
123
|
+
value = self.request("GET", f"/repos/{repo_q}/docs/{doc_q}")
|
|
124
|
+
if not isinstance(value, dict):
|
|
125
|
+
die("Yuque returned malformed document data.")
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
def create_doc(self, repo: str, body: dict) -> dict:
|
|
129
|
+
quoted = urllib.parse.quote(str(repo), safe="")
|
|
130
|
+
value = self.request("POST", f"/repos/{quoted}/docs", body=body, write=True)
|
|
131
|
+
if not isinstance(value, dict) or not value.get("id"):
|
|
132
|
+
die("Yuque did not return a valid created document.")
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
def update_doc(self, repo: str, doc_id: str, body: dict) -> dict:
|
|
136
|
+
repo_q = urllib.parse.quote(str(repo), safe="")
|
|
137
|
+
doc_q = urllib.parse.quote(str(doc_id), safe="")
|
|
138
|
+
value = self.request("PUT", f"/repos/{repo_q}/docs/{doc_q}", body=body, write=True)
|
|
139
|
+
if not isinstance(value, dict) or not value.get("id"):
|
|
140
|
+
die("Yuque did not return a valid updated document.")
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
def delete_doc(self, repo: str, doc_id: str) -> None:
|
|
144
|
+
repo_q = urllib.parse.quote(str(repo), safe="")
|
|
145
|
+
doc_q = urllib.parse.quote(str(doc_id), safe="")
|
|
146
|
+
self.request("DELETE", f"/repos/{repo_q}/docs/{doc_q}", write=True, expect_json=False)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def read_content(args) -> str:
|
|
150
|
+
if args.content_file:
|
|
151
|
+
path = pathlib.Path(args.content_file)
|
|
152
|
+
try:
|
|
153
|
+
if path.stat().st_size > MAX_CONTENT_BYTES:
|
|
154
|
+
die("Content file exceeds the 10 MiB safety limit.")
|
|
155
|
+
return path.read_text(encoding="utf-8")
|
|
156
|
+
except OSError as error:
|
|
157
|
+
die(f"Cannot read --content-file: {error}")
|
|
158
|
+
if args.content is not None:
|
|
159
|
+
if len(args.content.encode("utf-8")) > MAX_CONTENT_BYTES:
|
|
160
|
+
die("Content exceeds the 10 MiB safety limit.")
|
|
161
|
+
return args.content
|
|
162
|
+
die("Provide --content-file <path.md> or --content <markdown>.")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def format_repo(repo: dict) -> dict:
|
|
166
|
+
return {
|
|
167
|
+
"repo_id": repo.get("id"),
|
|
168
|
+
"namespace": repo.get("namespace"),
|
|
169
|
+
"name": repo.get("name"),
|
|
170
|
+
"slug": repo.get("slug"),
|
|
171
|
+
"type": repo.get("type"),
|
|
172
|
+
"public": repo.get("public"),
|
|
173
|
+
"items_count": repo.get("items_count"),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def format_doc(doc: dict, repo: str | None = None) -> dict:
|
|
178
|
+
namespace = repo or (doc.get("book") or {}).get("namespace")
|
|
179
|
+
slug = doc.get("slug")
|
|
180
|
+
return {
|
|
181
|
+
"doc_id": doc.get("id"),
|
|
182
|
+
"title": doc.get("title"),
|
|
183
|
+
"slug": slug,
|
|
184
|
+
"public": doc.get("public"),
|
|
185
|
+
"status": doc.get("status"),
|
|
186
|
+
"url": f"https://www.yuque.com/{namespace}/{slug}" if namespace and slug else None,
|
|
187
|
+
"word_count": doc.get("word_count"),
|
|
188
|
+
"created_at": doc.get("created_at"),
|
|
189
|
+
"updated_at": doc.get("updated_at"),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def build_body(args, content: str) -> dict:
|
|
194
|
+
# public: 0 = private, 1 = public. Yuque has no separate "draft" state, so a
|
|
195
|
+
# private doc is the closest equivalent and is the default.
|
|
196
|
+
body = {
|
|
197
|
+
"title": args.title,
|
|
198
|
+
"body": content,
|
|
199
|
+
"format": "markdown",
|
|
200
|
+
"public": 1 if args.public else 0,
|
|
201
|
+
}
|
|
202
|
+
if args.slug:
|
|
203
|
+
body["slug"] = args.slug
|
|
204
|
+
return body
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
208
|
+
parser = argparse.ArgumentParser(prog="yuque.py", description="Yuque open API CLI")
|
|
209
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
210
|
+
sub.add_parser("whoami", help="show the token's account")
|
|
211
|
+
|
|
212
|
+
repos = sub.add_parser("repos", help="list the account's knowledge bases")
|
|
213
|
+
repos.add_argument("--login", help="user login; defaults to the token's own account")
|
|
214
|
+
|
|
215
|
+
docs = sub.add_parser("docs", help="list documents in a knowledge base")
|
|
216
|
+
docs.add_argument("repo", help="repo namespace (user/book) or numeric repo id")
|
|
217
|
+
docs.add_argument("--limit", type=int, default=20)
|
|
218
|
+
|
|
219
|
+
one = sub.add_parser("doc", help="read one document")
|
|
220
|
+
one.add_argument("repo")
|
|
221
|
+
one.add_argument("doc_id", help="document id or slug")
|
|
222
|
+
|
|
223
|
+
for command in ("create", "update"):
|
|
224
|
+
write = sub.add_parser(command, help=f"{command} a document (GATED by trailing --confirm)")
|
|
225
|
+
write.add_argument("repo")
|
|
226
|
+
if command == "update":
|
|
227
|
+
write.add_argument("doc_id")
|
|
228
|
+
write.add_argument("--title", required=True)
|
|
229
|
+
write.add_argument("--content")
|
|
230
|
+
write.add_argument("--content-file")
|
|
231
|
+
write.add_argument("--slug")
|
|
232
|
+
write.add_argument(
|
|
233
|
+
"--public",
|
|
234
|
+
action="store_true",
|
|
235
|
+
help="publish publicly; omit to keep the document private",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
delete = sub.add_parser("delete", help="delete a document (GATED by trailing --confirm)")
|
|
239
|
+
delete.add_argument("repo")
|
|
240
|
+
delete.add_argument("doc_id")
|
|
241
|
+
return parser
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def dry_run(args, content: str | None = None) -> None:
|
|
245
|
+
value = {"dry_run": True, "command": args.command, "platform": "yuque", "repo": args.repo}
|
|
246
|
+
if args.command in {"create", "update"}:
|
|
247
|
+
value.update(
|
|
248
|
+
{
|
|
249
|
+
"doc_id": getattr(args, "doc_id", None),
|
|
250
|
+
"title": args.title,
|
|
251
|
+
"visibility": "public" if args.public else "private",
|
|
252
|
+
"slug": args.slug,
|
|
253
|
+
"content_characters": len(content or ""),
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
else:
|
|
257
|
+
value["doc_id"] = args.doc_id
|
|
258
|
+
value["note"] = "Re-run with --confirm as the final argument to write to Yuque."
|
|
259
|
+
output(value)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def main(argv: list[str] | None = None) -> None:
|
|
263
|
+
raw = list(sys.argv[1:] if argv is None else argv)
|
|
264
|
+
clean_argv, confirmed = split_confirmation(raw)
|
|
265
|
+
args = build_parser().parse_args(clean_argv)
|
|
266
|
+
content = read_content(args) if args.command in {"create", "update"} else None
|
|
267
|
+
if args.command in GATED_COMMANDS and not confirmed:
|
|
268
|
+
dry_run(args, content)
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
client = YuqueClient.from_environment()
|
|
272
|
+
if args.command == "whoami":
|
|
273
|
+
user = client.user()
|
|
274
|
+
output(
|
|
275
|
+
{
|
|
276
|
+
"user_id": user.get("id"),
|
|
277
|
+
"login": user.get("login"),
|
|
278
|
+
"name": user.get("name"),
|
|
279
|
+
"url": f"https://www.yuque.com/{user.get('login')}" if user.get("login") else None,
|
|
280
|
+
"books_count": user.get("books_count"),
|
|
281
|
+
"public_books_count": user.get("public_books_count"),
|
|
282
|
+
}
|
|
283
|
+
)
|
|
284
|
+
elif args.command == "repos":
|
|
285
|
+
login = args.login or client.user().get("login")
|
|
286
|
+
if not login:
|
|
287
|
+
die("Could not resolve the account login; pass --login explicitly.")
|
|
288
|
+
output({"repos": [format_repo(item) for item in client.repos(login)]})
|
|
289
|
+
elif args.command == "docs":
|
|
290
|
+
if not 1 <= args.limit <= MAX_ITEMS:
|
|
291
|
+
die(f"--limit must be between 1 and {MAX_ITEMS}.")
|
|
292
|
+
items = client.docs(args.repo)[: args.limit]
|
|
293
|
+
output({"count": len(items), "docs": [format_doc(item, args.repo) for item in items]})
|
|
294
|
+
elif args.command == "doc":
|
|
295
|
+
doc = client.doc(args.repo, args.doc_id)
|
|
296
|
+
result = format_doc(doc, args.repo)
|
|
297
|
+
result["body"] = doc.get("body")
|
|
298
|
+
output(result)
|
|
299
|
+
elif args.command == "create":
|
|
300
|
+
result = client.create_doc(args.repo, build_body(args, content or ""))
|
|
301
|
+
output({"ok": True, **format_doc(result, args.repo), "public": bool(args.public)})
|
|
302
|
+
elif args.command == "update":
|
|
303
|
+
result = client.update_doc(args.repo, args.doc_id, build_body(args, content or ""))
|
|
304
|
+
output({"ok": True, **format_doc(result, args.repo), "public": bool(args.public)})
|
|
305
|
+
elif args.command == "delete":
|
|
306
|
+
client.delete_doc(args.repo, args.doc_id)
|
|
307
|
+
output({"ok": True, "repo": args.repo, "doc_id": args.doc_id, "deleted": True})
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__":
|
|
311
|
+
main()
|