@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acedatacloud/skills",
3
- "version": "2026.621.6",
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.