@klarkxy/dsh-zhihu 0.1.10 → 0.2.1

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.
@@ -1,7 +1,178 @@
1
- import { ZHIHU_QUOTA_IDS } from "./contracts.js";
2
- import { readFile } from "node:fs/promises";
1
+ import { ZHIHU_QUOTA_IDS, normalizeZhihuQuotaId } from "./contracts.js";
2
+ import { mkdir, open, readFile, rename, rmdir, unlink } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
+ //#region src/call-policy.ts
8
+ var ZhihuCallPolicyError = class extends Error {};
9
+ const blocked = (message) => {
10
+ throw new ZhihuCallPolicyError(message);
11
+ };
12
+ const GROUPS = {
13
+ "/api/v1/content/zhihu_search": "zhihu_search",
14
+ "/api/v1/content/global_search": "global_search",
15
+ "/api/v1/content/hot_list": "hot_list",
16
+ "/api/v1/content/question_answers": "question_discovery",
17
+ "/api/v1/user/contents": "user_data",
18
+ "/api/v1/user/followees": "user_data",
19
+ "/api/v1/user/collections": "user_data",
20
+ "/api/v1/user/favlists": "user_data",
21
+ "/api/v1/user/favlist_contents": "user_data",
22
+ "/api/v1/user/content_detail": "creator",
23
+ "/api/v1/user/content_comments": "creator",
24
+ "/api/v1/user/creator_account_stats": "creator",
25
+ "/api/v1/user/creator_content_stats": "creator",
26
+ "/api/v1/knowledge/bases": "knowledge",
27
+ "/api/v1/knowledge/files": "knowledge",
28
+ "/api/v1/knowledge/search": "knowledge"
29
+ };
30
+ const PRICED_PATHS = new Set(Object.keys(GROUPS));
31
+ GROUPS["/api/v1/user/question_recommendations"] = "question_discovery";
32
+ function zhihuQuotaGroup(path, body) {
33
+ if (path === "/v1/chat/completions" && body && typeof body === "object" && [
34
+ "zhida-agent",
35
+ "zhida-fast-1p5",
36
+ "zhida-thinking-1p5"
37
+ ].includes(String(body.model))) return "zhida_openai";
38
+ if (/^\/api\/v1\/knowledge\/bases\/[^/]+\/items$/.test(path)) return "knowledge";
39
+ if (/^\/api\/v1\/(?:pdf-parse|ppt-generation)\/tasks(?:\/[^/]+)?$/.test(path)) return "tools";
40
+ return GROUPS[path] ?? blocked("调用已阻止:该能力的官方额度映射未确认;不能擅自套价。");
41
+ }
42
+ function resolveZhihuCallPolicy(env = process.env, options = {}) {
43
+ const raw = (env.ZHIHU_ALLOW_PAID ?? "false").toLowerCase();
44
+ if (options.allowPaid === void 0 && ![
45
+ "true",
46
+ "false",
47
+ "1",
48
+ "0"
49
+ ].includes(raw)) blocked("ZHIHU_ALLOW_PAID 必须是 true/false 或 1/0。");
50
+ const allowPaid = options.allowPaid ?? ["true", "1"].includes(raw);
51
+ const capText = env.ZHIHU_DAILY_CALL_LIMIT ?? "100";
52
+ if (options.dailyCallLimit === void 0 && !/^\d+$/.test(capText)) blocked("每日调用上限必须是 1-100000 的整数。");
53
+ const dailyCallLimit = options.dailyCallLimit ?? Number(capText);
54
+ if (typeof allowPaid !== "boolean" || !Number.isInteger(dailyCallLimit) || dailyCallLimit < 1 || dailyCallLimit > 1e5) blocked("付费开关必须是布尔值,每日调用上限必须是 1-100000 的整数。");
55
+ return {
56
+ allowPaid,
57
+ dailyCallLimit
58
+ };
59
+ }
60
+ const integer = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
61
+ const day = () => new Date(Date.now() + 288e5).toISOString().slice(0, 10);
62
+ const code = (error) => error?.code;
63
+ async function readLedger(directory) {
64
+ let value;
65
+ try {
66
+ value = JSON.parse(await readFile(join(directory, "daily.json"), "utf8"));
67
+ } catch (error) {
68
+ if (code(error) === "ENOENT") return {
69
+ version: 1,
70
+ day: day(),
71
+ calls: 0,
72
+ uncertain: {},
73
+ free: {}
74
+ };
75
+ return blocked("本地调用记录不可读;已阻止请求,请检查 call-policy/daily.json。");
76
+ }
77
+ const row = value;
78
+ if (!row || row.version !== 1 || typeof row.day !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(row.day) || !integer(row.calls) || !row.uncertain || Array.isArray(row.uncertain) || typeof row.uncertain !== "object" || !Object.values(row.uncertain).every(integer)) blocked("本地调用记录无效;已阻止请求,不能自动清零。");
79
+ if (row.free === void 0) row.free = {};
80
+ if (!row.free || Array.isArray(row.free) || typeof row.free !== "object" || !Object.values(row.free).every(integer)) blocked("本地免费预留记录无效;已阻止请求。");
81
+ if (row.day !== day()) {
82
+ row.day = day();
83
+ row.calls = 0;
84
+ row.free = {};
85
+ }
86
+ return row;
87
+ }
88
+ async function writeLedger(directory, row) {
89
+ const temporary = join(directory, `daily-${randomUUID()}.tmp`);
90
+ try {
91
+ const file = await open(temporary, "wx");
92
+ try {
93
+ await file.writeFile(JSON.stringify(row), "utf8");
94
+ await file.sync();
95
+ } finally {
96
+ await file.close();
97
+ }
98
+ await rename(temporary, join(directory, "daily.json"));
99
+ } catch {
100
+ blocked("本地调用记录保存失败;已阻止请求。");
101
+ } finally {
102
+ await unlink(temporary).catch((error) => {
103
+ if (code(error) !== "ENOENT") throw error;
104
+ });
105
+ }
106
+ }
107
+ async function withZhihuCallPolicy(path, body, quota, execute, options = {}) {
108
+ const group = zhihuQuotaGroup(path, body);
109
+ const env = options.env ?? process.env;
110
+ const policy = resolveZhihuCallPolicy(env, options);
111
+ const priced = PRICED_PATHS.has(path) || /^\/api\/v1\/knowledge\/bases\/[^/]+\/items$/.test(path) || path === "/v1/chat/completions" && body && typeof body === "object" && body.model === "zhida-agent";
112
+ if (policy.allowPaid && !priced) blocked("付费调用已阻止:该能力的官方计费未确认;请使用免费保护模式。");
113
+ const directory = options.directory ?? env.ZHIHU_CALL_POLICY_DIR ?? join(env.ZHIHU_SEARCH_HOME ?? join(homedir(), ".config", "zhihu-search"), "call-policy");
114
+ const lock = join(directory, "request.lock");
115
+ let acquired = false;
116
+ const deadline = Date.now() + (options.lockTimeoutMs ?? 3e4);
117
+ try {
118
+ options.signal?.throwIfAborted();
119
+ await mkdir(directory, { recursive: true });
120
+ while (!acquired) {
121
+ options.signal?.throwIfAborted();
122
+ try {
123
+ await mkdir(lock);
124
+ acquired = true;
125
+ } catch (error) {
126
+ if (code(error) !== "EEXIST") throw error;
127
+ if (Date.now() >= deadline) blocked("本地保护锁忙或遗留;已阻止请求。确认两个客户端均无在途请求后再移除 call-policy/request.lock。");
128
+ await setTimeout$1(50, void 0, { signal: options.signal });
129
+ }
130
+ }
131
+ options.signal?.throwIfAborted();
132
+ const row = await readLedger(directory);
133
+ if (row.calls >= policy.dailyCallLimit) blocked("已达到本地北京时间每日业务调用上限;额度查询仍可使用。");
134
+ if (!policy.allowPaid) {
135
+ let rows;
136
+ try {
137
+ rows = await quota(group);
138
+ } catch {
139
+ options.signal?.throwIfAborted();
140
+ blocked("免费额度预查失败;未发送业务请求,请稍后手动查询官方额度。");
141
+ }
142
+ options.signal?.throwIfAborted();
143
+ const matches = Array.isArray(rows) ? rows.filter((value) => value && typeof value === "object" && value.APIID === group) : [];
144
+ if (matches.length !== 1 || !integer(matches[0].RemainingQuota)) blocked("官方未返回有效的免费剩余额度;未发送业务请求。");
145
+ const available = Math.min(matches[0].RemainingQuota - (row.uncertain[group] ?? 0), row.free[group] ?? matches[0].RemainingQuota);
146
+ if (available <= 0) blocked("免费额度不足,或有结果未知的已发送请求;未发送业务请求。");
147
+ row.free[group] = available - 1;
148
+ } else if (row.free[group] !== void 0) row.free[group] = Math.max(0, row.free[group] - 1);
149
+ options.signal?.throwIfAborted();
150
+ row.calls++;
151
+ row.uncertain[group] = (row.uncertain[group] ?? 0) + 1;
152
+ await writeLedger(directory, row);
153
+ options.signal?.throwIfAborted();
154
+ const result = await execute();
155
+ row.uncertain[group]--;
156
+ await writeLedger(directory, row);
157
+ return result;
158
+ } catch (error) {
159
+ options.signal?.throwIfAborted();
160
+ if (error instanceof ZhihuCallPolicyError) throw error;
161
+ if ([
162
+ "EACCES",
163
+ "EPERM",
164
+ "ENOSPC",
165
+ "EIO",
166
+ "EROFS",
167
+ "ENOENT",
168
+ "ENOTDIR"
169
+ ].includes(String(code(error)))) blocked("本地保护存储不可用;已阻止业务请求。");
170
+ throw error;
171
+ } finally {
172
+ if (acquired) await rmdir(lock);
173
+ }
174
+ }
175
+ //#endregion
5
176
  //#region src/zhihu-client.ts
6
177
  /**
7
178
  * 知乎开放平台共享请求层:Access Secret 解析、超时封装、信封解析与执行计量。
@@ -11,13 +182,11 @@ import { join } from "node:path";
11
182
  * - 公共 Headers: Authorization: Bearer <access_secret>、X-Request-Timestamp、Accept
12
183
  * - 除直答外的 REST 接口返回 {Code, Message, Data} 信封,Code 非 0 表示业务错误。
13
184
  *
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。
185
+ * Token 解析(按契约,**不**把 token 放进 schema 参数):
186
+ * - 提供 credential 解析器时只使用它,缺失或无效时抛出 TOKEN_MISSING。
187
+ * 宿主通过 `ctx.credentials.resolve` 统一管理凭据来源和清除操作。
188
+ * - 未提供解析器的独立客户端依次兼容 ZHIHU_ACCESS_TOKEN、
189
+ * ZHIHU_ACCESS_SECRET 和 ~/.config/zhihu-search/credentials.json。
21
190
  */
22
191
  const ZHIHU_BASE_URL = "https://developer.zhihu.com";
23
192
  const ZHIHU_DEFAULT_TIMEOUT_MS = 15e3;
@@ -41,11 +210,13 @@ async function readCredentialsFile(path) {
41
210
  }
42
211
  async function resolveZhihuToken(env = process.env, options = {}) {
43
212
  if (options.resolveCredential) {
44
- const fromCredential = (await options.resolveCredential())?.trim();
213
+ const value = await options.resolveCredential();
214
+ const fromCredential = typeof value === "string" ? value.trim() : void 0;
45
215
  if (fromCredential && looksLikeToken(fromCredential)) return {
46
216
  token: fromCredential,
47
217
  source: "credential"
48
218
  };
219
+ throw new ZhihuSearchError("TOKEN_MISSING", "未配置有效的知乎 Access Secret。请在「插件 → 知乎 → 设置」中填写。");
49
220
  }
50
221
  const primary = env[ZHIHU_CREDENTIALS_PRIMARY]?.trim();
51
222
  if (primary && looksLikeToken(primary)) return {
@@ -180,10 +351,34 @@ async function zhihuFetchJson(path, init, label, options = {}) {
180
351
  if (!response.ok) throw httpError();
181
352
  return body;
182
353
  };
183
- return await Promise.race([request(), aborted]);
354
+ const quota = async (apiId) => {
355
+ const quotaUrl = new URL("/api/v1/quota", ZHIHU_BASE_URL);
356
+ quotaUrl.searchParams.set("APIIDs", apiId);
357
+ const response = await fetcher(quotaUrl.toString(), {
358
+ method: "GET",
359
+ signal: controller.signal,
360
+ headers: {
361
+ Authorization: `Bearer ${token.token}`,
362
+ "X-Request-Timestamp": String(Math.floor(Date.now() / 1e3)),
363
+ Accept: "application/json"
364
+ }
365
+ });
366
+ const data = parseZhihuEnvelope(await response.json(), {
367
+ status: response.status,
368
+ officialErrors: true
369
+ });
370
+ if (!response.ok) throw new Error("官方额度查询失败");
371
+ return data;
372
+ };
373
+ return await (path === "/api/v1/quota" && method === "GET" ? Promise.race([request(), aborted]) : withZhihuCallPolicy(path, init.body, (apiId) => Promise.race([quota(apiId), aborted]), () => Promise.race([request(), aborted]), {
374
+ ...options.callPolicy,
375
+ env: options.env,
376
+ signal: controller.signal
377
+ }));
184
378
  } catch (error) {
185
379
  if (linked?.aborted) throw new ZhihuSearchError("CANCELLED", "请求已取消");
186
380
  if (controller.signal.aborted || isAbortError(error)) throw new ZhihuSearchError("TIMEOUT", `${label}超时(${Math.round(timeout / 1e3)} 秒),请稍后再试。`);
381
+ if (error instanceof ZhihuCallPolicyError) throw new ZhihuSearchError("CALL_BLOCKED", error.message, void 0, { retryable: false });
187
382
  if (error instanceof ZhihuSearchError) {
188
383
  error.message = error.message.replaceAll(token.token, "[REDACTED]");
189
384
  throw error;
@@ -475,12 +670,12 @@ const ZHIHU_OPEN_PLATFORM_APIS = {
475
670
  "question.recommendations": {
476
671
  path: "/api/v1/user/question_recommendations",
477
672
  label: "适合回答的问题",
478
- quotaId: "creator"
673
+ quotaId: "question_discovery"
479
674
  },
480
675
  "question.answers": {
481
676
  path: "/api/v1/content/question_answers",
482
677
  label: "问题回答摘要",
483
- quotaId: "question_answers"
678
+ quotaId: "question_discovery"
484
679
  },
485
680
  "content.detail": {
486
681
  path: "/api/v1/user/content_detail",
@@ -550,7 +745,7 @@ function inputObject(input) {
550
745
  "useConfiguredOauthUser",
551
746
  "use_configured_oauth_user"
552
747
  ]) if (Object.hasOwn(input, key)) invalid("这些接口仅使用当前 Access Secret,不接受 token 参数或 OAuth 身份切换。");
553
- return input;
748
+ return { ...input };
554
749
  }
555
750
  function limit(value, fallback, maximum, name = "limit") {
556
751
  const count = value === void 0 ? fallback : value;
@@ -613,6 +808,7 @@ function paramsFor(operation, input) {
613
808
  ].includes(type)) invalid("contentType 必须是 all、answer、article、pin 或 zvideo。");
614
809
  params.ContentType = type;
615
810
  } else if (operation === "quota" && input.apiIds !== void 0) {
811
+ if (Array.isArray(input.apiIds)) input.apiIds = input.apiIds.map(normalizeZhihuQuotaId);
616
812
  if (!Array.isArray(input.apiIds) || input.apiIds.some((id) => typeof id !== "string" || !ZHIHU_QUOTA_IDS.includes(id))) invalid("apiIds 包含未知官方额度项。");
617
813
  if (input.apiIds.length) params.APIIDs = [...new Set(input.apiIds)].join(",");
618
814
  }
@@ -682,6 +878,6 @@ function zhihuOpenPlatformResultCount(result) {
682
878
  return Array.isArray(result.data.Items) ? result.data.Items.filter(record).length : 0;
683
879
  }
684
880
  //#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 };
881
+ export { resolveZhihuCallPolicy as C, 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
882
 
687
883
  //# sourceMappingURL=open-platform.js.map