@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.
- package/README.md +32 -10
- package/docs/README.zh-CN.md +39 -10
- package/lib/client.js +217 -310
- package/lib/contracts.d.ts +3 -2
- package/lib/contracts.js +3 -2
- package/lib/contracts.js.map +1 -1
- package/lib/index.d.ts +12 -2
- package/lib/index.js +89 -111
- package/lib/index.js.map +1 -1
- package/lib/open-platform.js +211 -15
- package/lib/open-platform.js.map +1 -1
- package/lib/tools.js +5 -5
- package/lib/tools.js.map +1 -1
- package/package.json +18 -13
package/lib/open-platform.js
CHANGED
|
@@ -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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
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
|
-
|
|
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: "
|
|
673
|
+
quotaId: "question_discovery"
|
|
479
674
|
},
|
|
480
675
|
"question.answers": {
|
|
481
676
|
path: "/api/v1/content/question_answers",
|
|
482
677
|
label: "问题回答摘要",
|
|
483
|
-
quotaId: "
|
|
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
|