@acedatacloud/skills 2026.622.1 → 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.1",
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",
@@ -87,6 +87,16 @@ python3 $BILI delete-draft <aid> <aid2> ... --confirm # PERMANENTLY delete those
87
87
  "yes" before `--confirm`. Pass multiple aids to batch a few per call.
88
88
  - Never bulk-delete blindly: list first, confirm the titles are junk/duplicates.
89
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
+
90
100
  ## Gotchas
91
101
 
92
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,
@@ -424,6 +581,8 @@ def main() -> None:
424
581
  sp.add_argument("--content", help="HTML content inline")
425
582
  sp.add_argument("--content-file", help="path to an HTML file")
426
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)")
427
586
  sp = sub.add_parser("drafts", help="list 专栏 drafts (id+title); use to prune the 999-draft cap")
428
587
  sp.add_argument("--limit", type=int, default=50)
429
588
  sp.add_argument("--page", type=int, default=1)
@@ -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,6 +65,14 @@ 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.
@@ -26,9 +26,13 @@ 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
33
37
  import time
34
38
  import urllib.error
@@ -253,25 +257,117 @@ def cmd_article(jar, args):
253
257
  })
254
258
 
255
259
 
256
- def _markdown_to_deltas(title: str, content: str) -> list:
257
- """Minimal Markdown → Medium paragraph deltas. Title is a type-3 paragraph;
258
- body blocks split on blank lines; ``#``/``##``/``###`` map to h1/h2/h3,
259
- ``>`` 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."""
260
343
  paras = [{"type": 3, "text": title}]
261
- blocks = re.split(r"\n\s*\n", content.strip())
262
- in_code = False
263
- code_buf = []
264
- for block in blocks:
344
+ for block in re.split(r"\n\s*\n", content.strip()):
265
345
  b = block.strip("\n")
266
346
  if not b.strip():
267
347
  continue
268
348
  first = b.lstrip()
269
- if first.startswith("```"):
270
- # toggle / inline fenced block
271
- body = re.sub(r"^```[^\n]*\n?|\n?```$", "", b)
272
- 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}"})
273
367
  continue
274
- if first.startswith("# "):
368
+ if first.startswith("```"):
369
+ paras.append({"type": 8, "text": re.sub(r"^```[^\n]*\n?|\n?```$", "", b)})
370
+ elif first.startswith("# "):
275
371
  paras.append({"type": 12, "text": first[2:].strip()})
276
372
  elif first.startswith("## "):
277
373
  paras.append({"type": 2, "text": first[3:].strip()})
@@ -281,11 +377,15 @@ def _markdown_to_deltas(title: str, content: str) -> list:
281
377
  paras.append({"type": 6, "text": first[2:].strip()})
282
378
  else:
283
379
  paras.append({"type": 1, "text": b.replace("\n", " ").strip()})
284
- deltas = []
380
+ out_deltas = []
285
381
  for i, p in enumerate(paras):
286
- deltas.append({"type": 1, "index": i,
287
- "paragraph": {"type": p["type"], "text": p["text"], "markups": []}})
288
- 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
289
389
 
290
390
 
291
391
  def cmd_publish(jar, args):
@@ -327,8 +427,9 @@ def cmd_publish(jar, args):
327
427
  if base_rev is None:
328
428
  base_rev = 0
329
429
 
330
- # 3. write the body as paragraph deltas
331
- 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)
332
433
  api("POST", f"{BASE}/p/{post_id}/deltas", jar,
333
434
  body={"baseRev": base_rev, "deltas": deltas})
334
435
 
@@ -364,6 +465,8 @@ def main() -> None:
364
465
  sp.add_argument("--content", help="Markdown content inline")
365
466
  sp.add_argument("--content-file", help="path to a Markdown file")
366
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)")
367
470
  args = p.parse_args(ARGV)
368
471
  jar = load_cookies()
369
472
  COMMANDS[args.command](jar, args)