@klarkxy/dsh-zhihu 0.1.8 → 0.1.10

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.
@@ -0,0 +1,687 @@
1
+ import { ZHIHU_QUOTA_IDS } from "./contracts.js";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ //#region src/zhihu-client.ts
6
+ /**
7
+ * 知乎开放平台共享请求层:Access Secret 解析、超时封装、信封解析与执行计量。
8
+ *
9
+ * 端点规范(来源:github.com/klarkxy/zhihu-search,src/zhihu_search/upstream/http_client.py):
10
+ * - Base URL: https://developer.zhihu.com
11
+ * - 公共 Headers: Authorization: Bearer <access_secret>、X-Request-Timestamp、Accept
12
+ * - 除直答外的 REST 接口返回 {Code, Message, Data} 信封,Code 非 0 表示业务错误。
13
+ *
14
+ * Token 解析顺序(按契约,**不**把 token 放进 schema 参数):
15
+ * 1. 可选的 credential 解析器(应用设置界面写入 `ZHIHU_ACCESS_TOKEN` 后,
16
+ * 通过 `ctx.credentials.resolve` 取回);命中即返回,source 标记为 `credential`。
17
+ * 2. 环境变量 ZHIHU_ACCESS_TOKEN
18
+ * 3. 环境变量 ZHIHU_ACCESS_SECRET(zhihu-search CLI 同名变量)
19
+ * 4. ~/.config/zhihu-search/credentials.json 的 access_secret 字段
20
+ * 都没有则抛出带中文配置指引的 TOKEN_MISSING。
21
+ */
22
+ const ZHIHU_BASE_URL = "https://developer.zhihu.com";
23
+ const ZHIHU_DEFAULT_TIMEOUT_MS = 15e3;
24
+ const ZHIHU_CREDENTIALS_PRIMARY = "ZHIHU_ACCESS_TOKEN";
25
+ const ZHIHU_CREDENTIALS_FALLBACK = "ZHIHU_ACCESS_SECRET";
26
+ const ZHIHU_CREDENTIALS_FILE = join(homedir(), ".config", "zhihu-search", "credentials.json");
27
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch === "function" ? (input, init) => {
28
+ const f = globalThis.fetch;
29
+ return f(input, init);
30
+ } : void 0;
31
+ function looksLikeToken(value) {
32
+ const trimmed = value.trim();
33
+ return trimmed.length >= 8 && trimmed.length <= 256;
34
+ }
35
+ async function readCredentialsFile(path) {
36
+ try {
37
+ const raw = await readFile(path, "utf8");
38
+ const parsed = JSON.parse(raw);
39
+ if (parsed && typeof parsed === "object" && typeof parsed.access_secret === "string" && looksLikeToken(parsed.access_secret)) return parsed.access_secret.trim();
40
+ } catch {}
41
+ }
42
+ async function resolveZhihuToken(env = process.env, options = {}) {
43
+ if (options.resolveCredential) {
44
+ const fromCredential = (await options.resolveCredential())?.trim();
45
+ if (fromCredential && looksLikeToken(fromCredential)) return {
46
+ token: fromCredential,
47
+ source: "credential"
48
+ };
49
+ }
50
+ const primary = env[ZHIHU_CREDENTIALS_PRIMARY]?.trim();
51
+ if (primary && looksLikeToken(primary)) return {
52
+ token: primary,
53
+ source: "env-primary"
54
+ };
55
+ const fallback = env[ZHIHU_CREDENTIALS_FALLBACK]?.trim();
56
+ if (fallback && looksLikeToken(fallback)) return {
57
+ token: fallback,
58
+ source: "env-fallback"
59
+ };
60
+ const fromFile = await readCredentialsFile(ZHIHU_CREDENTIALS_FILE);
61
+ if (fromFile) return {
62
+ token: fromFile,
63
+ source: "file"
64
+ };
65
+ throw new ZhihuSearchError("TOKEN_MISSING", ["未找到知乎 Access Secret。请在「设置 → 知乎」中填写;", `也可设置环境变量 ${ZHIHU_CREDENTIALS_PRIMARY} 或写入 ~/.config/zhihu-search/credentials.json。`].join(""));
66
+ }
67
+ var ZhihuSearchError = class extends Error {
68
+ code;
69
+ status;
70
+ upstreamCode;
71
+ retryAfter;
72
+ retryable;
73
+ constructor(code, message, status, details = {}) {
74
+ super(message);
75
+ this.name = "ZhihuSearchError";
76
+ this.code = code;
77
+ if (status !== void 0) this.status = status;
78
+ if (details.upstreamCode !== void 0) this.upstreamCode = details.upstreamCode;
79
+ if (details.retryAfter !== void 0) this.retryAfter = details.retryAfter;
80
+ if (details.retryable !== void 0) this.retryable = details.retryable;
81
+ }
82
+ };
83
+ /** Metering must never break the tool itself. */
84
+ function reportExecuted(hook, event) {
85
+ if (!hook) return;
86
+ try {
87
+ hook(event);
88
+ } catch {}
89
+ }
90
+ function isAbortError(error) {
91
+ if (error instanceof Error && error.name === "AbortError") return true;
92
+ return typeof error === "object" && error !== null && error.name === "AbortError";
93
+ }
94
+ /** Parse Retry-After seconds or an HTTP date; never invent a quota reset time. */
95
+ function parseZhihuRetryAfter(value, now = Date.now()) {
96
+ if (!value?.trim()) return void 0;
97
+ const text = value.trim();
98
+ if (/^\d+(?:\.\d+)?$/.test(text)) {
99
+ const seconds = Number(text);
100
+ return Number.isFinite(seconds) ? Math.ceil(seconds) : void 0;
101
+ }
102
+ if (!/[a-z]/i.test(text)) return void 0;
103
+ const date = Date.parse(text);
104
+ return Number.isFinite(date) ? Math.max(0, Math.ceil((date - now) / 1e3)) : void 0;
105
+ }
106
+ /**
107
+ * Shared authenticated request. envelope=true opts into official error semantics
108
+ * and returns Data. Legacy callers still receive the full JSON / chat response.
109
+ * The timeout and cancellation cover BOTH headers and response-body consumption.
110
+ * No requests are retried, including 429, exhausted quota, or risk-control denial.
111
+ */
112
+ async function zhihuFetchJson(path, init, label, options = {}) {
113
+ const linked = options.signal;
114
+ if (linked?.aborted) throw new ZhihuSearchError("CANCELLED", "请求已取消");
115
+ const url = new URL(path, ZHIHU_BASE_URL);
116
+ if (url.origin !== "https://developer.zhihu.com" || url.username || url.password) throw new ZhihuSearchError("INVALID_ARGUMENTS", "只允许请求知乎开放平台。");
117
+ for (const [key, value] of Object.entries(init.params ?? {})) url.searchParams.set(key, value);
118
+ const token = await resolveZhihuToken(options.env, { resolveCredential: options.resolveCredential });
119
+ if (linked?.aborted) throw new ZhihuSearchError("CANCELLED", "请求已取消");
120
+ const fetcher = options.fetcher ?? globalFetch;
121
+ if (!fetcher) throw new ZhihuSearchError("BAD_RESPONSE", "当前环境没有可用的 fetch。");
122
+ const method = init.method ?? (init.formData || init.body !== void 0 ? "POST" : "GET");
123
+ const timeout = options.timeoutMs !== void 0 && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : ZHIHU_DEFAULT_TIMEOUT_MS;
124
+ const controller = new AbortController();
125
+ const onAbort = () => controller.abort(linked?.reason);
126
+ if (linked?.aborted) onAbort();
127
+ else linked?.addEventListener("abort", onAbort, { once: true });
128
+ const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("zhihu request timeout")), timeout);
129
+ let stopWaiting = () => {};
130
+ const aborted = new Promise((_resolve, reject) => {
131
+ const rejectAbort = () => reject(controller.signal.reason);
132
+ if (controller.signal.aborted) rejectAbort();
133
+ else {
134
+ controller.signal.addEventListener("abort", rejectAbort, { once: true });
135
+ stopWaiting = () => controller.signal.removeEventListener("abort", rejectAbort);
136
+ }
137
+ });
138
+ try {
139
+ const request = async () => {
140
+ controller.signal.throwIfAborted();
141
+ const response = await fetcher(url.toString(), {
142
+ method,
143
+ headers: {
144
+ Authorization: `Bearer ${token.token}`,
145
+ "X-Request-Timestamp": String(Math.floor(Date.now() / 1e3)),
146
+ Accept: "application/json",
147
+ ...method === "POST" && !init.formData ? { "Content-Type": "application/json" } : {}
148
+ },
149
+ signal: controller.signal,
150
+ ...init.formData ? { body: init.formData } : init.body !== void 0 ? { body: JSON.stringify(init.body) } : {}
151
+ });
152
+ const retryAfter = parseZhihuRetryAfter(response.headers?.get("Retry-After"));
153
+ const httpError = () => {
154
+ const code = response.status === 429 ? "RATE_LIMITED" : init.envelope && (response.status === 401 || response.status === 403) ? "TOKEN_INVALID" : "HTTP_ERROR";
155
+ return new ZhihuSearchError(code, `${label}返回 HTTP ${response.status}。`, response.status, code === "RATE_LIMITED" ? {
156
+ retryAfter,
157
+ retryable: true
158
+ } : {});
159
+ };
160
+ let body;
161
+ try {
162
+ body = await response.json();
163
+ } catch (error) {
164
+ if (controller.signal.aborted) throw error;
165
+ if (!response.ok) throw httpError();
166
+ throw new ZhihuSearchError("BAD_RESPONSE", `${label}响应不是合法 JSON。`);
167
+ }
168
+ controller.signal.throwIfAborted();
169
+ const isEnvelope = body !== null && typeof body === "object" && !Array.isArray(body) && "Code" in body;
170
+ if (!response.ok && !isEnvelope) throw httpError();
171
+ if (init.envelope || isEnvelope && body.Code !== 0 && body.Code !== "0") {
172
+ const data = parseZhihuEnvelope(body, {
173
+ status: response.status,
174
+ retryAfter,
175
+ officialErrors: init.envelope
176
+ });
177
+ if (!response.ok) throw httpError();
178
+ if (init.envelope) return data;
179
+ }
180
+ if (!response.ok) throw httpError();
181
+ return body;
182
+ };
183
+ return await Promise.race([request(), aborted]);
184
+ } catch (error) {
185
+ if (linked?.aborted) throw new ZhihuSearchError("CANCELLED", "请求已取消");
186
+ if (controller.signal.aborted || isAbortError(error)) throw new ZhihuSearchError("TIMEOUT", `${label}超时(${Math.round(timeout / 1e3)} 秒),请稍后再试。`);
187
+ if (error instanceof ZhihuSearchError) {
188
+ error.message = error.message.replaceAll(token.token, "[REDACTED]");
189
+ throw error;
190
+ }
191
+ throw new ZhihuSearchError("BAD_RESPONSE", `${label}失败:${(error instanceof Error ? error.message : String(error)).replaceAll(token.token, "[REDACTED]")}`);
192
+ } finally {
193
+ clearTimeout(timer);
194
+ stopWaiting();
195
+ linked?.removeEventListener("abort", onAbort);
196
+ }
197
+ }
198
+ const ZHIHU_BUSINESS_ERRORS = {
199
+ 10001: "INVALID_ARGUMENTS",
200
+ 20001: "TOKEN_INVALID",
201
+ 30001: "RATE_LIMITED",
202
+ 30002: "RATE_LIMITED",
203
+ 40001: "INVALID_ARGUMENTS",
204
+ 40002: "INVALID_ARGUMENTS",
205
+ 40003: "RATE_LIMITED",
206
+ 40004: "INVALID_ARGUMENTS",
207
+ 40005: "INVALID_ARGUMENTS",
208
+ 40006: "INVALID_ARGUMENTS",
209
+ 50002: "UPSTREAM_UNAVAILABLE",
210
+ 90001: "UPSTREAM_UNAVAILABLE"
211
+ };
212
+ /** Parse the official envelope without coercing null/false/empty Code into success. */
213
+ function parseZhihuEnvelope(body, meta = {}) {
214
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new ZhihuSearchError("BAD_RESPONSE", "知乎响应缺少 {Code,Message,Data} 信封。", meta.status);
215
+ const envelope = body;
216
+ const rawCode = envelope.Code;
217
+ if (!(typeof rawCode === "number" && Number.isSafeInteger(rawCode) || typeof rawCode === "string" && /^\d+$/.test(rawCode) && Number.isSafeInteger(Number(rawCode)))) throw new ZhihuSearchError("BAD_RESPONSE", "知乎响应缺少有效的 Code 字段。", meta.status);
218
+ const code = Number(rawCode);
219
+ if (code !== 0) {
220
+ const message = typeof envelope.Message === "string" && envelope.Message ? envelope.Message : "未知错误";
221
+ if (code === 30003) throw new ZhihuSearchError("UPSTREAM_UNAVAILABLE", `知乎开放平台被风控拒绝:${message}。请不要立即重试。`, meta.status, {
222
+ upstreamCode: code,
223
+ retryable: false
224
+ });
225
+ const kind = ZHIHU_BUSINESS_ERRORS[code] ?? (meta.officialErrors ? "UPSTREAM_UNAVAILABLE" : "HTTP_ERROR");
226
+ throw new ZhihuSearchError(kind, `知乎开放平台返回错误(code=${code}):${message}`, meta.status, {
227
+ upstreamCode: code,
228
+ ...kind === "RATE_LIMITED" ? {
229
+ retryAfter: meta.retryAfter,
230
+ retryable: true
231
+ } : {}
232
+ });
233
+ }
234
+ return envelope.Data ?? {};
235
+ }
236
+ function truncateSummary(text, limit) {
237
+ if (text.length <= limit) return text;
238
+ return `${text.slice(0, limit)}…`;
239
+ }
240
+ function normalizeSearchItem(raw) {
241
+ const summary = typeof raw.ContentText === "string" ? raw.ContentText : "";
242
+ return {
243
+ title: typeof raw.Title === "string" && raw.Title.length > 0 ? raw.Title : "(无标题)",
244
+ type: typeof raw.ContentType === "string" && raw.ContentType.length > 0 ? raw.ContentType : "内容",
245
+ url: typeof raw.Url === "string" ? raw.Url : "",
246
+ summary: truncateSummary(summary.trim(), 200),
247
+ votes: typeof raw.VoteUpCount === "number" ? raw.VoteUpCount : 0,
248
+ comments: typeof raw.CommentCount === "number" ? raw.CommentCount : 0,
249
+ author: typeof raw.AuthorName === "string" && raw.AuthorName.length > 0 ? raw.AuthorName : "匿名",
250
+ authority: typeof raw.AuthorityLevel === "string" && raw.AuthorityLevel.length > 0 ? raw.AuthorityLevel : "?",
251
+ editTime: typeof raw.EditTime === "number" ? String(raw.EditTime) : ""
252
+ };
253
+ }
254
+ const ZHIHU_SEARCH_PATH = "/api/v1/content/zhihu_search";
255
+ function clampCount(value) {
256
+ const n = typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 5;
257
+ if (n < 1) return 1;
258
+ if (n > 10) return 10;
259
+ return n;
260
+ }
261
+ function filterItems(data) {
262
+ const row = data ?? {};
263
+ return {
264
+ items: Array.isArray(row.Items) ? row.Items.filter((item) => !!item && typeof item === "object" && !Array.isArray(item)) : [],
265
+ emptyReason: typeof row.EmptyReason === "string" ? row.EmptyReason : void 0
266
+ };
267
+ }
268
+ async function executeZhihuSearch(query, count, options = {}) {
269
+ const trimmed = query.trim();
270
+ if (!trimmed) throw new ZhihuSearchError("BAD_RESPONSE", "搜索词不能为空。");
271
+ const resolvedCount = clampCount(count);
272
+ const { items, emptyReason } = filterItems(parseZhihuEnvelope(await zhihuFetchJson(ZHIHU_SEARCH_PATH, { params: {
273
+ Query: trimmed,
274
+ Count: String(resolvedCount)
275
+ } }, "知乎搜索", options)));
276
+ return {
277
+ version: 1,
278
+ query: trimmed,
279
+ count: resolvedCount,
280
+ items: items.map(normalizeSearchItem),
281
+ emptyReason
282
+ };
283
+ }
284
+ function renderZhihuSearch(result) {
285
+ if (result.items.length === 0) return `知乎站内搜索「${result.query}」无结果${result.emptyReason ? `(${result.emptyReason})` : ""}。`;
286
+ const lines = [`知乎站内搜索「${result.query}」共 ${result.items.length} 条:`, ""];
287
+ result.items.forEach((item, index) => {
288
+ lines.push(`${index + 1}. [${item.title}](${item.url || "(无链接)"})`);
289
+ lines.push(` - 类型:${item.type} 作者:${item.author} 权威:${item.authority}`);
290
+ if (item.editTime) lines.push(` - 时间:${item.editTime}`);
291
+ lines.push(` - 赞同 ${item.votes} 评论 ${item.comments}`);
292
+ if (item.summary) lines.push(` - 摘要:${item.summary}`);
293
+ lines.push("");
294
+ });
295
+ return lines.join("\n").trimEnd();
296
+ }
297
+ const GLOBAL_SEARCH_PATH = "/api/v1/content/global_search";
298
+ const HOT_LIST_PATH = "/api/v1/content/hot_list";
299
+ const ASK_PATH = "/v1/chat/completions";
300
+ const KNOWLEDGE_SEARCH_PATH = "/api/v1/knowledge/search";
301
+ const ZHIHU_ASK_MODELS = [
302
+ "zhida-fast-1p5",
303
+ "zhida-thinking-1p5",
304
+ "zhida-agent"
305
+ ];
306
+ /** 直答的价值在于比本机模型更强的综合考据,默认用思考档;fast 只配简单事实查询。 */
307
+ const ZHIHU_ASK_DEFAULT_MODEL = "zhida-thinking-1p5";
308
+ function clampInt(value, fallback, max) {
309
+ const n = typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : fallback;
310
+ if (n < 1) return 1;
311
+ if (n > max) return max;
312
+ return n;
313
+ }
314
+ function requireQuery(query, hint) {
315
+ const trimmed = query.trim();
316
+ if (!trimmed) throw new ZhihuSearchError("BAD_RESPONSE", hint);
317
+ return trimmed;
318
+ }
319
+ function objectItems(data, key = "Items") {
320
+ const items = (data ?? {})[key];
321
+ return Array.isArray(items) ? items.filter((item) => !!item && typeof item === "object" && !Array.isArray(item)) : [];
322
+ }
323
+ async function executeZhihuGlobalSearch(query, count, searchDb, options = {}) {
324
+ const trimmed = requireQuery(query, "搜索词不能为空。");
325
+ const resolvedCount = clampInt(count, 10, 20);
326
+ const data = parseZhihuEnvelope(await zhihuFetchJson(GLOBAL_SEARCH_PATH, { params: {
327
+ Query: trimmed,
328
+ Count: String(resolvedCount),
329
+ SearchDB: searchDb
330
+ } }, "知乎全网搜索", options));
331
+ return {
332
+ version: 1,
333
+ query: trimmed,
334
+ count: resolvedCount,
335
+ searchDb,
336
+ items: objectItems(data).map(normalizeSearchItem),
337
+ emptyReason: typeof data.EmptyReason === "string" ? data.EmptyReason : void 0
338
+ };
339
+ }
340
+ function renderZhihuGlobalSearch(result) {
341
+ if (result.items.length === 0) return `全网搜索「${result.query}」无结果${result.emptyReason ? `(${result.emptyReason})` : ""}。`;
342
+ const lines = [`全网搜索「${result.query}」共 ${result.items.length} 条:`, ""];
343
+ result.items.forEach((item, index) => {
344
+ lines.push(`${index + 1}. [${item.title}](${item.url || "(无链接)"})`);
345
+ lines.push(` - 类型:${item.type} 作者:${item.author}`);
346
+ if (item.summary) lines.push(` - 摘要:${item.summary}`);
347
+ lines.push("");
348
+ });
349
+ return lines.join("\n").trimEnd();
350
+ }
351
+ const ZHIHU_HOT_LIST_SUMMARY_MAX_CHARS = 200;
352
+ function normalizeHotItem(raw) {
353
+ const summary = typeof raw.Summary === "string" ? raw.Summary.trim() : "";
354
+ return {
355
+ title: typeof raw.Title === "string" && raw.Title.length > 0 ? raw.Title : "(无标题)",
356
+ url: typeof raw.Url === "string" ? raw.Url : "",
357
+ summary: summary.length > ZHIHU_HOT_LIST_SUMMARY_MAX_CHARS ? `${summary.slice(0, ZHIHU_HOT_LIST_SUMMARY_MAX_CHARS)}…` : summary
358
+ };
359
+ }
360
+ async function executeZhihuHotList(limit, options = {}) {
361
+ const resolvedLimit = clampInt(limit, 10, 30);
362
+ return {
363
+ version: 1,
364
+ limit: resolvedLimit,
365
+ items: objectItems(parseZhihuEnvelope(await zhihuFetchJson(HOT_LIST_PATH, { params: { Limit: String(resolvedLimit) } }, "知乎热榜", options))).map(normalizeHotItem)
366
+ };
367
+ }
368
+ function renderZhihuHotList(result) {
369
+ if (result.items.length === 0) return "知乎热榜为空。";
370
+ const lines = ["知乎热榜:", ""];
371
+ result.items.forEach((item, index) => {
372
+ lines.push(`${index + 1}. [${item.title}](${item.url || "(无链接)"})`);
373
+ if (item.summary) lines.push(` - ${item.summary}`);
374
+ lines.push("");
375
+ });
376
+ return lines.join("\n").trimEnd();
377
+ }
378
+ async function executeZhihuAsk(query, model, options = {}) {
379
+ const trimmed = requireQuery(query, "直答问题不能为空。");
380
+ const body = await zhihuFetchJson(ASK_PATH, {
381
+ method: "POST",
382
+ body: {
383
+ model,
384
+ messages: [{
385
+ role: "user",
386
+ content: trimmed
387
+ }],
388
+ stream: false
389
+ }
390
+ }, "知乎直答", {
391
+ ...options,
392
+ timeoutMs: options.timeoutMs ?? 12e4
393
+ });
394
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new ZhihuSearchError("BAD_RESPONSE", "知乎直答响应不是 OpenAI 兼容格式。");
395
+ const payload = body;
396
+ if (payload.error) throw new ZhihuSearchError("HTTP_ERROR", `知乎直答返回错误:${typeof payload.error.message === "string" ? payload.error.message : "未知错误"}`);
397
+ const message = (Array.isArray(payload.choices) ? payload.choices[0] : void 0)?.message ?? {};
398
+ const content = typeof message.content === "string" ? message.content.trim() : "";
399
+ const reasoning = typeof message.reasoning_content === "string" ? message.reasoning_content.trim() : "";
400
+ if (!content) throw new ZhihuSearchError("BAD_RESPONSE", "知乎直答返回为空。");
401
+ return {
402
+ version: 1,
403
+ query: trimmed,
404
+ model,
405
+ content,
406
+ reasoning
407
+ };
408
+ }
409
+ function renderZhihuAsk(result) {
410
+ const parts = [];
411
+ if (result.reasoning) parts.push(`【思考过程】\n${result.reasoning}`);
412
+ parts.push(result.content);
413
+ return `知乎直答「${result.query}」:\n\n${parts.join("\n\n")}`;
414
+ }
415
+ const ZHIHU_RECALL_SCOPES = [
416
+ "personal",
417
+ "subscription",
418
+ "public"
419
+ ];
420
+ function normalizeKnowledgeItem(raw) {
421
+ const content = raw.Content;
422
+ const snippets = Array.isArray(content) ? content.map((snippet) => String(snippet).trim()).filter((snippet) => snippet.length > 0) : [];
423
+ return {
424
+ docName: typeof raw.DocName === "string" && raw.DocName.length > 0 ? raw.DocName : "(未命名文档)",
425
+ originUrl: typeof raw.OriginUrl === "string" ? raw.OriginUrl : "",
426
+ snippets
427
+ };
428
+ }
429
+ /** 收敛召回范围:只保留合法档位、去重;为空或未提供时回退公开库。 */
430
+ function normalizeRecallScopes(value) {
431
+ if (!Array.isArray(value)) return ["public"];
432
+ const scopes = [...new Set(value.filter((scope) => typeof scope === "string" && ZHIHU_RECALL_SCOPES.includes(scope)))];
433
+ return scopes.length > 0 ? scopes : ["public"];
434
+ }
435
+ async function executeZhihuKnowledgeSearch(query, limit, recallScopes, options = {}) {
436
+ const trimmed = requireQuery(query, "知识库检索词不能为空。");
437
+ const resolvedLimit = clampInt(limit, 5, 10);
438
+ return {
439
+ version: 1,
440
+ query: trimmed,
441
+ limit: resolvedLimit,
442
+ recallScopes,
443
+ items: objectItems(parseZhihuEnvelope(await zhihuFetchJson(KNOWLEDGE_SEARCH_PATH, {
444
+ method: "POST",
445
+ body: {
446
+ Query: trimmed,
447
+ Limit: resolvedLimit,
448
+ RecallScopes: recallScopes
449
+ }
450
+ }, "知乎知识库检索", {
451
+ ...options,
452
+ timeoutMs: options.timeoutMs ?? 3e4
453
+ }))).map(normalizeKnowledgeItem)
454
+ };
455
+ }
456
+ function renderZhihuKnowledgeSearch(result) {
457
+ if (result.items.length === 0) return `知乎知识库检索「${result.query}」无匹配结果。`;
458
+ const lines = [`知乎知识库检索「${result.query}」共 ${result.items.length} 条:`, ""];
459
+ result.items.forEach((item, index) => {
460
+ lines.push(`${index + 1}. ${item.docName}`);
461
+ if (item.originUrl) lines.push(` - 来源:${item.originUrl}`);
462
+ for (const snippet of item.snippets) lines.push(` - ${snippet}`);
463
+ lines.push("");
464
+ });
465
+ return lines.join("\n").trimEnd();
466
+ }
467
+ //#endregion
468
+ //#region src/open-platform.ts
469
+ /**
470
+ * Contract source: klarkxy/zhihu-search@62fd3ce5, upstream/http_client.py.
471
+ * Six question/creator APIs use the current Access Secret only, never OAuth.
472
+ * This module has no DSH/UI dependencies; RPC and tools share these validators.
473
+ */
474
+ const ZHIHU_OPEN_PLATFORM_APIS = {
475
+ "question.recommendations": {
476
+ path: "/api/v1/user/question_recommendations",
477
+ label: "适合回答的问题",
478
+ quotaId: "creator"
479
+ },
480
+ "question.answers": {
481
+ path: "/api/v1/content/question_answers",
482
+ label: "问题回答摘要",
483
+ quotaId: "question_answers"
484
+ },
485
+ "content.detail": {
486
+ path: "/api/v1/user/content_detail",
487
+ label: "本人创作全文",
488
+ quotaId: "creator"
489
+ },
490
+ "content.comments": {
491
+ path: "/api/v1/user/content_comments",
492
+ label: "本人创作评论",
493
+ quotaId: "creator"
494
+ },
495
+ "creator.account.stats": {
496
+ path: "/api/v1/user/creator_account_stats",
497
+ label: "账号创作数据",
498
+ quotaId: "creator"
499
+ },
500
+ "creator.content.stats": {
501
+ path: "/api/v1/user/creator_content_stats",
502
+ label: "单篇创作数据",
503
+ quotaId: "creator"
504
+ },
505
+ quota: {
506
+ path: "/api/v1/quota",
507
+ label: "官方每日额度",
508
+ quotaId: void 0
509
+ }
510
+ };
511
+ const ENDPOINT_ALIASES = {
512
+ question_recommendations: "question.recommendations",
513
+ question_answers: "question.answers",
514
+ content_detail: "content.detail",
515
+ user_content_detail: "content.detail",
516
+ "user.content.detail": "content.detail",
517
+ content_comments: "content.comments",
518
+ user_content_comments: "content.comments",
519
+ "user.content.comments": "content.comments",
520
+ creator_account_stats: "creator.account.stats",
521
+ creator_content_stats: "creator.content.stats"
522
+ };
523
+ function resolveZhihuOpenPlatformOperation(endpoint) {
524
+ if (Object.hasOwn(ZHIHU_OPEN_PLATFORM_APIS, endpoint)) return endpoint;
525
+ return Object.hasOwn(ENDPOINT_ALIASES, endpoint) ? ENDPOINT_ALIASES[endpoint] : void 0;
526
+ }
527
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
528
+ function jsonValue(value) {
529
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
530
+ if (typeof value === "number") return Number.isFinite(value);
531
+ if (Array.isArray(value)) return value.every(jsonValue);
532
+ return record(value) && Object.values(value).every(jsonValue);
533
+ }
534
+ function jsonRecord(value) {
535
+ return record(value) && Object.values(value).every(jsonValue);
536
+ }
537
+ function invalid(message) {
538
+ throw new ZhihuSearchError("INVALID_ARGUMENTS", message);
539
+ }
540
+ function inputObject(input) {
541
+ if (!record(input)) return invalid("参数必须是对象。");
542
+ for (const key of [
543
+ "token",
544
+ "accessSecret",
545
+ "access_secret",
546
+ "accessToken",
547
+ "access_token",
548
+ "oauthToken",
549
+ "oauth_token",
550
+ "useConfiguredOauthUser",
551
+ "use_configured_oauth_user"
552
+ ]) if (Object.hasOwn(input, key)) invalid("这些接口仅使用当前 Access Secret,不接受 token 参数或 OAuth 身份切换。");
553
+ return input;
554
+ }
555
+ function limit(value, fallback, maximum, name = "limit") {
556
+ const count = value === void 0 ? fallback : value;
557
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 1 || count > maximum) return invalid(`${name} 必须是 1-${maximum} 的整数。`);
558
+ return String(count);
559
+ }
560
+ /** Accept all nonnegative Int64 values as strings, but never accept an unsafe JS number. */
561
+ function normalizeZhihuOffset(value) {
562
+ let decimal;
563
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) decimal = String(value);
564
+ else if (typeof value === "string" && /^\d+$/.test(value)) decimal = value.replace(/^0+(?=\d)/, "");
565
+ else return invalid("offset 必须是非负 Int64 整数;超过 JavaScript 安全整数范围时请用十进制字符串,不能使用不透明游标。");
566
+ if (decimal.length > 19 || BigInt(decimal) > 9223372036854775807n) return invalid("offset 超出 Int64 范围。");
567
+ return decimal;
568
+ }
569
+ function zhihuUrl(value, question) {
570
+ if (typeof value !== "string" || !value.trim()) return invalid(question ? "questionUrl 不能为空。" : "contentUrl 不能为空。");
571
+ const cleaned = value.trim();
572
+ if (/[\u0000-\u0020\u007f\\]/.test(cleaned)) return invalid("知乎链接不能包含空白、控制字符或反斜线。");
573
+ let url;
574
+ try {
575
+ url = new URL(cleaned);
576
+ } catch {
577
+ return invalid("不是有效的知乎 URL。");
578
+ }
579
+ if (url.protocol !== "https:" || url.username || url.password || url.port) return invalid("必须使用无账号密码、无非标准端口的 HTTPS 知乎链接。");
580
+ if (!(question ? url.hostname === "www.zhihu.com" && /^\/question\/\d+\/?$/.test(url.pathname) : url.hostname === "www.zhihu.com" && /^\/(?:answer\/\d+|question\/\d+\/answer\/\d+|pin\/\d+|zvideo\/\d+)\/?$/.test(url.pathname) || url.hostname === "zhuanlan.zhihu.com" && /^\/p\/\d+\/?$/.test(url.pathname))) return invalid(question ? "questionUrl 仅支持 https://www.zhihu.com/question/{id}。" : "contentUrl 仅支持知乎回答、专栏文章、想法或视频链接。");
581
+ return cleaned;
582
+ }
583
+ function dates(input, params) {
584
+ const { startDate, endDate } = input;
585
+ if (startDate === void 0 && endDate === void 0) return;
586
+ if (startDate === void 0 || endDate === void 0) invalid("startDate 与 endDate 必须同时提供或同时省略。");
587
+ for (const [key, value] of [["StartDate", startDate], ["EndDate", endDate]]) {
588
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value) || value.slice(0, 4) === "0000") invalid(`${key} 必须是有效的 YYYY-MM-DD 公历日期。`);
589
+ const time = /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
590
+ if (!Number.isFinite(time.getTime()) || time.toISOString().slice(0, 10) !== value) invalid(`${key} 不是有效公历日期。`);
591
+ params[key] = value;
592
+ }
593
+ if (params.EndDate < params.StartDate) invalid("endDate 不得早于 startDate。");
594
+ }
595
+ function paramsFor(operation, input) {
596
+ const params = {};
597
+ if (operation === "question.recommendations") {
598
+ if (input.query !== void 0) {
599
+ if (typeof input.query !== "string" || !input.query.trim()) invalid("主题关键词不能为空;省略 query 才会按画像推荐。");
600
+ params.Query = input.query.trim();
601
+ }
602
+ params.Count = limit(input.count, 5, 20, "count");
603
+ } else if (operation === "question.answers") params.QuestionUrl = zhihuUrl(input.questionUrl, true);
604
+ else if (operation === "content.detail" || operation === "content.comments" || operation === "creator.content.stats") params.ContentUrl = zhihuUrl(input.contentUrl, false);
605
+ else if (operation === "creator.account.stats") {
606
+ const type = input.contentType === void 0 ? "all" : input.contentType;
607
+ if (typeof type !== "string" || ![
608
+ "all",
609
+ "answer",
610
+ "article",
611
+ "pin",
612
+ "zvideo"
613
+ ].includes(type)) invalid("contentType 必须是 all、answer、article、pin 或 zvideo。");
614
+ params.ContentType = type;
615
+ } else if (operation === "quota" && input.apiIds !== void 0) {
616
+ if (!Array.isArray(input.apiIds) || input.apiIds.some((id) => typeof id !== "string" || !ZHIHU_QUOTA_IDS.includes(id))) invalid("apiIds 包含未知官方额度项。");
617
+ if (input.apiIds.length) params.APIIDs = [...new Set(input.apiIds)].join(",");
618
+ }
619
+ if (operation === "question.answers" || operation === "content.comments") {
620
+ params.Offset = normalizeZhihuOffset(input.offset === void 0 ? 0 : input.offset);
621
+ params.Limit = limit(input.limit, 20, 50);
622
+ }
623
+ if (operation === "content.comments") {
624
+ const order = input.order === void 0 ? "score" : input.order;
625
+ if (typeof order !== "string" || ![
626
+ "score",
627
+ "reverse",
628
+ "ascending"
629
+ ].includes(order)) invalid("order 必须是 score、reverse 或 ascending。");
630
+ params.Order = order;
631
+ }
632
+ if (operation === "creator.account.stats" || operation === "creator.content.stats") dates(input, params);
633
+ return params;
634
+ }
635
+ function normalizeZhihuPaging(data, offset) {
636
+ const paging = data.Paging;
637
+ if (!record(paging) || typeof paging.IsEnd !== "boolean") return {
638
+ canContinue: false,
639
+ warning: "分页信息不完整:缺少有效的 Paging.IsEnd,请停止翻页。"
640
+ };
641
+ if (paging.IsEnd) return {
642
+ isEnd: true,
643
+ canContinue: false
644
+ };
645
+ try {
646
+ const nextOffset = normalizeZhihuOffset(paging.NextOffset);
647
+ if (BigInt(nextOffset) <= BigInt(offset)) throw new Error("non-advancing offset");
648
+ return {
649
+ isEnd: false,
650
+ canContinue: true,
651
+ nextOffset
652
+ };
653
+ } catch {
654
+ return {
655
+ isEnd: false,
656
+ canContinue: false,
657
+ warning: "分页信息不完整:NextOffset 缺失、无效或未前进,请停止翻页;不要按条数计算偏移。"
658
+ };
659
+ }
660
+ }
661
+ async function executeZhihuOpenPlatform(operation, input = {}, options = {}) {
662
+ const params = paramsFor(operation, inputObject(input));
663
+ const api = ZHIHU_OPEN_PLATFORM_APIS[operation];
664
+ const raw = await zhihuFetchJson(api.path, {
665
+ params,
666
+ envelope: true
667
+ }, api.label, options);
668
+ const data = operation === "quota" && Array.isArray(raw) ? { Items: raw } : raw;
669
+ if (!jsonRecord(data)) throw new ZhihuSearchError("BAD_RESPONSE", `${api.label}的 Data 必须是 JSON 对象。`);
670
+ return {
671
+ version: 1,
672
+ operation,
673
+ data,
674
+ ...api.quotaId ? { quotaId: api.quotaId } : {},
675
+ ...operation === "question.answers" || operation === "content.comments" ? { paging: normalizeZhihuPaging(data, params.Offset) } : {}
676
+ };
677
+ }
678
+ function zhihuOpenPlatformResultCount(result) {
679
+ if (result.operation === "quota") return 0;
680
+ if (result.operation === "content.detail") return typeof result.data.Body === "string" && result.data.Body.trim() ? 1 : 0;
681
+ if (result.operation === "creator.account.stats") return Object.keys(result.data).length ? 1 : 0;
682
+ return Array.isArray(result.data.Items) ? result.data.Items.filter(record).length : 0;
683
+ }
684
+ //#endregion
685
+ export { zhihuFetchJson as S, executeZhihuSearch as _, ZHIHU_ASK_DEFAULT_MODEL as a, parseZhihuEnvelope as b, executeZhihuAsk as c, executeZhihuKnowledgeSearch as d, normalizeRecallScopes as f, renderZhihuKnowledgeSearch as g, renderZhihuHotList as h, zhihuOpenPlatformResultCount as i, executeZhihuGlobalSearch as l, renderZhihuGlobalSearch as m, executeZhihuOpenPlatform as n, ZHIHU_ASK_MODELS as o, renderZhihuAsk as p, resolveZhihuOpenPlatformOperation as r, ZHIHU_RECALL_SCOPES as s, ZHIHU_OPEN_PLATFORM_APIS as t, executeZhihuHotList as u, renderZhihuSearch as v, reportExecuted as x, ZhihuSearchError as y };
686
+
687
+ //# sourceMappingURL=open-platform.js.map