dsh-free-search 0.7.3 → 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 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、DeepSeek 官方、You.com、百度千帆、Kimi、阿里云百炼、火山联网搜索(豆包搜索)、OpenAI / Gemini / Claude 模型内置搜索
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 会明确报错)
@@ -66,12 +66,15 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
66
66
  | `parallel` | Parallel | 免费 | **无 key 也可用**(官方 MCP 匿名额度),配 key 提升额度并支持精确时间过滤 |
67
67
  | `perplexity` | Perplexity | 付费 | 需 `PERPLEXITY_API_KEY` |
68
68
  | `serpbase` | SerpBase | 付费 | 需 `SERPBASE_API_KEY`(serpbase.dev,注册送 100 次免费额度) |
69
+ | `serply` | Serply | 付费 | 需 `SERPLY_API_KEY`(serply.io,新账户 30 天内 2,500 次免费额度);Google 网页结果,按设置页语言本地化 |
69
70
  | `deepseek-official` | DeepSeek 官方 | 付费 | 需 `DEEPSEEK_API_KEY` |
70
71
  | `you` | You.com | 付费 | 需 `YOUCOM_API_KEY`(you.com/platform/api-keys,注册即送免费额度) |
71
72
  | `baidu` | 百度千帆 AI 搜索 | 额度 | 需 `BAIDU_API_KEY`(千帆 AI 搜索,每日赠 50 次,超额按量后付费);中文全网,支持时间过滤 |
72
73
  | `kimi` | Kimi(Moonshot)联网搜索 | 付费 | 需 `MOONSHOT_API_KEY`(basic 约 ¥0.01/次),返回带正文 chunks 的中文结果 |
73
74
  | `aliyun` | 阿里云百炼 EnhancedSearch | 付费 | 需 `DASHSCOPE_API_KEY`(MCP search_pro,约 ¥0.03/次,新用户 200 次免费包);中文全网带来源 hostname |
74
75
  | `doubao` | 火山引擎联网搜索(豆包搜索) | 免费额度 | 需 `DOUBAO_SEARCH_API_KEY`(联网搜索控制台开通,**每月 500 次免费**,超出按量付费);中文时效内容强,支持时间/站点过滤,返回千字级摘要 |
76
+ | `zhihu_global` | 知乎全网搜索(开放平台 MCP) | 付费 | 需 `ZHIHU_API_KEY`;支持时间过滤(`publish_time`);无 key 时自动跳过 |
77
+ | `zhihu_site` | 知乎站内搜索(开放平台 MCP) | 付费 | 需 `ZHIHU_API_KEY`;不支持时间过滤 |
75
78
  | `openai` | OpenAI 模型内置搜索 | 付费 | 需 `OPENAI_API_KEY`;走 Responses API 的 `web_search` 工具,返回带引用的回答。**按次计费,仅在显式选中时使用**(不参与自动回退/auto);默认 `gpt-6-luna`,模型与端点可配(`openaiModel` / `openaiBaseUrl`) |
76
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`) |
77
80
  | `claude` | Claude 服务端 web_search | 付费 | 需 `ANTHROPIC_API_KEY`;结果来自 `web_search_tool_result`。**按次计费,仅在显式选中时使用**;默认 `claude-sonnet-5-5`,模型与端点可配(`claudeModel` / `claudeBaseUrl`) |
@@ -85,6 +88,7 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
85
88
  - Parallel:<https://platform.parallel.ai>
86
89
  - Perplexity:<https://www.perplexity.ai/settings/api>
87
90
  - SerpBase:<https://serpbase.dev>
91
+ - Serply:<https://serply.io>(API 文档:<https://serply.io/docs>)
88
92
  - DeepSeek:<https://platform.deepseek.com/api_keys>
89
93
  - 豆包搜索(火山联网搜索):<https://console.volcengine.com/search-infinity/web-search>
90
94
  - You.com:<https://you.com/platform/api-keys>
@@ -165,7 +169,7 @@ Error: configured web provider "ddg" is not registered
165
169
  配置页提供:
166
170
 
167
171
  - **Search engine**:下拉框切换引擎,保存即生效
168
- - **API keys**:为 Exa / Tavily / Keenable / Firecrawl / Parallel / Perplexity / DeepSeek / SerpBase / You.com 填写 key(密码框,保存后只显示"已配置";Exa / Tavily / Keenable / Firecrawl / Parallel 不填也可免 key 使用)
172
+ - **API keys**:为 Exa / Tavily / Keenable / Firecrawl / Parallel / Perplexity / DeepSeek / SerpBase / Serply / You.com 填写 key(密码框,保存后只显示"已配置";Exa / Tavily / Keenable / Firecrawl / Parallel 不填也可免 key 使用)
169
173
  - **推荐**:付费引擎 key 建议写入 harness 凭据中心 `~/.dsh/.credentials.yaml`(如 `DEEPSEEK_API_KEY: sk-...`,与官方 LLM provider 一致,一处管理所有 key)。插件读取优先级:凭据中心 > 设置页 > 环境变量,设置页填的 key 仅作为遗留兼容。
170
174
  - **Test engine**:直测当前引擎可用性(不走回退链,付费引擎无 key 会明确报错)
171
175
  - **Use Bing default**:把当前搜索引擎切回稳定的免费 Bing;`Discard` 只撤销尚未保存的编辑
@@ -229,7 +233,7 @@ DSH 0.1.7-rc.1 起,配置跟随 profile 的插件条目保存:设置页与 `
229
233
 
230
234
  ```yaml
231
235
  # profiles/<profile>/cordis.patch.yml 中该条目的 config:
232
- provider: bing # ddg / ddg-lite / bing / searxng / anysearch / exa / tavily / keenable / firecrawl / parallel / perplexity / serpbase / deepseek-official / you / baidu / kimi / aliyun / doubao / openai / gemini / claude / auto / multi
236
+ provider: bing # ddg / ddg-lite / bing / searxng / anysearch / exa / tavily / keenable / firecrawl / parallel / perplexity / serpbase / serply / deepseek-official / you / baidu / kimi / aliyun / doubao / openai / gemini / claude / auto / multi
233
237
  fallbackOn: [quota, auth, bot-wall, transient, invalid-response, unknown] # 允许触发回退的失败类别;[] = 任何失败都立即中止(默认全部)
234
238
  lang: zh # 设置页界面语言(zh / en)
235
239
  bingMarket: zh-CN # Bing 市场
@@ -243,6 +247,7 @@ firecrawlApiKey: ... # 或通过设置页填写
243
247
  parallelApiKey: ... # 或通过设置页填写
244
248
  perplexityApiKey: ...
245
249
  serpbaseApiKey: ... # 或通过设置页填写
250
+ serplyApiKey: ... # 或通过设置页填写
246
251
  deepseekApiKey: ...
247
252
  ```
248
253
 
@@ -391,7 +396,7 @@ This plugin provides multiple free search engines with automatic fallback, compl
391
396
  ### Features
392
397
 
393
398
  - **Zero Cost** — Multiple free engines with no API key or registration required
394
- - **Multi-Engine Support** — DuckDuckGo (HTML / Lite), Bing, AnySearch AI, SearXNG (meta-search with custom instances), Exa, Tavily, Keenable, Firecrawl, Parallel, Perplexity, SerpBase, 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
395
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+)
396
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)
397
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)
@@ -428,12 +433,15 @@ If this plugin has been helpful, a ⭐ on [GitHub](https://github.com/DDDMUC/dsh
428
433
  | `parallel` | Parallel | Free | **Works without a key** (official MCP anonymous quota); a key raises limits and enables precise time filtering |
429
434
  | `perplexity` | Perplexity | Paid | Requires `PERPLEXITY_API_KEY` |
430
435
  | `serpbase` | SerpBase | Paid | Requires `SERPBASE_API_KEY` (serpbase.dev, 100 free queries on signup) |
436
+ | `serply` | Serply | Paid | Requires `SERPLY_API_KEY` (serply.io, 2,500 free credits for the first 30 days); Google web results localized to the settings language |
431
437
  | `deepseek-official` | DeepSeek Official | Paid | Requires `DEEPSEEK_API_KEY` |
432
438
  | `you` | You.com | Paid | Requires `YOUCOM_API_KEY` (you.com/platform/api-keys, free tier on signup) |
433
439
  | `baidu` | Baidu Qianfan AI Search | Quota | Requires `BAIDU_API_KEY` (Qianfan AI Search, 50 free calls/day then pay-as-you-go); Chinese web-wide, supports time filtering |
434
440
  | `kimi` | Kimi (Moonshot) Web Search | Paid | Requires `MOONSHOT_API_KEY` (basic ~¥0.01/call); returns Chinese results with body chunks |
435
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 |
436
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 |
437
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`) |
438
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`) |
439
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`) |
@@ -447,6 +455,7 @@ If this plugin has been helpful, a ⭐ on [GitHub](https://github.com/DDDMUC/dsh
447
455
  - Parallel: <https://platform.parallel.ai>
448
456
  - Perplexity: <https://www.perplexity.ai/settings/api>
449
457
  - SerpBase: <https://serpbase.dev>
458
+ - Serply: <https://serply.io> (API docs: <https://serply.io/docs>)
450
459
  - DeepSeek: <https://platform.deepseek.com/api_keys>
451
460
  - Doubao / Volcano Web Search: <https://console.volcengine.com/search-infinity/web-search>
452
461
  - You.com: <https://you.com/platform/api-keys>
@@ -591,7 +600,7 @@ The old `free-search:` section of `~/.dsh/settings.yaml` is **not** imported by
591
600
 
592
601
  ```yaml
593
602
  # config of that entry in profiles/<profile>/cordis.patch.yml:
594
- provider: bing # ddg / ddg-lite / bing / searxng / anysearch / exa / tavily / keenable / firecrawl / parallel / perplexity / serpbase / deepseek-official / you / baidu / kimi / aliyun / doubao / openai / gemini / claude / auto / multi
603
+ provider: bing # ddg / ddg-lite / bing / searxng / anysearch / exa / tavily / keenable / firecrawl / parallel / perplexity / serpbase / serply / deepseek-official / you / baidu / kimi / aliyun / doubao / openai / gemini / claude / auto / multi
595
604
  fallbackOn: [quota, auth, bot-wall, transient, invalid-response, unknown] # failure classes allowed to trigger fallback; [] = abort on any failure (default: all)
596
605
  lang: zh # settings UI language (zh / en)
597
606
  bingMarket: zh-CN # Bing market
@@ -605,6 +614,7 @@ firecrawlApiKey: ... # Or configure via the web settings UI
605
614
  parallelApiKey: ... # Or configure via the web settings UI
606
615
  perplexityApiKey: ...
607
616
  serpbaseApiKey: ... # Or configure via the web settings UI
617
+ serplyApiKey: ... # Or configure via the web settings UI
608
618
  deepseekApiKey: ...
609
619
  ```
610
620
 
package/lib/client.js CHANGED
@@ -144,6 +144,7 @@ window.__ModuleLoader__.load({
144
144
  perplexityPh: (c) => c ? "Perplexity API 密钥(已配置)" : "Perplexity API 密钥(pplx-...)",
145
145
  deepseekPh: (c) => c ? "DeepSeek API 密钥(已配置)" : "DeepSeek API 密钥(sk-...)",
146
146
  serpbasePh: (c) => c ? "SerpBase API 密钥(已配置)" : "SerpBase API 密钥(serpbase.dev 获取)",
147
+ serplyPh: (c) => c ? "Serply API 密钥(已配置)" : "Serply API 密钥(serply.io 获取)",
147
148
  youcomPh: (c) => c ? "You.com API 密钥(已配置)" : "You.com API 密钥(you.com/platform/api-keys 获取)",
148
149
  baiduPh: (c) => c ? "Baidu API 密钥(已配置)" : "百度千帆 API 密钥(BAIDU_API_KEY)",
149
150
  kimiPh: (c) => c ? "Kimi API 密钥(已配置)" : "Kimi API 密钥(MOONSHOT_API_KEY)",
@@ -156,7 +157,7 @@ window.__ModuleLoader__.load({
156
157
  keyStorage: "Key 存储位置",
157
158
  keyStorageCred: "凭据中心(推荐)",
158
159
  keyStorageSettings: "设置页(兼容)",
159
- keyStorageCredHint: (c) => `保存后写入 ~/.dsh/.credentials.yaml(最高优先级)。当前已配置:${["anysearch", "exa", "tavily", "keenable", "firecrawl", "parallel", "perplexity", "serpbase", "deepseek", "youcom", "baidu", "kimi", "aliyun", "doubao", "openai", "gemini", "claude"].filter((k) => c[k]).map((k) => k.toUpperCase()).join(", ") || "无"}`,
160
+ keyStorageCredHint: (c) => `保存后写入 ~/.dsh/.credentials.yaml(最高优先级)。当前已配置:${["anysearch", "exa", "tavily", "keenable", "firecrawl", "parallel", "perplexity", "serpbase", "serply", "deepseek", "youcom", "baidu", "kimi", "aliyun", "doubao", "openai", "gemini", "claude"].filter((k) => c[k]).map((k) => k.toUpperCase()).join(", ") || "无"}`,
160
161
  keyStorageSettingsHint: "保存后写入当前 profile 的插件条目 config(cordis.patch.yml,优先级低于凭据中心)。",
161
162
  engineEnable: "全局启用的搜索引擎",
162
163
  engineEnableHint: "取消勾选后,该引擎会从普通 web_search 回退链、Auto 智能路由、advanced_search、multi_search 和引擎测试中全局排除。至少保留一个引擎。",
@@ -235,6 +236,7 @@ window.__ModuleLoader__.load({
235
236
  perplexityPh: (c) => c ? "Perplexity API key (configured)" : "Perplexity API key (pplx-...)",
236
237
  deepseekPh: (c) => c ? "DeepSeek API key (configured)" : "DeepSeek API key (sk-...)",
237
238
  serpbasePh: (c) => c ? "SerpBase API key (configured)" : "SerpBase API key (from serpbase.dev)",
239
+ serplyPh: (c) => c ? "Serply API key (configured)" : "Serply API key (from serply.io)",
238
240
  youcomPh: (c) => c ? "You.com API key (configured)" : "You.com API key (from you.com/platform/api-keys)",
239
241
  baiduPh: (c) => c ? "Baidu API key (configured)" : "Baidu Qianfan API key (BAIDU_API_KEY)",
240
242
  kimiPh: (c) => c ? "Kimi API key (configured)" : "Kimi API key (MOONSHOT_API_KEY)",
@@ -247,7 +249,7 @@ window.__ModuleLoader__.load({
247
249
  keyStorage: "Key storage",
248
250
  keyStorageCred: "Credential center (recommended)",
249
251
  keyStorageSettings: "Settings page (legacy)",
250
- keyStorageCredHint: (c) => `Saved to ~/.dsh/.credentials.yaml (highest priority). Currently configured: ${["anysearch", "exa", "tavily", "keenable", "firecrawl", "parallel", "perplexity", "serpbase", "deepseek", "youcom", "baidu", "kimi", "aliyun", "doubao", "openai", "gemini", "claude"].filter((k) => c[k]).map((k) => k.toUpperCase()).join(", ") || "none"}`,
252
+ keyStorageCredHint: (c) => `Saved to ~/.dsh/.credentials.yaml (highest priority). Currently configured: ${["anysearch", "exa", "tavily", "keenable", "firecrawl", "parallel", "perplexity", "serpbase", "serply", "deepseek", "youcom", "baidu", "kimi", "aliyun", "doubao", "openai", "gemini", "claude"].filter((k) => c[k]).map((k) => k.toUpperCase()).join(", ") || "none"}`,
251
253
  keyStorageSettingsHint: "Saved to the active profile plugin entry config (cordis.patch.yml; lower priority than the credential center).",
252
254
  engineEnable: "Globally enabled search engines",
253
255
  engineEnableHint: "Unchecked engines are excluded globally from web_search fallback, Auto routing, advanced_search, multi_search, and engine tests. At least one engine must remain enabled.",
@@ -312,7 +314,7 @@ window.__ModuleLoader__.load({
312
314
  };
313
315
  const tt = (lang) => I18N[lang === "en" ? "en" : "zh"];
314
316
  // 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION 保持一致)
315
- const PLUGIN_VERSION = "0.7.3";
317
+ const PLUGIN_VERSION = "0.7.5";
316
318
  const ENGINES = [
317
319
  { id: "auto", label: "Auto · 智能路由", badge: "AUTO", link: null },
318
320
  { id: "multi", label: "Multi Search · 多引擎", badge: "MULTI", link: null },
@@ -328,6 +330,7 @@ window.__ModuleLoader__.load({
328
330
  { id: "parallel", label: "Parallel", badge: "FREE", link: "https://platform.parallel.ai" },
329
331
  { id: "perplexity", label: "Perplexity", badge: "API KEY", link: "https://www.perplexity.ai/settings/api" },
330
332
  { id: "serpbase", label: "SerpBase · Google", badge: "API KEY", link: "https://serpbase.dev" },
333
+ { id: "serply", label: "Serply · Google", badge: "API KEY", link: "https://serply.io" },
331
334
  { id: "deepseek-official", label: "DeepSeek Official", badge: "API KEY", link: "https://platform.deepseek.com/api_keys" },
332
335
  { id: "you", label: "You.com", badge: "API KEY", link: "https://you.com/platform/api-keys" },
333
336
  { id: "baidu", label: "Baidu · 百度千帆", badge: "API KEY", link: "https://console.bce.baidu.com/qianfan" },
@@ -339,7 +342,7 @@ window.__ModuleLoader__.load({
339
342
  { id: "claude", label: "Claude · 内置搜索", badge: "API KEY", link: "https://console.anthropic.com/settings/keys" },
340
343
  ];
341
344
  const ENGINE_IDS = ENGINES.filter((engine) => engine.id !== "auto" && engine.id !== "multi").map((engine) => engine.id);
342
- const DEFAULT_FALLBACK_ORDER = ["exa", "tavily", "keenable", "firecrawl", "parallel", "perplexity", "serpbase", "deepseek-official", "you", "baidu", "kimi", "aliyun", "doubao", "bing", "anysearch", "ddg", "ddg-lite", "searxng"];
345
+ const DEFAULT_FALLBACK_ORDER = ["exa", "tavily", "keenable", "firecrawl", "parallel", "perplexity", "serpbase", "serply", "deepseek-official", "you", "baidu", "kimi", "aliyun", "doubao", "bing", "anysearch", "ddg", "ddg-lite", "searxng"];
343
346
  const normalizeFallbackOrder = (value) => {
344
347
  const configured = Array.isArray(value) ? value.filter((id) => ENGINE_IDS.includes(id)) : [];
345
348
  return [...new Set([...configured, ...DEFAULT_FALLBACK_ORDER, ...ENGINE_IDS])];
@@ -469,6 +472,7 @@ window.__ModuleLoader__.load({
469
472
  const [perplexityKey, setPerplexityKey] = react.useState("");
470
473
  const [deepseekKey, setDeepseekKey] = react.useState("");
471
474
  const [serpbaseKey, setSerpbaseKey] = react.useState("");
475
+ const [serplyKey, setSerplyKey] = react.useState("");
472
476
  const [youcomKey, setYoucomKey] = react.useState("");
473
477
  const [baiduKey, setBaiduKey] = react.useState("");
474
478
  const [kimiKey, setKimiKey] = react.useState("");
@@ -523,6 +527,7 @@ window.__ModuleLoader__.load({
523
527
  setPerplexityKey(v.perplexityApiKey ?? "");
524
528
  setDeepseekKey(v.deepseekApiKey ?? "");
525
529
  setSerpbaseKey(v.serpbaseApiKey ?? "");
530
+ setSerplyKey(v.serplyApiKey ?? "");
526
531
  setYoucomKey(v.youcomApiKey ?? "");
527
532
  setBaiduKey(v.baiduApiKey ?? "");
528
533
  setKimiKey(v.kimiApiKey ?? "");
@@ -547,6 +552,7 @@ window.__ModuleLoader__.load({
547
552
  if (path === "perplexityApiKey") configured.perplexity = true;
548
553
  if (path === "deepseekApiKey") configured.deepseek = true;
549
554
  if (path === "serpbaseApiKey") configured.serpbase = true;
555
+ if (path === "serplyApiKey") configured.serply = true;
550
556
  if (path === "youcomApiKey") configured.youcom = true;
551
557
  if (path === "baiduApiKey") configured.baidu = true;
552
558
  if (path === "kimiApiKey") configured.kimi = true;
@@ -566,7 +572,7 @@ window.__ModuleLoader__.load({
566
572
  const cred = await bridgeCredentialsStatus();
567
573
  if (cred.ok) {
568
574
  const cc = {};
569
- const map = { anysearchApiKey: "anysearch", exaApiKey: "exa", tavilyApiKey: "tavily", keenableApiKey: "keenable", firecrawlApiKey: "firecrawl", parallelApiKey: "parallel", perplexityApiKey: "perplexity", deepseekApiKey: "deepseek", serpbaseApiKey: "serpbase", youcomApiKey: "youcom", baiduApiKey: "baidu", kimiApiKey: "kimi", aliyunApiKey: "aliyun", doubaoApiKey: "doubao", openaiApiKey: "openai", geminiApiKey: "gemini", claudeApiKey: "claude" };
575
+ const map = { anysearchApiKey: "anysearch", exaApiKey: "exa", tavilyApiKey: "tavily", keenableApiKey: "keenable", firecrawlApiKey: "firecrawl", parallelApiKey: "parallel", perplexityApiKey: "perplexity", deepseekApiKey: "deepseek", serpbaseApiKey: "serpbase", serplyApiKey: "serply", youcomApiKey: "youcom", baiduApiKey: "baidu", kimiApiKey: "kimi", aliyunApiKey: "aliyun", doubaoApiKey: "doubao", openaiApiKey: "openai", geminiApiKey: "gemini", claudeApiKey: "claude" };
570
576
  for (const [k, v] of Object.entries(cred.value.configured ?? {})) {
571
577
  if (map[k]) cc[map[k]] = v;
572
578
  }
@@ -612,6 +618,7 @@ window.__ModuleLoader__.load({
612
618
  ["perplexityApiKey", perplexityKey],
613
619
  ["deepseekApiKey", deepseekKey],
614
620
  ["serpbaseApiKey", serpbaseKey],
621
+ ["serplyApiKey", serplyKey],
615
622
  ["youcomApiKey", youcomKey],
616
623
  ["baiduApiKey", baiduKey],
617
624
  ["kimiApiKey", kimiKey],
@@ -1185,6 +1192,19 @@ react_jsx_runtime.jsx("div", {
1185
1192
  setSaveError("");
1186
1193
  },
1187
1194
  }),
1195
+ react_jsx_runtime.jsx("input", {
1196
+ className: "dshfs-input",
1197
+ type: "password",
1198
+ placeholder: t.serplyPh(keysConfigured.serply),
1199
+ value: serplyKey,
1200
+ disabled: !ready || saving,
1201
+ onChange: (e) => {
1202
+ setSerplyKey(e.target.value);
1203
+ setDirty(true);
1204
+ setFailed(false);
1205
+ setSaveError("");
1206
+ },
1207
+ }),
1188
1208
  react_jsx_runtime.jsx("input", {
1189
1209
  className: "dshfs-input",
1190
1210
  type: "password",
package/lib/index.js CHANGED
@@ -5,6 +5,29 @@ 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";
9
+
10
+ // 兼容 guard(#55):Node 会优先解析插件目录下的嵌套依赖,若那里残留旧的
11
+ // @deepseek-ai/schemastery(如 node_modules/dsh-free-search/node_modules/... -> 3.18.1),
12
+ // 它会遮蔽顶层正确的 3.18.4,而旧版没有 .volatile(),导致 import 时报
13
+ // "z.string(...).volatile is not a function" 且完全不知道原因。这里提前检测并给出可操作的修复提示。
14
+ function assertSchemasterySupportsVolatile(schemaLib) {
15
+ let ok = false;
16
+ try {
17
+ ok = typeof schemaLib.string().default("x").volatile === "function";
18
+ } catch {
19
+ ok = false;
20
+ }
21
+ if (!ok) {
22
+ throw new Error(
23
+ "[dsh-free-search] the resolved @deepseek-ai/schemastery has no `.volatile()` (needs >= 3.18.2; this plugin declares ^3.18.4). " +
24
+ "This usually means a stale nested copy shadows the top-level install - check " +
25
+ "`node_modules/dsh-free-search/node_modules/@deepseek-ai/schemastery` (often a symlink to an old 3.18.x); " +
26
+ "delete that stale directory (or reinstall) so the top-level 3.18.4 is used, then fully restart DSH. See issue #55."
27
+ );
28
+ }
29
+ }
30
+ assertSchemasterySupportsVolatile(z);
8
31
 
9
32
  const DDG_HTML_URL = "https://html.duckduckgo.com/html/";
10
33
  const DDG_LITE_URL = "https://lite.duckduckgo.com/lite/";
@@ -16,8 +39,10 @@ const PARALLEL_MCP_URL = "https://search.parallel.ai/mcp";
16
39
  const KEENABLE_URL = "https://api.keenable.ai/v1/search";
17
40
  const KEENABLE_MCP_URL = "https://api.keenable.ai/mcp";
18
41
  const SERPBASE_URL = "https://api.serpbase.dev/google/search";
42
+ const SERPLY_URL = "https://api.serply.io/v1/search";
19
43
  const YOUCOM_URL = "https://ydc-index.io/v1/search";
20
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";
21
46
  const USER_AGENT =
22
47
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
23
48
  const ACCEPT_LANG = "zh-CN,zh;q=0.9,en;q=0.8";
@@ -55,7 +80,7 @@ const MARKET_TO_LANG = {
55
80
  const FREE_SEARCH_NS = "web-search-free";
56
81
  const BRIDGE_PREFIX = "/api/dsh-free-search-settings";
57
82
  const FREE_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch"];
58
- const ALL_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch", "exa", "tavily", "keenable", "firecrawl", "parallel", "perplexity", "serpbase", "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"];
59
84
 
60
85
  // 模型内置搜索(按次计费):只在你显式选中该引擎时使用;自动回退链与 auto 路由都不会兜底到它们。
61
86
  const EXPLICIT_ONLY_ENGINES = ["openai", "gemini", "claude"];
@@ -90,9 +115,11 @@ const clearCooldown = (engine) => engineCooldowns.delete(engine);
90
115
  const setCooldown = (engine, ms) => {
91
116
  engineCooldowns.set(engine, ms === COOLDOWN_SESSION_MS ? COOLDOWN_SESSION_MS : Date.now() + ms);
92
117
  };
93
- const applyFailureCooldown = (engine, cls) => {
118
+ const applyFailureCooldown = (engine, cls, rateLimited = false) => {
94
119
  if (cls === "quota" || cls === "auth") setCooldown(engine, COOLDOWN_SESSION_MS);
95
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);
96
123
  };
97
124
 
98
125
  const classifyFailure = (error) => {
@@ -109,7 +136,7 @@ const classifyFailure = (error) => {
109
136
  return { class: "bot-wall", message };
110
137
  }
111
138
  if (rateLimited || /http 5\d\d|timeout|timed out|aborted|abort|fetch failed|econn|network|socket/.test(m)) {
112
- return { class: "transient", message };
139
+ return { class: "transient", message, rateLimited };
113
140
  }
114
141
  if (/invalid json|unexpected|parse|empty response|returned 0 results|schema|missing .*field|malformed/.test(m)) {
115
142
  return { class: "invalid-response", message };
@@ -128,13 +155,13 @@ const FAILURE_CLASS_LABELS = {
128
155
  };
129
156
 
130
157
 
131
- // 支持 time_range 过滤的引擎
132
- 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"];
133
160
 
134
161
  // 虚拟搜索模式:不是单个引擎,而是 provider.search 内部的路由/并发策略
135
162
  const SEARCH_MODES = ["auto", "multi"];
136
163
 
137
- const DEFAULT_FALLBACK_ORDER = ["exa", "tavily", "keenable", "firecrawl", "parallel", "perplexity", "serpbase", "deepseek-official", "you", "baidu", "kimi", "aliyun", "doubao", "bing", "anysearch", "ddg", "ddg-lite", "searxng"];
164
+ const DEFAULT_FALLBACK_ORDER = ["exa", "tavily", "keenable", "firecrawl", "parallel", "perplexity", "serpbase", "serply", "deepseek-official", "you", "baidu", "kimi", "aliyun", "doubao", "bing", "anysearch", "ddg", "ddg-lite", "searxng"];
138
165
 
139
166
  function normalizeDisabledEngines(value) {
140
167
  if (!Array.isArray(value)) return [];
@@ -191,7 +218,7 @@ function routeEngines(query, { timeRange, disabledEngines = [], fallbackOrder =
191
218
  }
192
219
 
193
220
  // 当前插件版本(发布时与 package.json 同步)
194
- const PLUGIN_VERSION = "0.7.3";
221
+ const PLUGIN_VERSION = "0.7.5";
195
222
  // 检查更新的 npm registry 元数据地址(dsh-free-search 是 npmjs 上的公开包)
196
223
  const NPM_REGISTRY_URL = "https://registry.npmjs.org/dsh-free-search/latest";
197
224
  const PLUGIN_NPM_URL = "https://www.npmjs.com/package/dsh-free-search";
@@ -1784,6 +1811,47 @@ async function searchSerpbase(query, maxResults, apiKey, options, signal) {
1784
1811
  return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
1785
1812
  }
1786
1813
 
1814
+ // Serply: 必须配置 SERPLY_API_KEY(无 key 跳过,同 serpbase)
1815
+ // Google SERP API:GET + X-Api-Key 头,返回 results[](title/link/description);num 上限 10。
1816
+ // 错误走 HTTP 状态码,401 时 JSON 为 {"detail": "Invalid API key"}。
1817
+ async function searchSerply(query, maxResults, apiKey, options, signal) {
1818
+ if (!apiKey) throw new Error("Serply search requires SERPLY_API_KEY");
1819
+ // hl/gl 跟随设置页的 lang,与 SerpBase 同一套 Google 本地化参数,默认 en/us
1820
+ const locale = SERPBASE_LOCALE[options?.lang] ?? SERPBASE_LOCALE.en;
1821
+ const params = new URLSearchParams({
1822
+ q: query,
1823
+ num: String(Math.min(Math.max(maxResults ?? 10, 1), 10)),
1824
+ hl: locale.hl,
1825
+ gl: locale.gl,
1826
+ });
1827
+ const response = await fetch(`${SERPLY_URL}?${params}`, {
1828
+ method: "GET",
1829
+ redirect: "error",
1830
+ headers: {
1831
+ "X-Api-Key": apiKey,
1832
+ accept: "application/json",
1833
+ "user-agent": "deepseek-harness/free-search",
1834
+ },
1835
+ signal: AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(15000)]),
1836
+ });
1837
+ if (!response.ok) {
1838
+ const detail = await response.text().catch(() => "");
1839
+ if (response.status === 401 || response.status === 403) {
1840
+ throw new Error(`Serply API key is invalid (HTTP ${response.status}) - update it in Settings > Plugins > Free Search`);
1841
+ }
1842
+ throw new Error(`Serply API error (HTTP ${response.status}): ${detail.slice(0, 200)}`);
1843
+ }
1844
+ const data = await response.json();
1845
+ const sources = (data.results ?? [])
1846
+ .filter((r) => r.link)
1847
+ .map((r) => ({
1848
+ url: r.link,
1849
+ ...(r.title ? { title: String(r.title) } : {}),
1850
+ ...(r.description ? { snippet: String(r.description) } : {}),
1851
+ }));
1852
+ return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
1853
+ }
1854
+
1787
1855
  // You.com: requires YOUCOM_API_KEY (get a free key at https://you.com/platform/api-keys)
1788
1856
  async function searchYoucom(query, maxResults, apiKey, signal) {
1789
1857
  if (!apiKey) throw new Error("You.com search requires YOUCOM_API_KEY");
@@ -2090,6 +2158,78 @@ async function searchAliyun(query, maxResults, apiKey, timeRange, signal) {
2090
2158
  }
2091
2159
 
2092
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
+
2093
2233
  // ── 模型内置联网搜索(OpenAI / Gemini / Claude)──────────────────────────────
2094
2234
  // 这三个走"模型 + 服务端搜索工具":由模型执行搜索并返回引用,按次/按 token 计费,
2095
2235
  // 因此只在用户显式选中该引擎时运行(见 EXPLICIT_ONLY_ENGINES)。模型名与 Base URL
@@ -2768,12 +2908,14 @@ const KEY_REF_MAP = {
2768
2908
  parallelApiKey: "PARALLEL_API_KEY",
2769
2909
  perplexityApiKey: "PERPLEXITY_API_KEY",
2770
2910
  serpbaseApiKey: "SERPBASE_API_KEY",
2911
+ serplyApiKey: "SERPLY_API_KEY",
2771
2912
  deepseekApiKey: "DEEPSEEK_API_KEY",
2772
2913
  youcomApiKey: "YOUCOM_API_KEY",
2773
2914
  baiduApiKey: "BAIDU_API_KEY",
2774
2915
  kimiApiKey: "MOONSHOT_API_KEY",
2775
2916
  aliyunApiKey: "DASHSCOPE_API_KEY",
2776
2917
  doubaoApiKey: "DOUBAO_SEARCH_API_KEY",
2918
+ zhihuApiKey: "ZHIHU_API_KEY",
2777
2919
  openaiApiKey: "OPENAI_API_KEY",
2778
2920
  geminiApiKey: "GEMINI_API_KEY",
2779
2921
  claudeApiKey: "ANTHROPIC_API_KEY",
@@ -2805,12 +2947,14 @@ const Config = z.object({
2805
2947
  parallelApiKey: z.string().role("secret").volatile(),
2806
2948
  perplexityApiKey: z.string().role("secret").volatile(),
2807
2949
  serpbaseApiKey: z.string().role("secret").volatile(),
2950
+ serplyApiKey: z.string().role("secret").volatile(),
2808
2951
  deepseekApiKey: z.string().role("secret").volatile(),
2809
2952
  youcomApiKey: z.string().role("secret").volatile(),
2810
2953
  baiduApiKey: z.string().role("secret").volatile(),
2811
2954
  kimiApiKey: z.string().role("secret").volatile(),
2812
2955
  aliyunApiKey: z.string().role("secret").volatile(),
2813
2956
  doubaoApiKey: z.string().role("secret").volatile(),
2957
+ zhihuApiKey: z.string().role("secret").volatile(),
2814
2958
  // 模型内置搜索:key + 模型名 + Base URL(后两者留空用默认,便于走兼容网关)
2815
2959
  openaiApiKey: z.string().role("secret").volatile(),
2816
2960
  geminiApiKey: z.string().role("secret").volatile(),
@@ -2852,6 +2996,7 @@ const LEGACY_MIGRATABLE_KEYS = [
2852
2996
  "parallelApiKey",
2853
2997
  "perplexityApiKey",
2854
2998
  "serpbaseApiKey",
2999
+ "serplyApiKey",
2855
3000
  "deepseekApiKey",
2856
3001
  "youcomApiKey",
2857
3002
  "baiduApiKey",
@@ -2997,6 +3142,8 @@ function apply(ctx, config) {
2997
3142
  if (primary) return primary;
2998
3143
  return resolveApiKey("WEB_SEARCH_API_KEY", undefined);
2999
3144
  };
3145
+ // 知乎:凭据中心 ZHIHU_API_KEY → 本条目 config zhihuApiKey → 环境变量(三级解析;无 key 时引擎跳过)
3146
+ const resolveZhihuKey = () => resolveApiKey("ZHIHU_API_KEY", "zhihuApiKey");
3000
3147
  // 模型内置搜索:主 key 名 + 常见别名回退
3001
3148
  const resolveOpenaiKey = () => resolveApiKey("OPENAI_API_KEY", "openaiApiKey");
3002
3149
  const resolveGeminiKey = async () => {
@@ -3200,6 +3347,16 @@ function apply(ctx, config) {
3200
3347
  continue; // 无 key 跳过
3201
3348
  }
3202
3349
  result = await searchSerpbase(request.query, request.maxResults, key, cfg, effSignal);
3350
+ } else if (engine === "serply") {
3351
+ // serply:必须配置 SERPLY_API_KEY(无 key 跳过)
3352
+ const key = await resolveApiKey("SERPLY_API_KEY", "serplyApiKey");
3353
+ if (!key) {
3354
+ lastError = new Error("Serply requires SERPLY_API_KEY (get one at serply.io)");
3355
+ if (engine === preferred) preferredFailure = "SERPLY_API_KEY is not configured";
3356
+ logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
3357
+ continue; // 无 key 跳过
3358
+ }
3359
+ result = await searchSerply(request.query, request.maxResults, key, cfg, effSignal);
3203
3360
  } else if (engine === "you") {
3204
3361
  // you.com:必须配置 YOUCOM_API_KEY(无 key 跳过)
3205
3362
  const key = await resolveApiKey("YOUCOM_API_KEY", "youcomApiKey");
@@ -3246,6 +3403,18 @@ function apply(ctx, config) {
3246
3403
  continue;
3247
3404
  }
3248
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);
3249
3418
  } else if (engine === "openai") {
3250
3419
  const key = await resolveOpenaiKey();
3251
3420
  if (!key) {
@@ -3328,9 +3497,10 @@ function apply(ctx, config) {
3328
3497
  const failure = classifyFailure(error);
3329
3498
  const message = failure.message;
3330
3499
  if (engine === preferred) { preferredFailure = message; preferredFailureClass = failure.class; }
3331
- applyFailureCooldown(engine, failure.class);
3332
- // transient(超时/5xx/网络/限流):同引擎重试一次再回退,避免一次抖动丢掉好引擎
3333
- if (failure.class === "transient" && !retriedEngines.has(engine)) {
3500
+ applyFailureCooldown(engine, failure.class, failure.rateLimited);
3501
+ // transient(超时/5xx/网络):同引擎重试一次再回退,避免一次抖动丢掉好引擎;
3502
+ // 限流(rateLimited)不重试——短窗内再发必再撞,已设 60s 冷却
3503
+ if (failure.class === "transient" && !failure.rateLimited && !retriedEngines.has(engine)) {
3334
3504
  retriedEngines.add(engine);
3335
3505
  logger.warn(`free-search: engine "${engine}" failed [transient] (${message}), retrying once`);
3336
3506
  chain.splice(qi + 1, 0, engine);
@@ -3478,6 +3648,11 @@ function apply(ctx, config) {
3478
3648
  if (!key) return { ok: false, error: "SERPBASE_API_KEY not configured" };
3479
3649
  return await searchSerpbase(q, n, key, cfg);
3480
3650
  }
3651
+ case "serply": {
3652
+ const key = await resolveApiKey("SERPLY_API_KEY", "serplyApiKey");
3653
+ if (!key) return { ok: false, error: "SERPLY_API_KEY not configured" };
3654
+ return await searchSerply(q, n, key, cfg);
3655
+ }
3481
3656
  case "baidu": {
3482
3657
  const key = await resolveApiKey("BAIDU_API_KEY", "baiduApiKey");
3483
3658
  if (!key) return { ok: false, error: "BAIDU_API_KEY not configured" };
@@ -3498,6 +3673,16 @@ function apply(ctx, config) {
3498
3673
  if (!key) return { ok: false, error: "DOUBAO_SEARCH_API_KEY not configured" };
3499
3674
  return await searchDoubao(q, n, key, tr);
3500
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
+ }
3501
3686
  case "openai": {
3502
3687
  const key = await resolveOpenaiKey();
3503
3688
  if (!key) return { ok: false, error: "OPENAI_API_KEY not configured" };
@@ -3661,7 +3846,7 @@ function apply(ctx, config) {
3661
3846
  parameters: {
3662
3847
  engines: {
3663
3848
  type: "array",
3664
- description: "Which engines to test (default: all enabled engines). Options: ddg, ddg-lite, bing, searxng, anysearch, exa, tavily, keenable, firecrawl, parallel, perplexity, serpbase, deepseek-official, you, baidu, kimi, aliyun, doubao, openai, gemini, claude; 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.",
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.",
3665
3850
  items: { type: "string" },
3666
3851
  },
3667
3852
  query: {
@@ -3983,7 +4168,7 @@ function apply(ctx, config) {
3983
4168
  },
3984
4169
  engine: {
3985
4170
  type: "string",
3986
- description: "Optional search mode/engine: multi (parallel top-3 merge), auto, ddg, ddg-lite, bing, searxng, anysearch, exa, tavily, keenable, firecrawl, parallel, perplexity, serpbase, 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).",
3987
4172
  },
3988
4173
  },
3989
4174
  output: {
@@ -4166,6 +4351,7 @@ function apply(ctx, config) {
4166
4351
  export {
4167
4352
  ALL_ENGINES,
4168
4353
  EXPLICIT_ONLY_ENGINES,
4354
+ assertSchemasterySupportsVolatile,
4169
4355
  COOLDOWN_SESSION_MS,
4170
4356
  engineCooldowns,
4171
4357
  classifyFailure,
@@ -4229,7 +4415,11 @@ export {
4229
4415
  searchReddit,
4230
4416
  searchSearxng,
4231
4417
  searchSerpbase,
4418
+ searchSerply,
4232
4419
  searchYoucom,
4420
+ searchZhihuGlobal,
4421
+ searchZhihuSite,
4422
+ ZHIHU_MCP_URL,
4233
4423
  searchStackOverflow,
4234
4424
  searchTavily,
4235
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.3",
4
- "description": "Free web search for DeepSeek Harness: 21 engines (Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable/Firecrawl/Parallel keyless; 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.",
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": [
@@ -28,6 +29,7 @@
28
29
  "parallel",
29
30
  "perplexity",
30
31
  "serpbase",
32
+ "serply",
31
33
  "search",
32
34
  "free",
33
35
  "baidu",
@@ -39,7 +41,8 @@
39
41
  "auto-routing",
40
42
  "doubao",
41
43
  "volcengine",
42
- "volcano"
44
+ "volcano",
45
+ "zhihu"
43
46
  ],
44
47
  "license": "MIT",
45
48
  "publishConfig": {