@acedatacloud/skills 2026.630.1 → 2026.701.0
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 +1 -1
- package/skills/zhihu/SKILL.md +136 -19
- package/skills/zhihu/scripts/blog.py +349 -0
- package/skills/zhihu/scripts/search.py +234 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.701.0",
|
|
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/zhihu/SKILL.md
CHANGED
|
@@ -1,34 +1,102 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: zhihu
|
|
3
|
-
description:
|
|
3
|
+
description: Search Zhihu & the web, get trending topics, and read/publish on Zhihu (知乎) — search Zhihu content or the entire web via the official Developer Platform API, get hot topics (热榜), list published articles & answers with stats, inspect content, publish articles, and answer questions. Use when the user mentions 知乎 / Zhihu, 搜索知乎, 全网搜索, 热榜, "我的知乎文章/回答", reading stats, 发文, or 回答问题.
|
|
4
4
|
when_to_use: |
|
|
5
|
-
Trigger for anything
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
Trigger for anything involving Zhihu (知乎):
|
|
6
|
+
- Searching Zhihu content (站内搜索) or the entire web (全网搜索)
|
|
7
|
+
- Getting Zhihu trending topics (热榜)
|
|
8
|
+
- Reading the user's own articles/answers with stats
|
|
9
|
+
- Publishing articles or answering questions (gated behind confirmation)
|
|
10
|
+
Search commands use the Zhihu Developer Platform API (Bearer token auth).
|
|
11
|
+
Read/write commands use the user's login cookies (BYOC).
|
|
10
12
|
connections: [zhihu]
|
|
11
13
|
allowed_tools: [Bash]
|
|
12
14
|
license: Apache-2.0
|
|
13
15
|
metadata:
|
|
14
16
|
author: acedatacloud
|
|
15
|
-
version: "
|
|
17
|
+
version: "2.0"
|
|
16
18
|
---
|
|
17
19
|
|
|
18
|
-
# zhihu — read & publish on Zhihu
|
|
20
|
+
# zhihu — search, read & publish on Zhihu
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
own editor uses, authenticated by the login cookie they captured with the ACE
|
|
22
|
-
extension. No browser, no third-party deps — just `urllib`.
|
|
22
|
+
Two authentication layers, two scripts:
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
| Script | Auth | Capabilities |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `scripts/search.py` | `ZHIHU_DEVELOPER_TOKEN` (Bearer) | Search Zhihu, search the web, get hot topics |
|
|
27
|
+
| `scripts/blog.py` | `ZHIHU_COOKIES` (login cookie) | Read/write own articles & answers |
|
|
25
28
|
|
|
29
|
+
No browser, no third-party deps — just `urllib`.
|
|
30
|
+
|
|
31
|
+
The connector injects credentials as env vars:
|
|
32
|
+
|
|
33
|
+
- `ZHIHU_DEVELOPER_TOKEN` — Zhihu Developer Platform access secret (Bearer token).
|
|
34
|
+
Used for search and hot-list queries. **Secret — never echo or print it.**
|
|
26
35
|
- `ZHIHU_COOKIES` — a JSON array of `{name, value, domain, path, ...}` cookies.
|
|
27
|
-
**Secret — never echo or print it.**
|
|
36
|
+
Used for reading/writing the user's own content. **Secret — never echo or print it.**
|
|
37
|
+
|
|
38
|
+
## Search CLI (search.py)
|
|
39
|
+
|
|
40
|
+
[`scripts/search.py`](scripts/search.py) — search Zhihu and the web. Requires
|
|
41
|
+
only `ZHIHU_DEVELOPER_TOKEN` (no cookies needed).
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
SEARCH=$SKILL_DIR/scripts/search.py
|
|
45
|
+
|
|
46
|
+
# Search Zhihu content (站内搜索) — questions, answers, articles
|
|
47
|
+
python3 $SEARCH search "Python 爬虫"
|
|
48
|
+
python3 $SEARCH search "Python 爬虫" --count 5
|
|
49
|
+
|
|
50
|
+
# Search the entire web (全网搜索) — all indexed sites
|
|
51
|
+
python3 $SEARCH global "AI Agent"
|
|
52
|
+
python3 $SEARCH global "AI Agent" --count 15
|
|
53
|
+
|
|
54
|
+
# Filter by site or time
|
|
55
|
+
python3 $SEARCH global "React" --filter 'host=="github.com"'
|
|
56
|
+
python3 $SEARCH global "新闻" --filter 'publish_time>=1720000000'
|
|
57
|
+
python3 $SEARCH global "技术" --filter 'host=="github.com" AND publish_time>=1720000000'
|
|
58
|
+
python3 $SEARCH global "实时新闻" --db realtime
|
|
59
|
+
|
|
60
|
+
# Get Zhihu trending topics (热榜)
|
|
61
|
+
python3 $SEARCH hot
|
|
62
|
+
python3 $SEARCH hot --limit 10
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Search commands
|
|
66
|
+
|
|
67
|
+
| Goal | Command |
|
|
68
|
+
|---|---|
|
|
69
|
+
| Search Zhihu (max 10 results) | `python3 $SEARCH search "<query>" --count N` |
|
|
70
|
+
| Search entire web (max 20) | `python3 $SEARCH global "<query>" --count N` |
|
|
71
|
+
| Filter by site | `--filter 'host=="example.com"'` |
|
|
72
|
+
| Filter by time | `--filter 'publish_time>=<unix_ts>'` |
|
|
73
|
+
| Search only realtime/static index | `--db realtime` or `--db static` |
|
|
74
|
+
| Zhihu trending topics (max 30) | `python3 $SEARCH hot --limit N` |
|
|
75
|
+
|
|
76
|
+
### Search result fields
|
|
28
77
|
|
|
29
|
-
|
|
78
|
+
**zhihu_search** returns: title, type (Article/Answer), content_id, url,
|
|
79
|
+
excerpt, vote_up (赞同), comments, author, authority level, edit_time.
|
|
80
|
+
|
|
81
|
+
**global_search** returns the same fields plus has_more indicator. The `url`
|
|
82
|
+
includes utm tracking params from Zhihu's platform.
|
|
83
|
+
|
|
84
|
+
**hot_list** returns: rank, title, url, summary, thumbnail.
|
|
85
|
+
|
|
86
|
+
### global_search Filter syntax
|
|
87
|
+
|
|
88
|
+
- `host=="example.com"` — filter by domain (note: `host=="zhihu.com"` not
|
|
89
|
+
supported — use `search` command instead)
|
|
90
|
+
- `publish_time>=1720000000` — filter by publish time (unix seconds)
|
|
91
|
+
- Logical operators: `AND`, `OR` (must be uppercase)
|
|
92
|
+
- Parentheses for grouping: `(host=="a.com" OR host=="b.com") AND publish_time>=T`
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Blog CLI (blog.py)
|
|
30
97
|
|
|
31
98
|
The skill ships [`scripts/blog.py`](scripts/blog.py) — self-contained, stdlib only.
|
|
99
|
+
Requires `ZHIHU_COOKIES` (login cookie).
|
|
32
100
|
|
|
33
101
|
```sh
|
|
34
102
|
BLOG=$SKILL_DIR/scripts/blog.py
|
|
@@ -37,6 +105,9 @@ BLOG=$SKILL_DIR/scripts/blog.py
|
|
|
37
105
|
python3 $BLOG whoami # who is logged in
|
|
38
106
|
python3 $BLOG articles --limit 20 # my published articles + stats
|
|
39
107
|
python3 $BLOG article <article-id> # one article's details + stats
|
|
108
|
+
python3 $BLOG answers --limit 20 # my published answers + stats
|
|
109
|
+
python3 $BLOG answer <answer-id> # one answer's details + stats
|
|
110
|
+
python3 $BLOG question <question-id> # a question's info + whether I answered it
|
|
40
111
|
```
|
|
41
112
|
|
|
42
113
|
## Verify the connection first
|
|
@@ -56,11 +127,21 @@ extension). Do **not** retry in a loop.
|
|
|
56
127
|
|---|---|
|
|
57
128
|
| Who am I | `python3 $BLOG whoami` |
|
|
58
129
|
| My latest articles + vote/comment counts | `python3 $BLOG articles --limit 20` |
|
|
59
|
-
|
|
|
130
|
+
| My latest answers + like/favorite/comment counts | `python3 $BLOG answers --limit 20` |
|
|
131
|
+
| Next page (any list) | add `--offset 20` |
|
|
60
132
|
| One article's stats | `python3 $BLOG article <id>` |
|
|
133
|
+
| One answer's stats (incl. 赞同 voteup) | `python3 $BLOG answer <id>` |
|
|
134
|
+
| A question's info + my answer id (if any) | `python3 $BLOG question <id>` |
|
|
135
|
+
|
|
136
|
+
Article stats: `voteup_count` (赞同), `comment_count` (评论). Zhihu does not
|
|
137
|
+
expose per-article read counts on these endpoints.
|
|
61
138
|
|
|
62
|
-
|
|
63
|
-
|
|
139
|
+
**Answer stat caveat:** the `answers` *list* endpoint does **not** return
|
|
140
|
+
`voteup_count` (赞同) — it only exposes `like_count` (喜欢), `favorite_count`
|
|
141
|
+
(收藏) and `comment_count`. For the authoritative 赞同 count of an answer, call
|
|
142
|
+
`answer <id>` (the single-answer endpoint returns `voteup_count`). `question <id>`
|
|
143
|
+
reports `already_answered` + `my_answer_id` so you know whether to use
|
|
144
|
+
`answer-question` (new) or `edit-answer` (update).
|
|
64
145
|
|
|
65
146
|
## Publishing — GATED (dry-run unless trailing `--confirm`)
|
|
66
147
|
|
|
@@ -88,10 +169,46 @@ python3 $BLOG publish --title "标题" --content-file article.html --confirm
|
|
|
88
169
|
reports `images_found`. Pass `--no-images` to skip this. So you can hand the
|
|
89
170
|
CLI HTML/Markdown with normal public image URLs and the pictures survive.
|
|
90
171
|
|
|
172
|
+
## Answering questions — GATED (dry-run unless trailing `--confirm`)
|
|
173
|
+
|
|
174
|
+
Two write commands cover the question/answer side. Both gate exactly like
|
|
175
|
+
`publish`: no trailing `--confirm` → **dry-run**; `--confirm` is honored **only
|
|
176
|
+
as the last argument**. Always show the dry-run, get an explicit "yes", then
|
|
177
|
+
re-run with `--confirm` last.
|
|
178
|
+
|
|
179
|
+
```sh
|
|
180
|
+
# Content is HTML (same as articles). For Markdown, convert to HTML first.
|
|
181
|
+
|
|
182
|
+
# Post a NEW answer to a question
|
|
183
|
+
python3 $BLOG answer-question --question <qid> --content-file ans.html # dry-run
|
|
184
|
+
python3 $BLOG answer-question --question <qid> --content-file ans.html --draft-only --confirm # PRIVATE draft (safe)
|
|
185
|
+
python3 $BLOG answer-question --question <qid> --content-file ans.html --confirm # PUBLIC, goes live
|
|
186
|
+
|
|
187
|
+
# Edit an EXISTING answer (replaces its live, public content)
|
|
188
|
+
python3 $BLOG edit-answer --id <answer-id> --content-file ans.html # dry-run
|
|
189
|
+
python3 $BLOG edit-answer --id <answer-id> --content-file ans.html --confirm # overwrites live answer
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- **One answer per question.** Zhihu allows a single answer per user per
|
|
193
|
+
question. If the user already answered, `answer-question --confirm` returns a
|
|
194
|
+
clear error telling you to use `edit-answer` instead — find the existing answer
|
|
195
|
+
id with `answers` or `question <qid>` (which reports `my_answer_id`).
|
|
196
|
+
- **`--draft-only` is the safe path for new answers** — it saves a *private*
|
|
197
|
+
draft on the question (nothing public). The user reviews it on Zhihu, then you
|
|
198
|
+
re-run without `--draft-only` (with `--confirm`) to publish. Prefer this unless
|
|
199
|
+
the user clearly asked to go live immediately.
|
|
200
|
+
- **`edit-answer` has no private mode** — the answer is already public, so any
|
|
201
|
+
`--confirm` edit is live immediately. It preserves the answer's current repost
|
|
202
|
+
setting unless you override with `--repost allowed|disallowed`.
|
|
203
|
+
- **转载授权** (`--repost allowed|disallowed`): new answers default to
|
|
204
|
+
`disallowed` (don't grant repost rights); editing keeps the current setting.
|
|
205
|
+
- **Images are auto-hosted** for answers too — same behavior and `--no-images`
|
|
206
|
+
flag as `publish`.
|
|
207
|
+
|
|
91
208
|
## Gotchas — surface before the user is surprised
|
|
92
209
|
|
|
93
|
-
- **This is the user's real Zhihu account.** Confirm before any publish
|
|
94
|
-
exposes their own private drafts.
|
|
210
|
+
- **This is the user's real Zhihu account.** Confirm before any publish / answer /
|
|
211
|
+
edit; reading exposes their own private drafts.
|
|
95
212
|
- **Cookie expiry**: Zhihu cookies are short-lived. A `401`/`403` means
|
|
96
213
|
reconnect at auth.acedata.cloud/user/connections — never loop-retry.
|
|
97
214
|
- **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)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
zhihu search — search Zhihu and the web via the Zhihu Developer Platform API.
|
|
4
|
+
Standard-library only (urllib), no third-party deps.
|
|
5
|
+
|
|
6
|
+
Auth: Bearer token via ``ZHIHU_DEVELOPER_TOKEN`` env var.
|
|
7
|
+
Does NOT require login cookies — independent of blog.py.
|
|
8
|
+
|
|
9
|
+
Endpoints:
|
|
10
|
+
zhihu_search — search Zhihu content (questions/answers/articles)
|
|
11
|
+
global_search — search the entire web with optional filters
|
|
12
|
+
hot_list — current Zhihu trending topics
|
|
13
|
+
|
|
14
|
+
Quick examples:
|
|
15
|
+
python3 $SKILL_DIR/scripts/search.py search "Python 爬虫"
|
|
16
|
+
python3 $SKILL_DIR/scripts/search.py search "Python 爬虫" --count 5
|
|
17
|
+
python3 $SKILL_DIR/scripts/search.py global "AI Agent" --count 10
|
|
18
|
+
python3 $SKILL_DIR/scripts/search.py global "React" --filter 'host=="github.com"'
|
|
19
|
+
python3 $SKILL_DIR/scripts/search.py global "新闻" --db realtime
|
|
20
|
+
python3 $SKILL_DIR/scripts/search.py hot
|
|
21
|
+
python3 $SKILL_DIR/scripts/search.py hot --limit 10
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
import urllib.error
|
|
32
|
+
import urllib.parse
|
|
33
|
+
import urllib.request
|
|
34
|
+
|
|
35
|
+
BASE_URL = "https://developer.zhihu.com/api/v1/content"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def out(obj) -> None:
|
|
39
|
+
json.dump(obj, sys.stdout, ensure_ascii=False, indent=2)
|
|
40
|
+
sys.stdout.write("\n")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def die(msg: str, code: int = 1) -> None:
|
|
44
|
+
sys.stderr.write(f"ERROR: {msg}\n")
|
|
45
|
+
sys.exit(code)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_token() -> str:
|
|
49
|
+
token = os.environ.get("ZHIHU_DEVELOPER_TOKEN", "").strip()
|
|
50
|
+
if not token:
|
|
51
|
+
die(
|
|
52
|
+
"ZHIHU_DEVELOPER_TOKEN not set. "
|
|
53
|
+
"Set this env var to your Zhihu Developer Platform access secret."
|
|
54
|
+
)
|
|
55
|
+
return token
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def api_get(endpoint: str, params: dict | None = None) -> dict:
|
|
59
|
+
"""GET request to the Zhihu Developer Platform API."""
|
|
60
|
+
token = get_token()
|
|
61
|
+
url = f"{BASE_URL}/{endpoint}"
|
|
62
|
+
if params:
|
|
63
|
+
# Filter out None values
|
|
64
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
65
|
+
if params:
|
|
66
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
67
|
+
|
|
68
|
+
headers = {
|
|
69
|
+
"Authorization": f"Bearer {token}",
|
|
70
|
+
"X-Request-Timestamp": str(int(time.time())),
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
req = urllib.request.Request(url, headers=headers, method="GET")
|
|
75
|
+
try:
|
|
76
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
77
|
+
return json.loads(resp.read().decode())
|
|
78
|
+
except urllib.error.HTTPError as e:
|
|
79
|
+
body = e.read().decode() if e.fp else ""
|
|
80
|
+
die(f"HTTP {e.code}: {body[:500]}")
|
|
81
|
+
except urllib.error.URLError as e:
|
|
82
|
+
die(f"Network error: {e.reason}")
|
|
83
|
+
return {}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fmt_zhihu_item(item: dict) -> dict:
|
|
87
|
+
"""Format a zhihu_search result item for display."""
|
|
88
|
+
return {
|
|
89
|
+
"title": item.get("Title", ""),
|
|
90
|
+
"type": item.get("ContentType", ""),
|
|
91
|
+
"content_id": item.get("ContentID", ""),
|
|
92
|
+
"url": item.get("Url", ""),
|
|
93
|
+
"excerpt": item.get("ContentText", "")[:200],
|
|
94
|
+
"vote_up": item.get("VoteUpCount", 0),
|
|
95
|
+
"comments": item.get("CommentCount", 0),
|
|
96
|
+
"author": item.get("AuthorName", ""),
|
|
97
|
+
"authority": item.get("AuthorityLevel", ""),
|
|
98
|
+
"edit_time": item.get("EditTime", 0),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _fmt_global_item(item: dict) -> dict:
|
|
103
|
+
"""Format a global_search result item for display."""
|
|
104
|
+
return {
|
|
105
|
+
"title": item.get("Title", ""),
|
|
106
|
+
"type": item.get("ContentType", ""),
|
|
107
|
+
"content_id": item.get("ContentID", ""),
|
|
108
|
+
"url": item.get("Url", ""),
|
|
109
|
+
"excerpt": item.get("ContentText", "")[:300],
|
|
110
|
+
"vote_up": item.get("VoteUpCount", 0),
|
|
111
|
+
"comments": item.get("CommentCount", 0),
|
|
112
|
+
"author": item.get("AuthorName", ""),
|
|
113
|
+
"authority": item.get("AuthorityLevel", ""),
|
|
114
|
+
"edit_time": item.get("EditTime", 0),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _fmt_hot_item(item: dict, rank: int) -> dict:
|
|
119
|
+
"""Format a hot_list result item for display."""
|
|
120
|
+
return {
|
|
121
|
+
"rank": rank,
|
|
122
|
+
"title": item.get("Title", ""),
|
|
123
|
+
"url": item.get("Url", ""),
|
|
124
|
+
"summary": item.get("Summary", ""),
|
|
125
|
+
"thumbnail": item.get("ThumbnailUrl", ""),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cmd_search(args):
|
|
130
|
+
"""Search Zhihu content (站内搜索)."""
|
|
131
|
+
params = {"Query": args.query, "Count": args.count}
|
|
132
|
+
resp = api_get("zhihu_search", params)
|
|
133
|
+
|
|
134
|
+
if resp.get("Code") != 0:
|
|
135
|
+
die(f"API error: Code={resp.get('Code')} Message={resp.get('Message')}")
|
|
136
|
+
|
|
137
|
+
data = resp.get("Data", {})
|
|
138
|
+
items = data.get("Items") or []
|
|
139
|
+
|
|
140
|
+
result = {
|
|
141
|
+
"query": args.query,
|
|
142
|
+
"count": len(items),
|
|
143
|
+
"results": [_fmt_zhihu_item(it) for it in items],
|
|
144
|
+
}
|
|
145
|
+
if data.get("EmptyReason"):
|
|
146
|
+
result["empty_reason"] = data["EmptyReason"]
|
|
147
|
+
|
|
148
|
+
out(result)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cmd_global(args):
|
|
152
|
+
"""Search the entire web (全网搜索)."""
|
|
153
|
+
params: dict = {"Query": args.query, "Count": args.count}
|
|
154
|
+
if args.filter:
|
|
155
|
+
params["Filter"] = args.filter
|
|
156
|
+
if args.db:
|
|
157
|
+
params["SearchDB"] = args.db
|
|
158
|
+
|
|
159
|
+
resp = api_get("global_search", params)
|
|
160
|
+
|
|
161
|
+
if resp.get("Code") != 0:
|
|
162
|
+
die(f"API error: Code={resp.get('Code')} Message={resp.get('Message')}")
|
|
163
|
+
|
|
164
|
+
data = resp.get("Data", {})
|
|
165
|
+
items = data.get("Items") or []
|
|
166
|
+
|
|
167
|
+
result = {
|
|
168
|
+
"query": args.query,
|
|
169
|
+
"has_more": data.get("HasMore", False),
|
|
170
|
+
"count": len(items),
|
|
171
|
+
"results": [_fmt_global_item(it) for it in items],
|
|
172
|
+
}
|
|
173
|
+
out(result)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cmd_hot(args):
|
|
177
|
+
"""Get current Zhihu trending topics (热榜)."""
|
|
178
|
+
params = {"Limit": args.limit}
|
|
179
|
+
resp = api_get("hot_list", params)
|
|
180
|
+
|
|
181
|
+
if resp.get("Code") != 0:
|
|
182
|
+
die(f"API error: Code={resp.get('Code')} Message={resp.get('Message')}")
|
|
183
|
+
|
|
184
|
+
data = resp.get("Data", {})
|
|
185
|
+
items = data.get("Items") or []
|
|
186
|
+
|
|
187
|
+
result = {
|
|
188
|
+
"total": data.get("Total", len(items)),
|
|
189
|
+
"items": [_fmt_hot_item(it, i + 1) for i, it in enumerate(items)],
|
|
190
|
+
}
|
|
191
|
+
out(result)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def main() -> None:
|
|
195
|
+
parser = argparse.ArgumentParser(
|
|
196
|
+
prog="zhihu-search",
|
|
197
|
+
description="Search Zhihu and the web via the Zhihu Developer Platform API",
|
|
198
|
+
)
|
|
199
|
+
sub = parser.add_subparsers(dest="command")
|
|
200
|
+
|
|
201
|
+
# search (zhihu_search)
|
|
202
|
+
p_search = sub.add_parser("search", help="Search Zhihu content (站内搜索)")
|
|
203
|
+
p_search.add_argument("query", help="Search query")
|
|
204
|
+
p_search.add_argument("--count", type=int, default=10, help="Results count (max 10)")
|
|
205
|
+
|
|
206
|
+
# global (global_search)
|
|
207
|
+
p_global = sub.add_parser("global", help="Search the entire web (全网搜索)")
|
|
208
|
+
p_global.add_argument("query", help="Search query")
|
|
209
|
+
p_global.add_argument("--count", type=int, default=10, help="Results count (max 20)")
|
|
210
|
+
p_global.add_argument(
|
|
211
|
+
"--filter",
|
|
212
|
+
help='Advanced filter expression, e.g. host=="example.com" AND publish_time>=1700000000',
|
|
213
|
+
)
|
|
214
|
+
p_global.add_argument(
|
|
215
|
+
"--db",
|
|
216
|
+
choices=["all", "realtime", "static"],
|
|
217
|
+
help="Index to search: all (default), realtime, static",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# hot (hot_list)
|
|
221
|
+
p_hot = sub.add_parser("hot", help="Get Zhihu trending topics (热榜)")
|
|
222
|
+
p_hot.add_argument("--limit", type=int, default=30, help="Number of items (max 30)")
|
|
223
|
+
|
|
224
|
+
args = parser.parse_args()
|
|
225
|
+
if not args.command:
|
|
226
|
+
parser.print_help()
|
|
227
|
+
sys.exit(1)
|
|
228
|
+
|
|
229
|
+
dispatch = {"search": cmd_search, "global": cmd_global, "hot": cmd_hot}
|
|
230
|
+
dispatch[args.command](args)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
main()
|