@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acedatacloud/skills",
3
- "version": "2026.622.0",
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",
@@ -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 ![alt](...)
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"![{m.group(1)}]({new or m.group(2)})"
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)
@@ -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 (`![](url)`) 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"![{alt}]({new})"
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)
@@ -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 (`![](url)`) 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
- def _markdown_to_deltas(title: str, content: str) -> list:
249
- """Minimal Markdown → Medium paragraph deltas. Title is a type-3 paragraph;
250
- body blocks split on blank lines; ``#``/``##``/``###`` map to h1/h2/h3,
251
- ``>`` to blockquote, ``` ``` to code, everything else to body text."""
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
+ ``![alt](url)`` 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
- blocks = re.split(r"\n\s*\n", content.strip())
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
- if first.startswith("```"):
262
- # toggle / inline fenced block
263
- body = re.sub(r"^```[^\n]*\n?|\n?```$", "", b)
264
- paras.append({"type": 8, "text": body})
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
- deltas = []
380
+ out_deltas = []
277
381
  for i, p in enumerate(paras):
278
- deltas.append({"type": 1, "index": i,
279
- "paragraph": {"type": p["type"], "text": p["text"], "markups": []}})
280
- return deltas
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 = _markdown_to_deltas(args.title, content)
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)