@acedatacloud/skills 2026.630.1 → 2026.630.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.630.1",
3
+ "version": "2026.630.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",
@@ -1,18 +1,19 @@
1
1
  ---
2
2
  name: zhihu
3
- description: Read and publish on Zhihu (知乎) with the user's own login cookies (BYOC) — list their published articles with vote/comment stats, inspect one article, and publish a new article. Use when the user mentions 知乎 / Zhihu, "我的知乎文章", reading their article stats (点赞/评论), or publishing/发文 to Zhihu.
3
+ description: Read and publish on Zhihu (知乎) with the user's own login cookies (BYOC) — list published articles & answers with vote/comment stats, inspect an article/answer/question, publish an article, and answer or edit answers to questions. Use when the user mentions 知乎 / Zhihu, "我的知乎文章/回答", reading their stats (点赞/评论), 发文, or 回答/编辑某个问题的回答.
4
4
  when_to_use: |
5
5
  Trigger for anything on the user's Zhihu (知乎) account driven by their own
6
- login cookie: show who they are, list their published articles with
7
- vote-up / comment counts, look at one article's stats, or publish a new
8
- article. This acts as the user's real account, so writes are gated behind
9
- an explicit confirmation.
6
+ login cookie: show who they are, list their published articles or answers
7
+ with vote/comment counts, look at one article / answer / question, publish a
8
+ new article, post a new answer to a question, or edit an existing answer.
9
+ This acts as the user's real account, so writes are gated behind an explicit
10
+ confirmation.
10
11
  connections: [zhihu]
11
12
  allowed_tools: [Bash]
12
13
  license: Apache-2.0
13
14
  metadata:
14
15
  author: acedatacloud
15
- version: "1.1"
16
+ version: "1.2"
16
17
  ---
17
18
 
18
19
  # zhihu — read & publish on Zhihu via your own cookies
@@ -37,6 +38,9 @@ BLOG=$SKILL_DIR/scripts/blog.py
37
38
  python3 $BLOG whoami # who is logged in
38
39
  python3 $BLOG articles --limit 20 # my published articles + stats
39
40
  python3 $BLOG article <article-id> # one article's details + stats
41
+ python3 $BLOG answers --limit 20 # my published answers + stats
42
+ python3 $BLOG answer <answer-id> # one answer's details + stats
43
+ python3 $BLOG question <question-id> # a question's info + whether I answered it
40
44
  ```
41
45
 
42
46
  ## Verify the connection first
@@ -56,11 +60,21 @@ extension). Do **not** retry in a loop.
56
60
  |---|---|
57
61
  | Who am I | `python3 $BLOG whoami` |
58
62
  | My latest articles + vote/comment counts | `python3 $BLOG articles --limit 20` |
59
- | Next page | `python3 $BLOG articles --limit 20 --offset 20` |
63
+ | My latest answers + like/favorite/comment counts | `python3 $BLOG answers --limit 20` |
64
+ | Next page (any list) | add `--offset 20` |
60
65
  | One article's stats | `python3 $BLOG article <id>` |
66
+ | One answer's stats (incl. 赞同 voteup) | `python3 $BLOG answer <id>` |
67
+ | A question's info + my answer id (if any) | `python3 $BLOG question <id>` |
61
68
 
62
- Stats come straight from Zhihu: `voteup_count` (赞同), `comment_count` (评论).
63
- Zhihu does not expose per-article read counts on these endpoints.
69
+ Article stats: `voteup_count` (赞同), `comment_count` (评论). Zhihu does not
70
+ expose per-article read counts on these endpoints.
71
+
72
+ **Answer stat caveat:** the `answers` *list* endpoint does **not** return
73
+ `voteup_count` (赞同) — it only exposes `like_count` (喜欢), `favorite_count`
74
+ (收藏) and `comment_count`. For the authoritative 赞同 count of an answer, call
75
+ `answer <id>` (the single-answer endpoint returns `voteup_count`). `question <id>`
76
+ reports `already_answered` + `my_answer_id` so you know whether to use
77
+ `answer-question` (new) or `edit-answer` (update).
64
78
 
65
79
  ## Publishing — GATED (dry-run unless trailing `--confirm`)
66
80
 
@@ -88,10 +102,46 @@ python3 $BLOG publish --title "标题" --content-file article.html --confirm
88
102
  reports `images_found`. Pass `--no-images` to skip this. So you can hand the
89
103
  CLI HTML/Markdown with normal public image URLs and the pictures survive.
90
104
 
105
+ ## Answering questions — GATED (dry-run unless trailing `--confirm`)
106
+
107
+ Two write commands cover the question/answer side. Both gate exactly like
108
+ `publish`: no trailing `--confirm` → **dry-run**; `--confirm` is honored **only
109
+ as the last argument**. Always show the dry-run, get an explicit "yes", then
110
+ re-run with `--confirm` last.
111
+
112
+ ```sh
113
+ # Content is HTML (same as articles). For Markdown, convert to HTML first.
114
+
115
+ # Post a NEW answer to a question
116
+ python3 $BLOG answer-question --question <qid> --content-file ans.html # dry-run
117
+ python3 $BLOG answer-question --question <qid> --content-file ans.html --draft-only --confirm # PRIVATE draft (safe)
118
+ python3 $BLOG answer-question --question <qid> --content-file ans.html --confirm # PUBLIC, goes live
119
+
120
+ # Edit an EXISTING answer (replaces its live, public content)
121
+ python3 $BLOG edit-answer --id <answer-id> --content-file ans.html # dry-run
122
+ python3 $BLOG edit-answer --id <answer-id> --content-file ans.html --confirm # overwrites live answer
123
+ ```
124
+
125
+ - **One answer per question.** Zhihu allows a single answer per user per
126
+ question. If the user already answered, `answer-question --confirm` returns a
127
+ clear error telling you to use `edit-answer` instead — find the existing answer
128
+ id with `answers` or `question <qid>` (which reports `my_answer_id`).
129
+ - **`--draft-only` is the safe path for new answers** — it saves a *private*
130
+ draft on the question (nothing public). The user reviews it on Zhihu, then you
131
+ re-run without `--draft-only` (with `--confirm`) to publish. Prefer this unless
132
+ the user clearly asked to go live immediately.
133
+ - **`edit-answer` has no private mode** — the answer is already public, so any
134
+ `--confirm` edit is live immediately. It preserves the answer's current repost
135
+ setting unless you override with `--repost allowed|disallowed`.
136
+ - **转载授权** (`--repost allowed|disallowed`): new answers default to
137
+ `disallowed` (don't grant repost rights); editing keeps the current setting.
138
+ - **Images are auto-hosted** for answers too — same behavior and `--no-images`
139
+ flag as `publish`.
140
+
91
141
  ## Gotchas — surface before the user is surprised
92
142
 
93
- - **This is the user's real Zhihu account.** Confirm before any publish; reading
94
- exposes their own private drafts.
143
+ - **This is the user's real Zhihu account.** Confirm before any publish / answer /
144
+ edit; reading exposes their own private drafts.
95
145
  - **Cookie expiry**: Zhihu cookies are short-lived. A `401`/`403` means
96
146
  reconnect at auth.acedata.cloud/user/connections — never loop-retry.
97
147
  - **ToS**: cookie automation is against most platforms' terms. This only ever
@@ -165,6 +165,12 @@ ZH = {
165
165
  "articles": "https://www.zhihu.com/api/v4/members/{token}/articles",
166
166
  "article": "https://www.zhihu.com/api/v4/articles/{id}",
167
167
  "create_draft": "https://zhuanlan.zhihu.com/api/articles/drafts",
168
+ # Answers (回答) — the question/answer side of Zhihu, distinct from articles.
169
+ "member_answers": "https://www.zhihu.com/api/v4/members/{token}/answers",
170
+ "answer": "https://www.zhihu.com/api/v4/answers/{id}",
171
+ "question": "https://www.zhihu.com/api/v4/questions/{id}",
172
+ "question_answers": "https://www.zhihu.com/api/v4/questions/{id}/answers",
173
+ "question_draft": "https://www.zhihu.com/api/v4/questions/{id}/draft",
168
174
  }
169
175
  ZH_FETCH = {"x-requested-with": "fetch"}
170
176
 
@@ -245,6 +251,141 @@ def cmd_article(jar, args):
245
251
  out(res)
246
252
 
247
253
 
254
+ # ── Answers (回答) ───────────────────────────────────────────────────
255
+ #
256
+ # Answers are Zhihu's question/answer side, separate from articles. A user has
257
+ # at most ONE answer per question (a second POST returns RepeatedActionException
258
+ # 103003 — surface that and point at edit-answer). Writes go through the same
259
+ # cookie-only web APIs as articles; no x-zse signature is required.
260
+
261
+ ZH_ANSWER_LIST_INCLUDE = (
262
+ "data[*].comment_count,created_time,updated_time,question"
263
+ )
264
+
265
+
266
+ def _fmt_answer(a: dict) -> dict:
267
+ aid = a.get("id")
268
+ q = a.get("question") or {}
269
+ qid = q.get("id")
270
+ # The member/answers endpoint never returns voteup_count (赞同); it only
271
+ # exposes like_count / favorites under reaction.statistics. The authoritative
272
+ # 赞同 count lives on the single-answer detail endpoint (`answer <id>`).
273
+ react = (a.get("reaction") or {}).get("statistics") or {}
274
+ return {
275
+ "answer_id": str(aid) if aid is not None else None,
276
+ "question_id": str(qid) if qid is not None else None,
277
+ "question_title": q.get("title"),
278
+ "url": (f"https://www.zhihu.com/question/{qid}/answer/{aid}" if (qid and aid) else None),
279
+ "voteup_count": a.get("voteup_count"),
280
+ "like_count": react.get("like_count"),
281
+ "favorite_count": react.get("favorites"),
282
+ "comment_count": a.get("comment_count"),
283
+ "created_time": a.get("created_time"),
284
+ "updated_time": a.get("updated_time"),
285
+ }
286
+
287
+
288
+ def cmd_answers(jar, args):
289
+ me = zh_me(jar)
290
+ token = me.get("url_token")
291
+ if not token:
292
+ die("Zhihu profile has no url_token; cannot list answers.")
293
+ url = ZH["member_answers"].format(token=token)
294
+ q = urllib.parse.urlencode({
295
+ "include": ZH_ANSWER_LIST_INCLUDE,
296
+ "limit": args.limit,
297
+ "offset": args.offset,
298
+ })
299
+ _, data = get_json(f"{url}?{q}", jar, headers=ZH_FETCH)
300
+ items = data.get("data", []) if isinstance(data, dict) else []
301
+ out({
302
+ "total": (data.get("paging") or {}).get("totals") if isinstance(data, dict) else None,
303
+ "count": len(items),
304
+ "answers": [_fmt_answer(a) for a in items],
305
+ })
306
+
307
+
308
+ def cmd_answer(jar, args):
309
+ base = ZH["answer"].format(id=args.id)
310
+ q = urllib.parse.urlencode({
311
+ "include": "content,voteup_count,comment_count,created_time,updated_time,question",
312
+ })
313
+ _, a = get_json(f"{base}?{q}", jar, headers=ZH_FETCH)
314
+ if not isinstance(a, dict) or not a.get("id"):
315
+ die(f"answer {args.id} not found or not accessible: {str(a)[:300]}")
316
+ res = _fmt_answer(a)
317
+ res["content_excerpt"] = (a.get("excerpt") or "")[:200]
318
+ out(res)
319
+
320
+
321
+ def cmd_question(jar, args):
322
+ base = ZH["question"].format(id=args.id)
323
+ q = urllib.parse.urlencode({
324
+ "include": "answer_count,follower_count,visit_count,comment_count,detail",
325
+ })
326
+ _, qd = get_json(f"{base}?{q}", jar, headers=ZH_FETCH)
327
+ if not isinstance(qd, dict) or not (qd.get("id") or qd.get("title")):
328
+ die(f"question {args.id} not found or not accessible: {str(qd)[:300]}")
329
+ # Detect whether the logged-in user already answered this question so the
330
+ # caller knows to use `edit-answer` instead of `answer-question`.
331
+ mine = _my_answer_for_question(jar, args.id)
332
+ out({
333
+ "question_id": str(qd.get("id") or args.id),
334
+ "title": qd.get("title"),
335
+ "url": f"https://www.zhihu.com/question/{args.id}",
336
+ "answer_count": qd.get("answer_count"),
337
+ "follower_count": qd.get("follower_count"),
338
+ "visit_count": qd.get("visit_count"),
339
+ "detail_excerpt": _strip_tags(qd.get("detail") or "")[:300],
340
+ "my_answer_id": mine,
341
+ "already_answered": bool(mine),
342
+ })
343
+
344
+
345
+ _TAG_RE = re.compile(r"<[^>]+>")
346
+
347
+
348
+ def _strip_tags(html: str) -> str:
349
+ return _TAG_RE.sub("", html or "").strip()
350
+
351
+
352
+ def _my_answer_for_question(jar, question_id: str):
353
+ """Return the logged-in user's answer id for a question, or None.
354
+
355
+ Scans the *user's own* answers (bounded by their answer count) and matches
356
+ on question id. This is reliable even for popular questions — unlike walking
357
+ the question's own answer list, which may have thousands of entries. For an
358
+ account with more answers than the scan cap this is best-effort.
359
+ """
360
+ me = zh_me(jar)
361
+ token = me.get("url_token")
362
+ if not token:
363
+ return None
364
+ url = ZH["member_answers"].format(token=token)
365
+ qid = str(question_id)
366
+ offset = 0
367
+ for _ in range(15): # up to 300 of the user's own answers
368
+ q = urllib.parse.urlencode({
369
+ "include": "data[*].question",
370
+ "limit": 20,
371
+ "offset": offset,
372
+ })
373
+ status, data = request("GET", f"{url}?{q}", jar, headers=ZH_FETCH)
374
+ try:
375
+ data = json.loads(data)
376
+ except json.JSONDecodeError:
377
+ return None
378
+ items = data.get("data", []) if isinstance(data, dict) else []
379
+ for a in items:
380
+ if str((a.get("question") or {}).get("id")) == qid:
381
+ return str(a.get("id"))
382
+ if (data.get("paging") or {}).get("is_end", True) or not items:
383
+ return None
384
+ offset += 20
385
+ return None
386
+
387
+
388
+
248
389
  # ── Image re-hosting ────────────────────────────────────────
249
390
  #
250
391
  # Zhihu silently drops any <img> whose src is not on its own CDN, so external
@@ -539,11 +680,187 @@ def cmd_publish(jar, args):
539
680
  })
540
681
 
541
682
 
683
+ def _read_content(args) -> str:
684
+ content = args.content
685
+ if args.content_file:
686
+ try:
687
+ with open(args.content_file, encoding="utf-8") as f:
688
+ content = f.read()
689
+ except OSError as e:
690
+ die(f"cannot read --content-file: {e}")
691
+ if content is None:
692
+ die("provide --content-file <path> or --content <html>")
693
+ return content
694
+
695
+
696
+ def _reshipment(value):
697
+ # Zhihu accepts "allowed" / "disallowed" for 转载授权 (repost permission).
698
+ return value if value in ("allowed", "disallowed") else None
699
+
700
+
701
+ def cmd_answer_question(jar, args):
702
+ """Post a NEW answer to a question (GATED). --draft-only saves a private draft."""
703
+ qid = args.question
704
+ if not qid:
705
+ die("--question <question-id> is required")
706
+ content = _read_content(args)
707
+ # Fetch the question title so the dry-run shows what is being answered.
708
+ title = None
709
+ try:
710
+ _, qd = get_json(
711
+ f"{ZH['question'].format(id=qid)}?{urllib.parse.urlencode({'include': 'title'})}",
712
+ jar, headers=ZH_FETCH,
713
+ )
714
+ title = qd.get("title") if isinstance(qd, dict) else None
715
+ except SystemExit:
716
+ raise
717
+ except Exception:
718
+ pass
719
+
720
+ reship = _reshipment(args.repost) or "disallowed"
721
+
722
+ if not CONFIRM:
723
+ out({
724
+ "dry_run": True,
725
+ "command": "answer-question",
726
+ "platform": "zhihu",
727
+ "question_id": str(qid),
728
+ "question_title": title,
729
+ "draft_only": args.draft_only,
730
+ "reshipment_settings": reship,
731
+ "content_bytes": len(content),
732
+ "images_found": count_images(content),
733
+ "note": "re-run with --confirm as the LAST argument to actually write. "
734
+ "Without --draft-only this publishes a PUBLIC answer on the user's real "
735
+ "account. One answer per question — if you already answered, use edit-answer.",
736
+ })
737
+ return
738
+
739
+ image_stats = {"found": 0, "rehosted": 0, "failed": 0}
740
+ if not args.no_images:
741
+ content, image_stats = rehost_images(jar, content)
742
+
743
+ if args.draft_only:
744
+ # Private autosave draft on the question — nothing goes public.
745
+ status, text = request(
746
+ "PUT", ZH["question_draft"].format(id=qid), jar,
747
+ headers=ZH_FETCH, body={"content": content, "delta_time": 5},
748
+ )
749
+ if status >= 400:
750
+ die(f"save-draft failed ({status}) for question {qid}: {text[:300]}")
751
+ out({
752
+ "ok": True,
753
+ "draft_only": True,
754
+ "question_id": str(qid),
755
+ "images": image_stats,
756
+ "review_url": f"https://www.zhihu.com/question/{qid}",
757
+ "note": "Private draft saved. Open the question to review, then re-run "
758
+ "without --draft-only (and --confirm) to publish.",
759
+ })
760
+ return
761
+
762
+ status, text = request(
763
+ "POST", ZH["question_answers"].format(id=qid), jar,
764
+ headers=ZH_FETCH, body={"content": content, "reshipment_settings": reship},
765
+ )
766
+ try:
767
+ res = json.loads(text)
768
+ except json.JSONDecodeError:
769
+ res = {}
770
+ if status >= 400:
771
+ # Zhihu's `error` is usually a dict {code, name, message} but can be a
772
+ # bare string; guard both. `code` may arrive as int or str.
773
+ err = res.get("error") if isinstance(res, dict) and isinstance(res.get("error"), dict) else {}
774
+ if str(err.get("code")) == "103003":
775
+ die(
776
+ f"you already answered question {qid}; Zhihu allows one answer per "
777
+ f"question. Find your answer id with `answers` and use "
778
+ f"`edit-answer --id <answer-id>` instead."
779
+ )
780
+ die(f"publish answer failed ({status}) for question {qid}: {text[:300]}")
781
+ aid = res.get("id") if isinstance(res, dict) else None
782
+ if not aid:
783
+ die(f"publish answer returned no id ({status}): {text[:300]}")
784
+ out({
785
+ "ok": True,
786
+ "published": True,
787
+ "question_id": str(qid),
788
+ "answer_id": str(aid),
789
+ "images": image_stats,
790
+ "url": f"https://www.zhihu.com/question/{qid}/answer/{aid}",
791
+ })
792
+
793
+
794
+ def cmd_edit_answer(jar, args):
795
+ """Edit an EXISTING answer's content (GATED). The answer stays public."""
796
+ aid = args.id
797
+ if not aid:
798
+ die("--id <answer-id> is required")
799
+ content = _read_content(args)
800
+ # Read the current answer to preserve its repost setting and show context.
801
+ cur = {}
802
+ try:
803
+ _, cur = get_json(
804
+ f"{ZH['answer'].format(id=aid)}?{urllib.parse.urlencode({'include': 'reshipment_settings,question'})}",
805
+ jar, headers=ZH_FETCH,
806
+ )
807
+ except SystemExit:
808
+ raise
809
+ except Exception:
810
+ cur = {}
811
+ if not isinstance(cur, dict):
812
+ cur = {}
813
+ # Keep the answer's current repost setting; if it couldn't be read and the
814
+ # user gave no override, fall back to the conservative "disallowed" rather
815
+ # than silently granting repost rights.
816
+ reship = _reshipment(args.repost) or cur.get("reshipment_settings") or "disallowed"
817
+ q = cur.get("question") or {}
818
+
819
+ if not CONFIRM:
820
+ out({
821
+ "dry_run": True,
822
+ "command": "edit-answer",
823
+ "platform": "zhihu",
824
+ "answer_id": str(aid),
825
+ "question_title": q.get("title"),
826
+ "reshipment_settings": reship,
827
+ "content_bytes": len(content),
828
+ "images_found": count_images(content),
829
+ "note": "re-run with --confirm as the LAST argument to actually overwrite. "
830
+ "This replaces the live, public answer's content on the user's real account.",
831
+ })
832
+ return
833
+
834
+ image_stats = {"found": 0, "rehosted": 0, "failed": 0}
835
+ if not args.no_images:
836
+ content, image_stats = rehost_images(jar, content)
837
+
838
+ status, text = request(
839
+ "PUT", ZH["answer"].format(id=aid), jar,
840
+ headers=ZH_FETCH, body={"content": content, "reshipment_settings": reship},
841
+ )
842
+ if status >= 400:
843
+ die(f"edit answer failed ({status}) for {aid}: {text[:300]}")
844
+ qid = q.get("id")
845
+ out({
846
+ "ok": True,
847
+ "edited": True,
848
+ "answer_id": str(aid),
849
+ "images": image_stats,
850
+ "url": (f"https://www.zhihu.com/question/{qid}/answer/{aid}" if qid else None),
851
+ })
852
+
853
+
542
854
  COMMANDS = {
543
855
  "whoami": cmd_whoami,
544
856
  "articles": cmd_articles,
545
857
  "article": cmd_article,
546
858
  "publish": cmd_publish,
859
+ "answers": cmd_answers,
860
+ "answer": cmd_answer,
861
+ "question": cmd_question,
862
+ "answer-question": cmd_answer_question,
863
+ "edit-answer": cmd_edit_answer,
547
864
  }
548
865
 
549
866
 
@@ -571,6 +888,38 @@ def main() -> None:
571
888
  sp.add_argument("--no-images", action="store_true",
572
889
  help="skip re-hosting external images to Zhihu's CDN")
573
890
 
891
+ sp = sub.add_parser("answers", help="list the user's published answers + stats")
892
+ sp.add_argument("--limit", type=int, default=20)
893
+ sp.add_argument("--offset", type=int, default=0)
894
+
895
+ sp = sub.add_parser("answer", help="one answer's details + stats")
896
+ sp.add_argument("id")
897
+
898
+ sp = sub.add_parser("question", help="a question's info + whether you already answered it")
899
+ sp.add_argument("id")
900
+
901
+ sp = sub.add_parser("answer-question",
902
+ help="post a NEW answer to a question (GATED by trailing --confirm)")
903
+ sp.add_argument("--question", help="question id to answer")
904
+ sp.add_argument("--content", help="HTML content inline")
905
+ sp.add_argument("--content-file", help="path to an HTML file")
906
+ sp.add_argument("--draft-only", action="store_true",
907
+ help="save a private draft on the question; do NOT go public")
908
+ sp.add_argument("--repost", choices=["allowed", "disallowed"],
909
+ help="转载授权: allow or disallow reposting (default disallowed)")
910
+ sp.add_argument("--no-images", action="store_true",
911
+ help="skip re-hosting external images to Zhihu's CDN")
912
+
913
+ sp = sub.add_parser("edit-answer",
914
+ help="overwrite an existing answer's content (GATED by trailing --confirm)")
915
+ sp.add_argument("--id", help="answer id to edit")
916
+ sp.add_argument("--content", help="HTML content inline")
917
+ sp.add_argument("--content-file", help="path to an HTML file")
918
+ sp.add_argument("--repost", choices=["allowed", "disallowed"],
919
+ help="转载授权: override repost permission (default keeps current)")
920
+ sp.add_argument("--no-images", action="store_true",
921
+ help="skip re-hosting external images to Zhihu's CDN")
922
+
574
923
  args = p.parse_args(ARGV)
575
924
  jar = load_cookies(args.platform)
576
925
  COMMANDS[args.command](jar, args)