@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.
|
|
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
|
@@ -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 
|
|
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,
|
|
@@ -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)
|
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,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 (``) 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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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."""
|
|
260
343
|
paras = [{"type": 3, "text": title}]
|
|
261
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
380
|
+
out_deltas = []
|
|
285
381
|
for i, p in enumerate(paras):
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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 =
|
|
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)
|