@acedatacloud/skills 2026.628.1 → 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.
|
|
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",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Unattended Scheduled-Task Confirmation
|
|
2
|
+
|
|
3
|
+
Write-capable Skill helpers may support unattended scheduled-task execution with
|
|
4
|
+
a common `--unattended-confirm` flag. The flag must only bypass the normal
|
|
5
|
+
dry-run/confirmation flow when all platform-provided environment checks pass:
|
|
6
|
+
|
|
7
|
+
- `AICHAT_UNATTENDED_MODE=true`
|
|
8
|
+
- `AICHAT_ACTIVE_SKILL` matches the helper's Skill slug
|
|
9
|
+
- `AICHAT_ACTIVE_SKILL` is present in `AICHAT_UNATTENDED_ALLOWED_SKILLS`
|
|
10
|
+
- `AICHAT_UNATTENDED_EXPIRES_AT`, when present, is still in the future
|
|
11
|
+
|
|
12
|
+
If any check fails, the helper must return a dry-run style response and must not
|
|
13
|
+
perform the write/send/publish/delete action. The current Skills packaging does
|
|
14
|
+
not automatically bundle `_shared` files into each Skill, so helpers that need
|
|
15
|
+
runtime code should vendor a small guard module in their own `scripts/` folder
|
|
16
|
+
until packaging grows shared-runtime support.
|
|
@@ -7,11 +7,12 @@ import argparse
|
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
9
|
import sys
|
|
10
|
-
import time
|
|
11
10
|
import urllib.error
|
|
12
11
|
import urllib.parse
|
|
13
12
|
import urllib.request
|
|
14
13
|
|
|
14
|
+
from unattended import unattended_confirm_allowed
|
|
15
|
+
|
|
15
16
|
|
|
16
17
|
BASE_URL = os.environ.get("PERSONALWECHAT_BASE_URL", "").rstrip("/")
|
|
17
18
|
API_TOKEN = os.environ.get("PERSONALWECHAT_API_TOKEN", "")
|
|
@@ -28,34 +29,6 @@ def _json(data) -> None:
|
|
|
28
29
|
print(json.dumps(data, ensure_ascii=False, default=str))
|
|
29
30
|
|
|
30
31
|
|
|
31
|
-
def unattended_confirm_allowed() -> tuple[bool, str]:
|
|
32
|
-
if os.environ.get("AICHAT_UNATTENDED_MODE") != "true":
|
|
33
|
-
return False, "not running in AceDataCloud unattended scheduled-task mode"
|
|
34
|
-
|
|
35
|
-
active_skill = os.environ.get("AICHAT_ACTIVE_SKILL", "")
|
|
36
|
-
if active_skill not in SKILL_SLUGS:
|
|
37
|
-
return False, f"active skill {active_skill or '<empty>'!r} is not personal-wechat"
|
|
38
|
-
|
|
39
|
-
raw_allowed = os.environ.get("AICHAT_UNATTENDED_ALLOWED_SKILLS", "[]")
|
|
40
|
-
try:
|
|
41
|
-
allowed = json.loads(raw_allowed)
|
|
42
|
-
except json.JSONDecodeError:
|
|
43
|
-
return False, "AICHAT_UNATTENDED_ALLOWED_SKILLS is not valid JSON"
|
|
44
|
-
if not isinstance(allowed, list) or active_skill not in allowed:
|
|
45
|
-
return False, f"skill {active_skill!r} is not pre-authorized for unattended confirmation"
|
|
46
|
-
|
|
47
|
-
expires_raw = os.environ.get("AICHAT_UNATTENDED_EXPIRES_AT", "")
|
|
48
|
-
if expires_raw:
|
|
49
|
-
try:
|
|
50
|
-
expires_at = int(expires_raw)
|
|
51
|
-
except ValueError:
|
|
52
|
-
return False, "AICHAT_UNATTENDED_EXPIRES_AT is invalid"
|
|
53
|
-
if expires_at < int(time.time()):
|
|
54
|
-
return False, "unattended authorization has expired"
|
|
55
|
-
|
|
56
|
-
return True, "ok"
|
|
57
|
-
|
|
58
|
-
|
|
59
32
|
def request(method: str, path: str, *, params: dict | None = None, body: dict | None = None):
|
|
60
33
|
if not BASE_URL:
|
|
61
34
|
_die("PERSONALWECHAT_BASE_URL is not set. Reconnect the Personal WeChat connector.")
|
|
@@ -220,7 +193,7 @@ def main() -> None:
|
|
|
220
193
|
_json(request_task("POST", "/api/search", body={"query": args.query}, timeout=120))
|
|
221
194
|
elif args.cmd == "send":
|
|
222
195
|
if args.unattended_confirm:
|
|
223
|
-
allowed, reason = unattended_confirm_allowed()
|
|
196
|
+
allowed, reason = unattended_confirm_allowed(SKILL_SLUGS)
|
|
224
197
|
if not allowed:
|
|
225
198
|
_json({"dry_run": True, "target": args.target, "text": args.text, "error": "unattended_confirmation_denied", "reason": reason})
|
|
226
199
|
return
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Unattended scheduled-task confirmation guard bundled with this Skill."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def unattended_confirm_allowed(skill_slugs: Iterable[str]) -> tuple[bool, str]:
|
|
12
|
+
"""Return whether the active Skill is pre-authorized for unattended writes."""
|
|
13
|
+
|
|
14
|
+
if os.environ.get("AICHAT_UNATTENDED_MODE") != "true":
|
|
15
|
+
return False, "not running in AceDataCloud unattended scheduled-task mode"
|
|
16
|
+
|
|
17
|
+
active_skill = os.environ.get("AICHAT_ACTIVE_SKILL", "")
|
|
18
|
+
allowed_skill_slugs = set(skill_slugs)
|
|
19
|
+
if active_skill not in allowed_skill_slugs:
|
|
20
|
+
return False, f"active skill {active_skill or '<empty>'!r} is not one of {sorted(allowed_skill_slugs)!r}"
|
|
21
|
+
|
|
22
|
+
raw_allowed = os.environ.get("AICHAT_UNATTENDED_ALLOWED_SKILLS", "[]")
|
|
23
|
+
try:
|
|
24
|
+
allowed = json.loads(raw_allowed)
|
|
25
|
+
except json.JSONDecodeError:
|
|
26
|
+
return False, "AICHAT_UNATTENDED_ALLOWED_SKILLS is not valid JSON"
|
|
27
|
+
if not isinstance(allowed, list) or active_skill not in allowed:
|
|
28
|
+
return False, f"skill {active_skill!r} is not pre-authorized for unattended confirmation"
|
|
29
|
+
|
|
30
|
+
expires_raw = os.environ.get("AICHAT_UNATTENDED_EXPIRES_AT", "")
|
|
31
|
+
if expires_raw:
|
|
32
|
+
try:
|
|
33
|
+
expires_at = int(expires_raw)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return False, "AICHAT_UNATTENDED_EXPIRES_AT is invalid"
|
|
36
|
+
if expires_at < int(time.time()):
|
|
37
|
+
return False, "unattended authorization has expired"
|
|
38
|
+
|
|
39
|
+
return True, "ok"
|
package/skills/zhihu/SKILL.md
CHANGED
|
@@ -12,7 +12,7 @@ allowed_tools: [Bash]
|
|
|
12
12
|
license: Apache-2.0
|
|
13
13
|
metadata:
|
|
14
14
|
author: acedatacloud
|
|
15
|
-
version: "1.
|
|
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
|
|
84
|
-
|
|
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 ``, 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.
|
|
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
|
-
#
|
|
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)
|