dsh-free-search 0.7.4 → 0.7.5
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 +6 -2
- package/lib/client.js +1 -1
- package/lib/index.js +117 -11
- package/lib/mcp_adapter.js +193 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
|
|
|
29
29
|
### 特性
|
|
30
30
|
|
|
31
31
|
- **零成本** —— 多个免费引擎,无需 key、无需注册
|
|
32
|
-
- **多引擎可选**:DuckDuckGo(html/lite)、Bing、SearXNG(元搜索,支持自定义实例)、AnySearch、Exa、Tavily、Keenable、Firecrawl、Parallel、Perplexity、SerpBase、Serply、DeepSeek 官方、You.com、百度千帆、Kimi
|
|
32
|
+
- **多引擎可选**:DuckDuckGo(html/lite)、Bing、SearXNG(元搜索,支持自定义实例)、AnySearch、Exa、Tavily、Keenable、Firecrawl、Parallel、Perplexity、SerpBase、Serply、DeepSeek 官方、You.com、百度千帆、Kimi、阿里云百炼、火山联网搜索(豆包搜索)、知乎全网/站内(`zhihu_global`/`zhihu_site`,经知乎开放平台 MCP)、OpenAI / Gemini / Claude 模型内置搜索
|
|
33
33
|
- **网页设置页** —— 引擎切换 + API key 配置(UI 中 key 脱敏显示"已配置")+ 中英文切换;入口在左侧「插件」页的组件行配置(`plugins.row.config`,DSH 0.1.7-rc.1+)
|
|
34
34
|
- **弹出式切换命令** —— 聊天框输入 `/free-search-engine`,弹出引擎选择窗口,点选即切换(等效设置页 + 保存)
|
|
35
35
|
- **引擎测试** —— `free_search_test` 工具让 agent 一键测试所有引擎;设置页也有"测试引擎"按钮(直测当前引擎,不走回退链,付费引擎无 key 会明确报错)
|
|
@@ -73,6 +73,8 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
|
|
|
73
73
|
| `kimi` | Kimi(Moonshot)联网搜索 | 付费 | 需 `MOONSHOT_API_KEY`(basic 约 ¥0.01/次),返回带正文 chunks 的中文结果 |
|
|
74
74
|
| `aliyun` | 阿里云百炼 EnhancedSearch | 付费 | 需 `DASHSCOPE_API_KEY`(MCP search_pro,约 ¥0.03/次,新用户 200 次免费包);中文全网带来源 hostname |
|
|
75
75
|
| `doubao` | 火山引擎联网搜索(豆包搜索) | 免费额度 | 需 `DOUBAO_SEARCH_API_KEY`(联网搜索控制台开通,**每月 500 次免费**,超出按量付费);中文时效内容强,支持时间/站点过滤,返回千字级摘要 |
|
|
76
|
+
| `zhihu_global` | 知乎全网搜索(开放平台 MCP) | 付费 | 需 `ZHIHU_API_KEY`;支持时间过滤(`publish_time`);无 key 时自动跳过 |
|
|
77
|
+
| `zhihu_site` | 知乎站内搜索(开放平台 MCP) | 付费 | 需 `ZHIHU_API_KEY`;不支持时间过滤 |
|
|
76
78
|
| `openai` | OpenAI 模型内置搜索 | 付费 | 需 `OPENAI_API_KEY`;走 Responses API 的 `web_search` 工具,返回带引用的回答。**按次计费,仅在显式选中时使用**(不参与自动回退/auto);默认 `gpt-6-luna`,模型与端点可配(`openaiModel` / `openaiBaseUrl`) |
|
|
77
79
|
| `gemini` | Gemini Grounding with Google Search | 付费 | 需 `GEMINI_API_KEY`(兼容 `GOOGLE_API_KEY`);走官方推荐的 **Interactions API**(`POST /v1beta/interactions`,`tools:[{type:"google_search"}]`),从 `steps[].content[].annotations` 取引用。**按次计费,仅在显式选中时使用**;默认 `gemini-3.8-flash`,模型与端点可配(`geminiModel` / `geminiBaseUrl`) |
|
|
78
80
|
| `claude` | Claude 服务端 web_search | 付费 | 需 `ANTHROPIC_API_KEY`;结果来自 `web_search_tool_result`。**按次计费,仅在显式选中时使用**;默认 `claude-sonnet-5-5`,模型与端点可配(`claudeModel` / `claudeBaseUrl`) |
|
|
@@ -394,7 +396,7 @@ This plugin provides multiple free search engines with automatic fallback, compl
|
|
|
394
396
|
### Features
|
|
395
397
|
|
|
396
398
|
- **Zero Cost** — Multiple free engines with no API key or registration required
|
|
397
|
-
- **Multi-Engine Support** — DuckDuckGo (HTML / Lite), Bing, AnySearch AI, SearXNG (meta-search with custom instances), Exa, Tavily, Keenable, Firecrawl, Parallel, Perplexity, SerpBase, Serply, DeepSeek Official, You.com, Baidu Qianfan, Kimi, Aliyun Bailian, Volcano Doubao, and the model-based OpenAI / Gemini / Claude searches
|
|
399
|
+
- **Multi-Engine Support** — DuckDuckGo (HTML / Lite), Bing, AnySearch AI, SearXNG (meta-search with custom instances), Exa, Tavily, Keenable, Firecrawl, Parallel, Perplexity, SerpBase, Serply, DeepSeek Official, You.com, Baidu Qianfan, Kimi, Aliyun Bailian, Volcano Doubao, Zhihu web-wide/site search (`zhihu_global`/`zhihu_site`, via the Zhihu open-platform MCP), and the model-based OpenAI / Gemini / Claude searches
|
|
398
400
|
- **Web Settings UI** — Engine switching, API key configuration (keys masked as "configured" in the UI), and a Chinese/English toggle; the entry is the component-row config (`plugins.row.config`) on the sidebar Plugins page (DSH 0.1.7-rc.1+)
|
|
399
401
|
- **Popup Switch Command** — Type `/free-search-engine` in the chat: a picker opens with all engines; click one to switch (equivalent to the settings page + save)
|
|
400
402
|
- **Engine Testing** — `free_search_test` for the agent to check all engines in one call; the settings UI also has a "Test engine" button that tests the selected engine directly (no fallback chain; paid engines without a key report an explicit error)
|
|
@@ -438,6 +440,8 @@ If this plugin has been helpful, a ⭐ on [GitHub](https://github.com/DDDMUC/dsh
|
|
|
438
440
|
| `kimi` | Kimi (Moonshot) Web Search | Paid | Requires `MOONSHOT_API_KEY` (basic ~¥0.01/call); returns Chinese results with body chunks |
|
|
439
441
|
| `aliyun` | Aliyun Bailian EnhancedSearch | Paid | Requires `DASHSCOPE_API_KEY` (MCP search_pro, ~¥0.03/call, 200 free calls for new users); Chinese web-wide with source hostnames |
|
|
440
442
|
| `doubao` | Volcano Web Search (Doubao) | Free quota | Requires `DOUBAO_SEARCH_API_KEY` (open it in the Web Search console; **500 free queries/month**, pay-as-you-go beyond); strong for fresh Chinese content, supports time/site filters, returns long summaries |
|
|
443
|
+
| `zhihu_global` | Zhihu web-wide search (open-platform MCP) | Paid | Requires `ZHIHU_API_KEY`; supports the time filter (`publish_time`); skipped automatically when no key is set |
|
|
444
|
+
| `zhihu_site` | Zhihu site search (open-platform MCP) | Paid | Requires `ZHIHU_API_KEY`; time filtering not verified, so it is not offered |
|
|
441
445
|
| `openai` | OpenAI built-in web search | Paid | Requires `OPENAI_API_KEY`; uses the Responses API `web_search` tool and returns a cited answer. **Billed per search, explicit selection only** (never picked by the fallback chain or Auto); default `gpt-6-luna`, model/endpoint configurable (`openaiModel` / `openaiBaseUrl`) |
|
|
442
446
|
| `gemini` | Gemini grounding with Google Search | Paid | Requires `GEMINI_API_KEY` (falls back to `GOOGLE_API_KEY`); uses the recommended **Interactions API** (`POST /v1beta/interactions`, `tools:[{type:"google_search"}]`) and reads citations from `steps[].content[].annotations`. **Billed per search, explicit selection only**; default `gemini-3.8-flash`, model/endpoint configurable (`geminiModel` / `geminiBaseUrl`) |
|
|
443
447
|
| `claude` | Claude server-side web_search | Paid | Requires `ANTHROPIC_API_KEY`; results come from `web_search_tool_result`. **Billed per search, explicit selection only**; default `claude-sonnet-5-5`, model/endpoint configurable (`claudeModel` / `claudeBaseUrl`) |
|
package/lib/client.js
CHANGED
|
@@ -314,7 +314,7 @@ window.__ModuleLoader__.load({
|
|
|
314
314
|
};
|
|
315
315
|
const tt = (lang) => I18N[lang === "en" ? "en" : "zh"];
|
|
316
316
|
// 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION 保持一致)
|
|
317
|
-
const PLUGIN_VERSION = "0.7.
|
|
317
|
+
const PLUGIN_VERSION = "0.7.5";
|
|
318
318
|
const ENGINES = [
|
|
319
319
|
{ id: "auto", label: "Auto · 智能路由", badge: "AUTO", link: null },
|
|
320
320
|
{ id: "multi", label: "Multi Search · 多引擎", badge: "MULTI", link: null },
|
package/lib/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import fs from "node:fs";
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { exec } from "node:child_process";
|
|
8
|
+
import { mcpCall, mcpContentText } from "./mcp_adapter.js";
|
|
8
9
|
|
|
9
10
|
// 兼容 guard(#55):Node 会优先解析插件目录下的嵌套依赖,若那里残留旧的
|
|
10
11
|
// @deepseek-ai/schemastery(如 node_modules/dsh-free-search/node_modules/... -> 3.18.1),
|
|
@@ -41,6 +42,7 @@ const SERPBASE_URL = "https://api.serpbase.dev/google/search";
|
|
|
41
42
|
const SERPLY_URL = "https://api.serply.io/v1/search";
|
|
42
43
|
const YOUCOM_URL = "https://ydc-index.io/v1/search";
|
|
43
44
|
const DOUBAO_SEARCH_URL = "https://open.feedcoopapi.com/search_api/web_search";
|
|
45
|
+
const ZHIHU_MCP_URL = "https://developer.zhihu.com/api/mcp/v1";
|
|
44
46
|
const USER_AGENT =
|
|
45
47
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
|
|
46
48
|
const ACCEPT_LANG = "zh-CN,zh;q=0.9,en;q=0.8";
|
|
@@ -78,7 +80,7 @@ const MARKET_TO_LANG = {
|
|
|
78
80
|
const FREE_SEARCH_NS = "web-search-free";
|
|
79
81
|
const BRIDGE_PREFIX = "/api/dsh-free-search-settings";
|
|
80
82
|
const FREE_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch"];
|
|
81
|
-
const ALL_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch", "exa", "tavily", "keenable", "firecrawl", "parallel", "perplexity", "serpbase", "serply", "deepseek-official", "you", "baidu", "kimi", "aliyun", "doubao", "openai", "gemini", "claude"];
|
|
83
|
+
const ALL_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch", "exa", "tavily", "keenable", "firecrawl", "parallel", "perplexity", "serpbase", "serply", "deepseek-official", "you", "baidu", "kimi", "aliyun", "doubao", "zhihu_global", "zhihu_site", "openai", "gemini", "claude"];
|
|
82
84
|
|
|
83
85
|
// 模型内置搜索(按次计费):只在你显式选中该引擎时使用;自动回退链与 auto 路由都不会兜底到它们。
|
|
84
86
|
const EXPLICIT_ONLY_ENGINES = ["openai", "gemini", "claude"];
|
|
@@ -113,9 +115,11 @@ const clearCooldown = (engine) => engineCooldowns.delete(engine);
|
|
|
113
115
|
const setCooldown = (engine, ms) => {
|
|
114
116
|
engineCooldowns.set(engine, ms === COOLDOWN_SESSION_MS ? COOLDOWN_SESSION_MS : Date.now() + ms);
|
|
115
117
|
};
|
|
116
|
-
const applyFailureCooldown = (engine, cls) => {
|
|
118
|
+
const applyFailureCooldown = (engine, cls, rateLimited = false) => {
|
|
117
119
|
if (cls === "quota" || cls === "auth") setCooldown(engine, COOLDOWN_SESSION_MS);
|
|
118
120
|
else if (cls === "bot-wall") setCooldown(engine, BOT_WALL_COOLDOWN_MS);
|
|
121
|
+
// 短窗限流(429/rate limit):60 秒退避,避免下一次搜索立刻再撞(知乎 MCP 等实测存在 RPM 级限流)
|
|
122
|
+
else if (rateLimited) setCooldown(engine, BOT_WALL_COOLDOWN_MS);
|
|
119
123
|
};
|
|
120
124
|
|
|
121
125
|
const classifyFailure = (error) => {
|
|
@@ -132,7 +136,7 @@ const classifyFailure = (error) => {
|
|
|
132
136
|
return { class: "bot-wall", message };
|
|
133
137
|
}
|
|
134
138
|
if (rateLimited || /http 5\d\d|timeout|timed out|aborted|abort|fetch failed|econn|network|socket/.test(m)) {
|
|
135
|
-
return { class: "transient", message };
|
|
139
|
+
return { class: "transient", message, rateLimited };
|
|
136
140
|
}
|
|
137
141
|
if (/invalid json|unexpected|parse|empty response|returned 0 results|schema|missing .*field|malformed/.test(m)) {
|
|
138
142
|
return { class: "invalid-response", message };
|
|
@@ -151,8 +155,8 @@ const FAILURE_CLASS_LABELS = {
|
|
|
151
155
|
};
|
|
152
156
|
|
|
153
157
|
|
|
154
|
-
// 支持 time_range
|
|
155
|
-
const TIME_ENGINES = ["tavily", "exa", "keenable", "firecrawl", "parallel", "searxng", "ddg", "ddg-lite", "baidu", "doubao"];
|
|
158
|
+
// 支持 time_range 过滤的引擎(zhihu_global:global_search 的 filter 支持 publish_time>=unix秒,已实测;zhihu_site 未验证,不加)
|
|
159
|
+
const TIME_ENGINES = ["tavily", "exa", "keenable", "firecrawl", "parallel", "searxng", "ddg", "ddg-lite", "baidu", "doubao", "zhihu_global"];
|
|
156
160
|
|
|
157
161
|
// 虚拟搜索模式:不是单个引擎,而是 provider.search 内部的路由/并发策略
|
|
158
162
|
const SEARCH_MODES = ["auto", "multi"];
|
|
@@ -214,7 +218,7 @@ function routeEngines(query, { timeRange, disabledEngines = [], fallbackOrder =
|
|
|
214
218
|
}
|
|
215
219
|
|
|
216
220
|
// 当前插件版本(发布时与 package.json 同步)
|
|
217
|
-
const PLUGIN_VERSION = "0.7.
|
|
221
|
+
const PLUGIN_VERSION = "0.7.5";
|
|
218
222
|
// 检查更新的 npm registry 元数据地址(dsh-free-search 是 npmjs 上的公开包)
|
|
219
223
|
const NPM_REGISTRY_URL = "https://registry.npmjs.org/dsh-free-search/latest";
|
|
220
224
|
const PLUGIN_NPM_URL = "https://www.npmjs.com/package/dsh-free-search";
|
|
@@ -2154,6 +2158,78 @@ async function searchAliyun(query, maxResults, apiKey, timeRange, signal) {
|
|
|
2154
2158
|
}
|
|
2155
2159
|
|
|
2156
2160
|
|
|
2161
|
+
// ── 知乎开放平台 MCP(developer.zhihu.com/api/mcp/v1)─────────────────────────
|
|
2162
|
+
// 两个引擎共用一套 MCP 适配器(lib/mcp_adapter.js,方案 v4.3 §2.7):
|
|
2163
|
+
// zhihu_global → tools/call global_search(全网,filter 支持 publish_time>=unix秒 → 入 TIME_ENGINES)
|
|
2164
|
+
// zhihu_site → tools/call zhihu_search(站内,未验证时间过滤 → 不入 TIME_ENGINES)
|
|
2165
|
+
// 服务端为无状态 streamable-http:实测直连 tools/call 即可(initialize 非必需,也无 session 头),
|
|
2166
|
+
// 响应可能是纯 JSON 或 SSE 帧——两种格式由适配器统一解析。
|
|
2167
|
+
// key = ZHIHU_API_KEY(凭据中心 → 本条目 config zhihuApiKey → 环境变量,三级解析;无 key 时引擎跳过不阻断回退链)
|
|
2168
|
+
function zhihuMcpHeaders(apiKey) {
|
|
2169
|
+
return {
|
|
2170
|
+
authorization: `Bearer ${apiKey}`,
|
|
2171
|
+
"user-agent": "deepseek-harness/free-search",
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
// CallToolResult.content[0].text 内层 JSON → sources;{code:!=0} 与解析失败都算失败(不当搜索结果)
|
|
2176
|
+
function zhihuToSources(result, maxResults) {
|
|
2177
|
+
const text = mcpContentText(result);
|
|
2178
|
+
if (!text) return { sources: [], truncated: false };
|
|
2179
|
+
let inner;
|
|
2180
|
+
try {
|
|
2181
|
+
inner = JSON.parse(text);
|
|
2182
|
+
} catch {
|
|
2183
|
+
throw new Error(`Zhihu inner content JSON parse error: ${text.slice(0, 200)}`);
|
|
2184
|
+
}
|
|
2185
|
+
if (inner && typeof inner.code === "number" && inner.code !== 0) {
|
|
2186
|
+
throw new Error(`Zhihu API error ${inner.code}: ${inner.message ?? JSON.stringify(inner).slice(0, 200)}`);
|
|
2187
|
+
}
|
|
2188
|
+
const items = inner?.data?.items;
|
|
2189
|
+
if (!Array.isArray(items)) return { sources: [], truncated: false };
|
|
2190
|
+
const sources = items
|
|
2191
|
+
.filter((it) => it && it.url)
|
|
2192
|
+
.map((it) => ({
|
|
2193
|
+
url: String(it.url),
|
|
2194
|
+
...(it.title ? { title: String(it.title) } : {}),
|
|
2195
|
+
...(it.summary ? { snippet: String(it.summary) } : {}),
|
|
2196
|
+
...(Number.isFinite(it.edit_time) && it.edit_time > 0
|
|
2197
|
+
? { publishedAt: new Date(it.edit_time * 1000).toISOString().replace(/\.\d{3}Z$/, "Z") }
|
|
2198
|
+
: {}),
|
|
2199
|
+
...(it.author_name ? { author: String(it.author_name) } : {}),
|
|
2200
|
+
}));
|
|
2201
|
+
return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
async function searchZhihuGlobal(query, maxResults, apiKey, timeRange, signal) {
|
|
2205
|
+
if (!apiKey) throw new Error("Zhihu search requires ZHIHU_API_KEY");
|
|
2206
|
+
const count = Math.min(Math.max(maxResults ?? 10, 1), 20);
|
|
2207
|
+
const arguments_ = { query: String(query).slice(0, 100), count, search_db: "all" };
|
|
2208
|
+
if (timeRange) {
|
|
2209
|
+
// 已验证原生时间过滤:filter 表达式 publish_time>=<unix秒>;{days} 相对 / {after} 绝对日期都折算成秒
|
|
2210
|
+
const afterMs = timeRange.after
|
|
2211
|
+
? Date.parse(`${timeRange.after}T00:00:00Z`)
|
|
2212
|
+
: Date.now() - (timeRange.days ?? 1) * 86_400_000;
|
|
2213
|
+
if (Number.isFinite(afterMs)) arguments_.filter = `publish_time>=${Math.floor(afterMs / 1000)}`;
|
|
2214
|
+
}
|
|
2215
|
+
const result = await mcpCall(ZHIHU_MCP_URL, zhihuMcpHeaders(apiKey), "tools/call", {
|
|
2216
|
+
name: "global_search",
|
|
2217
|
+
arguments: arguments_,
|
|
2218
|
+
}, { timeoutMs: 20000, signal, label: "Zhihu" });
|
|
2219
|
+
return zhihuToSources(result, maxResults);
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
async function searchZhihuSite(query, maxResults, apiKey, signal) {
|
|
2223
|
+
if (!apiKey) throw new Error("Zhihu search requires ZHIHU_API_KEY");
|
|
2224
|
+
const count = Math.min(Math.max(maxResults ?? 10, 1), 20);
|
|
2225
|
+
const result = await mcpCall(ZHIHU_MCP_URL, zhihuMcpHeaders(apiKey), "tools/call", {
|
|
2226
|
+
name: "zhihu_search",
|
|
2227
|
+
arguments: { query: String(query).slice(0, 100), count },
|
|
2228
|
+
}, { timeoutMs: 20000, signal, label: "Zhihu" });
|
|
2229
|
+
return zhihuToSources(result, maxResults);
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
|
|
2157
2233
|
// ── 模型内置联网搜索(OpenAI / Gemini / Claude)──────────────────────────────
|
|
2158
2234
|
// 这三个走"模型 + 服务端搜索工具":由模型执行搜索并返回引用,按次/按 token 计费,
|
|
2159
2235
|
// 因此只在用户显式选中该引擎时运行(见 EXPLICIT_ONLY_ENGINES)。模型名与 Base URL
|
|
@@ -2839,6 +2915,7 @@ const KEY_REF_MAP = {
|
|
|
2839
2915
|
kimiApiKey: "MOONSHOT_API_KEY",
|
|
2840
2916
|
aliyunApiKey: "DASHSCOPE_API_KEY",
|
|
2841
2917
|
doubaoApiKey: "DOUBAO_SEARCH_API_KEY",
|
|
2918
|
+
zhihuApiKey: "ZHIHU_API_KEY",
|
|
2842
2919
|
openaiApiKey: "OPENAI_API_KEY",
|
|
2843
2920
|
geminiApiKey: "GEMINI_API_KEY",
|
|
2844
2921
|
claudeApiKey: "ANTHROPIC_API_KEY",
|
|
@@ -2877,6 +2954,7 @@ const Config = z.object({
|
|
|
2877
2954
|
kimiApiKey: z.string().role("secret").volatile(),
|
|
2878
2955
|
aliyunApiKey: z.string().role("secret").volatile(),
|
|
2879
2956
|
doubaoApiKey: z.string().role("secret").volatile(),
|
|
2957
|
+
zhihuApiKey: z.string().role("secret").volatile(),
|
|
2880
2958
|
// 模型内置搜索:key + 模型名 + Base URL(后两者留空用默认,便于走兼容网关)
|
|
2881
2959
|
openaiApiKey: z.string().role("secret").volatile(),
|
|
2882
2960
|
geminiApiKey: z.string().role("secret").volatile(),
|
|
@@ -3064,6 +3142,8 @@ function apply(ctx, config) {
|
|
|
3064
3142
|
if (primary) return primary;
|
|
3065
3143
|
return resolveApiKey("WEB_SEARCH_API_KEY", undefined);
|
|
3066
3144
|
};
|
|
3145
|
+
// 知乎:凭据中心 ZHIHU_API_KEY → 本条目 config zhihuApiKey → 环境变量(三级解析;无 key 时引擎跳过)
|
|
3146
|
+
const resolveZhihuKey = () => resolveApiKey("ZHIHU_API_KEY", "zhihuApiKey");
|
|
3067
3147
|
// 模型内置搜索:主 key 名 + 常见别名回退
|
|
3068
3148
|
const resolveOpenaiKey = () => resolveApiKey("OPENAI_API_KEY", "openaiApiKey");
|
|
3069
3149
|
const resolveGeminiKey = async () => {
|
|
@@ -3323,6 +3403,18 @@ function apply(ctx, config) {
|
|
|
3323
3403
|
continue;
|
|
3324
3404
|
}
|
|
3325
3405
|
result = await searchDoubao(request.query, request.maxResults, key, timeRange, effSignal);
|
|
3406
|
+
} else if (engine === "zhihu_global" || engine === "zhihu_site") {
|
|
3407
|
+
// 知乎 MCP 双引擎:无 key 跳过(不阻断回退链);global 带时间过滤,site 不带
|
|
3408
|
+
const key = await resolveZhihuKey();
|
|
3409
|
+
if (!key) {
|
|
3410
|
+
lastError = new Error("Zhihu requires ZHIHU_API_KEY");
|
|
3411
|
+
if (engine === preferred) preferredFailure = "ZHIHU_API_KEY is not configured";
|
|
3412
|
+
logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
|
|
3413
|
+
continue;
|
|
3414
|
+
}
|
|
3415
|
+
result = engine === "zhihu_global"
|
|
3416
|
+
? await searchZhihuGlobal(request.query, request.maxResults, key, timeRange, effSignal)
|
|
3417
|
+
: await searchZhihuSite(request.query, request.maxResults, key, effSignal);
|
|
3326
3418
|
} else if (engine === "openai") {
|
|
3327
3419
|
const key = await resolveOpenaiKey();
|
|
3328
3420
|
if (!key) {
|
|
@@ -3405,9 +3497,10 @@ function apply(ctx, config) {
|
|
|
3405
3497
|
const failure = classifyFailure(error);
|
|
3406
3498
|
const message = failure.message;
|
|
3407
3499
|
if (engine === preferred) { preferredFailure = message; preferredFailureClass = failure.class; }
|
|
3408
|
-
applyFailureCooldown(engine, failure.class);
|
|
3409
|
-
// transient(超时/5xx
|
|
3410
|
-
|
|
3500
|
+
applyFailureCooldown(engine, failure.class, failure.rateLimited);
|
|
3501
|
+
// transient(超时/5xx/网络):同引擎重试一次再回退,避免一次抖动丢掉好引擎;
|
|
3502
|
+
// 限流(rateLimited)不重试——短窗内再发必再撞,已设 60s 冷却
|
|
3503
|
+
if (failure.class === "transient" && !failure.rateLimited && !retriedEngines.has(engine)) {
|
|
3411
3504
|
retriedEngines.add(engine);
|
|
3412
3505
|
logger.warn(`free-search: engine "${engine}" failed [transient] (${message}), retrying once`);
|
|
3413
3506
|
chain.splice(qi + 1, 0, engine);
|
|
@@ -3580,6 +3673,16 @@ function apply(ctx, config) {
|
|
|
3580
3673
|
if (!key) return { ok: false, error: "DOUBAO_SEARCH_API_KEY not configured" };
|
|
3581
3674
|
return await searchDoubao(q, n, key, tr);
|
|
3582
3675
|
}
|
|
3676
|
+
case "zhihu_global": {
|
|
3677
|
+
const key = await resolveZhihuKey();
|
|
3678
|
+
if (!key) return { ok: false, error: "ZHIHU_API_KEY not configured" };
|
|
3679
|
+
return await searchZhihuGlobal(q, n, key, tr);
|
|
3680
|
+
}
|
|
3681
|
+
case "zhihu_site": {
|
|
3682
|
+
const key = await resolveZhihuKey();
|
|
3683
|
+
if (!key) return { ok: false, error: "ZHIHU_API_KEY not configured" };
|
|
3684
|
+
return await searchZhihuSite(q, n, key);
|
|
3685
|
+
}
|
|
3583
3686
|
case "openai": {
|
|
3584
3687
|
const key = await resolveOpenaiKey();
|
|
3585
3688
|
if (!key) return { ok: false, error: "OPENAI_API_KEY not configured" };
|
|
@@ -3743,7 +3846,7 @@ function apply(ctx, config) {
|
|
|
3743
3846
|
parameters: {
|
|
3744
3847
|
engines: {
|
|
3745
3848
|
type: "array",
|
|
3746
|
-
description: "Which engines to test (default: all enabled engines). Options: ddg, ddg-lite, bing, searxng, anysearch, exa, tavily, keenable, firecrawl, parallel, perplexity, serpbase, serply, deepseek-official, you, baidu, kimi, aliyun, doubao, openai, gemini, claude;
|
|
3849
|
+
description: "Which engines to test (default: all enabled engines). Options: ddg, ddg-lite, bing, searxng, anysearch, exa, tavily, keenable, firecrawl, parallel, perplexity, serpbase, serply, deepseek-official, you, baidu, kimi, aliyun, doubao, zhihu_global, zhihu_site, openai, gemini, claude; zhihu_global (web-wide, supports time filter) and zhihu_site (Zhihu-only) need ZHIHU_API_KEY. The virtual modes auto and multi are also accepted and run the real routing/concurrent path. openai/gemini/claude are model-based and bill per search, so they are only used when explicitly named.",
|
|
3747
3850
|
items: { type: "string" },
|
|
3748
3851
|
},
|
|
3749
3852
|
query: {
|
|
@@ -4065,7 +4168,7 @@ function apply(ctx, config) {
|
|
|
4065
4168
|
},
|
|
4066
4169
|
engine: {
|
|
4067
4170
|
type: "string",
|
|
4068
|
-
description: "Optional search mode/engine: multi (parallel top-3 merge), auto, ddg, ddg-lite, bing, searxng, anysearch, exa, tavily, keenable, firecrawl, parallel, perplexity, serpbase, serply, deepseek-official, you, baidu, kimi, aliyun, doubao, openai, gemini, claude (model-based; explicit selection only).",
|
|
4171
|
+
description: "Optional search mode/engine: multi (parallel top-3 merge), auto, ddg, ddg-lite, bing, searxng, anysearch, exa, tavily, keenable, firecrawl, parallel, perplexity, serpbase, serply, deepseek-official, you, baidu, kimi, aliyun, doubao, zhihu_global, zhihu_site, openai, gemini, claude (model-based; explicit selection only).",
|
|
4069
4172
|
},
|
|
4070
4173
|
},
|
|
4071
4174
|
output: {
|
|
@@ -4314,6 +4417,9 @@ export {
|
|
|
4314
4417
|
searchSerpbase,
|
|
4315
4418
|
searchSerply,
|
|
4316
4419
|
searchYoucom,
|
|
4420
|
+
searchZhihuGlobal,
|
|
4421
|
+
searchZhihuSite,
|
|
4422
|
+
ZHIHU_MCP_URL,
|
|
4317
4423
|
searchStackOverflow,
|
|
4318
4424
|
searchTavily,
|
|
4319
4425
|
searchV2ex,
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// MCP JSON-RPC 适配器(方案 v4.3 §2.7):知乎开放平台等 streamable-http MCP 引擎共用。
|
|
2
|
+
// 零新依赖:网络只用 node 标准库(node:https;http:// 端点仅为本地单测保留——
|
|
3
|
+
// 不用 undici/fetch,部分网关按指纹拦 undici,见 research.js #crawl4ai 注记)。
|
|
4
|
+
// 协议要求全部落地:
|
|
5
|
+
// - JSON 与 SSE(text/event-stream 的 data: 行)双响应格式解析;
|
|
6
|
+
// - JSON-RPC error(envelope.error)与工具级 isError(result.isError)区分并各自抛错;
|
|
7
|
+
// - 认证失败(HTTP 401/403)与超时/网络错误给出可被 classifyFailure 归类的消息;
|
|
8
|
+
// - 不假定 HTTP 200 + JSON 解析成功即调用成功:envelope/result/content 逐层校验。
|
|
9
|
+
//
|
|
10
|
+
// 错误消息与 free-search 失败分类(classifyFailure)的约定:
|
|
11
|
+
// 认证 → "authentication failed (HTTP 401/403)" → auth(会话级冷却)
|
|
12
|
+
// 超时/网络 → "timed out"/"aborted"/"ECONN…" → transient(同引擎重试一次)
|
|
13
|
+
// 协议/解析 → "invalid JSON"/"unexpected"/"parse" → invalid-response
|
|
14
|
+
|
|
15
|
+
import https from "node:https";
|
|
16
|
+
import http from "node:http";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 解析 MCP 响应体:先按纯 JSON 解析,失败再按 SSE 帧提取。
|
|
20
|
+
* SSE 帧:多个 `data:` 行(同一事件内按规范以 \n 拼接);这里同时兼容
|
|
21
|
+
* “每行独立一个完整 JSON-RPC envelope”的服务端实现——两种都取能解析出的 envelope,
|
|
22
|
+
* 优先带 result/error 字段的那条,否则取最后一条可解析的。
|
|
23
|
+
* @param {string} rawText
|
|
24
|
+
* @returns {object|null} JSON-RPC envelope,无法解析返回 null
|
|
25
|
+
*/
|
|
26
|
+
export function parseMcpResponseText(rawText) {
|
|
27
|
+
if (typeof rawText !== "string" || rawText.length === 0) return null;
|
|
28
|
+
const direct = tryJson(rawText);
|
|
29
|
+
if (direct) return direct;
|
|
30
|
+
|
|
31
|
+
// SSE:按空行切事件;事件内拼接 data: 行负载
|
|
32
|
+
const events = rawText.split(/\r?\n\r?\n/);
|
|
33
|
+
const payloads = [];
|
|
34
|
+
for (const event of events) {
|
|
35
|
+
const dataLines = [];
|
|
36
|
+
for (const line of event.split(/\r?\n/)) {
|
|
37
|
+
if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
38
|
+
}
|
|
39
|
+
if (dataLines.length > 0) payloads.push(dataLines.join("\n"));
|
|
40
|
+
}
|
|
41
|
+
// 兼容没有空行分隔、每行一个 data: 的实现
|
|
42
|
+
if (payloads.length === 0) {
|
|
43
|
+
for (const line of rawText.split(/\r?\n/)) {
|
|
44
|
+
if (line.startsWith("data:")) payloads.push(line.slice(5).replace(/^ /, ""));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
let fallback = null;
|
|
48
|
+
for (const payload of payloads) {
|
|
49
|
+
const parsed = tryJson(payload);
|
|
50
|
+
if (!parsed || typeof parsed !== "object") continue;
|
|
51
|
+
if (parsed.result !== undefined || parsed.error !== undefined) return parsed;
|
|
52
|
+
fallback = fallback ?? parsed;
|
|
53
|
+
}
|
|
54
|
+
return fallback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function tryJson(text) {
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(text);
|
|
60
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 发送一次 JSON-RPC 2.0 请求并返回 envelope.result(工具调用的 CallToolResult)。
|
|
68
|
+
* 任何失败路径都以 Error 抛出(消息见文件头分类约定),不返回部分结果。
|
|
69
|
+
*
|
|
70
|
+
* @param {string} endpoint MCP HTTP 端点(如 https://developer.zhihu.com/api/mcp/v1)
|
|
71
|
+
* @param {object} headers 额外请求头(Authorization / 自定义 UA 等;Content-Type/Accept 内置可覆盖)
|
|
72
|
+
* @param {string} method JSON-RPC 方法("tools/call" / "initialize" / …)
|
|
73
|
+
* @param {object|undefined} params JSON-RPC params
|
|
74
|
+
* @param {{timeoutMs?: number, signal?: AbortSignal, id?: number|string, label?: string}} [options]
|
|
75
|
+
* timeoutMs 默认 30000,<=0 表示不限;label 用于错误消息前缀(默认取端点 hostname)
|
|
76
|
+
* @returns {Promise<object>} JSON-RPC result 对象(tools/call 时为 {content, isError?})
|
|
77
|
+
*/
|
|
78
|
+
export async function mcpCall(endpoint, headers = {}, method, params, options = {}) {
|
|
79
|
+
const { timeoutMs = 30000, signal, id = 1 } = options;
|
|
80
|
+
const label = options.label ?? safeHost(endpoint);
|
|
81
|
+
const body = JSON.stringify({
|
|
82
|
+
jsonrpc: "2.0",
|
|
83
|
+
id,
|
|
84
|
+
method,
|
|
85
|
+
...(params !== undefined ? { params } : {}),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const rawText = await httpsPost(endpoint, body, {
|
|
89
|
+
"content-type": "application/json",
|
|
90
|
+
accept: "application/json, text/event-stream",
|
|
91
|
+
...headers,
|
|
92
|
+
}, { timeoutMs, signal, label });
|
|
93
|
+
|
|
94
|
+
const envelope = parseMcpResponseText(rawText);
|
|
95
|
+
if (!envelope) {
|
|
96
|
+
throw new Error(`${label} MCP response is not valid JSON or SSE (parse failed): ${rawText.slice(0, 200)}`);
|
|
97
|
+
}
|
|
98
|
+
if (envelope.error) {
|
|
99
|
+
const { code, message } = envelope.error;
|
|
100
|
+
throw new Error(`${label} MCP error ${code ?? "?"}: ${message ?? JSON.stringify(envelope.error)}`);
|
|
101
|
+
}
|
|
102
|
+
const result = envelope.result;
|
|
103
|
+
if (result === null || result === undefined || typeof result !== "object" || Array.isArray(result)) {
|
|
104
|
+
throw new Error(`${label} MCP result missing or malformed: ${JSON.stringify(envelope).slice(0, 200)}`);
|
|
105
|
+
}
|
|
106
|
+
if (method === "tools/call" && result.isError) {
|
|
107
|
+
// 工具级错误:isError=true,正文在 content 里,不能当搜索结果
|
|
108
|
+
const errText = mcpContentText(result);
|
|
109
|
+
throw new Error(`${label} MCP tool error (isError): ${errText.slice(0, 200) || JSON.stringify(result).slice(0, 200)}`);
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 从 CallToolResult.content 里取第一条 text 类型的文本(无则返回空串)。 */
|
|
115
|
+
export function mcpContentText(result) {
|
|
116
|
+
if (!result || !Array.isArray(result.content)) return "";
|
|
117
|
+
const item = result.content.find((c) => c && c.type === "text" && typeof c.text === "string");
|
|
118
|
+
return item ? item.text : "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function safeHost(endpoint) {
|
|
122
|
+
try {
|
|
123
|
+
return new URL(endpoint).hostname;
|
|
124
|
+
} catch {
|
|
125
|
+
return "MCP";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 真实引擎端点都是 https;http 仅为本地错误路径单测保留(同一套 node 标准库实现)
|
|
130
|
+
function transportFor(endpoint) {
|
|
131
|
+
try {
|
|
132
|
+
return new URL(endpoint).protocol === "http:" ? http : https;
|
|
133
|
+
} catch {
|
|
134
|
+
return https;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function httpsPost(endpoint, body, headers, { timeoutMs, signal, label }) {
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
let settled = false;
|
|
141
|
+
let timer = null;
|
|
142
|
+
const onAbort = () => {
|
|
143
|
+
req.destroy(new Error(`${label} MCP request aborted`));
|
|
144
|
+
};
|
|
145
|
+
const req = transportFor(endpoint).request(endpoint, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { ...headers, "content-length": Buffer.byteLength(body) },
|
|
148
|
+
}, (res) => {
|
|
149
|
+
const chunks = [];
|
|
150
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
151
|
+
res.on("end", () => {
|
|
152
|
+
const status = res.statusCode ?? 0;
|
|
153
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
154
|
+
if (settled) return;
|
|
155
|
+
settled = true;
|
|
156
|
+
cleanup();
|
|
157
|
+
if (status === 401 || status === 403) {
|
|
158
|
+
reject(new Error(`${label} MCP authentication failed (HTTP ${status}) - check the API key`));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (status < 200 || status >= 300) {
|
|
162
|
+
reject(new Error(`${label} MCP HTTP ${status}: ${text.slice(0, 200)}`));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
resolve(text);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
const cleanup = () => {
|
|
169
|
+
if (timer !== null) clearTimeout(timer);
|
|
170
|
+
signal?.removeEventListener("abort", onAbort);
|
|
171
|
+
};
|
|
172
|
+
req.on("error", (error) => {
|
|
173
|
+
if (settled) return;
|
|
174
|
+
settled = true;
|
|
175
|
+
cleanup();
|
|
176
|
+
// destroy(err) 的 err 带上下文直接透传;网络栈错误补前缀
|
|
177
|
+
reject(error instanceof Error && /MCP/.test(error.message ?? "")
|
|
178
|
+
? error
|
|
179
|
+
: new Error(`${label} MCP request failed: ${error?.message ?? String(error)}`));
|
|
180
|
+
});
|
|
181
|
+
if (timeoutMs > 0) {
|
|
182
|
+
timer = setTimeout(() => {
|
|
183
|
+
req.destroy(new Error(`${label} MCP request timed out after ${timeoutMs}ms (timeout)`));
|
|
184
|
+
}, timeoutMs);
|
|
185
|
+
}
|
|
186
|
+
if (signal?.aborted) {
|
|
187
|
+
req.destroy(new Error(`${label} MCP request aborted`));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
191
|
+
req.end(body);
|
|
192
|
+
});
|
|
193
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-free-search",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"description": "Free web search for DeepSeek Harness:
|
|
3
|
+
"version": "0.7.5",
|
|
4
|
+
"description": "Free web search for DeepSeek Harness: 23 engines (Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable/Firecrawl/Parallel keyless; Zhihu global/site via MCP and Perplexity/SerpBase/DeepSeek/You.com/Baidu/Kimi/Aliyun/Doubao with key; OpenAI/Gemini/Claude model built-in search, explicit-only) + auto smart routing + global engine enable/disable & fallback priority + multi mode (parallel top-3 merge) + multi_search cross-source tool + failure-aware fallback (quota/auth cooldown, transient retry, fallbackOn) + time filtering + platform search + web_fetch, with web settings UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
"lib/index.js",
|
|
14
14
|
"lib/client.js",
|
|
15
|
+
"lib/mcp_adapter.js",
|
|
15
16
|
"cordis.patch.yml"
|
|
16
17
|
],
|
|
17
18
|
"keywords": [
|
|
@@ -40,7 +41,8 @@
|
|
|
40
41
|
"auto-routing",
|
|
41
42
|
"doubao",
|
|
42
43
|
"volcengine",
|
|
43
|
-
"volcano"
|
|
44
|
+
"volcano",
|
|
45
|
+
"zhihu"
|
|
44
46
|
],
|
|
45
47
|
"license": "MIT",
|
|
46
48
|
"publishConfig": {
|