@dcrays/web-search 0.1.0 → 0.1.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/package.json +6 -6
- package/plugin/index.d.ts +1 -0
- package/plugin/index.js +112 -84
- package/src/api-client.d.ts +2 -0
- package/src/api-client.js +97 -72
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dcrays/web-search",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "DeepSeek Harness web search plugin for Mobook (production)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "plugin/index.js",
|
|
@@ -28,12 +28,12 @@
|
|
|
28
28
|
"engines": {
|
|
29
29
|
"node": ">=24.0.0"
|
|
30
30
|
},
|
|
31
|
-
"dependencies": {
|
|
32
|
-
"@deepseek-ai/schemastery": "^3.18.1"
|
|
33
|
-
},
|
|
31
|
+
"dependencies": {},
|
|
34
32
|
"peerDependencies": {
|
|
35
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
36
|
-
"@deepseek-ai/dsh-
|
|
33
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
34
|
+
"@deepseek-ai/dsh-home-paths": "0.1.2-rc.1",
|
|
35
|
+
"@deepseek-ai/dsh-tools": "^0.1.2-rc.1",
|
|
36
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
37
37
|
},
|
|
38
38
|
"publishConfig": {
|
|
39
39
|
"access": "public"
|
package/plugin/index.d.ts
CHANGED
|
@@ -27,4 +27,5 @@ export declare class MbhWebSearchService extends Service {
|
|
|
27
27
|
isEnabled(): boolean;
|
|
28
28
|
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
|
|
29
29
|
}
|
|
30
|
+
export declare function resolveMetadataPath(configuredPath: string): string;
|
|
30
31
|
export declare function apply(ctx: Context, config: Config): void;
|
package/plugin/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createRequire as __mobookCreateRequire } from 'node:module'; const require = __mobookCreateRequire(import.meta.url);
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
import { homedir } from "node:os";
|
|
3
|
+
// dist-npm/plugin/index.js
|
|
5
4
|
import { resolve } from "node:path";
|
|
6
5
|
import { Service } from "@deepseek-ai/cordis";
|
|
6
|
+
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
7
7
|
import z from "@deepseek-ai/schemastery";
|
|
8
8
|
|
|
9
|
-
//
|
|
9
|
+
// dist-npm/src/api-client.js
|
|
10
10
|
import { createHmac, randomUUID } from "node:crypto";
|
|
11
11
|
import { readFile } from "node:fs/promises";
|
|
12
12
|
var WebSearchApiError = class extends Error {
|
|
@@ -17,6 +17,10 @@ var WebSearchApiError = class extends Error {
|
|
|
17
17
|
this.name = "WebSearchApiError";
|
|
18
18
|
}
|
|
19
19
|
};
|
|
20
|
+
var RESPONSE_TOO_LARGE_MESSAGE = "\u641C\u7D22\u670D\u52A1\u8FD4\u56DE\u7684\u5185\u5BB9\u8FC7\u5927\uFF0C\u6682\u65F6\u65E0\u6CD5\u5904\u7406\u3002\u8BF7\u7F29\u5C0F\u641C\u7D22\u8303\u56F4\u540E\u91CD\u8BD5\u3002";
|
|
21
|
+
function searchServiceLabel(service) {
|
|
22
|
+
return service === "tavily_search" ? "Tavily \u641C\u7D22" : "\u535A\u67E5\u641C\u7D22";
|
|
23
|
+
}
|
|
20
24
|
function record(value) {
|
|
21
25
|
return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
22
26
|
}
|
|
@@ -35,7 +39,7 @@ function resolveSearchCredentials(metadata, channelId, legacyChannelId) {
|
|
|
35
39
|
if (userId && botToken && deviceId)
|
|
36
40
|
return { userId, botToken, deviceId };
|
|
37
41
|
}
|
|
38
|
-
throw new WebSearchApiError("MISSING_CREDENTIALS",
|
|
42
|
+
throw new WebSearchApiError("MISSING_CREDENTIALS", "\u641C\u7D22\u670D\u52A1\u5C1A\u672A\u51C6\u5907\u597D\uFF0C\u8BF7\u786E\u8BA4\u5DF2\u767B\u5F55\u58A8\u5B9D\u5E76\u5B8C\u6210\u5BA2\u6237\u7AEF\u521D\u59CB\u5316\u540E\u91CD\u8BD5\u3002");
|
|
39
43
|
}
|
|
40
44
|
function requestSignal(signal, timeoutMs) {
|
|
41
45
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
@@ -45,7 +49,7 @@ async function responseText(response, maxBytes) {
|
|
|
45
49
|
const declared = Number(response.headers.get("content-length"));
|
|
46
50
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
47
51
|
await response.body?.cancel().catch(() => void 0);
|
|
48
|
-
throw new WebSearchApiError("RESPONSE_TOO_LARGE",
|
|
52
|
+
throw new WebSearchApiError("RESPONSE_TOO_LARGE", RESPONSE_TOO_LARGE_MESSAGE);
|
|
49
53
|
}
|
|
50
54
|
if (!response.body)
|
|
51
55
|
return "";
|
|
@@ -61,7 +65,7 @@ async function responseText(response, maxBytes) {
|
|
|
61
65
|
size += item.value.byteLength;
|
|
62
66
|
if (size > maxBytes) {
|
|
63
67
|
await reader.cancel().catch(() => void 0);
|
|
64
|
-
throw new WebSearchApiError("RESPONSE_TOO_LARGE",
|
|
68
|
+
throw new WebSearchApiError("RESPONSE_TOO_LARGE", RESPONSE_TOO_LARGE_MESSAGE);
|
|
65
69
|
}
|
|
66
70
|
text += decoder.decode(item.value, { stream: true });
|
|
67
71
|
}
|
|
@@ -70,43 +74,51 @@ async function responseText(response, maxBytes) {
|
|
|
70
74
|
reader.releaseLock();
|
|
71
75
|
}
|
|
72
76
|
}
|
|
73
|
-
function
|
|
77
|
+
function searchItem(value, snippetKeys, publishedAtKeys) {
|
|
78
|
+
const item = record(value);
|
|
79
|
+
const url = nonEmptyString(item?.url);
|
|
80
|
+
if (!item || !url)
|
|
81
|
+
return [];
|
|
82
|
+
const result = {
|
|
83
|
+
title: nonEmptyString(item.title) ?? nonEmptyString(item.name) ?? url,
|
|
84
|
+
url,
|
|
85
|
+
snippet: snippetKeys.map((key) => nonEmptyString(item[key])).find(Boolean) ?? ""
|
|
86
|
+
};
|
|
87
|
+
const siteName = nonEmptyString(item.siteName);
|
|
88
|
+
const publishedAt = publishedAtKeys.map((key) => nonEmptyString(item[key])).find(Boolean);
|
|
89
|
+
if (siteName)
|
|
90
|
+
result.siteName = siteName;
|
|
91
|
+
if (publishedAt)
|
|
92
|
+
result.publishedAt = publishedAt;
|
|
93
|
+
return [result];
|
|
94
|
+
}
|
|
95
|
+
function parseResponse(text, fallbackQuery, service) {
|
|
74
96
|
let payload;
|
|
75
97
|
try {
|
|
76
98
|
payload = JSON.parse(text);
|
|
77
99
|
} catch (error) {
|
|
78
|
-
throw new WebSearchApiError("INVALID_RESPONSE", "
|
|
100
|
+
throw new WebSearchApiError("INVALID_RESPONSE", "\u641C\u7D22\u670D\u52A1\u8FD4\u56DE\u7684\u6570\u636E\u683C\u5F0F\u5F02\u5E38\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002", { cause: error });
|
|
79
101
|
}
|
|
80
102
|
const wrapper = record(payload);
|
|
81
103
|
const upstream = record(wrapper?.data);
|
|
82
104
|
if (wrapper?.success !== true || typeof upstream?.code === "number" && upstream.code !== 200) {
|
|
83
|
-
throw new WebSearchApiError("API_ERROR", "
|
|
105
|
+
throw new WebSearchApiError("API_ERROR", "\u641C\u7D22\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002");
|
|
106
|
+
}
|
|
107
|
+
if (service === "tavily_search") {
|
|
108
|
+
const values = Array.isArray(upstream?.results) ? upstream.results : [];
|
|
109
|
+
return {
|
|
110
|
+
query: nonEmptyString(upstream?.query) ?? fallbackQuery,
|
|
111
|
+
provider: service,
|
|
112
|
+
results: values.flatMap((value) => searchItem(value, ["content", "snippet"], ["published_date", "publishedAt"]))
|
|
113
|
+
};
|
|
84
114
|
}
|
|
85
115
|
const searchData = record(upstream?.data);
|
|
86
116
|
const pages = record(searchData?.webPages);
|
|
87
117
|
const queryContext = record(searchData?.queryContext);
|
|
88
|
-
const values = Array.isArray(pages?.value) ? pages.value : [];
|
|
89
|
-
const results = values.flatMap((value) => {
|
|
90
|
-
const item = record(value);
|
|
91
|
-
const url = nonEmptyString(item?.url);
|
|
92
|
-
if (!item || !url)
|
|
93
|
-
return [];
|
|
94
|
-
const result2 = {
|
|
95
|
-
title: nonEmptyString(item.name) ?? url,
|
|
96
|
-
url,
|
|
97
|
-
snippet: nonEmptyString(item.snippet) ?? ""
|
|
98
|
-
};
|
|
99
|
-
const siteName = nonEmptyString(item.siteName);
|
|
100
|
-
const publishedAt = nonEmptyString(item.datePublished);
|
|
101
|
-
if (siteName)
|
|
102
|
-
result2.siteName = siteName;
|
|
103
|
-
if (publishedAt)
|
|
104
|
-
result2.publishedAt = publishedAt;
|
|
105
|
-
return [result2];
|
|
106
|
-
});
|
|
107
118
|
const result = {
|
|
108
119
|
query: nonEmptyString(queryContext?.originalQuery) ?? fallbackQuery,
|
|
109
|
-
|
|
120
|
+
provider: service,
|
|
121
|
+
results: (Array.isArray(pages?.value) ? pages.value : []).flatMap((value) => searchItem(value, ["snippet"], ["datePublished"]))
|
|
110
122
|
};
|
|
111
123
|
if (typeof pages?.totalEstimatedMatches === "number" && Number.isFinite(pages.totalEstimatedMatches)) {
|
|
112
124
|
result.totalEstimatedMatches = pages.totalEstimatedMatches;
|
|
@@ -125,10 +137,10 @@ var WebSearchApiClient = class {
|
|
|
125
137
|
try {
|
|
126
138
|
this.endpoint = new URL(config.endpointUrl);
|
|
127
139
|
} catch (error) {
|
|
128
|
-
throw new WebSearchApiError("INVALID_CONFIG", "
|
|
140
|
+
throw new WebSearchApiError("INVALID_CONFIG", "\u641C\u7D22\u670D\u52A1\u5730\u5740\u914D\u7F6E\u4E0D\u6B63\u786E\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u68C0\u67E5\u63D2\u4EF6\u914D\u7F6E\u3002", { cause: error });
|
|
129
141
|
}
|
|
130
142
|
if (!["http:", "https:"].includes(this.endpoint.protocol)) {
|
|
131
|
-
throw new WebSearchApiError("INVALID_CONFIG", "
|
|
143
|
+
throw new WebSearchApiError("INVALID_CONFIG", "\u641C\u7D22\u670D\u52A1\u5730\u5740\u4EC5\u652F\u6301 HTTP \u6216 HTTPS\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u68C0\u67E5\u63D2\u4EF6\u914D\u7F6E\u3002");
|
|
132
144
|
}
|
|
133
145
|
for (const [name2, value] of [
|
|
134
146
|
["timeoutMs", config.timeoutMs],
|
|
@@ -137,11 +149,11 @@ var WebSearchApiClient = class {
|
|
|
137
149
|
["maxCount", config.maxCount]
|
|
138
150
|
]) {
|
|
139
151
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
140
|
-
throw new WebSearchApiError("INVALID_CONFIG",
|
|
152
|
+
throw new WebSearchApiError("INVALID_CONFIG", `\u641C\u7D22\u63D2\u4EF6\u914D\u7F6E\u9879 ${name2} \u5FC5\u987B\u662F\u5927\u4E8E 0 \u7684\u6574\u6570\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u68C0\u67E5\u914D\u7F6E\u3002`);
|
|
141
153
|
}
|
|
142
154
|
}
|
|
143
155
|
if (config.defaultCount > config.maxCount) {
|
|
144
|
-
throw new WebSearchApiError("INVALID_CONFIG", "
|
|
156
|
+
throw new WebSearchApiError("INVALID_CONFIG", "\u9ED8\u8BA4\u641C\u7D22\u7ED3\u679C\u6570\u4E0D\u80FD\u5927\u4E8E\u6700\u5927\u7ED3\u679C\u6570\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u68C0\u67E5\u63D2\u4EF6\u914D\u7F6E\u3002");
|
|
145
157
|
}
|
|
146
158
|
this.fetchImpl = dependencies.fetch ?? fetch;
|
|
147
159
|
this.readFileImpl = dependencies.readFile ?? readFile;
|
|
@@ -151,73 +163,85 @@ var WebSearchApiClient = class {
|
|
|
151
163
|
async search(request, signal) {
|
|
152
164
|
const query = request.query.trim();
|
|
153
165
|
if (!query)
|
|
154
|
-
throw new WebSearchApiError("INVALID_REQUEST", "
|
|
166
|
+
throw new WebSearchApiError("INVALID_REQUEST", "\u8BF7\u8F93\u5165\u8981\u641C\u7D22\u7684\u5185\u5BB9\u540E\u91CD\u8BD5\u3002");
|
|
155
167
|
const count = request.count ?? this.config.defaultCount;
|
|
156
168
|
const offset = request.offset ?? 0;
|
|
157
169
|
if (!Number.isSafeInteger(count) || count < 1 || count > this.config.maxCount) {
|
|
158
|
-
throw new WebSearchApiError("INVALID_REQUEST",
|
|
170
|
+
throw new WebSearchApiError("INVALID_REQUEST", `\u641C\u7D22\u7ED3\u679C\u6570\u91CF\u9700\u8BBE\u7F6E\u4E3A 1 \u5230 ${this.config.maxCount} \u4E4B\u95F4\u7684\u6574\u6570\u3002`);
|
|
159
171
|
}
|
|
160
172
|
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
161
|
-
throw new WebSearchApiError("INVALID_REQUEST", "
|
|
173
|
+
throw new WebSearchApiError("INVALID_REQUEST", "\u641C\u7D22\u7ED3\u679C\u504F\u79FB\u91CF\u5FC5\u987B\u662F\u5927\u4E8E\u6216\u7B49\u4E8E 0 \u7684\u6574\u6570\u3002");
|
|
162
174
|
}
|
|
163
175
|
let metadata;
|
|
164
176
|
try {
|
|
165
177
|
metadata = JSON.parse(await this.readFileImpl(this.config.metadataPath, "utf8"));
|
|
166
178
|
} catch (error) {
|
|
167
|
-
throw new WebSearchApiError("MISSING_CREDENTIALS", "
|
|
179
|
+
throw new WebSearchApiError("MISSING_CREDENTIALS", "\u6682\u65F6\u65E0\u6CD5\u8BFB\u53D6\u58A8\u5B9D\u8FD0\u884C\u914D\u7F6E\uFF0C\u8BF7\u786E\u8BA4\u5BA2\u6237\u7AEF\u5DF2\u6B63\u5E38\u521D\u59CB\u5316\u540E\u91CD\u8BD5\u3002", { cause: error });
|
|
168
180
|
}
|
|
169
181
|
const credentials = resolveSearchCredentials(metadata, this.config.channelId, this.config.legacyChannelId);
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
182
|
+
const call = async (service) => {
|
|
183
|
+
const timestamp = String(this.nowSeconds());
|
|
184
|
+
const nonce = this.nonce();
|
|
185
|
+
const message = `${credentials.userId}
|
|
173
186
|
${credentials.deviceId}
|
|
174
187
|
${timestamp}
|
|
175
188
|
${nonce}`;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
+
const signature = createHmac("sha256", credentials.botToken).update(message, "utf8").digest("hex");
|
|
190
|
+
const body = {
|
|
191
|
+
user_id: credentials.userId,
|
|
192
|
+
bot_token: credentials.botToken,
|
|
193
|
+
device_id: credentials.deviceId,
|
|
194
|
+
signature,
|
|
195
|
+
timestamp,
|
|
196
|
+
nonce,
|
|
197
|
+
service,
|
|
198
|
+
query,
|
|
199
|
+
count,
|
|
200
|
+
offset,
|
|
201
|
+
language: request.language?.trim() || "zh-CN"
|
|
202
|
+
};
|
|
203
|
+
if (request.includeDomains?.length)
|
|
204
|
+
body.include_domains = request.includeDomains;
|
|
205
|
+
if (request.excludeDomains?.length)
|
|
206
|
+
body.exclude_domains = request.excludeDomains;
|
|
207
|
+
const effectiveSignal = requestSignal(signal, this.config.timeoutMs);
|
|
208
|
+
let response;
|
|
209
|
+
try {
|
|
210
|
+
response = await this.fetchImpl(this.endpoint, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: { accept: "application/json", "content-type": "application/json" },
|
|
213
|
+
body: JSON.stringify(body),
|
|
214
|
+
signal: effectiveSignal
|
|
215
|
+
});
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (signal?.aborted)
|
|
218
|
+
throw error;
|
|
219
|
+
if (effectiveSignal.aborted) {
|
|
220
|
+
throw new WebSearchApiError("TIMEOUT", `${searchServiceLabel(service)}\u54CD\u5E94\u8D85\u65F6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002`, {
|
|
221
|
+
cause: error
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
throw new WebSearchApiError("NETWORK_ERROR", `\u6682\u65F6\u65E0\u6CD5\u8FDE\u63A5${searchServiceLabel(service)}\uFF0C\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u8BD5\u3002`, { cause: error });
|
|
225
|
+
}
|
|
226
|
+
const text = await responseText(response, this.config.maxResponseBytes);
|
|
227
|
+
if (!response.ok) {
|
|
228
|
+
throw new WebSearchApiError("HTTP_ERROR", `${searchServiceLabel(service)}\u6682\u65F6\u4E0D\u53EF\u7528\uFF08\u670D\u52A1\u72B6\u6001 ${response.status}\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002`);
|
|
229
|
+
}
|
|
230
|
+
return parseResponse(text, query, service);
|
|
189
231
|
};
|
|
190
|
-
if (request.includeDomains?.length)
|
|
191
|
-
body.include_domains = request.includeDomains;
|
|
192
|
-
if (request.excludeDomains?.length)
|
|
193
|
-
body.exclude_domains = request.excludeDomains;
|
|
194
|
-
const effectiveSignal = requestSignal(signal, this.config.timeoutMs);
|
|
195
|
-
let response;
|
|
196
232
|
try {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
body: JSON.stringify(body),
|
|
201
|
-
signal: effectiveSignal
|
|
202
|
-
});
|
|
233
|
+
const tavily = await call("tavily_search");
|
|
234
|
+
if (tavily.results.length > 0)
|
|
235
|
+
return tavily;
|
|
203
236
|
} catch (error) {
|
|
204
237
|
if (signal?.aborted)
|
|
205
238
|
throw error;
|
|
206
|
-
if (effectiveSignal.aborted) {
|
|
207
|
-
throw new WebSearchApiError("TIMEOUT", `web search request timed out after ${this.config.timeoutMs}ms`, {
|
|
208
|
-
cause: error
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
throw new WebSearchApiError("NETWORK_ERROR", "web search request failed", { cause: error });
|
|
212
239
|
}
|
|
213
|
-
|
|
214
|
-
if (!response.ok)
|
|
215
|
-
throw new WebSearchApiError("HTTP_ERROR", `web search request failed with HTTP ${response.status}`);
|
|
216
|
-
return parseResponse(text, query);
|
|
240
|
+
return call("bocha_search");
|
|
217
241
|
}
|
|
218
242
|
};
|
|
219
243
|
|
|
220
|
-
//
|
|
244
|
+
// dist-npm/src/tools.js
|
|
221
245
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
222
246
|
function createWebSearchTool(search) {
|
|
223
247
|
return defineTool({
|
|
@@ -250,10 +274,10 @@ function createWebSearchTool(search) {
|
|
|
250
274
|
});
|
|
251
275
|
}
|
|
252
276
|
|
|
253
|
-
//
|
|
277
|
+
// dist-npm/src/index.js
|
|
254
278
|
var MBH_WEB_SEARCH_SERVICE_KEY = "mbhWebSearch";
|
|
255
279
|
|
|
256
|
-
//
|
|
280
|
+
// dist-npm/src/config/env-config.js
|
|
257
281
|
var ENV_CONFIG = {
|
|
258
282
|
production: {
|
|
259
283
|
packageName: "@dcrays/web-search",
|
|
@@ -271,7 +295,7 @@ var ENV_CONFIG = {
|
|
|
271
295
|
var DEFAULT_BUILD_ENV = "production";
|
|
272
296
|
var ACTIVE_ENV_CONFIG = ENV_CONFIG[DEFAULT_BUILD_ENV];
|
|
273
297
|
|
|
274
|
-
//
|
|
298
|
+
// dist-npm/plugin/index.js
|
|
275
299
|
var name = "@dcrays/web-search";
|
|
276
300
|
var inject = ["tools"];
|
|
277
301
|
var Config = z.object({
|
|
@@ -291,10 +315,9 @@ var MbhWebSearchService = class extends Service {
|
|
|
291
315
|
this.config = config;
|
|
292
316
|
if (!config.enabled)
|
|
293
317
|
return;
|
|
294
|
-
const dshHome = process.env.DSH_HOME?.trim() || resolve(homedir(), ".mobook-harness");
|
|
295
318
|
this.operations = new WebSearchApiClient({
|
|
296
319
|
endpointUrl: config.endpointUrl.trim() || ACTIVE_ENV_CONFIG.endpointUrl,
|
|
297
|
-
metadataPath: config.metadataPath
|
|
320
|
+
metadataPath: resolveMetadataPath(config.metadataPath),
|
|
298
321
|
channelId: ACTIVE_ENV_CONFIG.channelId,
|
|
299
322
|
legacyChannelId: ACTIVE_ENV_CONFIG.legacyChannelId,
|
|
300
323
|
timeoutMs: config.timeoutMs,
|
|
@@ -307,11 +330,15 @@ var MbhWebSearchService = class extends Service {
|
|
|
307
330
|
return this.config.enabled;
|
|
308
331
|
}
|
|
309
332
|
search(request, signal) {
|
|
310
|
-
if (!this.operations)
|
|
311
|
-
return Promise.reject(new Error("
|
|
333
|
+
if (!this.operations) {
|
|
334
|
+
return Promise.reject(new Error("\u8054\u7F51\u641C\u7D22\u529F\u80FD\u5F53\u524D\u672A\u542F\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u68C0\u67E5\u63D2\u4EF6\u914D\u7F6E\u3002"));
|
|
335
|
+
}
|
|
312
336
|
return this.operations.search(request, signal);
|
|
313
337
|
}
|
|
314
338
|
};
|
|
339
|
+
function resolveMetadataPath(configuredPath) {
|
|
340
|
+
return configuredPath.trim() || resolve(resolveDshHome(), "mobook.json");
|
|
341
|
+
}
|
|
315
342
|
function apply(ctx, config) {
|
|
316
343
|
const service = new MbhWebSearchService(ctx, config);
|
|
317
344
|
ctx.effect(() => {
|
|
@@ -328,5 +355,6 @@ export {
|
|
|
328
355
|
MbhWebSearchService,
|
|
329
356
|
apply,
|
|
330
357
|
inject,
|
|
331
|
-
name
|
|
358
|
+
name,
|
|
359
|
+
resolveMetadataPath
|
|
332
360
|
};
|
package/src/api-client.d.ts
CHANGED
|
@@ -15,9 +15,11 @@ export interface WebSearchItem {
|
|
|
15
15
|
}
|
|
16
16
|
export interface WebSearchResult {
|
|
17
17
|
query: string;
|
|
18
|
+
provider: SearchService;
|
|
18
19
|
totalEstimatedMatches?: number;
|
|
19
20
|
results: WebSearchItem[];
|
|
20
21
|
}
|
|
22
|
+
export type SearchService = 'tavily_search' | 'bocha_search';
|
|
21
23
|
export interface WebSearchOperations {
|
|
22
24
|
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
|
|
23
25
|
}
|
package/src/api-client.js
CHANGED
|
@@ -8,6 +8,10 @@ export class WebSearchApiError extends Error {
|
|
|
8
8
|
this.name = 'WebSearchApiError';
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
|
+
const RESPONSE_TOO_LARGE_MESSAGE = '搜索服务返回的内容过大,暂时无法处理。请缩小搜索范围后重试。';
|
|
12
|
+
function searchServiceLabel(service) {
|
|
13
|
+
return service === 'tavily_search' ? 'Tavily 搜索' : '博查搜索';
|
|
14
|
+
}
|
|
11
15
|
function record(value) {
|
|
12
16
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
13
17
|
}
|
|
@@ -26,7 +30,7 @@ export function resolveSearchCredentials(metadata, channelId, legacyChannelId) {
|
|
|
26
30
|
if (userId && botToken && deviceId)
|
|
27
31
|
return { userId, botToken, deviceId };
|
|
28
32
|
}
|
|
29
|
-
throw new WebSearchApiError('MISSING_CREDENTIALS',
|
|
33
|
+
throw new WebSearchApiError('MISSING_CREDENTIALS', '搜索服务尚未准备好,请确认已登录墨宝并完成客户端初始化后重试。');
|
|
30
34
|
}
|
|
31
35
|
function requestSignal(signal, timeoutMs) {
|
|
32
36
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
@@ -36,7 +40,7 @@ async function responseText(response, maxBytes) {
|
|
|
36
40
|
const declared = Number(response.headers.get('content-length'));
|
|
37
41
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
38
42
|
await response.body?.cancel().catch(() => undefined);
|
|
39
|
-
throw new WebSearchApiError('RESPONSE_TOO_LARGE',
|
|
43
|
+
throw new WebSearchApiError('RESPONSE_TOO_LARGE', RESPONSE_TOO_LARGE_MESSAGE);
|
|
40
44
|
}
|
|
41
45
|
if (!response.body)
|
|
42
46
|
return '';
|
|
@@ -52,7 +56,7 @@ async function responseText(response, maxBytes) {
|
|
|
52
56
|
size += item.value.byteLength;
|
|
53
57
|
if (size > maxBytes) {
|
|
54
58
|
await reader.cancel().catch(() => undefined);
|
|
55
|
-
throw new WebSearchApiError('RESPONSE_TOO_LARGE',
|
|
59
|
+
throw new WebSearchApiError('RESPONSE_TOO_LARGE', RESPONSE_TOO_LARGE_MESSAGE);
|
|
56
60
|
}
|
|
57
61
|
text += decoder.decode(item.value, { stream: true });
|
|
58
62
|
}
|
|
@@ -62,44 +66,52 @@ async function responseText(response, maxBytes) {
|
|
|
62
66
|
reader.releaseLock();
|
|
63
67
|
}
|
|
64
68
|
}
|
|
65
|
-
function
|
|
69
|
+
function searchItem(value, snippetKeys, publishedAtKeys) {
|
|
70
|
+
const item = record(value);
|
|
71
|
+
const url = nonEmptyString(item?.url);
|
|
72
|
+
if (!item || !url)
|
|
73
|
+
return [];
|
|
74
|
+
const result = {
|
|
75
|
+
title: nonEmptyString(item.title) ?? nonEmptyString(item.name) ?? url,
|
|
76
|
+
url,
|
|
77
|
+
snippet: snippetKeys.map((key) => nonEmptyString(item[key])).find(Boolean) ?? ''
|
|
78
|
+
};
|
|
79
|
+
const siteName = nonEmptyString(item.siteName);
|
|
80
|
+
const publishedAt = publishedAtKeys.map((key) => nonEmptyString(item[key])).find(Boolean);
|
|
81
|
+
if (siteName)
|
|
82
|
+
result.siteName = siteName;
|
|
83
|
+
if (publishedAt)
|
|
84
|
+
result.publishedAt = publishedAt;
|
|
85
|
+
return [result];
|
|
86
|
+
}
|
|
87
|
+
function parseResponse(text, fallbackQuery, service) {
|
|
66
88
|
let payload;
|
|
67
89
|
try {
|
|
68
90
|
payload = JSON.parse(text);
|
|
69
91
|
}
|
|
70
92
|
catch (error) {
|
|
71
|
-
throw new WebSearchApiError('INVALID_RESPONSE', '
|
|
93
|
+
throw new WebSearchApiError('INVALID_RESPONSE', '搜索服务返回的数据格式异常,请稍后重试。', { cause: error });
|
|
72
94
|
}
|
|
73
95
|
const wrapper = record(payload);
|
|
74
96
|
const upstream = record(wrapper?.data);
|
|
75
97
|
if (wrapper?.success !== true || (typeof upstream?.code === 'number' && upstream.code !== 200)) {
|
|
76
|
-
throw new WebSearchApiError('API_ERROR', '
|
|
98
|
+
throw new WebSearchApiError('API_ERROR', '搜索服务暂时不可用,请稍后重试。');
|
|
99
|
+
}
|
|
100
|
+
if (service === 'tavily_search') {
|
|
101
|
+
const values = Array.isArray(upstream?.results) ? upstream.results : [];
|
|
102
|
+
return {
|
|
103
|
+
query: nonEmptyString(upstream?.query) ?? fallbackQuery,
|
|
104
|
+
provider: service,
|
|
105
|
+
results: values.flatMap((value) => searchItem(value, ['content', 'snippet'], ['published_date', 'publishedAt']))
|
|
106
|
+
};
|
|
77
107
|
}
|
|
78
108
|
const searchData = record(upstream?.data);
|
|
79
109
|
const pages = record(searchData?.webPages);
|
|
80
110
|
const queryContext = record(searchData?.queryContext);
|
|
81
|
-
const values = Array.isArray(pages?.value) ? pages.value : [];
|
|
82
|
-
const results = values.flatMap((value) => {
|
|
83
|
-
const item = record(value);
|
|
84
|
-
const url = nonEmptyString(item?.url);
|
|
85
|
-
if (!item || !url)
|
|
86
|
-
return [];
|
|
87
|
-
const result = {
|
|
88
|
-
title: nonEmptyString(item.name) ?? url,
|
|
89
|
-
url,
|
|
90
|
-
snippet: nonEmptyString(item.snippet) ?? ''
|
|
91
|
-
};
|
|
92
|
-
const siteName = nonEmptyString(item.siteName);
|
|
93
|
-
const publishedAt = nonEmptyString(item.datePublished);
|
|
94
|
-
if (siteName)
|
|
95
|
-
result.siteName = siteName;
|
|
96
|
-
if (publishedAt)
|
|
97
|
-
result.publishedAt = publishedAt;
|
|
98
|
-
return [result];
|
|
99
|
-
});
|
|
100
111
|
const result = {
|
|
101
112
|
query: nonEmptyString(queryContext?.originalQuery) ?? fallbackQuery,
|
|
102
|
-
|
|
113
|
+
provider: service,
|
|
114
|
+
results: (Array.isArray(pages?.value) ? pages.value : []).flatMap((value) => searchItem(value, ['snippet'], ['datePublished']))
|
|
103
115
|
};
|
|
104
116
|
if (typeof pages?.totalEstimatedMatches === 'number' && Number.isFinite(pages.totalEstimatedMatches)) {
|
|
105
117
|
result.totalEstimatedMatches = pages.totalEstimatedMatches;
|
|
@@ -119,10 +131,10 @@ export class WebSearchApiClient {
|
|
|
119
131
|
this.endpoint = new URL(config.endpointUrl);
|
|
120
132
|
}
|
|
121
133
|
catch (error) {
|
|
122
|
-
throw new WebSearchApiError('INVALID_CONFIG', '
|
|
134
|
+
throw new WebSearchApiError('INVALID_CONFIG', '搜索服务地址配置不正确,请联系管理员检查插件配置。', { cause: error });
|
|
123
135
|
}
|
|
124
136
|
if (!['http:', 'https:'].includes(this.endpoint.protocol)) {
|
|
125
|
-
throw new WebSearchApiError('INVALID_CONFIG', '
|
|
137
|
+
throw new WebSearchApiError('INVALID_CONFIG', '搜索服务地址仅支持 HTTP 或 HTTPS,请联系管理员检查插件配置。');
|
|
126
138
|
}
|
|
127
139
|
for (const [name, value] of [
|
|
128
140
|
['timeoutMs', config.timeoutMs],
|
|
@@ -131,11 +143,11 @@ export class WebSearchApiClient {
|
|
|
131
143
|
['maxCount', config.maxCount]
|
|
132
144
|
]) {
|
|
133
145
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
134
|
-
throw new WebSearchApiError('INVALID_CONFIG',
|
|
146
|
+
throw new WebSearchApiError('INVALID_CONFIG', `搜索插件配置项 ${name} 必须是大于 0 的整数,请联系管理员检查配置。`);
|
|
135
147
|
}
|
|
136
148
|
}
|
|
137
149
|
if (config.defaultCount > config.maxCount) {
|
|
138
|
-
throw new WebSearchApiError('INVALID_CONFIG', '
|
|
150
|
+
throw new WebSearchApiError('INVALID_CONFIG', '默认搜索结果数不能大于最大结果数,请联系管理员检查插件配置。');
|
|
139
151
|
}
|
|
140
152
|
this.fetchImpl = dependencies.fetch ?? fetch;
|
|
141
153
|
this.readFileImpl = dependencies.readFile ?? readFile;
|
|
@@ -145,67 +157,80 @@ export class WebSearchApiClient {
|
|
|
145
157
|
async search(request, signal) {
|
|
146
158
|
const query = request.query.trim();
|
|
147
159
|
if (!query)
|
|
148
|
-
throw new WebSearchApiError('INVALID_REQUEST', '
|
|
160
|
+
throw new WebSearchApiError('INVALID_REQUEST', '请输入要搜索的内容后重试。');
|
|
149
161
|
const count = request.count ?? this.config.defaultCount;
|
|
150
162
|
const offset = request.offset ?? 0;
|
|
151
163
|
if (!Number.isSafeInteger(count) || count < 1 || count > this.config.maxCount) {
|
|
152
|
-
throw new WebSearchApiError('INVALID_REQUEST',
|
|
164
|
+
throw new WebSearchApiError('INVALID_REQUEST', `搜索结果数量需设置为 1 到 ${this.config.maxCount} 之间的整数。`);
|
|
153
165
|
}
|
|
154
166
|
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
155
|
-
throw new WebSearchApiError('INVALID_REQUEST', '
|
|
167
|
+
throw new WebSearchApiError('INVALID_REQUEST', '搜索结果偏移量必须是大于或等于 0 的整数。');
|
|
156
168
|
}
|
|
157
169
|
let metadata;
|
|
158
170
|
try {
|
|
159
171
|
metadata = JSON.parse(await this.readFileImpl(this.config.metadataPath, 'utf8'));
|
|
160
172
|
}
|
|
161
173
|
catch (error) {
|
|
162
|
-
throw new WebSearchApiError('MISSING_CREDENTIALS', '
|
|
174
|
+
throw new WebSearchApiError('MISSING_CREDENTIALS', '暂时无法读取墨宝运行配置,请确认客户端已正常初始化后重试。', { cause: error });
|
|
163
175
|
}
|
|
164
176
|
const credentials = resolveSearchCredentials(metadata, this.config.channelId, this.config.legacyChannelId);
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
177
|
+
const call = async (service) => {
|
|
178
|
+
const timestamp = String(this.nowSeconds());
|
|
179
|
+
const nonce = this.nonce();
|
|
180
|
+
const message = `${credentials.userId}\n${credentials.deviceId}\n${timestamp}\n${nonce}`;
|
|
181
|
+
const signature = createHmac('sha256', credentials.botToken).update(message, 'utf8').digest('hex');
|
|
182
|
+
const body = {
|
|
183
|
+
user_id: credentials.userId,
|
|
184
|
+
bot_token: credentials.botToken,
|
|
185
|
+
device_id: credentials.deviceId,
|
|
186
|
+
signature,
|
|
187
|
+
timestamp,
|
|
188
|
+
nonce,
|
|
189
|
+
service,
|
|
190
|
+
query,
|
|
191
|
+
count,
|
|
192
|
+
offset,
|
|
193
|
+
language: request.language?.trim() || 'zh-CN'
|
|
194
|
+
};
|
|
195
|
+
if (request.includeDomains?.length)
|
|
196
|
+
body.include_domains = request.includeDomains;
|
|
197
|
+
if (request.excludeDomains?.length)
|
|
198
|
+
body.exclude_domains = request.excludeDomains;
|
|
199
|
+
const effectiveSignal = requestSignal(signal, this.config.timeoutMs);
|
|
200
|
+
let response;
|
|
201
|
+
try {
|
|
202
|
+
response = await this.fetchImpl(this.endpoint, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
205
|
+
body: JSON.stringify(body),
|
|
206
|
+
signal: effectiveSignal
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (signal?.aborted)
|
|
211
|
+
throw error;
|
|
212
|
+
if (effectiveSignal.aborted) {
|
|
213
|
+
throw new WebSearchApiError('TIMEOUT', `${searchServiceLabel(service)}响应超时,请稍后重试。`, {
|
|
214
|
+
cause: error
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
throw new WebSearchApiError('NETWORK_ERROR', `暂时无法连接${searchServiceLabel(service)},请检查网络后重试。`, { cause: error });
|
|
218
|
+
}
|
|
219
|
+
const text = await responseText(response, this.config.maxResponseBytes);
|
|
220
|
+
if (!response.ok) {
|
|
221
|
+
throw new WebSearchApiError('HTTP_ERROR', `${searchServiceLabel(service)}暂时不可用(服务状态 ${response.status}),请稍后重试。`);
|
|
222
|
+
}
|
|
223
|
+
return parseResponse(text, query, service);
|
|
181
224
|
};
|
|
182
|
-
if (request.includeDomains?.length)
|
|
183
|
-
body.include_domains = request.includeDomains;
|
|
184
|
-
if (request.excludeDomains?.length)
|
|
185
|
-
body.exclude_domains = request.excludeDomains;
|
|
186
|
-
const effectiveSignal = requestSignal(signal, this.config.timeoutMs);
|
|
187
|
-
let response;
|
|
188
225
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
body: JSON.stringify(body),
|
|
193
|
-
signal: effectiveSignal
|
|
194
|
-
});
|
|
226
|
+
const tavily = await call('tavily_search');
|
|
227
|
+
if (tavily.results.length > 0)
|
|
228
|
+
return tavily;
|
|
195
229
|
}
|
|
196
230
|
catch (error) {
|
|
197
231
|
if (signal?.aborted)
|
|
198
232
|
throw error;
|
|
199
|
-
if (effectiveSignal.aborted) {
|
|
200
|
-
throw new WebSearchApiError('TIMEOUT', `web search request timed out after ${this.config.timeoutMs}ms`, {
|
|
201
|
-
cause: error
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
throw new WebSearchApiError('NETWORK_ERROR', 'web search request failed', { cause: error });
|
|
205
233
|
}
|
|
206
|
-
|
|
207
|
-
if (!response.ok)
|
|
208
|
-
throw new WebSearchApiError('HTTP_ERROR', `web search request failed with HTTP ${response.status}`);
|
|
209
|
-
return parseResponse(text, query);
|
|
234
|
+
return call('bocha_search');
|
|
210
235
|
}
|
|
211
236
|
}
|