@acedatacloud/skills 2026.621.6 → 2026.621.8
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/bilibili/SKILL.md +81 -0
- package/skills/bilibili/scripts/bilibili.py +384 -0
- package/skills/csdn/SKILL.md +85 -0
- package/skills/csdn/scripts/csdn.py +375 -0
- package/skills/juejin/SKILL.md +75 -0
- package/skills/juejin/scripts/juejin.py +313 -0
- package/skills/medium/SKILL.md +76 -0
- package/skills/medium/scripts/medium.py +365 -0
- package/skills/weibo/SKILL.md +79 -0
- package/skills/weibo/scripts/weibo.py +279 -0
- package/skills/{cn-blog → zhihu}/SKILL.md +6 -5
- package/skills/{cn-blog → zhihu}/scripts/blog.py +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.621.
|
|
3
|
+
"version": "2026.621.8",
|
|
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,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bilibili
|
|
3
|
+
description: Read and publish 专栏 articles on Bilibili (bilibili.com) with the user's own login cookies (BYOC) — list their published articles with view/like/comment stats, inspect one article, and publish a new article. Use when the user mentions Bilibili / B站 / 专栏, "我的B站专栏", reading article stats (阅读/点赞), or publishing/投稿 a 专栏 article.
|
|
4
|
+
when_to_use: |
|
|
5
|
+
Trigger for anything on the user's Bilibili (bilibili.com) 专栏 account driven
|
|
6
|
+
by their own login cookie: show who they are, list their published 专栏
|
|
7
|
+
articles with view / like / comment counts, look at one article's stats, or
|
|
8
|
+
publish a new 专栏 article. This acts as the user's real account, so writes are
|
|
9
|
+
gated behind an explicit confirmation.
|
|
10
|
+
connections: [bilibili]
|
|
11
|
+
allowed_tools: [Bash]
|
|
12
|
+
license: Apache-2.0
|
|
13
|
+
metadata:
|
|
14
|
+
author: acedatacloud
|
|
15
|
+
version: "1.0"
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# bilibili — read & publish 专栏 via your own cookies
|
|
19
|
+
|
|
20
|
+
Drives the user's **real** Bilibili 专栏 (article) account through the same
|
|
21
|
+
`api.bilibili.com` web endpoints the site uses, authenticated by the login
|
|
22
|
+
cookie they captured with the ACE extension. No browser, no third-party deps —
|
|
23
|
+
`urllib` + `hashlib` (the article-list read endpoint needs WBI signing, done
|
|
24
|
+
with stdlib).
|
|
25
|
+
|
|
26
|
+
The connector injects the cookie jar as an env var:
|
|
27
|
+
|
|
28
|
+
- `BILIBILI_COOKIES` — a JSON array of cookies. **Secret — never echo or print
|
|
29
|
+
it.** It includes `SESSDATA` (auth) and `bili_jct` (the CSRF token used for
|
|
30
|
+
writes).
|
|
31
|
+
|
|
32
|
+
## CLI
|
|
33
|
+
|
|
34
|
+
The skill ships [`scripts/bilibili.py`](scripts/bilibili.py) — self-contained, stdlib only.
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
BILI=$SKILL_DIR/scripts/bilibili.py
|
|
38
|
+
python3 $BILI whoami # who is logged in (mid, name)
|
|
39
|
+
python3 $BILI articles --limit 20 # my 专栏 articles + stats
|
|
40
|
+
python3 $BILI article <cvid> # one article's stats (cv id)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Stats come straight from Bilibili: `view` (阅读), `like` (点赞), `reply` (评论),
|
|
44
|
+
`favorite` (收藏), `coin` (投币).
|
|
45
|
+
|
|
46
|
+
## Verify the connection first
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
python3 $BILI whoami
|
|
50
|
+
# → {"mid": 91207595, "name": "...", "level": 4}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
On a not-logged-in / auth error the cookie is expired — have the user reconnect
|
|
54
|
+
at <https://auth.acedata.cloud/user/connections>. Do **not** loop-retry.
|
|
55
|
+
|
|
56
|
+
## Publishing — GATED (dry-run unless trailing `--confirm`)
|
|
57
|
+
|
|
58
|
+
`publish` writes to the user's real account. 专栏 content is **HTML**. Without a
|
|
59
|
+
trailing `--confirm` it dry-runs. `--confirm` is honored **only as the last
|
|
60
|
+
argument**. Always show the dry-run, get an explicit "yes", then re-run.
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
python3 $BILI publish --title "标题" --content-file a.html # dry-run
|
|
64
|
+
python3 $BILI publish --title "标题" --content-file a.html --draft-only --confirm # save a draft
|
|
65
|
+
python3 $BILI publish --title "标题" --content-file a.html --confirm # save draft + submit (publish)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- `--draft-only` saves a draft (no submit) — safe; finish/publish in the editor.
|
|
69
|
+
- The **submit** (go public) step is frequently rate-limited by Bilibili
|
|
70
|
+
risk-control (HTTP 412). When that happens the CLI reports the saved draft +
|
|
71
|
+
edit URL so the user can publish from the web editor. Default to `--draft-only`.
|
|
72
|
+
|
|
73
|
+
## Gotchas
|
|
74
|
+
|
|
75
|
+
- **This is the user's real Bilibili account.** Confirm before any publish.
|
|
76
|
+
- **submit may 412** (anti-bot) even when the draft saved fine — the draft is the
|
|
77
|
+
reliable result; don't loop-retry submit.
|
|
78
|
+
- A wrong cover layout (`tid`) / category returns `-17`; the CLI auto-retries
|
|
79
|
+
common `tid` values.
|
|
80
|
+
- **Never print `BILIBILI_COOKIES`** — it is full account access.
|
|
81
|
+
- **ToS**: acts only on the user's own account with their own captured cookie.
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
bilibili — read & publish 专栏 articles on Bilibili (bilibili.com) with the
|
|
4
|
+
user's own login cookies (BYOC). Standard-library only (urllib + hashlib for
|
|
5
|
+
WBI signing), no third-party deps.
|
|
6
|
+
|
|
7
|
+
The connector injects the cookie jar as a JSON env var ``BILIBILI_COOKIES``.
|
|
8
|
+
Reads use the login cookies; the article-list endpoint needs WBI signing
|
|
9
|
+
(keys come from the nav response). Writes need ``csrf`` = the ``bili_jct`` cookie.
|
|
10
|
+
|
|
11
|
+
Read commands run directly. ``publish`` is GATED by a trailing ``--confirm``
|
|
12
|
+
(honored only as the last arg). ``--draft-only`` saves a draft (no submit).
|
|
13
|
+
Note: Bilibili 专栏 content is HTML. The publish *submit* step is often
|
|
14
|
+
rate-limited (HTTP 412) by risk-control; the draft is the reliable result.
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
python3 bilibili.py whoami
|
|
18
|
+
python3 bilibili.py articles --limit 20
|
|
19
|
+
python3 bilibili.py article <cvid>
|
|
20
|
+
python3 bilibili.py publish --title T --content-file a.html --draft-only --confirm
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import gzip
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.parse
|
|
34
|
+
import urllib.request
|
|
35
|
+
|
|
36
|
+
UA = (
|
|
37
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
38
|
+
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
|
39
|
+
)
|
|
40
|
+
PLATFORM = "bilibili"
|
|
41
|
+
API = "https://api.bilibili.com"
|
|
42
|
+
|
|
43
|
+
# Fixed permutation table for WBI mixin-key derivation (from bilibili web JS).
|
|
44
|
+
MIXIN_KEY_ENC_TAB = [
|
|
45
|
+
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
|
|
46
|
+
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
|
|
47
|
+
61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
|
|
48
|
+
36, 20, 34, 44, 52,
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
_RAW = sys.argv[1:]
|
|
52
|
+
CONFIRM = bool(_RAW) and _RAW[-1] == "--confirm"
|
|
53
|
+
ARGV = _RAW[:-1] if CONFIRM else list(_RAW)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def out(obj) -> None:
|
|
57
|
+
print(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def die(msg: str, code: int = 1) -> None:
|
|
61
|
+
out({"error": msg})
|
|
62
|
+
sys.exit(code)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Cookie jar ──────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def load_cookies() -> list:
|
|
68
|
+
env = f"{PLATFORM.upper()}_COOKIES"
|
|
69
|
+
raw = os.environ.get(env)
|
|
70
|
+
if not raw:
|
|
71
|
+
die(f"{env} is not set — connect Bilibili at "
|
|
72
|
+
f"https://auth.acedata.cloud/user/connections, then retry.")
|
|
73
|
+
try:
|
|
74
|
+
jar = json.loads(raw)
|
|
75
|
+
except json.JSONDecodeError as e:
|
|
76
|
+
die(f"{env} is not valid JSON: {e}")
|
|
77
|
+
if not isinstance(jar, list):
|
|
78
|
+
die(f"{env} must be a JSON list of cookies, got {type(jar).__name__}")
|
|
79
|
+
return jar
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _domain_matches(host: str, domain: str) -> bool:
|
|
83
|
+
d = domain.lstrip(".").lower()
|
|
84
|
+
h = host.lower()
|
|
85
|
+
return not d or h == d or h.endswith("." + d)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cookie_header(jar: list, url: str) -> str:
|
|
89
|
+
host = urllib.parse.urlsplit(url).hostname or ""
|
|
90
|
+
host_in_scope = any(
|
|
91
|
+
c.get("domain") and _domain_matches(host, str(c["domain"])) for c in jar
|
|
92
|
+
)
|
|
93
|
+
parts = []
|
|
94
|
+
for c in jar:
|
|
95
|
+
name, value = c.get("name"), c.get("value")
|
|
96
|
+
if not name or value is None:
|
|
97
|
+
continue
|
|
98
|
+
domain = c.get("domain")
|
|
99
|
+
if domain:
|
|
100
|
+
if not _domain_matches(host, str(domain)):
|
|
101
|
+
continue
|
|
102
|
+
elif not host_in_scope:
|
|
103
|
+
continue
|
|
104
|
+
parts.append(f"{name}={value}")
|
|
105
|
+
return "; ".join(parts)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cookie_value(jar: list, name: str):
|
|
109
|
+
for c in jar:
|
|
110
|
+
if c.get("name") == name:
|
|
111
|
+
return c.get("value")
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── HTTP ────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def request(method, url, jar, *, referer=None, headers=None, body=None, form=None):
|
|
118
|
+
hdrs = {
|
|
119
|
+
"User-Agent": UA,
|
|
120
|
+
"Accept": "application/json, text/plain, */*",
|
|
121
|
+
}
|
|
122
|
+
if referer:
|
|
123
|
+
hdrs["Referer"] = referer
|
|
124
|
+
if headers:
|
|
125
|
+
hdrs.update(headers)
|
|
126
|
+
data = None
|
|
127
|
+
if form is not None:
|
|
128
|
+
data = urllib.parse.urlencode(form).encode("utf-8")
|
|
129
|
+
hdrs["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8"
|
|
130
|
+
elif body is not None:
|
|
131
|
+
data = json.dumps(body).encode("utf-8")
|
|
132
|
+
hdrs.setdefault("Content-Type", "application/json")
|
|
133
|
+
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
|
|
134
|
+
# Unredirected → the cookie is not re-sent if the API 30x-redirects to a
|
|
135
|
+
# different host (e.g. a login page), so the jar never leaks off-site.
|
|
136
|
+
req.add_unredirected_header("Cookie", cookie_header(jar, url))
|
|
137
|
+
try:
|
|
138
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
139
|
+
raw = resp.read()
|
|
140
|
+
if resp.headers.get("Content-Encoding") == "gzip":
|
|
141
|
+
raw = gzip.decompress(raw)
|
|
142
|
+
return resp.status, raw.decode("utf-8", "replace")
|
|
143
|
+
except urllib.error.HTTPError as e:
|
|
144
|
+
raw = e.read()
|
|
145
|
+
try:
|
|
146
|
+
if e.headers.get("Content-Encoding") == "gzip":
|
|
147
|
+
raw = gzip.decompress(raw)
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
return e.code, raw.decode("utf-8", "replace")
|
|
151
|
+
except urllib.error.URLError as e:
|
|
152
|
+
die(f"network error reaching {url}: {e.reason}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_json(url, jar, *, referer=None):
|
|
156
|
+
status, text = request("GET", url, jar, referer=referer)
|
|
157
|
+
try:
|
|
158
|
+
d = json.loads(text)
|
|
159
|
+
except json.JSONDecodeError:
|
|
160
|
+
die(f"non-JSON response ({status}) from {url}: {text[:300]}")
|
|
161
|
+
return d
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ── WBI signing (for x/space/wbi/article) ───────────────────────────
|
|
165
|
+
|
|
166
|
+
def _wbi_keys(jar):
|
|
167
|
+
nav = get_json(f"{API}/x/web-interface/nav", jar)
|
|
168
|
+
data = nav.get("data") or {}
|
|
169
|
+
img = ((data.get("wbi_img") or {}).get("img_url") or "")
|
|
170
|
+
sub = ((data.get("wbi_img") or {}).get("sub_url") or "")
|
|
171
|
+
img_key = img.rsplit("/", 1)[-1].split(".")[0]
|
|
172
|
+
sub_key = sub.rsplit("/", 1)[-1].split(".")[0]
|
|
173
|
+
return nav, img_key, sub_key
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _mixin_key(img_key, sub_key):
|
|
177
|
+
orig = img_key + sub_key
|
|
178
|
+
return "".join(orig[i] for i in MIXIN_KEY_ENC_TAB if i < len(orig))[:32]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _wbi_sign(params: dict, img_key, sub_key) -> dict:
|
|
182
|
+
mixin = _mixin_key(img_key, sub_key)
|
|
183
|
+
params = dict(params)
|
|
184
|
+
params["wts"] = int(time.time())
|
|
185
|
+
params = {k: params[k] for k in sorted(params)}
|
|
186
|
+
# bilibili drops these chars from values before signing
|
|
187
|
+
cleaned = {k: "".join(ch for ch in str(v) if ch not in "!'()*") for k, v in params.items()}
|
|
188
|
+
query = urllib.parse.urlencode(cleaned)
|
|
189
|
+
params["w_rid"] = hashlib.md5((query + mixin).encode()).hexdigest()
|
|
190
|
+
return params
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ── commands ────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
def bili_nav(jar):
|
|
196
|
+
nav = get_json(f"{API}/x/web-interface/nav", jar)
|
|
197
|
+
data = nav.get("data") or {}
|
|
198
|
+
if not data.get("isLogin"):
|
|
199
|
+
die("not logged in (cookie expired?) — reconnect at "
|
|
200
|
+
"https://auth.acedata.cloud/user/connections.")
|
|
201
|
+
return data
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cmd_whoami(jar, _args):
|
|
205
|
+
d = bili_nav(jar)
|
|
206
|
+
out({
|
|
207
|
+
"mid": d.get("mid"),
|
|
208
|
+
"name": d.get("uname"),
|
|
209
|
+
"url": f"https://space.bilibili.com/{d.get('mid')}",
|
|
210
|
+
"level": (d.get("level_info") or {}).get("current_level"),
|
|
211
|
+
"vip": (d.get("vipStatus") or d.get("vip", {}).get("status")),
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _fmt(a: dict) -> dict:
|
|
216
|
+
st = a.get("stats") or {}
|
|
217
|
+
cvid = a.get("id")
|
|
218
|
+
return {
|
|
219
|
+
"id": str(cvid) if cvid is not None else None,
|
|
220
|
+
"title": a.get("title"),
|
|
221
|
+
"url": f"https://www.bilibili.com/read/cv{cvid}" if cvid else None,
|
|
222
|
+
"view": st.get("view"),
|
|
223
|
+
"like": st.get("like"),
|
|
224
|
+
"reply": st.get("reply"),
|
|
225
|
+
"favorite": st.get("favorite"),
|
|
226
|
+
"coin": st.get("coin"),
|
|
227
|
+
"publish_time": a.get("publish_time"),
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def cmd_articles(jar, args):
|
|
232
|
+
nav, img_key, sub_key = _wbi_keys(jar)
|
|
233
|
+
mid = (nav.get("data") or {}).get("mid")
|
|
234
|
+
if not mid:
|
|
235
|
+
die("could not resolve your mid from nav (cookie expired?).")
|
|
236
|
+
collected, pn = [], 1
|
|
237
|
+
while len(collected) < args.limit:
|
|
238
|
+
signed = _wbi_sign({"mid": mid, "pn": pn, "ps": 30, "sort": "publish_time"},
|
|
239
|
+
img_key, sub_key)
|
|
240
|
+
url = f"{API}/x/space/wbi/article?" + urllib.parse.urlencode(signed)
|
|
241
|
+
# WBI endpoints penalize a Referer header — omit it.
|
|
242
|
+
d = get_json(url, jar)
|
|
243
|
+
if d.get("code") != 0:
|
|
244
|
+
die(f"article list error (code={d.get('code')}): {d.get('message')}")
|
|
245
|
+
data = d.get("data") or {}
|
|
246
|
+
items = data.get("articles") or []
|
|
247
|
+
if not items:
|
|
248
|
+
break
|
|
249
|
+
collected.extend(items)
|
|
250
|
+
total = data.get("count")
|
|
251
|
+
if total is not None and len(collected) >= total:
|
|
252
|
+
break
|
|
253
|
+
pn += 1
|
|
254
|
+
items = collected[: args.limit]
|
|
255
|
+
out({"count": len(items), "articles": [_fmt(a) for a in items]})
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def cmd_article(jar, args):
|
|
259
|
+
cvid = str(args.id).lstrip("cv")
|
|
260
|
+
d = get_json(f"{API}/x/article/viewinfo?id={cvid}", jar)
|
|
261
|
+
if d.get("code") != 0:
|
|
262
|
+
die(f"article {args.id} not found (code={d.get('code')}): {d.get('message')}")
|
|
263
|
+
data = d.get("data") or {}
|
|
264
|
+
st = data.get("stats") or {}
|
|
265
|
+
out({
|
|
266
|
+
"id": cvid,
|
|
267
|
+
"title": data.get("title"),
|
|
268
|
+
"url": f"https://www.bilibili.com/read/cv{cvid}",
|
|
269
|
+
"author": data.get("author_name"),
|
|
270
|
+
"view": st.get("view"), "like": st.get("like"), "reply": st.get("reply"),
|
|
271
|
+
"favorite": st.get("favorite"), "coin": st.get("coin"),
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# tid = cover layout; a wrong one returns code -17 / "分类". Try common ones.
|
|
276
|
+
_TID_CANDIDATES = ["4", "3", "6", "7", "2", "17", "28", "41"]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def cmd_publish(jar, args):
|
|
280
|
+
if not args.title:
|
|
281
|
+
die("--title is required")
|
|
282
|
+
if not args.content_file and args.content is None:
|
|
283
|
+
die("provide --content-file <path.html> or --content <html>")
|
|
284
|
+
content = args.content
|
|
285
|
+
if args.content_file:
|
|
286
|
+
try:
|
|
287
|
+
with open(args.content_file, encoding="utf-8") as f:
|
|
288
|
+
content = f.read()
|
|
289
|
+
except OSError as e:
|
|
290
|
+
die(f"cannot read --content-file: {e}")
|
|
291
|
+
content = content or ""
|
|
292
|
+
csrf = cookie_value(jar, "bili_jct")
|
|
293
|
+
if not csrf:
|
|
294
|
+
die("no bili_jct cookie (CSRF token) — reconnect Bilibili.")
|
|
295
|
+
|
|
296
|
+
if not CONFIRM:
|
|
297
|
+
out({
|
|
298
|
+
"dry_run": True, "command": "publish", "platform": "bilibili",
|
|
299
|
+
"title": args.title, "draft_only": args.draft_only,
|
|
300
|
+
"content_bytes": len(content),
|
|
301
|
+
"note": "Bilibili 专栏 content is HTML. Re-run with --confirm as the "
|
|
302
|
+
"LAST argument to write. The submit step is often 412-limited; "
|
|
303
|
+
"the saved draft is the reliable result.",
|
|
304
|
+
})
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
ref = "https://member.bilibili.com/"
|
|
308
|
+
base = {
|
|
309
|
+
"title": args.title, "content": content, "csrf": csrf,
|
|
310
|
+
"category": "0", "list_id": "0", "reprint": "0", "original": "1",
|
|
311
|
+
"media_id": "0", "spoiler": "0", "save": "0", "pgc_id": "0",
|
|
312
|
+
}
|
|
313
|
+
# 1. save draft, retrying tid until the category is accepted
|
|
314
|
+
aid, last = None, None
|
|
315
|
+
for tid in _TID_CANDIDATES:
|
|
316
|
+
body = dict(base, tid=tid)
|
|
317
|
+
status, text = request("POST", f"{API}/x/article/creative/draft/addupdate",
|
|
318
|
+
jar, referer=ref, form=body)
|
|
319
|
+
try:
|
|
320
|
+
r = json.loads(text)
|
|
321
|
+
except json.JSONDecodeError:
|
|
322
|
+
last = f"non-JSON ({status}): {text[:200]}"
|
|
323
|
+
continue
|
|
324
|
+
if r.get("code") == 0:
|
|
325
|
+
aid = (r.get("data") or {}).get("aid")
|
|
326
|
+
chosen_tid = tid
|
|
327
|
+
break
|
|
328
|
+
last = f"code={r.get('code')} {r.get('message')}"
|
|
329
|
+
if "分类" not in str(r.get("message", "")) and r.get("code") != -17:
|
|
330
|
+
break # a non-category error won't be fixed by another tid
|
|
331
|
+
if not aid:
|
|
332
|
+
die(f"save-draft failed: {last}")
|
|
333
|
+
|
|
334
|
+
if args.draft_only:
|
|
335
|
+
out({"ok": True, "draft_only": True, "aid": str(aid),
|
|
336
|
+
"edit_url": f"https://member.bilibili.com/article-text/home?aid={aid}"})
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
# 2. submit (publish) — may be 412 risk-controlled; report draft if so
|
|
340
|
+
body = dict(base, tid=chosen_tid, aid=aid)
|
|
341
|
+
status, text = request("POST", f"{API}/x/article/creative/article/submit",
|
|
342
|
+
jar, referer=ref, form=body)
|
|
343
|
+
try:
|
|
344
|
+
r = json.loads(text)
|
|
345
|
+
except json.JSONDecodeError:
|
|
346
|
+
r = None
|
|
347
|
+
if r and r.get("code") == 0:
|
|
348
|
+
out({"ok": True, "published": True, "aid": str(aid),
|
|
349
|
+
"url": f"https://www.bilibili.com/read/cv{aid}"})
|
|
350
|
+
else:
|
|
351
|
+
out({"ok": False, "published": False, "draft_saved": True, "aid": str(aid),
|
|
352
|
+
"edit_url": f"https://member.bilibili.com/article-text/home?aid={aid}",
|
|
353
|
+
"note": f"submit blocked ({status}); the draft is saved — finish in the "
|
|
354
|
+
f"web editor. Detail: {(r or {}).get('message') if r else text[:200]}"})
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
COMMANDS = {
|
|
358
|
+
"whoami": cmd_whoami,
|
|
359
|
+
"articles": cmd_articles,
|
|
360
|
+
"article": cmd_article,
|
|
361
|
+
"publish": cmd_publish,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def main() -> None:
|
|
366
|
+
p = argparse.ArgumentParser(prog="bilibili.py", description="bilibili 专栏 cookie CLI")
|
|
367
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
368
|
+
sub.add_parser("whoami", help="show the logged-in account")
|
|
369
|
+
sp = sub.add_parser("articles", help="list the user's 专栏 articles + stats")
|
|
370
|
+
sp.add_argument("--limit", type=int, default=20)
|
|
371
|
+
sp = sub.add_parser("article", help="one article's stats (by cvid)")
|
|
372
|
+
sp.add_argument("id")
|
|
373
|
+
sp = sub.add_parser("publish", help="create/publish a 专栏 article (GATED by trailing --confirm)")
|
|
374
|
+
sp.add_argument("--title")
|
|
375
|
+
sp.add_argument("--content", help="HTML content inline")
|
|
376
|
+
sp.add_argument("--content-file", help="path to an HTML file")
|
|
377
|
+
sp.add_argument("--draft-only", action="store_true", help="save a draft; do NOT submit")
|
|
378
|
+
args = p.parse_args(ARGV)
|
|
379
|
+
jar = load_cookies()
|
|
380
|
+
COMMANDS[args.command](jar, args)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
if __name__ == "__main__":
|
|
384
|
+
main()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: csdn
|
|
3
|
+
description: Read and publish on CSDN (blog.csdn.net) with the user's own login cookies (BYOC) — list their published articles with view/like/comment stats, inspect one article, and publish a new article. Use when the user mentions CSDN, "我的 CSDN 文章", reading their article stats (阅读/点赞), or publishing/发文 to CSDN.
|
|
4
|
+
when_to_use: |
|
|
5
|
+
Trigger for anything on the user's CSDN (blog.csdn.net) account driven by
|
|
6
|
+
their own login cookie: show who they are, list their published articles with
|
|
7
|
+
view / like / comment counts, look at one article's stats, or publish a new
|
|
8
|
+
article. This acts as the user's real account, so writes are gated behind an
|
|
9
|
+
explicit confirmation.
|
|
10
|
+
connections: [csdn]
|
|
11
|
+
allowed_tools: [Bash]
|
|
12
|
+
license: Apache-2.0
|
|
13
|
+
metadata:
|
|
14
|
+
author: acedatacloud
|
|
15
|
+
version: "1.0"
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# csdn — read & publish on CSDN via your own cookies
|
|
19
|
+
|
|
20
|
+
Drives the user's **real** CSDN account through the same web APIs the site's own
|
|
21
|
+
editor uses, authenticated by the login cookie they captured with the ACE
|
|
22
|
+
extension. No browser, no third-party deps — `urllib` + `hmac` (the editor's
|
|
23
|
+
save endpoint requires an HMAC signature, computed with stdlib).
|
|
24
|
+
|
|
25
|
+
The connector injects the cookie jar as an env var:
|
|
26
|
+
|
|
27
|
+
- `CSDN_COOKIES` — a JSON array of cookies. **Secret — never echo or print it.**
|
|
28
|
+
The CLI reads it for you.
|
|
29
|
+
|
|
30
|
+
> CSDN fronts its APIs with a WAF; the CLI already sends a full browser
|
|
31
|
+
> fingerprint so reads aren't 403'd. If you still get a WAF 403, the cookie
|
|
32
|
+
> expired — have the user reconnect.
|
|
33
|
+
|
|
34
|
+
## CLI
|
|
35
|
+
|
|
36
|
+
The skill ships [`scripts/csdn.py`](scripts/csdn.py) — self-contained, stdlib only.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
CSDN=$SKILL_DIR/scripts/csdn.py
|
|
40
|
+
python3 $CSDN whoami # who is logged in (+ total article count)
|
|
41
|
+
python3 $CSDN articles --limit 20 # my published articles + stats
|
|
42
|
+
python3 $CSDN article <article-id> # one article's stats
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Stats come straight from CSDN: `view_count` (阅读), `digg_count` (点赞),
|
|
46
|
+
`comment_count` (评论), `collect_count` (收藏).
|
|
47
|
+
|
|
48
|
+
## Verify the connection first
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
python3 $CSDN whoami
|
|
52
|
+
# → {"username": "...", "nickname": "...", "articles_total": 1597}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
On a WAF 403 / auth error the cookie is expired — tell the user to reconnect at
|
|
56
|
+
<https://auth.acedata.cloud/user/connections>. Do **not** retry in a loop.
|
|
57
|
+
|
|
58
|
+
## Publishing — GATED (dry-run unless trailing `--confirm`)
|
|
59
|
+
|
|
60
|
+
`publish` writes to the user's real account. Content is **Markdown**. Without a
|
|
61
|
+
trailing `--confirm` it dry-runs. `--confirm` is honored **only as the last
|
|
62
|
+
argument**. Always show the dry-run, get an explicit "yes", then re-run with
|
|
63
|
+
`--confirm` last.
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
python3 $CSDN publish --title "标题" --content-file a.md # dry-run
|
|
67
|
+
python3 $CSDN publish --title "标题" --content-file a.md --draft-only --confirm # private draft (status=2)
|
|
68
|
+
python3 $CSDN publish --title "标题" --content-file a.md --tags "AI,Python" --confirm # PUBLIC, goes live
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- `--draft-only` saves a private draft (CSDN `status=2`) — safe, nothing public.
|
|
72
|
+
- Without `--draft-only` the article is **published publicly** under the user's
|
|
73
|
+
name. Default to `--draft-only` unless the user clearly asked to go live.
|
|
74
|
+
- `--tags` is a comma-separated list of article tags.
|
|
75
|
+
|
|
76
|
+
## Gotchas — surface before the user is surprised
|
|
77
|
+
|
|
78
|
+
- **This is the user's real CSDN account.** Confirm before any publish.
|
|
79
|
+
- **Cookie expiry / WAF 403**: reconnect at auth.acedata.cloud/user/connections —
|
|
80
|
+
never loop-retry a WAF block.
|
|
81
|
+
- The editor save endpoint is signed with an HMAC key baked into CSDN's web
|
|
82
|
+
bundle; if CSDN rotates it, publish fails loudly (reads still work).
|
|
83
|
+
- **Never print `CSDN_COOKIES`** — it is full account access.
|
|
84
|
+
- **ToS**: cookie automation acts only on the user's own account with their own
|
|
85
|
+
captured cookie; the user owns that risk.
|