@acedatacloud/skills 2026.628.2 → 2026.628.3

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.628.2",
3
+ "version": "2026.628.3",
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",
@@ -12,7 +12,7 @@ allowed_tools: [Bash]
12
12
  license: Apache-2.0
13
13
  metadata:
14
14
  author: acedatacloud
15
- version: "1.0"
15
+ version: "1.1"
16
16
  ---
17
17
 
18
18
  # zhihu — read & publish on Zhihu via your own cookies
@@ -71,7 +71,7 @@ never silently go live. Always show the dry-run to the user, get an explicit
71
71
  "yes", then re-run with `--confirm` last.
72
72
 
73
73
  ```sh
74
- # Content is HTML. For Markdown, convert to HTML first.
74
+ # Content is HTML. For Markdown, convert to HTML first (e.g. `pandoc -f gfm -t html`).
75
75
  python3 $BLOG publish --title "标题" --content-file article.html # dry-run
76
76
  python3 $BLOG publish --title "标题" --content-file article.html --draft-only --confirm # save a private draft
77
77
  python3 $BLOG publish --title "标题" --content-file article.html --confirm # PUBLIC, goes live
@@ -80,8 +80,13 @@ python3 $BLOG publish --title "标题" --content-file article.html --confirm
80
80
  - `--draft-only` stops after saving a private draft (safe — nothing public).
81
81
  - Without `--draft-only`, the article is **published publicly** under the user's
82
82
  name. Default to `--draft-only` unless the user clearly asked to go live.
83
- - Images: only image URLs already reachable on the public web are kept as-is;
84
- this CLI does not re-upload local images to Zhihu's CDN.
83
+ - **Images are auto-hosted.** Zhihu strips any `<img>` whose `src` is not on its
84
+ own CDN, so on `--confirm` the CLI re-uploads every external image (HTML
85
+ `<img src>` **and** Markdown `![](url)`, plus `data:` URIs) to Zhihu's image
86
+ service and rewrites the URLs first — images already on `*.zhimg.com` are left
87
+ untouched. The result reports `images: {found, rehosted, failed}`; the dry-run
88
+ reports `images_found`. Pass `--no-images` to skip this. So you can hand the
89
+ CLI HTML/Markdown with normal public image URLs and the pictures survive.
85
90
 
86
91
  ## Gotchas — surface before the user is surprised
87
92
 
@@ -24,13 +24,19 @@ Quick examples:
24
24
  from __future__ import annotations
25
25
 
26
26
  import argparse
27
+ import base64
27
28
  import gzip
29
+ import hashlib
30
+ import hmac
28
31
  import json
29
32
  import os
33
+ import re
30
34
  import sys
35
+ import time
31
36
  import urllib.error
32
37
  import urllib.parse
33
38
  import urllib.request
39
+ from email.utils import formatdate
34
40
 
35
41
  UA = (
36
42
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
@@ -239,6 +245,201 @@ def cmd_article(jar, args):
239
245
  out(res)
240
246
 
241
247
 
248
+ # ── Image re-hosting ────────────────────────────────────────
249
+ #
250
+ # Zhihu silently drops any <img> whose src is not on its own CDN, so external
251
+ # images must be re-hosted first. Primary path: hand Zhihu the remote URL and
252
+ # let it fetch (uploaded_images). Fallback: download the bytes and PUT them to
253
+ # Zhihu's Aliyun-OSS bucket with an HMAC-SHA1-signed request. Ported from
254
+ # PlatformPublisher app/publisher/zhihu.py (proven in production).
255
+
256
+ ZH_UPLOADED_IMAGES = "https://zhuanlan.zhihu.com/api/uploaded_images"
257
+ ZH_IMAGES = "https://api.zhihu.com/images"
258
+ OSS_ENDPOINT = "https://zhihu-pics-upload.zhimg.com"
259
+ OSS_BUCKET = "zhihu-pics"
260
+ _ZHIMG = "zhimg.com" # a src already on Zhihu's CDN needs no re-upload
261
+ _IMG_SRC_RE = re.compile(r'(<img[^>]*?\ssrc=["\'])([^"\']+)(["\'])', re.IGNORECASE)
262
+ _MD_IMG_RE = re.compile(r'(!\[[^\]]*\]\()(\s*<?)([^)\s>]+)(>?\s*\))')
263
+
264
+
265
+ def _raw(method, url, jar, *, headers=None, data=None, timeout=60):
266
+ """Low-level request returning (status, raw_bytes); handles gzip + binary."""
267
+ hdrs = {"User-Agent": UA, "Cookie": cookie_header(jar, url)}
268
+ if headers:
269
+ hdrs.update(headers)
270
+ req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
271
+ try:
272
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
273
+ raw = resp.read()
274
+ if resp.headers.get("Content-Encoding") == "gzip":
275
+ raw = gzip.decompress(raw)
276
+ return resp.status, raw
277
+ except urllib.error.HTTPError as e:
278
+ raw = e.read()
279
+ try:
280
+ if e.headers.get("Content-Encoding") == "gzip":
281
+ raw = gzip.decompress(raw)
282
+ except Exception:
283
+ pass
284
+ return e.code, raw
285
+ except urllib.error.URLError:
286
+ return 0, b""
287
+
288
+
289
+ def _img_content_type(data: bytes) -> str:
290
+ if data[:8].startswith(b"\x89PNG"):
291
+ return "image/png"
292
+ if data[:3] == b"\xff\xd8\xff":
293
+ return "image/jpeg"
294
+ if data[:6] in (b"GIF87a", b"GIF89a"):
295
+ return "image/gif"
296
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
297
+ return "image/webp"
298
+ return "application/octet-stream"
299
+
300
+
301
+ def _upload_image_url(jar, src: str):
302
+ """Primary path: Zhihu fetches the remote URL server-side and hosts it."""
303
+ body = urllib.parse.urlencode({"url": src, "source": "article"}).encode("utf-8")
304
+ status, raw = _raw(
305
+ "POST", ZH_UPLOADED_IMAGES, jar,
306
+ headers={"x-requested-with": "fetch", "Content-Type": "application/x-www-form-urlencoded"},
307
+ data=body,
308
+ )
309
+ if status and status < 400:
310
+ try:
311
+ d = json.loads(raw.decode("utf-8", "replace"))
312
+ except json.JSONDecodeError:
313
+ return None
314
+ if isinstance(d, dict) and d.get("src"):
315
+ return d["src"]
316
+ return None
317
+
318
+
319
+ def _oss_put(object_key: str, data: bytes, token: dict) -> bool:
320
+ access_id = token.get("access_id", "")
321
+ access_key = token.get("access_key", "")
322
+ access_token = token.get("access_token", "")
323
+ if not (access_id and access_key and object_key):
324
+ return False
325
+ ctype = _img_content_type(data)
326
+ oss_date = formatdate(usegmt=True)
327
+ oss_ua = "aliyun-sdk-js/6.8.0"
328
+ oss_headers = {
329
+ "x-oss-date": oss_date,
330
+ "x-oss-security-token": access_token,
331
+ "x-oss-user-agent": oss_ua,
332
+ }
333
+ canon = "\n".join(f"{k}:{oss_headers[k]}" for k in sorted(oss_headers))
334
+ string_to_sign = f"PUT\n\n{ctype}\n{oss_date}\n{canon}\n/{OSS_BUCKET}/{object_key}"
335
+ sig = base64.b64encode(
336
+ hmac.new(access_key.encode("utf-8"), string_to_sign.encode("utf-8"), hashlib.sha1).digest()
337
+ ).decode("utf-8")
338
+ status, _ = _raw(
339
+ "PUT", f"{OSS_ENDPOINT}/{object_key}", [],
340
+ headers={
341
+ "Content-Type": ctype,
342
+ "Authorization": f"OSS {access_id}:{sig}",
343
+ "x-oss-date": oss_date,
344
+ "x-oss-security-token": access_token,
345
+ "x-oss-user-agent": oss_ua,
346
+ },
347
+ data=data,
348
+ )
349
+ return bool(status) and status < 400
350
+
351
+
352
+ def _wait_image_ready(jar, image_id: str) -> str:
353
+ for _ in range(10):
354
+ status, raw = _raw("GET", f"https://api.zhihu.com/images/{image_id}", jar, headers=ZH_FETCH)
355
+ try:
356
+ d = json.loads(raw.decode("utf-8", "replace"))
357
+ except json.JSONDecodeError:
358
+ d = {}
359
+ if d.get("status") == "completed" or d.get("original_hash"):
360
+ return d.get("original_hash", "")
361
+ time.sleep(1.0)
362
+ return ""
363
+
364
+
365
+ def _upload_image_binary(jar, data: bytes):
366
+ """Fallback: MD5 -> request OSS token -> (dedup or) signed PUT to OSS."""
367
+ image_hash = hashlib.md5(data).hexdigest()
368
+ status, raw = _raw(
369
+ "POST", ZH_IMAGES, jar,
370
+ headers={"x-requested-with": "fetch", "Content-Type": "application/json"},
371
+ data=json.dumps({"image_hash": image_hash, "source": "article"}).encode("utf-8"),
372
+ )
373
+ if not status or status >= 400:
374
+ return None
375
+ try:
376
+ token_data = json.loads(raw.decode("utf-8", "replace"))
377
+ except json.JSONDecodeError:
378
+ return None
379
+ upload_file = token_data.get("upload_file") or {}
380
+ upload_token = token_data.get("upload_token") or {}
381
+ if upload_file.get("state") == 1: # already on Zhihu — just resolve its hash
382
+ object_key = _wait_image_ready(jar, upload_file.get("image_id", ""))
383
+ return f"https://pic4.zhimg.com/{object_key}" if object_key else None
384
+ object_key = upload_file.get("object_key", "")
385
+ if not object_key or not _oss_put(object_key, data, upload_token):
386
+ return None
387
+ if data[:6] in (b"GIF87a", b"GIF89a"):
388
+ object_key += ".gif"
389
+ return f"https://pic4.zhimg.com/{object_key}"
390
+
391
+
392
+ def _download_image(src: str):
393
+ # Route through _raw so a gzip-encoded response is decompressed (otherwise we
394
+ # would upload gzipped bytes and Zhihu would reject/garble the image). jar=[]
395
+ # so no cookie is ever sent to a third-party image host.
396
+ status, raw = _raw("GET", src, [], headers={"User-Agent": UA}, timeout=30)
397
+ return raw if status and status < 400 and raw else None
398
+
399
+
400
+ def count_images(content: str) -> int:
401
+ srcs = {m.group(2) for m in _IMG_SRC_RE.finditer(content or "")}
402
+ srcs |= {m.group(3) for m in _MD_IMG_RE.finditer(content or "")}
403
+ return sum(1 for s in srcs if _ZHIMG not in s)
404
+
405
+
406
+ def rehost_images(jar, content: str):
407
+ """Replace every non-Zhihu image URL (HTML <img> + markdown ![]()) with a
408
+ Zhihu-CDN URL. Returns (new_content, stats)."""
409
+ cache: dict = {}
410
+ stats = {"found": 0, "rehosted": 0, "failed": 0}
411
+
412
+ def resolve(src: str) -> str:
413
+ if not src or _ZHIMG in src:
414
+ return src
415
+ if src in cache:
416
+ return cache[src]
417
+ stats["found"] += 1
418
+ new = None
419
+ if src.startswith("data:"):
420
+ try:
421
+ new = _upload_image_binary(jar, base64.b64decode(src.split(",", 1)[1]))
422
+ except Exception:
423
+ new = None
424
+ else:
425
+ new = _upload_image_url(jar, src)
426
+ if not new:
427
+ data = _download_image(src)
428
+ if data:
429
+ new = _upload_image_binary(jar, data)
430
+ if new:
431
+ stats["rehosted"] += 1
432
+ cache[src] = new
433
+ return new
434
+ stats["failed"] += 1
435
+ cache[src] = src # leave as-is; don't corrupt the doc if upload fails
436
+ return src
437
+
438
+ content = _IMG_SRC_RE.sub(lambda m: m.group(1) + resolve(m.group(2)) + m.group(3), content or "")
439
+ content = _MD_IMG_RE.sub(lambda m: m.group(1) + m.group(2) + resolve(m.group(3)) + m.group(4), content)
440
+ return content, stats
441
+
442
+
242
443
  def cmd_publish(jar, args):
243
444
  if not args.title:
244
445
  die("--title is required")
@@ -260,6 +461,7 @@ def cmd_publish(jar, args):
260
461
  "title": args.title,
261
462
  "draft_only": args.draft_only,
262
463
  "content_bytes": len(content or ""),
464
+ "images_found": count_images(content or ""),
263
465
  "note": "re-run with --confirm as the LAST argument to actually write. "
264
466
  "Without --draft-only this publishes a PUBLIC article on the user's real account.",
265
467
  })
@@ -278,7 +480,12 @@ def cmd_publish(jar, args):
278
480
  if not draft_id:
279
481
  die(f"create-draft failed ({status}): {str(created)[:300]}")
280
482
 
281
- # 2. set draft content
483
+ # 2. re-host external images to Zhihu's CDN (Zhihu strips foreign <img>)
484
+ image_stats = {"found": 0, "rehosted": 0, "failed": 0}
485
+ if not args.no_images:
486
+ content, image_stats = rehost_images(jar, content)
487
+
488
+ # 3. set draft content
282
489
  status, text = request(
283
490
  "PATCH", f"https://zhuanlan.zhihu.com/api/articles/{draft_id}/draft", jar,
284
491
  headers=ZH_FETCH, body={"title": args.title, "content": content},
@@ -291,11 +498,12 @@ def cmd_publish(jar, args):
291
498
  "ok": True,
292
499
  "draft_only": True,
293
500
  "draft_id": str(draft_id),
501
+ "images": image_stats,
294
502
  "edit_url": f"https://zhuanlan.zhihu.com/write?draftId={draft_id}",
295
503
  })
296
504
  return
297
505
 
298
- # 3. publish (go live)
506
+ # 4. publish (go live)
299
507
  status, text = request(
300
508
  "PUT", f"https://zhuanlan.zhihu.com/api/articles/{draft_id}/publish", jar,
301
509
  headers=ZH_FETCH, body={},
@@ -306,6 +514,7 @@ def cmd_publish(jar, args):
306
514
  "ok": True,
307
515
  "published": True,
308
516
  "article_id": str(draft_id),
517
+ "images": image_stats,
309
518
  "url": f"https://zhuanlan.zhihu.com/p/{draft_id}",
310
519
  })
311
520
 
@@ -339,6 +548,8 @@ def main() -> None:
339
548
  sp.add_argument("--content-file", help="path to an HTML file")
340
549
  sp.add_argument("--draft-only", action="store_true",
341
550
  help="create a draft only; do NOT go public")
551
+ sp.add_argument("--no-images", action="store_true",
552
+ help="skip re-hosting external images to Zhihu's CDN")
342
553
 
343
554
  args = p.parse_args(ARGV)
344
555
  jar = load_cookies(args.platform)