@acedatacloud/skills 2026.622.0 → 2026.622.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/bilibili/SKILL.md +27 -0
- package/skills/bilibili/scripts/bilibili.py +213 -0
- package/skills/csdn/SKILL.md +8 -0
- package/skills/csdn/scripts/csdn.py +149 -1
- package/skills/medium/SKILL.md +11 -1
- package/skills/medium/scripts/medium.py +131 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.622.
|
|
3
|
+
"version": "2026.622.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",
|
package/skills/bilibili/SKILL.md
CHANGED
|
@@ -38,6 +38,7 @@ BILI=$SKILL_DIR/scripts/bilibili.py
|
|
|
38
38
|
python3 $BILI whoami # who is logged in (mid, name)
|
|
39
39
|
python3 $BILI articles --limit 20 # my 专栏 articles + stats
|
|
40
40
|
python3 $BILI article <cvid> # one article's stats (cv id)
|
|
41
|
+
python3 $BILI drafts --limit 50 # list saved drafts (aid + title)
|
|
41
42
|
```
|
|
42
43
|
|
|
43
44
|
Stats come straight from Bilibili: `view` (阅读), `like` (点赞), `reply` (评论),
|
|
@@ -70,6 +71,32 @@ python3 $BILI publish --title "标题" --content-file a.html --confirm
|
|
|
70
71
|
risk-control (HTTP 412). When that happens the CLI reports the saved draft +
|
|
71
72
|
edit URL so the user can publish from the web editor. Default to `--draft-only`.
|
|
72
73
|
|
|
74
|
+
## Managing drafts (the 999-draft cap)
|
|
75
|
+
|
|
76
|
+
Bilibili caps 专栏 drafts at **999**; once full, saving a new draft fails with
|
|
77
|
+
`code 37106 草稿数已达最大上限`. List drafts and delete the ones you don't need:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
python3 $BILI drafts --limit 50 # list (aid + title)
|
|
81
|
+
python3 $BILI delete-draft <aid> <aid2> ... # dry-run (shows what would delete)
|
|
82
|
+
python3 $BILI delete-draft <aid> <aid2> ... --confirm # PERMANENTLY delete those drafts
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- `delete-draft` is **GATED** (dry-run unless trailing `--confirm`) and deletion
|
|
86
|
+
is **permanent** — always show the dry-run + the titles and get an explicit
|
|
87
|
+
"yes" before `--confirm`. Pass multiple aids to batch a few per call.
|
|
88
|
+
- Never bulk-delete blindly: list first, confirm the titles are junk/duplicates.
|
|
89
|
+
|
|
90
|
+
## Images
|
|
91
|
+
|
|
92
|
+
`publish` automatically re-hosts external images (both `<img src>` and markdown)
|
|
93
|
+
onto Bilibili's CDN (`i0.hdslb.com` / `article.biliimg.com`) before saving —
|
|
94
|
+
Bilibili hotlink-blocks external images and rejects the whole article (`37130`)
|
|
95
|
+
if any external link remains. webp sources (which upcover rejects) are
|
|
96
|
+
transcoded to png via the CDN when possible; an image that still can't upload is
|
|
97
|
+
**dropped** from the article rather than failing the post. `--no-rehost-images`
|
|
98
|
+
skips this.
|
|
99
|
+
|
|
73
100
|
## Gotchas
|
|
74
101
|
|
|
75
102
|
- **This is the user's real Bilibili account.** Confirm before any publish.
|
|
@@ -25,8 +25,12 @@ from __future__ import annotations
|
|
|
25
25
|
import argparse
|
|
26
26
|
import gzip
|
|
27
27
|
import hashlib
|
|
28
|
+
import ipaddress
|
|
28
29
|
import json
|
|
29
30
|
import os
|
|
31
|
+
import random
|
|
32
|
+
import re
|
|
33
|
+
import socket
|
|
30
34
|
import sys
|
|
31
35
|
import time
|
|
32
36
|
import urllib.error
|
|
@@ -276,6 +280,155 @@ def cmd_article(jar, args):
|
|
|
276
280
|
_TID_CANDIDATES = ["4", "3", "6", "7", "2", "17", "28", "41"]
|
|
277
281
|
|
|
278
282
|
|
|
283
|
+
# ── image upload (Bilibili hotlink-blocks external imgs; re-host via
|
|
284
|
+
# upcover, mirroring our PlatformPublisher bilibili.py) ────────────
|
|
285
|
+
|
|
286
|
+
_IMG_SKIP = ("hdslb.com", "bilibili.com", "biliimg.com")
|
|
287
|
+
# matches both HTML <img src="..."> and markdown 
|
|
288
|
+
# <img ... src = "..."> / '...' (tolerates spaces and either quote style)
|
|
289
|
+
_HTML_IMG = re.compile(r"""(<img\b[^>]*?\bsrc\s*=\s*)(["'])(https?://[^"']+)(\2)""", re.IGNORECASE)
|
|
290
|
+
_MD_IMG = re.compile(r"!\[([^\]]*)\]\((https?://[^)\s]+)\)")
|
|
291
|
+
UPCOVER = "https://api.bilibili.com/x/article/creative/article/upcover"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _rand_hex(n):
|
|
295
|
+
return "".join(random.choice("0123456789abcdef") for _ in range(n))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
MAX_IMG_BYTES = 12 * 1024 * 1024
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
302
|
+
# Refuse redirects on image fetches — a 30x could reach an internal host the
|
|
303
|
+
# _assert_public_url() check never saw (SSRF).
|
|
304
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
305
|
+
raise RuntimeError(f"image redirect blocked ({code}) -> {newurl[:80]}")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
_IMG_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _assert_public_url(url):
|
|
312
|
+
"""SSRF guard — block non-http(s) and private/loopback/link-local hosts."""
|
|
313
|
+
parts = urllib.parse.urlsplit(url)
|
|
314
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
315
|
+
raise RuntimeError(f"unsupported image URL: {url[:80]}")
|
|
316
|
+
try:
|
|
317
|
+
addrs = socket.getaddrinfo(parts.hostname, None)
|
|
318
|
+
except OSError as e:
|
|
319
|
+
raise RuntimeError(f"cannot resolve {parts.hostname}: {e}")
|
|
320
|
+
for info in addrs:
|
|
321
|
+
ip = ipaddress.ip_address(info[4][0])
|
|
322
|
+
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
|
323
|
+
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
|
324
|
+
raise RuntimeError(f"blocked non-public image host: {parts.hostname}")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _same_or_sub(host, suffix):
|
|
328
|
+
return host == suffix or host.endswith("." + suffix)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _get_bytes(url):
|
|
332
|
+
_assert_public_url(url)
|
|
333
|
+
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "image/*"})
|
|
334
|
+
with _IMG_OPENER.open(req, timeout=30) as r:
|
|
335
|
+
data = r.read(MAX_IMG_BYTES + 1)
|
|
336
|
+
if len(data) > MAX_IMG_BYTES:
|
|
337
|
+
raise RuntimeError(f"image exceeds {MAX_IMG_BYTES} bytes")
|
|
338
|
+
return data
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _is_webp(data):
|
|
342
|
+
return data[:4] == b"RIFF" and data[8:12] == b"WEBP"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _download_image(url):
|
|
346
|
+
# Bilibili's upcover rejects webp. Many of our images live on Tencent COS
|
|
347
|
+
# (cdn.acedata.cloud) which can transcode server-side, so on a webp body
|
|
348
|
+
# retry with the COS format param to get a png Bilibili accepts.
|
|
349
|
+
data = _get_bytes(url)
|
|
350
|
+
if _is_webp(data):
|
|
351
|
+
sep = "&" if "?" in url else "?"
|
|
352
|
+
try:
|
|
353
|
+
conv = _get_bytes(f"{url}{sep}imageMogr2/format/png")
|
|
354
|
+
if not _is_webp(conv):
|
|
355
|
+
return conv
|
|
356
|
+
except Exception: # noqa: BLE001 — fall through with original bytes
|
|
357
|
+
pass
|
|
358
|
+
return data
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _sniff_image(data):
|
|
362
|
+
"""Real (ext, mime) from magic bytes — a URL's extension or the CDN's
|
|
363
|
+
Content-Type can lie (e.g. a `.png` URL serving webp bytes), and Bilibili
|
|
364
|
+
rejects a filename/format mismatch."""
|
|
365
|
+
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
366
|
+
return "png", "image/png"
|
|
367
|
+
if data[:2] == b"\xff\xd8":
|
|
368
|
+
return "jpg", "image/jpeg"
|
|
369
|
+
if data[:6] in (b"GIF87a", b"GIF89a"):
|
|
370
|
+
return "gif", "image/gif"
|
|
371
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
372
|
+
return "webp", "image/webp"
|
|
373
|
+
return "jpg", "image/jpeg"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def bili_upload_image(jar, csrf, src):
|
|
377
|
+
img = _download_image(src)
|
|
378
|
+
ext, ct = _sniff_image(img)
|
|
379
|
+
boundary = "----acedata" + _rand_hex(20)
|
|
380
|
+
body = (
|
|
381
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="binary";'
|
|
382
|
+
f' filename="image.{ext}"\r\nContent-Type: {ct}\r\n\r\n'.encode()
|
|
383
|
+
+ img + f'\r\n--{boundary}\r\nContent-Disposition: form-data; name="csrf"'
|
|
384
|
+
f"\r\n\r\n{csrf}\r\n--{boundary}--\r\n".encode()
|
|
385
|
+
)
|
|
386
|
+
hdrs = {"User-Agent": UA, "Origin": "https://member.bilibili.com",
|
|
387
|
+
"Referer": "https://member.bilibili.com/",
|
|
388
|
+
"Content-Type": f"multipart/form-data; boundary={boundary}"}
|
|
389
|
+
req = urllib.request.Request(UPCOVER, data=body, headers=hdrs, method="POST")
|
|
390
|
+
req.add_unredirected_header("Cookie", cookie_header(jar, UPCOVER))
|
|
391
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
392
|
+
d = json.loads(r.read().decode("utf-8", "replace"))
|
|
393
|
+
if d.get("code") == 0 and (d.get("data") or {}).get("url"):
|
|
394
|
+
return d["data"]["url"]
|
|
395
|
+
raise RuntimeError(f"upcover failed (code={d.get('code')}): {d.get('message')}")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def rehost_images(jar, csrf, content):
|
|
399
|
+
"""Re-host external images (both <img src> and markdown) on Bilibili's CDN.
|
|
400
|
+
An image that can't be uploaded is DROPPED (not kept as an external link) —
|
|
401
|
+
Bilibili rejects the whole article with code 37130 if any external image
|
|
402
|
+
link remains. Rate-limited to dodge code -1."""
|
|
403
|
+
def _one(src):
|
|
404
|
+
host = (urllib.parse.urlsplit(src).hostname or "").lower()
|
|
405
|
+
if any(_same_or_sub(host, s) for s in _IMG_SKIP):
|
|
406
|
+
return "keep", src
|
|
407
|
+
try:
|
|
408
|
+
new = bili_upload_image(jar, csrf, src)
|
|
409
|
+
sys.stderr.write(f"[img] rehosted {src} -> {new}\n")
|
|
410
|
+
time.sleep(0.3)
|
|
411
|
+
return "ok", new
|
|
412
|
+
except Exception as e: # noqa: BLE001 — drop the image rather than fail
|
|
413
|
+
sys.stderr.write(f"[img] dropped {src} (upload failed: {e})\n")
|
|
414
|
+
return "drop", None
|
|
415
|
+
|
|
416
|
+
def html_repl(m):
|
|
417
|
+
# groups: 1=<img ... src= , 2=quote, 3=url, 4=closing quote (backref)
|
|
418
|
+
st, new = _one(m.group(3))
|
|
419
|
+
if st == "drop":
|
|
420
|
+
return ""
|
|
421
|
+
return m.group(1) + m.group(2) + (new or m.group(3)) + m.group(2)
|
|
422
|
+
|
|
423
|
+
def md_repl(m):
|
|
424
|
+
st, new = _one(m.group(2))
|
|
425
|
+
return "" if st == "drop" else f"})"
|
|
426
|
+
|
|
427
|
+
content = _HTML_IMG.sub(html_repl, content)
|
|
428
|
+
content = _MD_IMG.sub(md_repl, content)
|
|
429
|
+
return content
|
|
430
|
+
|
|
431
|
+
|
|
279
432
|
def cmd_publish(jar, args):
|
|
280
433
|
if not args.title:
|
|
281
434
|
die("--title is required")
|
|
@@ -304,6 +457,10 @@ def cmd_publish(jar, args):
|
|
|
304
457
|
})
|
|
305
458
|
return
|
|
306
459
|
|
|
460
|
+
# Bilibili hotlink-blocks external images → re-host them on its CDN first.
|
|
461
|
+
if not args.no_rehost_images:
|
|
462
|
+
content = rehost_images(jar, csrf, content)
|
|
463
|
+
|
|
307
464
|
ref = "https://member.bilibili.com/"
|
|
308
465
|
base = {
|
|
309
466
|
"title": args.title, "content": content, "csrf": csrf,
|
|
@@ -354,11 +511,60 @@ def cmd_publish(jar, args):
|
|
|
354
511
|
f"web editor. Detail: {(r or {}).get('message') if r else text[:200]}"})
|
|
355
512
|
|
|
356
513
|
|
|
514
|
+
def _drafts_of(d: dict) -> list:
|
|
515
|
+
al = (d.get("data") or {}).get("artlist") or d.get("artlist") or {}
|
|
516
|
+
return al.get("drafts") or []
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def cmd_drafts(jar, args):
|
|
520
|
+
# 专栏 drafts are capped at 999; this lists them so they can be pruned.
|
|
521
|
+
d = get_json(f"{API}/x/article/creative/draft/list?pn={args.page}&ps={args.limit}",
|
|
522
|
+
jar, referer="https://member.bilibili.com/")
|
|
523
|
+
if d.get("code"): # 0 = ok; anything truthy is an auth/API error
|
|
524
|
+
die(f"draft list error (code={d.get('code')}): {d.get('message')} — "
|
|
525
|
+
f"cookie may be expired; reconnect at https://auth.acedata.cloud/user/connections.")
|
|
526
|
+
items = _drafts_of(d)
|
|
527
|
+
out({"page": args.page, "count": len(items), "drafts": [{
|
|
528
|
+
"aid": x.get("id"),
|
|
529
|
+
"title": x.get("title"),
|
|
530
|
+
"edit_url": f"https://member.bilibili.com/article-text/home?aid={x.get('id')}",
|
|
531
|
+
} for x in items]})
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def cmd_delete_draft(jar, args):
|
|
535
|
+
if not args.aids:
|
|
536
|
+
die("provide one or more draft aids: delete-draft <aid> [<aid> ...] --confirm")
|
|
537
|
+
bad = [a for a in args.aids if not str(a).isdigit()]
|
|
538
|
+
if bad:
|
|
539
|
+
die(f"invalid draft aid(s) — must be numeric: {bad}")
|
|
540
|
+
csrf = cookie_value(jar, "bili_jct")
|
|
541
|
+
if not csrf:
|
|
542
|
+
die("no bili_jct cookie (CSRF token) — reconnect Bilibili.")
|
|
543
|
+
if not CONFIRM:
|
|
544
|
+
out({"dry_run": True, "command": "delete-draft", "platform": "bilibili",
|
|
545
|
+
"aids": args.aids, "note": "Deletion is PERMANENT. Re-run with --confirm "
|
|
546
|
+
"as the LAST argument to actually delete these draft(s)."})
|
|
547
|
+
return
|
|
548
|
+
results = []
|
|
549
|
+
for aid in args.aids:
|
|
550
|
+
_, text = request("POST", f"{API}/x/article/creative/draft/delete", jar,
|
|
551
|
+
referer="https://member.bilibili.com/", form={"aid": aid, "csrf": csrf})
|
|
552
|
+
try:
|
|
553
|
+
code = json.loads(text).get("code")
|
|
554
|
+
except json.JSONDecodeError:
|
|
555
|
+
code = None
|
|
556
|
+
results.append({"aid": aid, "deleted": code == 0, "code": code})
|
|
557
|
+
out({"command": "delete-draft", "deleted": sum(1 for r in results if r["deleted"]),
|
|
558
|
+
"results": results})
|
|
559
|
+
|
|
560
|
+
|
|
357
561
|
COMMANDS = {
|
|
358
562
|
"whoami": cmd_whoami,
|
|
359
563
|
"articles": cmd_articles,
|
|
360
564
|
"article": cmd_article,
|
|
361
565
|
"publish": cmd_publish,
|
|
566
|
+
"drafts": cmd_drafts,
|
|
567
|
+
"delete-draft": cmd_delete_draft,
|
|
362
568
|
}
|
|
363
569
|
|
|
364
570
|
|
|
@@ -375,6 +581,13 @@ def main() -> None:
|
|
|
375
581
|
sp.add_argument("--content", help="HTML content inline")
|
|
376
582
|
sp.add_argument("--content-file", help="path to an HTML file")
|
|
377
583
|
sp.add_argument("--draft-only", action="store_true", help="save a draft; do NOT submit")
|
|
584
|
+
sp.add_argument("--no-rehost-images", action="store_true",
|
|
585
|
+
help="keep external image URLs as-is (skip Bilibili CDN re-host)")
|
|
586
|
+
sp = sub.add_parser("drafts", help="list 专栏 drafts (id+title); use to prune the 999-draft cap")
|
|
587
|
+
sp.add_argument("--limit", type=int, default=50)
|
|
588
|
+
sp.add_argument("--page", type=int, default=1)
|
|
589
|
+
sp = sub.add_parser("delete-draft", help="delete draft(s) by aid (GATED by trailing --confirm)")
|
|
590
|
+
sp.add_argument("aids", nargs="*", help="one or more draft aids to delete")
|
|
378
591
|
args = p.parse_args(ARGV)
|
|
379
592
|
jar = load_cookies()
|
|
380
593
|
COMMANDS[args.command](jar, args)
|
package/skills/csdn/SKILL.md
CHANGED
|
@@ -73,6 +73,14 @@ python3 $CSDN publish --title "标题" --content-file a.md --tags "AI,Python" --
|
|
|
73
73
|
name. Default to `--draft-only` unless the user clearly asked to go live.
|
|
74
74
|
- `--tags` is a comma-separated list of article tags.
|
|
75
75
|
|
|
76
|
+
## Images
|
|
77
|
+
|
|
78
|
+
`publish` automatically re-hosts external markdown images (``) onto
|
|
79
|
+
CSDN's own CDN (`i-blog.csdnimg.cn`) before saving — CSDN 防盗链 blocks external
|
|
80
|
+
images, and saving an article full of external URLs can even time out. Images
|
|
81
|
+
already on `csdnimg.cn` are left alone; pass `--no-rehost-images` to skip. An
|
|
82
|
+
image that fails to upload keeps its original URL (never blocks the post).
|
|
83
|
+
|
|
76
84
|
## Gotchas — surface before the user is surprised
|
|
77
85
|
|
|
78
86
|
- **This is the user's real CSDN account.** Confirm before any publish.
|
|
@@ -28,8 +28,13 @@ import base64
|
|
|
28
28
|
import gzip
|
|
29
29
|
import hashlib
|
|
30
30
|
import hmac
|
|
31
|
+
import ipaddress
|
|
31
32
|
import json
|
|
33
|
+
import mimetypes
|
|
32
34
|
import os
|
|
35
|
+
import random
|
|
36
|
+
import re
|
|
37
|
+
import socket
|
|
33
38
|
import sys
|
|
34
39
|
import urllib.error
|
|
35
40
|
import urllib.parse
|
|
@@ -127,7 +132,8 @@ def _browser_headers(jar: list, url: str, referer: str, origin: str) -> dict:
|
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
|
|
130
|
-
def request(method: str, url: str, jar: list, *, referer, origin, headers=None, body=None
|
|
135
|
+
def request(method: str, url: str, jar: list, *, referer, origin, headers=None, body=None,
|
|
136
|
+
nonfatal=False):
|
|
131
137
|
hdrs = _browser_headers(jar, url, referer, origin)
|
|
132
138
|
if headers:
|
|
133
139
|
hdrs.update(headers)
|
|
@@ -154,6 +160,8 @@ def request(method: str, url: str, jar: list, *, referer, origin, headers=None,
|
|
|
154
160
|
pass
|
|
155
161
|
return e.code, raw.decode("utf-8", "replace")
|
|
156
162
|
except urllib.error.URLError as e:
|
|
163
|
+
if nonfatal:
|
|
164
|
+
raise RuntimeError(f"network error reaching {url}: {e.reason}")
|
|
157
165
|
die(f"network error reaching {url}: {e.reason}")
|
|
158
166
|
|
|
159
167
|
|
|
@@ -274,6 +282,140 @@ def cmd_article(jar, args):
|
|
|
274
282
|
die(f"article {args.id} not found among your published articles")
|
|
275
283
|
|
|
276
284
|
|
|
285
|
+
# ── image upload (CSDN 防盗链s external images; re-host them first) ───
|
|
286
|
+
# Signed signature → Huawei OBS multipart → csdnimg.cn URL. Mirrors our
|
|
287
|
+
# PlatformPublisher csdn.py flow.
|
|
288
|
+
|
|
289
|
+
_MD_IMG = re.compile(r"!\[([^\]]*)\]\((https?://[^)\s]+)\)")
|
|
290
|
+
_IMG_SKIP = ("csdnimg.cn", "csdn.net")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _rand_hex(n):
|
|
294
|
+
return "".join(random.choice("0123456789abcdef") for _ in range(n))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _multipart(files, data):
|
|
298
|
+
boundary = "----acedata" + _rand_hex(20)
|
|
299
|
+
parts = []
|
|
300
|
+
for k, v in data.items():
|
|
301
|
+
parts.append((f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"'
|
|
302
|
+
f"\r\n\r\n{v}\r\n").encode())
|
|
303
|
+
for field, (filename, blob, ctype) in files.items():
|
|
304
|
+
parts.append((f'--{boundary}\r\nContent-Disposition: form-data; name="{field}";'
|
|
305
|
+
f' filename="{filename}"\r\nContent-Type: {ctype}\r\n\r\n').encode())
|
|
306
|
+
parts.append(blob)
|
|
307
|
+
parts.append(b"\r\n")
|
|
308
|
+
parts.append(f"--{boundary}--\r\n".encode())
|
|
309
|
+
return b"".join(parts), boundary
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
MAX_IMG_BYTES = 12 * 1024 * 1024
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
316
|
+
# Refuse redirects on image fetches — otherwise a 30x could send the request
|
|
317
|
+
# to an internal host that the _assert_public_url() check never saw (SSRF).
|
|
318
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
319
|
+
raise RuntimeError(f"image redirect blocked ({code}) -> {newurl[:80]}")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
_IMG_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _assert_public_url(url):
|
|
326
|
+
"""Block non-http(s) and private/loopback/link-local hosts (SSRF guard) —
|
|
327
|
+
article content can carry arbitrary image URLs."""
|
|
328
|
+
parts = urllib.parse.urlsplit(url)
|
|
329
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
330
|
+
raise RuntimeError(f"unsupported image URL: {url[:80]}")
|
|
331
|
+
try:
|
|
332
|
+
addrs = socket.getaddrinfo(parts.hostname, None)
|
|
333
|
+
except OSError as e:
|
|
334
|
+
raise RuntimeError(f"cannot resolve {parts.hostname}: {e}")
|
|
335
|
+
for info in addrs:
|
|
336
|
+
ip = ipaddress.ip_address(info[4][0])
|
|
337
|
+
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
|
338
|
+
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
|
339
|
+
raise RuntimeError(f"blocked non-public image host: {parts.hostname}")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _download_image(url):
|
|
343
|
+
_assert_public_url(url)
|
|
344
|
+
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
345
|
+
with _IMG_OPENER.open(req, timeout=30) as r:
|
|
346
|
+
data = r.read(MAX_IMG_BYTES + 1)
|
|
347
|
+
if len(data) > MAX_IMG_BYTES:
|
|
348
|
+
raise RuntimeError(f"image exceeds {MAX_IMG_BYTES} bytes")
|
|
349
|
+
return data
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _same_or_sub(host, suffix):
|
|
353
|
+
return host == suffix or host.endswith("." + suffix)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _ext_of(url, default="png"):
|
|
357
|
+
tail = url.rsplit("/", 1)[-1].split("?")[0]
|
|
358
|
+
if "." in tail:
|
|
359
|
+
e = tail.rsplit(".", 1)[-1].lower()
|
|
360
|
+
if e in ("jpg", "jpeg", "png", "gif", "webp"):
|
|
361
|
+
return e
|
|
362
|
+
return default
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def csdn_upload_image(jar, src) -> str:
|
|
366
|
+
img = _download_image(src)
|
|
367
|
+
ext = _ext_of(src)
|
|
368
|
+
# 1. signed signature request
|
|
369
|
+
sig_path = "/resource-api/v1/image/direct/upload/signature"
|
|
370
|
+
sign = ca_sign("POST", sig_path, "application/json")
|
|
371
|
+
sign["Accept"] = "*/*"
|
|
372
|
+
status, text = request("POST", f"{BIZ}{sig_path}", jar, referer="https://editor.csdn.net/",
|
|
373
|
+
origin="https://editor.csdn.net", headers=sign, nonfatal=True,
|
|
374
|
+
body={"imageTemplate": "", "appName": "direct_blog_markdown",
|
|
375
|
+
"imageSuffix": ext})
|
|
376
|
+
sd = json.loads(text)
|
|
377
|
+
if sd.get("code") != 200 or not sd.get("data"):
|
|
378
|
+
raise RuntimeError(f"signature failed ({sd.get('code')}): {sd.get('message')}")
|
|
379
|
+
info = sd["data"]
|
|
380
|
+
cp = info.get("customParam") or {}
|
|
381
|
+
# 2. multipart POST to Huawei OBS (no signing/cookies — the policy authorizes it)
|
|
382
|
+
form = {
|
|
383
|
+
"key": info["filePath"], "policy": info["policy"], "signature": info["signature"],
|
|
384
|
+
"callbackBody": info["callbackBody"], "callbackBodyType": info["callbackBodyType"],
|
|
385
|
+
"callbackUrl": info["callbackUrl"], "AccessKeyId": info["accessId"],
|
|
386
|
+
"x:rtype": cp.get("rtype", ""), "x:filePath": cp.get("filePath", ""),
|
|
387
|
+
"x:isAudit": str(cp.get("isAudit", 0)), "x:x-image-app": cp.get("x-image-app", ""),
|
|
388
|
+
"x:type": cp.get("type", ""), "x:x-image-suffix": cp.get("x-image-suffix", ""),
|
|
389
|
+
"x:username": cp.get("username", ""),
|
|
390
|
+
}
|
|
391
|
+
body, boundary = _multipart({"file": (f"image.{ext}", img, f"image/{ext}")}, form)
|
|
392
|
+
req = urllib.request.Request(info["host"], data=body, method="POST", headers={
|
|
393
|
+
"User-Agent": UA, "Content-Type": f"multipart/form-data; boundary={boundary}"})
|
|
394
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
395
|
+
od = json.loads(r.read().decode("utf-8", "replace"))
|
|
396
|
+
if od.get("code") == 200 and (od.get("data") or {}).get("imageUrl"):
|
|
397
|
+
return od["data"]["imageUrl"]
|
|
398
|
+
raise RuntimeError(f"OBS upload failed: {str(od)[:200]}")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def rehost_images(jar, content):
|
|
402
|
+
"""Re-host external markdown images on CSDN's CDN (防盗链 blocks external).
|
|
403
|
+
Best-effort: keep the original URL if an image fails."""
|
|
404
|
+
def repl(m):
|
|
405
|
+
alt, src = m.group(1), m.group(2)
|
|
406
|
+
host = (urllib.parse.urlsplit(src).hostname or "").lower()
|
|
407
|
+
if any(_same_or_sub(host, s) for s in _IMG_SKIP):
|
|
408
|
+
return m.group(0)
|
|
409
|
+
try:
|
|
410
|
+
new = csdn_upload_image(jar, src)
|
|
411
|
+
sys.stderr.write(f"[img] rehosted {src} -> {new}\n")
|
|
412
|
+
return f""
|
|
413
|
+
except Exception as e: # noqa: BLE001 — best-effort
|
|
414
|
+
sys.stderr.write(f"[img] kept {src} (upload failed: {e})\n")
|
|
415
|
+
return m.group(0)
|
|
416
|
+
return _MD_IMG.sub(repl, content)
|
|
417
|
+
|
|
418
|
+
|
|
277
419
|
def cmd_publish(jar, args):
|
|
278
420
|
if not args.title:
|
|
279
421
|
die("--title is required")
|
|
@@ -299,6 +441,10 @@ def cmd_publish(jar, args):
|
|
|
299
441
|
})
|
|
300
442
|
return
|
|
301
443
|
|
|
444
|
+
# CSDN 防盗链s external images → re-host them on CSDN's CDN first.
|
|
445
|
+
if not args.no_rehost_images:
|
|
446
|
+
content = rehost_images(jar, content)
|
|
447
|
+
|
|
302
448
|
path = "/blog-console-api/v3/mdeditor/saveArticle"
|
|
303
449
|
url = f"{BIZ}{path}"
|
|
304
450
|
status_code = 2 if args.draft_only else 0
|
|
@@ -366,6 +512,8 @@ def main() -> None:
|
|
|
366
512
|
sp.add_argument("--content-file", help="path to a Markdown file")
|
|
367
513
|
sp.add_argument("--draft-only", action="store_true", help="save a private draft; do NOT go public")
|
|
368
514
|
sp.add_argument("--tags", help="comma-separated article tags")
|
|
515
|
+
sp.add_argument("--no-rehost-images", action="store_true",
|
|
516
|
+
help="keep external image URLs as-is (skip CSDN CDN re-host)")
|
|
369
517
|
args = p.parse_args(ARGV)
|
|
370
518
|
jar = load_cookies()
|
|
371
519
|
COMMANDS[args.command](jar, args)
|
package/skills/medium/SKILL.md
CHANGED
|
@@ -65,12 +65,22 @@ Publishing is Medium's multi-step editor flow (new-story → write deltas →
|
|
|
65
65
|
publish). `--draft-only` stops at the draft (visible only at the user's
|
|
66
66
|
`/me/stories/drafts`). Default to `--draft-only` unless the user asked to go live.
|
|
67
67
|
|
|
68
|
+
## Images
|
|
69
|
+
|
|
70
|
+
`publish` automatically uploads each external markdown image (``) to
|
|
71
|
+
Medium and inserts it as a real image block — Medium has no markdown-image
|
|
72
|
+
syntax, so this is the only way images render. Pass `--no-rehost-images` to
|
|
73
|
+
degrade images to link-only paragraphs. An image that fails to upload falls back
|
|
74
|
+
to a link paragraph (never blocks the post).
|
|
75
|
+
|
|
68
76
|
## Gotchas
|
|
69
77
|
|
|
70
78
|
- **This is the user's real Medium account.** Confirm before any publish.
|
|
71
79
|
- Markdown→Medium conversion is paragraph-level (headings, quotes, code, body);
|
|
72
80
|
complex inline formatting / images aren't converted — the user can polish in
|
|
73
81
|
the Medium editor before going public.
|
|
74
|
-
- Medium sits behind Cloudflare; an occasional 403/429 is transient
|
|
82
|
+
- Medium sits behind Cloudflare; an occasional 403/429 is transient — the CLI
|
|
83
|
+
auto-retries once after a short pause. A *persistent* 403 means the cookie is
|
|
84
|
+
genuinely expired (reconnect).
|
|
75
85
|
- **Never print `MEDIUM_COOKIES`** — it is full account access.
|
|
76
86
|
- **ToS**: acts only on the user's own account with their own captured cookie.
|
|
@@ -26,10 +26,15 @@ from __future__ import annotations
|
|
|
26
26
|
|
|
27
27
|
import argparse
|
|
28
28
|
import gzip
|
|
29
|
+
import ipaddress
|
|
29
30
|
import json
|
|
31
|
+
import mimetypes
|
|
30
32
|
import os
|
|
33
|
+
import random
|
|
31
34
|
import re
|
|
35
|
+
import socket
|
|
32
36
|
import sys
|
|
37
|
+
import time
|
|
33
38
|
import urllib.error
|
|
34
39
|
import urllib.parse
|
|
35
40
|
import urllib.request
|
|
@@ -147,8 +152,15 @@ def request(method, url, jar, *, headers=None, body=None, accept="application/js
|
|
|
147
152
|
die(f"network error reaching {url}: {e.reason}")
|
|
148
153
|
|
|
149
154
|
|
|
150
|
-
def api(method, url, jar, *, body=None):
|
|
155
|
+
def api(method, url, jar, *, body=None, _retried=False):
|
|
151
156
|
status, text = request(method, url, jar, body=body)
|
|
157
|
+
# Medium sits behind Cloudflare, which intermittently 403s/429s an otherwise
|
|
158
|
+
# valid session; one retry after a short pause clears the transient block.
|
|
159
|
+
# Only retry idempotent GETs — never replay a POST (new-story/deltas/publish),
|
|
160
|
+
# which could duplicate a write if the origin already processed it.
|
|
161
|
+
if status in (403, 429) and method == "GET" and not _retried:
|
|
162
|
+
time.sleep(1.5)
|
|
163
|
+
return api(method, url, jar, body=body, _retried=True)
|
|
152
164
|
if status in (401, 403):
|
|
153
165
|
die(f"auth failed ({status}) on {url} — cookie likely expired. "
|
|
154
166
|
f"Reconnect at https://auth.acedata.cloud/user/connections.")
|
|
@@ -245,25 +257,117 @@ def cmd_article(jar, args):
|
|
|
245
257
|
})
|
|
246
258
|
|
|
247
259
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
260
|
+
# ── image upload (Medium has no markdown image; upload bytes → image
|
|
261
|
+
# paragraph delta type 4) ─────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
_MD_IMG = re.compile(r"!\[([^\]]*)\]\((https?://[^)\s]+)\)")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _rand_hex(n):
|
|
267
|
+
return "".join(random.choice("0123456789abcdef") for _ in range(n))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
MAX_IMG_BYTES = 12 * 1024 * 1024
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
274
|
+
# Refuse redirects on image fetches — a 30x could reach an internal host the
|
|
275
|
+
# _assert_public_url() check never saw (SSRF).
|
|
276
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
277
|
+
raise RuntimeError(f"image redirect blocked ({code}) -> {newurl[:80]}")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
_IMG_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _assert_public_url(url):
|
|
284
|
+
"""SSRF guard — block non-http(s) and private/loopback/link-local hosts."""
|
|
285
|
+
parts = urllib.parse.urlsplit(url)
|
|
286
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
287
|
+
raise RuntimeError(f"unsupported image URL: {url[:80]}")
|
|
288
|
+
try:
|
|
289
|
+
addrs = socket.getaddrinfo(parts.hostname, None)
|
|
290
|
+
except OSError as e:
|
|
291
|
+
raise RuntimeError(f"cannot resolve {parts.hostname}: {e}")
|
|
292
|
+
for info in addrs:
|
|
293
|
+
ip = ipaddress.ip_address(info[4][0])
|
|
294
|
+
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
|
295
|
+
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
|
296
|
+
raise RuntimeError(f"blocked non-public image host: {parts.hostname}")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _download_image(url):
|
|
300
|
+
_assert_public_url(url)
|
|
301
|
+
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
302
|
+
with _IMG_OPENER.open(req, timeout=30) as r:
|
|
303
|
+
data = r.read(MAX_IMG_BYTES + 1)
|
|
304
|
+
ct = (r.headers.get("Content-Type") or "image/png").split(";")[0].strip()
|
|
305
|
+
if len(data) > MAX_IMG_BYTES:
|
|
306
|
+
raise RuntimeError(f"image exceeds {MAX_IMG_BYTES} bytes")
|
|
307
|
+
return data, (ct if ct.startswith("image/") else "image/png")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def medium_upload_image(jar, post_id, img_bytes, content_type):
|
|
311
|
+
"""POST the bytes to Medium's /_/upload and return (fileId, w, h)."""
|
|
312
|
+
ext = (mimetypes.guess_extension(content_type) or ".png").lstrip(".")
|
|
313
|
+
boundary = "----acedata" + _rand_hex(20)
|
|
314
|
+
body = (
|
|
315
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="uploadedFile";'
|
|
316
|
+
f' filename="image.{ext}"\r\nContent-Type: {content_type}\r\n\r\n'.encode()
|
|
317
|
+
+ img_bytes + f"\r\n--{boundary}--\r\n".encode()
|
|
318
|
+
)
|
|
319
|
+
xsrf = cookie_value(jar, "xsrf")
|
|
320
|
+
hdrs = {
|
|
321
|
+
"User-Agent": UA, "Accept": "application/json, text/plain, */*",
|
|
322
|
+
"Origin": BASE, "Referer": f"{BASE}/p/{post_id}/edit",
|
|
323
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
324
|
+
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
325
|
+
}
|
|
326
|
+
if xsrf:
|
|
327
|
+
hdrs["x-xsrf-token"] = xsrf
|
|
328
|
+
url = f"{BASE}/_/upload?is2x=true"
|
|
329
|
+
req = urllib.request.Request(url, data=body, headers=hdrs, method="POST")
|
|
330
|
+
req.add_unredirected_header("Cookie", cookie_header(jar, url))
|
|
331
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
332
|
+
text = r.read().decode("utf-8", "replace")
|
|
333
|
+
val = (json.loads(_PREFIX.sub("", text)).get("payload") or {}).get("value") or {}
|
|
334
|
+
if not val.get("fileId"):
|
|
335
|
+
raise RuntimeError(f"upload returned no fileId: {text[:200]}")
|
|
336
|
+
return val["fileId"], val.get("imgWidth"), val.get("imgHeight")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _build_deltas(jar, post_id, title, content, rehost=True):
|
|
340
|
+
"""Markdown → Medium paragraph deltas (image-aware). A standalone
|
|
341
|
+
```` block is uploaded to Medium and emitted as an image
|
|
342
|
+
paragraph (type 4); on upload failure it degrades to a link paragraph."""
|
|
252
343
|
paras = [{"type": 3, "text": title}]
|
|
253
|
-
|
|
254
|
-
in_code = False
|
|
255
|
-
code_buf = []
|
|
256
|
-
for block in blocks:
|
|
344
|
+
for block in re.split(r"\n\s*\n", content.strip()):
|
|
257
345
|
b = block.strip("\n")
|
|
258
346
|
if not b.strip():
|
|
259
347
|
continue
|
|
260
348
|
first = b.lstrip()
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
349
|
+
m = _MD_IMG.fullmatch(first)
|
|
350
|
+
if m:
|
|
351
|
+
alt, src = m.group(1), m.group(2)
|
|
352
|
+
if rehost:
|
|
353
|
+
try:
|
|
354
|
+
data, ct = _download_image(src)
|
|
355
|
+
fid, w, h = medium_upload_image(jar, post_id, data, ct)
|
|
356
|
+
sys.stderr.write(f"[img] uploaded {src} -> {fid}\n")
|
|
357
|
+
meta = {"id": fid}
|
|
358
|
+
if w:
|
|
359
|
+
meta["originalWidth"] = w
|
|
360
|
+
if h:
|
|
361
|
+
meta["originalHeight"] = h
|
|
362
|
+
paras.append({"type": 4, "text": alt or "", "layout": 1, "metadata": meta})
|
|
363
|
+
continue
|
|
364
|
+
except Exception as e: # noqa: BLE001 — degrade to a link
|
|
365
|
+
sys.stderr.write(f"[img] link-only {src} (upload failed: {e})\n")
|
|
366
|
+
paras.append({"type": 1, "text": f"{alt or 'image'}: {src}"})
|
|
265
367
|
continue
|
|
266
|
-
if first.startswith("
|
|
368
|
+
if first.startswith("```"):
|
|
369
|
+
paras.append({"type": 8, "text": re.sub(r"^```[^\n]*\n?|\n?```$", "", b)})
|
|
370
|
+
elif first.startswith("# "):
|
|
267
371
|
paras.append({"type": 12, "text": first[2:].strip()})
|
|
268
372
|
elif first.startswith("## "):
|
|
269
373
|
paras.append({"type": 2, "text": first[3:].strip()})
|
|
@@ -273,11 +377,15 @@ def _markdown_to_deltas(title: str, content: str) -> list:
|
|
|
273
377
|
paras.append({"type": 6, "text": first[2:].strip()})
|
|
274
378
|
else:
|
|
275
379
|
paras.append({"type": 1, "text": b.replace("\n", " ").strip()})
|
|
276
|
-
|
|
380
|
+
out_deltas = []
|
|
277
381
|
for i, p in enumerate(paras):
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
382
|
+
para = {"type": p["type"], "text": p["text"], "markups": []}
|
|
383
|
+
if "layout" in p:
|
|
384
|
+
para["layout"] = p["layout"]
|
|
385
|
+
if "metadata" in p:
|
|
386
|
+
para["metadata"] = p["metadata"]
|
|
387
|
+
out_deltas.append({"type": 1, "index": i, "paragraph": para})
|
|
388
|
+
return out_deltas
|
|
281
389
|
|
|
282
390
|
|
|
283
391
|
def cmd_publish(jar, args):
|
|
@@ -319,8 +427,9 @@ def cmd_publish(jar, args):
|
|
|
319
427
|
if base_rev is None:
|
|
320
428
|
base_rev = 0
|
|
321
429
|
|
|
322
|
-
# 3. write the body as paragraph deltas
|
|
323
|
-
deltas =
|
|
430
|
+
# 3. write the body as paragraph deltas (images uploaded to Medium inline)
|
|
431
|
+
deltas = _build_deltas(jar, post_id, args.title, content,
|
|
432
|
+
rehost=not args.no_rehost_images)
|
|
324
433
|
api("POST", f"{BASE}/p/{post_id}/deltas", jar,
|
|
325
434
|
body={"baseRev": base_rev, "deltas": deltas})
|
|
326
435
|
|
|
@@ -356,6 +465,8 @@ def main() -> None:
|
|
|
356
465
|
sp.add_argument("--content", help="Markdown content inline")
|
|
357
466
|
sp.add_argument("--content-file", help="path to a Markdown file")
|
|
358
467
|
sp.add_argument("--draft-only", action="store_true", help="create a draft only; do NOT go public")
|
|
468
|
+
sp.add_argument("--no-rehost-images", action="store_true",
|
|
469
|
+
help="don't upload images to Medium (degrade to link-only paragraphs)")
|
|
359
470
|
args = p.parse_args(ARGV)
|
|
360
471
|
jar = load_cookies()
|
|
361
472
|
COMMANDS[args.command](jar, args)
|