# dsh-search-tools

[简体中文](README.md) · [English](https://github.com/ThzxxArt/dsh-search-tools/blob/main/README.en.md)

[![npm](https://img.shields.io/npm/v/dsh-search-tools?label=npm)](https://www.npmjs.com/package/dsh-search-tools)
[![CI](https://github.com/ThzxxArt/dsh-search-tools/actions/workflows/ci.yml/badge.svg)](https://github.com/ThzxxArt/dsh-search-tools/actions/workflows/ci.yml)
[![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/ThzxxArt/dsh-search-tools/blob/main/LICENSE)

为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)（DSH）提供 **adapter-first 的网页搜索与正文抓取**。所有配置都在 DSH Web GUI 的 **设置 → 搜索工具** 完成，不要求用户手改 YAML。

> **仓库版本：0.1.9（2026-10-24）。** 本版为 Local Direct 增加 **Brave Search 与 360 Search** 两个可选 HTML 引擎，并把执行模型改为**每引擎独立 worker**：一个慢引擎不会吞掉另一个引擎的成功结果。默认引擎仍是 DuckDuckGo + Bing；解析、去重、RRF 融合、域名多样性和缓存都在本地完成，不需要 SearXNG、API key、Reader、浏览器或第三方聚合服务。查询会直接发往启用的公网搜索引擎，但不会经过 SearXNG/Reader；不执行页面 JavaScript。默认搜索仍是 `searxng`，稳定 provider id 和工具 schema 不变。详细边界见 [Local Direct 搜索配置](#local-direct-搜索配置) 与 [v0.1.9 发布清单](https://github.com/ThzxxArt/dsh-search-tools/blob/main/docs/release-v0.1.9.md)。

---

## 目录

- [它解决什么问题](#它解决什么问题)
- [功能一览](#功能一览)
- [工作原理](#工作原理)
- [环境要求](#环境要求)
- [安装与升级](#安装与升级)
- [快速开始](#快速开始)
- [设置页说明](#设置页说明)
- [配置参考](#配置参考)
  - [配置存放位置](#配置存放位置)
  - [SearXNG 搜索配置](#searxng-搜索配置)
  - [Local Direct 搜索配置](#local-direct-搜索配置)
  - [Readability HTTP 抓取配置](#readability-http-抓取配置)
  - [Wire HTTP 抓取配置](#wire-http-抓取配置)
  - [Jina Reader 抓取配置](#jina-reader-抓取配置)
  - [web_research 配置](#web_research-配置)
  - [接管配置](#接管配置)
- [模型工具](#模型工具)
- [安全模型](#安全模型)
- [适配器开发](#适配器开发)
- [本地开发](#本地开发)
- [发布流程](#发布流程)
- [故障排查](#故障排查)
- [已知限制](#已知限制)
- [许可证与第三方组件](#许可证与第三方组件)

---

## 它解决什么问题

DSH 自带 `web_search` 和 `web_fetch`，但默认后端不一定适合每个人：有人已经有自己的 SearXNG，有人需要把搜索请求走代理，有人希望抓取结果直接是干净的正文，而不是整页 HTML。

`dsh-search-tools` 把“搜索/抓取后端”从 DSH 核心中解耦出来：

- 用户可以在 Web 设置页选择后端、填写实例、测试连接；
- 插件向 DSH 注册**稳定 provider id**，切换后端不会改变 DSH 的 `web.searchProvider`；
- 新增后端只需要注册 adapter，设置页按 manifest 自动渲染，不需要改页面代码；
- 额外提供 `web_research`：一次调用完成“搜索 + 抓取前 N 条正文”，适合需要页面内容而非只拿链接的模型回合。

它**不**做这些事：不提供公共搜索 API，不绕过目标站点的登录/付费墙，也不替代 DSH 的权限与沙箱模型。渲染能力取决于抓取 adapter：`readable-http` 不执行页面 JavaScript；可选的 `wire-http` 在本地做浏览器级 HTTP/TLS 指纹和 PDF 文本提取，但同样不执行页面 JavaScript；可选的 `jina-reader` 会把公网 URL 交给 Reader 服务做浏览器渲染（自托管时留在本机），三者都不登录、不绕过付费墙、不破解验证码。

## 界面预览

以下截图使用 demo 数据（`https://searxng.example.com`，无真实凭据）：

![搜索工具概览](https://raw.githubusercontent.com/ThzxxArt/dsh-search-tools/main/docs/assets/settings-overview.png)

![搜索后端与实例卡](https://raw.githubusercontent.com/ThzxxArt/dsh-search-tools/main/docs/assets/settings-search.png)

![Fetch 正文 reader](https://raw.githubusercontent.com/ThzxxArt/dsh-search-tools/main/docs/assets/settings-fetch-reader.png)

![Jina Reader 抓取配置](https://raw.githubusercontent.com/ThzxxArt/dsh-search-tools/main/docs/assets/settings-fetch-jina.png)

## 功能一览

| 能力 | 说明 |
|---|---|
| 稳定的搜索/抓取 provider | `dsh-search-tools`、`dsh-search-tools-fetch` 永不随后端变化 |
| 内置搜索 adapter | SearXNG：多实例、顺序/轮询、故障转移、JSON/HTML、GET/POST、Bearer/Basic、代理、缓存、分页、域名过滤 |
| 本地直连搜索 adapter（可选） | Local Direct：本机直接查询 DuckDuckGo + Bing HTML 首页，可选 Brave Search / 360；本地解析/去重/RRF 融合/域名多样性/缓存；不需要服务、Key、Reader 或浏览器，不执行 JS |
| 内置抓取 adapter | Readability HTTP：复用 DSH 安全 HTTP，Readability 提取正文，失败可回退原文 |
| 本地强抓取 adapter（可选） | Wire HTTP：`impit` 本地浏览器级 TLS/HTTP2 指纹、逐跳公网出口 pinning、Readability、PDF 文本；不需要 Reader/Key/浏览器/容器，不执行 JS |
| 可选渲染抓取 adapter | Jina Reader：浏览器渲染、PDF/Office、CSS 选择器、iframe/Shadow DOM、代理选项；目标必须是公网地址，Reader 可托管或自托管 |
| 研究工具 | `web_research`：搜索、按并发抓取前 N 页、按页/总预算截断、返回来源与正文 |
| Web 设置页 | 概览、搜索、抓取、研究、诊断五个页签；Connection / Search behavior 子页、实例卡、内联测试、正文 reader、预算条、字段级错误、诊断、缓存清理、键盘保存 |
| 凭据隔离 | 搜索 token 写入 DSH credentials，不落 YAML、不进日志、不出现在诊断文本 |
| 接管与恢复 | 可选让内置 `web_search` / `web_fetch` 使用本插件；关闭时恢复之前的 provider |
| 外部 adapter | 通过 `ctx.searchTools` 注册新后端；设置页自动渲染 manifest 字段 |

## 工作原理

```text
DSH ctx.web
  searchProvider = "dsh-search-tools"         （稳定）
  fetchProvider  = "dsh-search-tools-fetch"   （稳定）
        │
        ▼
  SearchDispatcher / FetchDispatcher
        │ 读取 config.search.adapter / config.fetch.adapter
        ▼
  ctx.searchTools（adapter 注册表）
    ├── searxng         内置搜索 adapter（默认）
    ├── local-direct    内置搜索 adapter（本机直连 DDG + Bing）
    ├── readable-http   内置抓取 adapter（默认，DSH 安全 HTTP）
    ├── wire-http       内置抓取 adapter（可选，本地浏览器级 HTTP + PDF）
    ├── jina-reader     内置抓取 adapter（可选，Reader 渲染）
    └── 未来由独立包注册的 adapter
```

关键设计：

1. **provider id 稳定，adapter 可换。** 加一个后端不会改 `web.searchProvider`，也不会要求 DSH 重启后重新注册 provider。
2. **配置按 section 开放。** `search.adapters` 和 `fetch.adapters` 是开放字典，adapter 自己拥有默认值与校验逻辑；核心 schema 不需要为每个后端加字段。
3. **manifest 驱动设置页。** Host 路由把 adapter manifest 发给浏览器，页面按 `fields` 通用渲染。新增 adapter 不需要改 React 代码。
4. **配置在调用时解析。** Dispatcher 每次按当前配置选择 adapter；切换后不需要重新注册 provider。

架构细节见 [docs/architecture.md](https://github.com/ThzxxArt/dsh-search-tools/blob/main/docs/architecture.md)。

## 环境要求

| 项目 | 要求 |
|---|---|
| DSH | `>=0.2.1-rc.2 <0.3.0`（当前按 `0.2.1-rc.2` 验证） |
| Node.js | `^22.19.0 || >=24.0.0` |
| 搜索后端 | `Local Direct`（不需要部署，直接查 DDG/Bing）或一个可访问的 SearXNG 实例；也可暂时保持 DSH 默认搜索 |
| 抓取后端 | 公网 `http(s)` 页面；`readable-http` 由 DSH SSRF 防护拦截内网，`wire-http` 在本地逐跳 pin 公网地址，`jina-reader` 在发出前做公网目标守卫 |
| Reader（可选） | `r.jina.ai` 的 API key，或一个可访问的自托管 Jina Reader 端点；不配置时继续用 `readable-http` 或 `wire-http` |
| 浏览器 | DSH Web GUI（设置页只在 Web 端注册） |

DSH 本身仍是 preview 软件，接口可能变化。插件把 peer 范围锁在已验证的 `0.2.1-rc.2` 线，升级 DSH 前请先看 [CHANGELOG](https://github.com/ThzxxArt/dsh-search-tools/blob/main/CHANGELOG.md)。

## 安装与升级

### 方式一：Web UI（推荐）

1. 打开 DSH Web GUI。
2. 进入 **插件（Plugins）**。
3. 点击 **添加插件（Add plugin）**。
4. 输入包名：

   ```text
   dsh-search-tools
   ```

5. 安装完成后启用这个 bundle。
6. 打开 **设置 → 搜索工具**。

设置页出现，说明 Host 与浏览器两半都已加载。

### 方式二：CLI

```sh
dsh plugin --profile <profile> add dsh-search-tools
```

`<profile>` 换成实际 profile 名。安装后启动该 profile：

```sh
dsh --profile <profile>
```

### 方式三：离线 tarball

从 [GitHub Releases](https://github.com/ThzxxArt/dsh-search-tools/releases) 下载最新可用的 `dsh-search-tools-<version>.tgz`，然后：

```sh
dsh plugin --profile <profile> add ./dsh-search-tools-<version>.tgz
```

发布资产与 npm 产物字节一致，适合内网或无法访问 npm 的环境。

### 升级

DSH 插件目前**不支持自动更新**。升级步骤：

1. 先在 **设置 → 搜索工具 → 概览** 关闭搜索/抓取接管（如果已开启）。
2. 在 **插件** 页卸载旧版本。
3. 重新添加 `dsh-search-tools` 或新的 tarball。
4. 重新启用，检查设置页配置是否保留（配置在 profile 的 YAML patch 中，通常不会因重装丢失）。
5. 重新测试搜索与抓取。

CLI 对应：

```sh
dsh plugin --profile <profile> remove dsh-search-tools
dsh plugin --profile <profile> add dsh-search-tools
```

> 不要在接管仍开启时直接卸载。内置 `web_search` 会指向一个不存在的 provider，直到你重装插件或手动恢复 `web.searchProvider`。

### 安装后检查

- **设置 → 搜索工具** 能看到五个页签。
- **诊断** 里能看到版本号、配置文件路径、抓取/研究运行状态和缓存统计。
- 如果模型工具已启用，会话里应能看到 `web_research`。
- 搜索测试通过后，才建议开启搜索接管。

## 快速开始

### 1. 准备搜索来源

有两条零代码路径：

- **Local Direct（推荐先试）**：不需要准备任何服务。它在本机直接请求 DuckDuckGo 和 Bing 的公开 HTML 结果页，解析和融合都在本机完成。可额外启用 Brave Search 或 360；Brave 内部使用 Firefox HTTP 指纹，其他引擎遵循 `profile` 设置。查询会离开本机发往启用的引擎；首次使用请确认所在网络能访问它们。
- **SearXNG**：需要一个可访问的 SearXNG 实例。实例地址是 `http(s)` 绝对 URL，例如 `https://searx.example.com`；JSON API 需要 `search.formats` 包含 `json`，HTML-only 也可以；有认证时准备 Bearer token 或 `username:password`；有限流时准备多个实例或代理。插件会自动去掉末尾 `/search`、query 和 hash，实例 URL 里不能带用户名/密码。

### 2. 在设置页配置

1. 打开 **设置 → 搜索工具 → 搜索**。
2. 在 **后端** 选择 `Local Direct` 或 `SearXNG`。
3. **Local Direct**：勾选 **DuckDuckGo** / **Bing** / **Brave Search** / **360 搜索** 中至少一个，按需选市场、安全搜索、结果数；不需要实例或密钥。点 **测试搜索引擎** 可检查可达性。
4. **SearXNG**：点击 **添加实例**，填写实例地址；备注名可选；按需设置引擎、分类、语言、安全搜索、分页、结果数、超时；需要认证时选择 `Bearer` / `Basic` 并保存密钥。
5. 点击 **测试**，确认返回结构化来源；Local Direct 还会显示**可达引擎**。
6. 点击 **保存并应用**。

### 3. 配置抓取（可选）

1. 打开 **抓取** 页签。
2. 先保持 **Readability HTTP**，用默认值测试一个公网页面，例如 `https://example.com`。
3. 如果目标站点是 SPA、PDF/Office，或静态 HTTP 抓不到正文，改选 **Jina Reader**：
   - 托管服务填 `https://r.jina.ai/`，在 **Jina API key** 保存密钥；
   - 自托管填 `http://127.0.0.1:3000/`（仅 loopback 允许明文 HTTP）或 HTTPS 反代地址；
   - 需要动态内容时把 **Fetch engine** 设为 `browser`；需要新鲜内容时打开 **Bypass Reader cache**。
4. 保存前看 form 顶部的数据边界 warning；**测试** 通过后保存并应用。
5. `web_fetch` 工具默认 30 秒；把 Reader 超时调到 30 秒以上时，必须同时调高 DSH `tool-web.fetchTimeoutMs`，否则会先收到 `TOOL_TIMEOUT`。

### 4. 开启接管（可选）

在 **概览**：

- **接管内置搜索**：让 DSH 的 `web_search` 使用本插件的搜索 provider。开启前会先用当前配置跑一次搜索测试，失败则不会接管。
- **接管内置抓取**：让 DSH 的 `web_fetch` 使用本插件的抓取 provider。

接管状态会写入 DSH 的 `web.searchProvider` / `web.fetchProvider`。关闭时插件会恢复之前记录的 provider（`activation.previousSearchProvider` / `previousFetchProvider`）。

如果只想用 `web_research`，**不一定要开启接管**。`web_research` 调用的是当前 DSH 的搜索/抓取 provider；不接管时就使用 DSH 默认后端。

### 5. 使用模型工具

模型会话中可以直接使用：

- `web_search`：DSH 内置工具，接管后走本插件；
- `web_fetch`：DSH 内置工具，接管后走本插件；
- `web_research`：本插件注册的研究工具，不依赖接管，但会使用当前 provider。

`web_research` 示例参数：

```json
{
  "query": "DeepSeek Harness release notes",
  "max_results": 8,
  "fetch_top": 3,
  "max_chars": 12000
}
```

返回内容包含 `sources`、`fetched` 和 `truncated`；会话卡片会显示来源数、成功抓取数、抓取详情和截断标记。

## 设置页说明

### 概览（Overview）

- 当前搜索 provider / 抓取 provider；
- 接管状态与快捷开关；
- 搜索后端是否可用、**搜索来源数**（SearXNG 实例或 Local Direct 引擎）、缓存条数；
- 快速搜索测试与抓取预览；
- 没有来源时的引导卡片。

### 搜索（Search）

- 卡片式 adapter picker：显示 adapter 描述、capabilities、配置状态；
- SearXNG 实例编辑器：添加、删除、上移、下移、启用/停用、单实例测试；
- Local Direct 引擎多选、市场、安全搜索、每站结果数；不需要实例；
- **测试搜索引擎**：使用固定探测词，不写缓存，直接列出可达引擎，无实例也能用；
- 由 manifest 自动渲染的字段；
- 内联测试：显示耗时、来源标题/链接/摘要、`Search notes`（例如单个引擎被限流或解析为空）；
- 校验错误会定位到具体字段，搜索草稿的错误不会阻塞抓取测试。

### 抓取（Fetch）

- 抓取 adapter 选择与字段；
- 输入公网 URL 做一次抓取预览；
- 选择 `wire-http` 时显示本地边界 notice、浏览器指纹/超时/PDF 字段；它不需要 Key，也不执行页面 JavaScript；
- 选择 `jina-reader` 时显示数据边界 warning、API key 和浏览器/Reader 选项；`readable-http` 仍保留原有字段；
- 显示状态码、内容类型、字符数、截断标记和正文预览；
- 内网地址会被 `readable-http` 的 SSRF 防护或 `jina-reader` 的公网目标守卫拒绝，这是预期行为。

### 研究（Research）

- 启用/关闭 `web_research`；
- 默认结果数、最大抓取条数、默认抓取条数、单页/总字符预算、并发、超时；
- **模型看到的工具提示** 预览：描述、参数名、默认值与范围都来自当前草稿；可一键复制；
- 关闭 `web_research` 后，保存后模型将看不到这个工具。

### 诊断（Diagnostics）

- 配置文件路径；
- 搜索后端、抓取后端、研究工具的真实运行状态（`fetch.available` / `research.registered`）；
- 缓存条数与命中/未命中统计；
- 清空缓存；
- 最近错误（环形缓冲）；
- 复制诊断信息（已脱敏）与清空错误；
- 当前版本。

### 键盘与状态

- `Cmd/Ctrl+S`：保存并应用当前草稿（仅在配置有效且有未保存更改时）。
- 页签支持 `←` / `→` / `Home` / `End`。
- 保存状态、测试结果、诊断操作都通过 `aria-live` 播报。
- 配置被更高层覆盖时页面只读；配置在别处修改时提示冲突，需重新加载。

## 配置参考

### 配置存放位置

配置通过 DSH 原生 settings 服务写入当前 profile 的 YAML patch：

```text
$DSH_HOME/profiles/<profile>/cordis.patch.yml
```

结构大致如下：

```yaml
- id: dsh-search-tools
  name: dsh-search-tools
  config:
    search:
      adapter: searxng
      adapters:
        searxng:
          enabled: true
          instances:
            - url: https://searx.example.com
              enabled: true
              label: primary
          engines: []
          categories: [general]
          language: all
          safesearch: 1
          pages: 1
          maxResults: 10
          strategy: ordered
          method: GET
          format: auto
          auth:
            type: none
          requestTimeoutMs: 12000
          deadlineMs: 45000
          retries: 1
          cacheTtlMs: 300000
          cacheMaxEntries: 200
          userAgent: dsh-search-tools/0.1.9 (+https://github.com/ThzxxArt/dsh-search-tools)
          proxy: ""
          includeDomains: []
          excludeDomains: []
        local-direct:
          enabled: true
          engines: [duckduckgo, bing]
          market: auto
          safeSearch: moderate
          maxResults: 10
          maxPerHost: 2
          profile: chrome
          requestTimeoutMs: 8000
          deadlineMs: 15000
          retries: 1
          minIntervalMs: 1000
          cacheTtlMs: 300000
          cacheMaxEntries: 200
          maxResponseBytes: 2000000
          proxy: ""
          includeDomains: []
          excludeDomains: []
    fetch:
      adapter: readable-http
      adapters:
        readable-http:
          enabled: true
          readability: true
          minArticleChars: 200
          maxArticleChars: 200000
          maxResponseBytes: 5000000
          maxBodyChars: 2000000
          timeoutMs: 20000
          maxRedirects: 5
          userAgent: dsh-search-tools/0.1.9 (+https://github.com/ThzxxArt/dsh-search-tools)
          failOpen: true
        wire-http:
          enabled: true
          profile: chrome
          timeoutMs: 25000
          maxRedirects: 5
          locale: ""
          maxResponseBytes: 5000000
          readability: true
          minArticleChars: 200
          maxArticleChars: 200000
          failOpen: true
          pdf: true
          maxPdfPages: 10
          maxBodyChars: 2000000
        jina-reader:
          enabled: true
          endpoint: https://r.jina.ai/
          engine: auto
          responseMode: markdown
          timeoutMs: 25000
          maxResponseBytes: 5000000
          maxBodyChars: 200000
          waitForSelector: ""
          targetSelector: ""
          removeSelector: ""
          includeIframes: false
          includeShadowDom: false
          noCache: false
          respectRobots: true
          retainImages: none
          retainLinks: all
          upstreamProxy: none
          locale: ""
          userAgent: ""
    research:
      enabled: true
      maxResults: 8
      maxFetch: 8
      defaultFetchTop: 3
      maxCharsPerPage: 12000
      maxTotalChars: 40000
      concurrency: 3
      timeoutMs: 120000
    activation:
      previousSearchProvider: ""
      previousFetchProvider: ""
```

**不要手改这个文件来配置插件。** 设置页会处理校验、section 级 diff、密钥隔离和冲突检测。手改容易写坏 YAML，也可能被更高层 overlay 覆盖。

密钥不进入 YAML。插件通过 DSH credentials 读取：

| 凭据引用 | 用途 | 格式 |
|---|---|---|
| `SEARXNG_TOKEN` | Bearer 认证 | token 原文 |
| `SEARXNG_BASIC_AUTH` | Basic 认证 | `username:password` |
| `JINA_API_KEY` | Jina Reader Bearer 认证（可选） | token 原文；自托管无认证时留空 |

设置页的密钥字段是 write-only：只显示“已配置/未配置”和来源，不回显值。测试使用已保存的密钥；未保存的输入不会发送。

### SearXNG 搜索配置

| 字段 | 默认 | 范围/取值 | 说明 |
|---|---:|---|---|
| `enabled` | `true` | boolean | 关闭后该 adapter 不可用 |
| `instances` | `[]` | 列表 | 实例按顺序尝试；每项有 `url`、`enabled`、`label` |
| `engines` | `[]` | 逗号分隔 | 留空使用实例默认引擎；例如 `google, bing` |
| `categories` | `["general"]` | 逗号分隔 | 留空不发送该参数，使用实例默认；通常 `general` |
| `language` | `all` | 字符串 | 例如 `zh-CN`、`en-US` |
| `safesearch` | `1` | `0` / `1` / `2` | 安全搜索等级 |
| `timeRange` | 空 | `day` / `week` / `month` / `year` | 时间范围 |
| `pages` | `1` | 1–3 | 并发请求的分页数，结果合并去重 |
| `maxResults` | `10` | 1–20 | 每次搜索最多返回条数 |
| `strategy` | `ordered` | `ordered` / `round-robin` | 多实例顺序优先或轮询 |
| `method` | `GET` | `GET` / `POST` | SearXNG 请求方法 |
| `format` | `auto` | `auto` / `json` / `html` | `auto` 先 JSON 后 HTML |
| `auth.type` | `none` | `none` / `bearer` / `basic` | 认证方式 |
| `requestTimeoutMs` | `12000` | 1000–120000 | 单次 HTTP 请求超时 |
| `deadlineMs` | `45000` | 1000–300000 | 整次搜索总预算，不能小于单次超时 |
| `retries` | `1` | 0–5 | 每个实例的重试次数，只重试可重试错误 |
| `cacheTtlMs` | `300000` | 0–86400000 | 搜索结果缓存 TTL；0 关闭 |
| `cacheMaxEntries` | `200` | 0–10000 | 缓存最大条数；0 关闭 |
| `userAgent` | 带版本号 | 非空、无换行 | 请求 UA |
| `proxy` | 空 | `http(s)` URL | 代理；不能包含用户名/密码 |
| `includeDomains` | `[]` | 域名列表 | 只保留匹配域名，支持子域名 |
| `excludeDomains` | `[]` | 域名列表 | 排除匹配域名，支持子域名 |

行为说明：

- **多实例故障转移**：一个实例失败会尝试下一个；全部失败才报错。
- **重试与预算**：单实例重试受 `retries` 和 `deadlineMs` 共同约束；预算耗尽后不再尝试新实例。429/5xx 的 `Retry-After`（秒或 HTTP 日期）优先于指数退避，且同样不超过剩余 deadline。
- **host 校验**：搜索/抓取 dispatcher 在 live 配置、页面草稿、probe 和 preview 前调用 adapter 的 `validate()`；非法数字、枚举、数组或跨字段配置返回 `SEARCH_CONFIG_INVALID` / `FETCH_CONFIG_INVALID`，不会静默 clamp。
- **预览与 probe**：Search 测试预览使用当前草稿的 `maxResults`；搜索 probe 使用 `requestTimeoutMs` 和配置的 `GET` / `POST` 方法。
- **JSON / HTML**：`auto` 先请求 JSON；遇到 403/415/406 或 JSON 解析失败时，同实例再请求一次 HTML。401 仍按认证失败处理，不会误判成格式问题。
- **Search notes**：SearXNG 的 `unresponsive_engines` 会被保留为提示。看到 0 条结果时先看这里，可能是引擎被限流/CAPTCHA，而不是查询真的没有结果。
- **缓存键**：包含查询、实例列表、引擎、分类、语言、安全搜索、时间范围、分页、策略、方法、格式、认证类型、代理、UA 和域名过滤；修改这些会自然失效。凭据轮换不会自动改键，建议清空缓存。
- **域名过滤**：在结果进入 `sources` 前过滤，支持 `example.com` 匹配 `www.example.com`。
- **分页部分成功**：一页失败不会丢弃其他成功页；失败页写入 `notes`，全部失败才报错。

### Local Direct 搜索配置

> **本地边界：** Select `Local Direct` 后，查询由 Host 进程直接发往启用的固定公开 HTML 端点（默认 `html.duckduckgo.com`、`www.bing.com`；可选 `search.brave.com`、`www.so.com`）；解析、去重、RRF 融合、域名多样性和缓存都在本机完成。不需要 SearXNG、API key、Reader、浏览器、Docker 或第三方聚合服务。**查询会离开本机**；它不执行页面 JavaScript，不登录、不绕过付费墙或验证码。引擎 HTML 结构变化可能导致解析为空，此时会显式报错或写入 `notes`，不会静默 fallback 到 SearXNG。

| 字段 | 默认 | 范围/取值 | 说明 |
|---|---:|---|---|
| `enabled` | `true` | boolean | 关闭后该 adapter 不可用 |
| `engines` | `["duckduckgo","bing"]` | `duckduckgo` / `bing` / `brave` / `so360`，至少一个 | 启用的公开 HTML 引擎；结果在本地融合。Brave 由 Firefox HTTP 指纹请求；360 面向中文市场 |
| `market` | `auto` | `auto` / `us-en` / `cn-zh` / `gb-en` / `jp-ja` / `de-de` / `fr-fr` | 传给 DDG `kl` 和 Bing `setlang`/`cc` 的市场 |
| `safeSearch` | `moderate` | `off` / `moderate` / `strict` | 映射到 DDG `kp`、Bing `adlt` 和 Brave `safeSearch` |
| `maxResults` | `10` | 1–20 | 融合后返回条数上限 |
| `maxPerHost` | `2` | 1–5 | 同一 hostname 最多保留条数，避免单站刷满 |
| `profile` | `chrome` | `chrome` / `firefox` | Impit TLS/HTTP2 指纹；不是浏览器 |
| `requestTimeoutMs` | `8000` | 1000–30000 | 单个引擎请求超时 |
| `deadlineMs` | `15000` | 1000–60000 | 整次搜索总预算，不能小于单次超时 |
| `retries` | `1` | 0–2 | 失败引擎的重试轮数 |
| `minIntervalMs` | `1000` | 0–10000 | 每个引擎的最小请求间隔，避免并发搜索打爆引擎 |
| `cacheTtlMs` | `300000` | 0–86400000 | 完整成功结果的缓存 TTL；0 关闭 |
| `cacheMaxEntries` | `200` | 0–1000 | 缓存条数 |
| `maxResponseBytes` | `2000000` | 65536–10485760 | 单引擎 HTML 上限；超限该引擎失败 |
| `proxy` | 空 | `http(s)` URL | 可选代理；不能包含用户名/密码 |
| `includeDomains` | `[]` | 域名列表 | 只保留匹配域名，支持子域名 |
| `excludeDomains` | `[]` | 域名列表 | 排除匹配域名，支持子域名 |

行为与失败语义：

- **固定 origin，无用户可控 URL。** 引擎 URL 由代码构造，只有查询、市场、安全搜索等参数变化；配置里没有自定义 endpoint。测试固定 origin，防止把 adapter 变成开放代理。
- **不跟随重定向。** Impit 使用 `followRedirects:false` + `redirect:'manual'`；3xx 视为该引擎失败，不会跟随到未知 host。
- **无 Cookie / Authorization / JS。** 每次搜索新建一个 Impit worker；不发送登录态，不执行页面脚本；worker 在成功、失败、abort、deadline 后都会终止。
- **本地 RRF。** 每个引擎按排名贡献 `1/(60+rank+1)`；同 URL 合并更长标题/摘要；再按 hostname 多样性截断。查询词不参与第二阶段 LLM 重排。
- **部分成功。** 一个引擎失败时返回另一个引擎的结果，并在 `notes` 写明失败引擎与原因；全部失败抛 `LOCAL_DIRECT_ALL_FAILED`，**不会**自动 fallback 到 SearXNG/Jina。若 200 页面的结果全部被 `includeDomains`/`excludeDomains` 过滤掉，该引擎仍算成功（空结果 + note），不会误报全部失败。
- **缓存只写完整成功。** 只有所有启用引擎都成功、没有失败 note、且至少有结果时才写缓存；部分成功/空结果不缓存，下一次会重试失败引擎。
- **探测。** Search 页的 **测试搜索引擎** 使用固定查询 `dsh-search-tools probe`，不写缓存；返回可达引擎列表。
- **不解析广告。** DDG 的 `result--ad` 和 Bing 的 `li.b_ad`（即使同时带 `b_algo`）广告项会被丢弃；DDG `uddg` 和 Bing `ck/a?u=a1...` 的跳转链接在本地解包为真实结果 URL。
- **引擎可用性不是 SLA。** 每个引擎都可能限流、返回 202/403 或改版；这是公开 HTML 路径的固有风险。出现空结果时先看 `notes` 和 **测试搜索引擎**，必要时切回 SearXNG。

### Readability HTTP 抓取配置

| 字段 | 默认 | 范围 | 说明 |
|---|---:|---|---|
| `enabled` | `true` | boolean | 关闭后该抓取 adapter 不可用 |
| `readability` | `true` | boolean | 是否用 Readability 提取正文 |
| `minArticleChars` | `200` | 0–100000 | 低于此长度不视为文章正文 |
| `maxArticleChars` | `200000` | 1000–5000000 | 提取后正文的**文本**最大字符数，不是 HTML 长度；不能小于 `minArticleChars`，也不能大于 `maxBodyChars` |
| `maxResponseBytes` | `5000000` | 1024–50000000 | 响应体最大字节数 |
| `maxBodyChars` | `2000000` | 1024–20000000 | 解码后最大字符数，不能小于正文下限 |
| `timeoutMs` | `20000` | 1000–120000 | 抓取超时 |
| `maxRedirects` | `5` | 0–10 | 最大重定向次数 |
| `userAgent` | 带版本号 | 非空、无换行 | 请求 UA |
| `failOpen` | `true` | boolean | 正文提取失败时回退到原始内容 |

`readable-http` 的行为：

- 复用 DSH 的安全 HTTP provider，目标是阻止 SSRF 和访问内网地址；
- 只处理 `http(s)` URL；
- 只解析文本/HTML；PDF、图片、二进制不会变成正文；
- 不执行 JavaScript，因此纯前端渲染页面可能抓不到实际内容；
- `maxArticleChars` 限制的是提取后的正文文本，不是原始 HTML；`truncated` 由提取器返回，短正文配长 HTML 不会再误报；
- 需要截断 HTML 时按块级节点处理；无法安全截断时退回纯文本，保证不会返回半截标签；
- Readability 失败且 `failOpen=true` 时返回原始文本/HTML，而不是让整个抓取失败；
- `readable-http` 的 `/probe` 会真实发起一次抓取并返回 HTTP 状态/内容类型。

### Wire HTTP 抓取配置

> **本地边界：** `wire-http` 在本地通过 loopback 出口守卫抓取，不把目标 URL 发给第三方 Reader，也不需要 API key、浏览器、Docker 或系统库。它使用 `impit` 的 Chrome/Firefox TLS/HTTP2 指纹；**不执行页面 JavaScript**，所以 SPA 仍应使用 `jina-reader`。它不会登录、绕过付费墙或破解验证码。

| 字段 | 默认 | 范围/取值 | 说明 |
|---|---:|---|---|
| `enabled` | `true` | boolean | 关闭后该 adapter 不可用 |
| `profile` | `chrome` | `chrome` / `firefox` | 浏览器 TLS/HTTP2 指纹；不是浏览器引擎 |
| `timeoutMs` | `25000` | 1000–180000 | worker 与出口守卫的硬超时；默认低于 DSH `web_fetch` 的 30 秒工具超时 |
| `maxRedirects` | `5` | 0–10 | 每一跳重定向都重新经过公网出口守卫 |
| `locale` | 空 | BCP 47 | 可选 `Accept-Language`，例如 `zh-CN` |
| `maxResponseBytes` | `5000000` | 1024–50000000 | 原始响应字节上限；超限显式失败 |
| `readability` | `true` | boolean | 用 Readability 提取 HTML 正文；关闭时返回原始 HTML |
| `minArticleChars` | `200` | 0–100000 | 正文最小字符数 |
| `maxArticleChars` | `200000` | 1000–5000000 | 提取后正文文本上限；不能小于下限，也不能大于 `maxBodyChars` |
| `failOpen` | `true` | boolean | 提取失败时回退原始 HTML；关闭时返回 `WIRE_UNREADABLE` |
| `pdf` | `true` | boolean | 本地 PDF 文本提取 |
| `maxPdfPages` | `10` | 1–20 | 只读前 N 页；超过上限标记 `truncated` |
| `maxBodyChars` | `2000000` | 1024–20000000 | 解码后字符上限，HTML/文本/PDF 共用 |

行为与失败语义：

- 初始目标先解析并校验，随后每一跳由本地出口代理重新解析；任何 DNS 答案包含私网/loopback/link-local/CGNAT/ULA/IPv4-mapped/6to4/Teredo/已知 NAT64 前缀（`64:ff9b::/96`、`64:ff9b:1::/48`）都会被拒绝；网络专用 NAT64 前缀无法在通用运行时中穷举，仍被文档标为残余边界；
- 只允许 `http(s)`、80/443，不允许 URL 凭据；代理连接到已验证的 IP，不会再次解析主机名；
- `impit` 运行在单次请求 worker 中，成功、失败、取消或超时都会终止 worker；原生 client 不会跨请求残留；
- 响应按 `Content-Type`/magic 分流：PDF → 本地 PDF.js 文本；HTML → Readability；文本/JSON → 直接文本；二进制 → `WIRE_UNSUPPORTED_CONTENT`；
- 2xx 空 body 返回 `WIRE_EMPTY_BODY`；4xx/5xx 的空 body 保留状态码；PDF 无文本层返回 `WIRE_PDF_UNREADABLE`，损坏 PDF 返回 `WIRE_PDF_INVALID`；
- 不自动 fallback 到 Jina 或 readable-http，避免把被本地守卫拒绝的 URL 静默发给第三方；
- 不需要 `JINA_API_KEY`；设置页不显示 secret 字段。

### Jina Reader 抓取配置

> **数据边界：** `jina-reader` 不是 `readable-http` 的替代默认值。托管 `https://r.jina.ai/` 时，目标 URL 会发给 Jina；只有自托管 Reader 才把页面留在本机。Reader 可能缓存页面，需要新鲜内容时打开 `noCache`。它不会登录、绕过付费墙或破解验证码。

| 字段 | 默认 | 范围/取值 | 说明 |
|---|---:|---|---|
| `enabled` | `true` | boolean | 关闭后该 adapter 不可用 |
| `endpoint` | `https://r.jina.ai/` | http(s) URL | 托管服务或自托管 Reader；远程必须 HTTPS，http 仅允许 loopback |
| `engine` | `auto` | `auto` / `browser` / `curl` | `browser` 强制无头浏览器；`curl` 使用轻量 HTTP，适合静态页 |
| `responseMode` | `markdown` | `markdown` / `html` / `text` | `html` 会映射为 `body.kind=html`，交给本地 Readability |
| `timeoutMs` | `25000` | 2000–180000 | 本地硬超时；发给 Reader 的 `X-Timeout` 会少 1 秒作为等待预算，最小值 2000 保证它仍短于本地截止。超过 30000 还需调高 DSH `tool-web.fetchTimeoutMs` |
| `maxResponseBytes` | `5000000` | 1024–50000000 | Reader 返回的 JSON 包体上限；超限返回 `JINA_RESPONSE_TOO_LARGE` |
| `maxBodyChars` | `200000` | 1024–20000000 | 按响应模式截断 `data.content` / `data.html` / `data.text`，并置 `truncated=true` |
| `waitForSelector` | 空 | CSS 选择器 | 等元素出现后再提取 |
| `targetSelector` | 空 | CSS 选择器 | 只提取匹配内容 |
| `removeSelector` | 空 | CSS 选择器 | 提取前移除导航/广告等 |
| `includeIframes` | `false` | boolean | `X-With-Iframe: true` |
| `includeShadowDom` | `false` | boolean | `X-With-Shadow-Dom: true` |
| `noCache` | `false` | boolean | `X-No-Cache: true`，要求实时抓取 |
| `respectRobots` | `true` | boolean | `X-Robots-Txt: dsh-search-tools` |
| `retainImages` | `none` | `none` / `all` | 默认移除图片，省 token |
| `retainLinks` | `all` | `none` / `all` | 是否保留链接 |
| `upstreamProxy` | `none` | `none` / `auto` | `auto` 需要 Reader 支持且通常需要 API key |
| `locale` | 空 | BCP 47 | 例如 `zh-CN`；控制 Reader 浏览器 locale |
| `userAgent` | 空 | 字符串 | 作为 `X-User-Agent` 发给目标页；空则 Reader 默认 |

行为与错误：

- 请求固定为 `POST <endpoint>/`，`Content-Type: application/x-www-form-urlencoded`，body 为 `url=<目标>`；`Accept: application/json`，并带 `X-Respond-With`、`X-Timeout` 等头。Markdown 请求 `markdown`；HTML/text 请求 `<mode>,content`，因为 Jina 把目标站错误warning 放在 content 分支。
- 目标 URL 在发出前经过公网目标守卫：只允许 `http(s)`、禁止 URL 内凭据、拒绝 loopback/私有/链路本地地址及 IPv4-compatible 等过渡地址，并要求 DNS 解析结果全部为公网地址。错误码为 `JINA_INVALID_TARGET` / `JINA_TARGET_BLOCKED` / `JINA_TARGET_UNRESOLVED`。
- 401/403 → `JINA_AUTH`；429 → `JINA_RATE_LIMIT`（有 `Retry-After` 时带 `retryAfterMs`，最多 1 小时）；5xx/其他 → `JINA_HTTP`；网络失败 → `JINA_NETWORK`；超时 → `JINA_TIMEOUT`；非法 JSON/缺少当前模式的字段（`data.content`、`data.html` 或 `data.text`）→ `JINA_BAD_RESPONSE`（若目标状态已是非 2xx，则返回空正文并保留状态码）；超限 → `JINA_RESPONSE_TOO_LARGE`。
- Reader 返回的目标页 404/5xx 优先从 `data.warning` 恢复，其次读官方 `data.httpStatus`；HTML/text 不再附加 content companion，直接依赖这两个官方字段。可选的 `data.statusCode` 作为自托管兼容字段最后兜底，且 warning/httpStatus 优先；只有三者都缺失时才无法恢复目标状态。
- **残余风险：** Reader 服务端会重新解析目标 DNS，并可能跟随目标页重定向/浏览器跳转；本插件无法像 `HttpFetchProvider` 那样 pin 住连接，所以这是 best-effort 守卫，不是 DSH 级别的 SSRF 保证。自托管时请限制 Reader 容器的网络出口。
- HTML 模式的 `maxBodyChars` 截断优先落在完整标签边界，但仍可能留下不完整文档；需要 token 预算时优先用 Markdown/text。
- **自托管：** Jina Reader 开源版可运行在本机/内网；把 `endpoint` 设为 `http://127.0.0.1:3000/`（或 HTTPS 反代）。托管 Jina 的 API key 只发给你配置的 endpoint，并保存在 DSH credentials。

### web_research 配置

| 字段 | 默认 | 范围 | 说明 |
|---|---:|---|---|
| `enabled` | `true` | boolean | 关闭后模型看不到该工具 |
| `maxResults` | `8` | 1–20 | 返回来源数上限，也是 `max_results` 的上界 |
| `maxFetch` | `8` | 1–10 | 抓取页数上限，也是 `fetch_top` 的上界 |
| `defaultFetchTop` | `3` | 0–10 | 模型未指定 `fetch_top` 时的默认抓取数；不能大于 `maxFetch` |
| `maxCharsPerPage` | `12000` | 1000–200000 | 单页字符上限，也是 `max_chars` 的上界 |
| `maxTotalChars` | `40000` | 1000–500000 | 所有页面合计字符预算；不能小于单页上限 |
| `concurrency` | `3` | 1–8 | 并发抓取数 |
| `timeoutMs` | `120000` | 5000–600000 | 工具超时预算 |

`web_research` 会把模型参数夹到这些边界内，并按 `maxTotalChars` 在多个页面之间分配剩余预算。`defaultFetchTop > maxFetch` 或 `maxCharsPerPage > maxTotalChars` 会在执行前返回 `RESEARCH_CONFIG_INVALID`。某个页面抓取失败不会让整次调用失败；失败页会带 `error` 字段，卡片仍会显示来源和成功抓取数。

> **超时字段说明：** `timeoutMs` 同时进入工具注册的声明超时和 `runResearch` 的内部 signal；到点后搜索/抓取会被取消并返回 `WEB_RESEARCH_TIMEOUT`，不再只是提示词里的展示值。

### 接管配置

接管不是必选项。

| 配置 | 作用 |
|---|---|
| `web.searchProvider = dsh-search-tools` | 内置 `web_search` 使用本插件搜索 provider |
| `web.fetchProvider = dsh-search-tools-fetch` | 内置 `web_fetch` 使用本插件抓取 provider |
| `activation.previousSearchProvider` | 开启接管前记录的搜索 provider，关闭时恢复 |
| `activation.previousFetchProvider` | 开启接管前记录的抓取 provider，关闭时恢复 |

规则：

- 开启搜索接管前，页面会先用已保存配置跑一次搜索测试；失败则不接管。
- 抓取接管的开关不强制先测试；建议先在 **抓取** 页手动测试一个公网页面。
- 关闭接管时，如果之前没有记录 provider，会 unset 对应配置，回到 DSH 默认。
- 停用/卸载 bundle 前先关闭接管；插件在插件详情页会显示接管警告和“关闭接管”入口。

## 模型工具

### 内置 `web_search` / `web_fetch`

这两个是 DSH 自带工具。插件不重新发明它们，只通过稳定 provider 接管底层实现：

- 不开接管：它们继续用 DSH 原来的 provider，本插件不影响；
- 开接管：请求进入本插件的 Dispatcher，再路由到当前 adapter；
- 关闭接管：恢复开启前记录的 provider。

这样模型看到的工具名、参数和权限模型都不变。

### `web_research`

插件注册的模型工具，用于“我要页面内容，而不只是链接”的场景。

参数：

| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `query` | string | 是 | 搜索查询 |
| `max_results` | number | 否 | 返回来源数，受配置 `maxResults` 限制 |
| `fetch_top` | number | 否 | 抓取前 N 条；`0` 表示只返回来源 |
| `max_chars` | number | 否 | 单页字符预算，受配置 `maxCharsPerPage` 限制 |

返回结构：

```json
{
  "query": "example",
  "sources": [
    { "url": "https://example.com", "title": "Example", "snippet": "..." }
  ],
  "fetched": [
    {
      "url": "https://example.com",
      "title": "Example",
      "content": " readable markdown ...",
      "truncated": false
    }
  ],
  "truncated": false
}
```

行为：

- 搜索走当前 `ctx.web.search`；抓取走当前 `ctx.web.fetch`；
- 抓取前 `fetch_top` 条结果，按 `concurrency` 并发；
- HTML 页面会尝试 Readability 提取，再转 Markdown；纯文本直接使用；
- 单页按 `max_chars` 截断，多页共享 `maxTotalChars` 预算；
- 单页失败带 `error`，不影响其他页；
- 会话卡片显示来源、成功抓取数、失败数、字符数和截断标记；
- 外部页面内容是**不可信数据**，不是指令。模型和用户都不应执行页面里的“指令”。

## 安全模型

- **设置路由有认证栅栏。** 所有 `/api/dsh-search-tools/*` 路由都经过 DSH 的 `connection.requestRejection`，未认证请求返回 401/403。
- **请求体有上限。** JSON body 限制 64 KiB，非 JSON 返回 415。
- **SSRF 边界按 adapter 区分。** `readable-http` 复用 DSH 的 `HttpFetchProvider`，内网/保留地址会被拒绝；`wire-http` 在本地 loopback 出口代理中对每个 CONNECT/HTTP 目标和每一跳重定向重新解析 DNS，要求所有答案都是公网，并连接到已验证的 IP（连接级 pinning）；`jina-reader` 在发出前做公网目标守卫，但 Reader 服务端会重新解析 DNS，并且可能跟随目标页重定向/浏览器跳转，因此不能提供与 DSH 相同的连接级 pinning。托管 Jina 时目标 URL 会离开本机；自托管时页面留在 Reader 服务内，但仍应限制 Reader 容器的网络出口。
- **Wire HTTP 不执行页面 JavaScript。** 它模仿浏览器 TLS/HTTP2 协议指纹，不是浏览器；SPA 请用 `jina-reader`。PDF 只在本地用 PDF.js 提取文本，`isEvalSupported: false`，不执行 PDF 内嵌脚本。
- **Wire HTTP worker 没有持久凭据。** 不发送 Cookie、Authorization 或登录态；不登录、不绕过付费墙、不破解验证码。
- **Reader 不是匿名代理。** 托管 Reader 可能缓存页面，`noCache` 只要求实时抓取；自托管也不能绕过登录、付费墙或验证码。
- **搜索重定向不跟随。** SearXNG 请求使用 `redirect: 'error'`，认证头不会跟随跨源重定向。Local Direct 使用 `followRedirects:false` + `redirect:'manual'`，3xx 直接视为该引擎失败。
- **搜索出口按 adapter 区分。** `searxng` 把查询发给用户配置的实例（可私有/loopback）；`local-direct` 只把查询发给启用的固定公网 origin（默认 DuckDuckGo/Bing，可选 Brave/360），没有用户可控 endpoint，不发送 Cookie/Authorization，也不执行 JS。`web_research` 的抓取阶段仍只接受公网页面；搜索结果中的本地/私有 URL 不会被 fetch adapter 读取。
- **凭据不落 YAML。** Bearer/Basic/Jina API key 写入 DSH credentials；YAML 只保存非敏感配置。诊断文本会脱敏，不包含 token。
- **代理不带凭据。** `proxy` 必须是 `http(s)` URL，且不能包含用户名/密码，避免把代理凭据写进配置。
- **错误信息有限。** 实例/目标主机名可能出现在诊断中，但查询字符串和凭据会被去掉。
- **无遥测。** 插件不向作者发送使用数据；SearXNG 搜索请求只发往你配置的实例，Local Direct 请求只发往启用的固定公网引擎，`jina-reader` 请求只发往你配置的 Reader endpoint（托管 Jina 时该 endpoint 是第三方）。Local Direct 的查询文本会到这些引擎，不会发给本站或其他聚合服务。

> 安全边界仍然取决于 DSH 本身和你的部署。不要把设置页暴露在不可信网络；不要在共享 profile 中把密钥当公共配置。

## 适配器开发

> 外部 adapter contract 在 0.1.x 标记为实验性。接口可能在小版本中调整；依赖它时请锁定 `dsh-search-tools` 版本并做集成测试。

一个 adapter 包大致做三件事：

1. 依赖 `ctx.searchTools` 服务；
2. 提供 manifest（设置页渲染用）和 runtime（实际搜索/抓取）；
3. 用 `ctx.effect` 注册，确保卸载时自动清理。

```ts
import type { Context } from '@deepseek-ai/cordis'

export const inject = ['searchTools']

export function apply(ctx: Context): void {
  ctx.effect(
    () => ctx.searchTools.registerSearchAdapter(myAdapter),
    'my-search-adapter',
  )
}
```

`myAdapter` 的结构：

```ts
const myAdapter = {
  manifest: {
    kind: 'search',
    id: 'my-search',
    label: { en: 'My Search', zh: '我的搜索' },
    description: { en: '...', zh: '...' },
    defaults: { endpoint: 'https://search.example.com' },
    capabilities: ['json', 'probe'],
    groups: [
      { id: 'connection', label: { en: 'Connection', zh: '连接' } },
      { id: 'advanced', label: { en: 'Advanced', zh: '高级' }, advanced: true },
    ],
    fields: [
      { kind: 'text', key: 'endpoint', label: { en: 'Endpoint', zh: '地址' }, group: 'connection' },
      { kind: 'number', key: 'timeoutMs', label: { en: 'Timeout', zh: '超时' }, group: 'advanced', unit: 'ms' },
    ],
  },
  validate(rawConfig) {
    return { ok: true, issues: [] }
  },
  create(ctx, getConfig) {
    return {
      available: () => true,
      async search(request, signal) {
        return { sources: [], truncated: false }
      },
    }
  },
}
```

约定：

- 搜索 adapter 的 `id` 必须唯一；`kind` 必须是 `search` 或 `fetch`。
- 配置存在 `config.search.adapters.<id>`（或 `fetch.adapters.<id>`），adapter 自己 normalize/validate。
- `fields` 支持 `text`、`url`、`password`、`number`、`boolean`、`select`、`tags`、`checkboxes`、`instances`；可选 `group`、`advanced`、`unit` 只影响展示，不改变配置结构。`checkboxes` 声明固定 `options`，用 `default` 给出多选默认值。
- `groups` 可选；不声明时页面渲染单个 Configuration 分组。字段的 `group` 不存在时回退到第一个分组，`advanced: true` 的字段自动进入折叠区。
- 需要密钥时用 `secretRefs` 声明引用，值走 DSH credentials，不要写进 manifest defaults；secret 也可声明 `group` / `advanced`。
- 新增 adapter 不需要改设置页代码；页面按 manifest 渲染，声明 groups 后自动获得分组与折叠。
- v0.1.x 不支持 adapter 自带完全自定义的设置 UI；如有需要，可以未来注册自己的 `settings.section`，但那就不再是统一页面的一部分。
- 外部 adapter 的本地化目前以 manifest 的 `label` / `description` 原文为准；内置 adapter 由插件字典提供中英文。

`ctx.effect` 返回 disposer；不要在 `apply` 外保存全局注册状态。重复注册同一 id 会被拒绝。

## 本地开发

```sh
pnpm install --frozen-lockfile --registry=https://registry.npmjs.org
pnpm typecheck
pnpm test
pnpm build
pnpm release
```

`pnpm build` 产出：

```text
lib/index.js        Host 半侧
lib/wire-worker.js  wire-http 单次请求 worker
lib/client.js       浏览器 lazy-CJS factory
lib/types/**        TypeScript 声明
```

测试与检查：

| 命令 | 内容 |
|---|---|
| `pnpm typecheck` | TypeScript 类型检查 |
| `pnpm test` | Vitest：Host 单测、设置页/研究卡片组件测试、pipeline、publint 策略 |
| `pnpm coverage` | 同上 + V8 覆盖率门禁；statements / branches / functions / lines 均不得低于 85% |
| `pnpm build` | Host、浏览器、类型声明 |
| `pnpm pack:check` | build + `pnpm pack --dry-run` + publint |
| `pnpm release` | typecheck + coverage + pack:check |

CI 在 Ubuntu/Windows × Node 22/24 上跑 typecheck、coverage、build、pack dry-run、publint。publint 只忽略 `lib/client.js` 的 `FILE_INVALID_FORMAT`：DSH 把它当浏览器 CJS 工厂加载，不是 Node ESM 入口；其他诊断仍然失败。

### 项目结构

```text
src/core/                 adapter 类型、注册表、稳定 dispatcher、诊断
src/search/adapters/      SearXNG + Local Direct adapter（manifest/schema/parser/fusion/worker）
src/fetch/adapters/       Readability HTTP / Wire HTTP / Jina Reader adapters
src/research/             web_research pipeline、prompt、render、tool
src/host/                 认证的设置路由
src/client/               设置 → 搜索工具 + 研究卡片
src/shared/               config、校验、diff、URL、公网目标守卫、限制
tests/                    Host 单测 + 设置页/研究卡片组件测试
docs/                     架构、设计、M0 验收、发布清单
```

### 为什么用 linkedom 而不是 jsdom

Readability 运行在 Host 内。`jsdom` 的 `tr46` 依赖会调用 `createRequire(...).resolve.paths`，而 DSH 的 profile resolution hook 没有实现它；`linkedom` 更小，也能在 Host 内工作。`jsdom` 只作为组件测试的 dev 依赖。

## 发布流程

发布前确认：

- `package.json` 版本是 `0.1.9`；
- `CHANGELOG.md` 有对应条目；
- `pnpm release` 全绿；
- 工作区干净；
- npm 认证可用，或仓库配置了 `NPM_TOKEN` secret。

本地检查：

```sh
pnpm install --frozen-lockfile --registry=https://registry.npmjs.org
pnpm release
pnpm pack --pack-destination dist
```

打 tag 并推送：

```sh
git tag -a v0.1.9 -m "dsh-search-tools v0.1.9"
git push origin main --follow-tags
```

推送 `v*` tag 会触发 `.github/workflows/release.yml`：

1. 在 Ubuntu/Windows × Node 22/24 上先跑完整验证矩阵（含 `check:package`）；
2. 校验 tag 名与 `package.json` 版本一致，且 tag commit 在 `main` 上；
3. 重复 `pnpm release`；
4. 检查该版本是否已发布，已存在则跳过；
5. 用 `NPM_TOKEN` 发布到 npm（provenance）；
6. 把 tarball 附加到 GitHub Release，并使用 `docs/release-v<version>.md` 作为发布说明。

手动发布（不产生 provenance；正式发布走 tag workflow）：

```sh
npm publish --access public
```

发布后做一次全新 profile 冒烟：安装、启用、分别用 `local-direct` 和 `searxng` 做搜索测试、分别用 `readable-http`、`wire-http` 和 `jina-reader` 做抓取测试、一次 `web_research` 调用。详细清单见 [v0.1.9 发布清单](https://github.com/ThzxxArt/dsh-search-tools/blob/main/docs/release-v0.1.9.md)。

## 故障排查

| 现象 | 可能原因 | 处理 |
|---|---|---|
| 设置页没有“搜索工具” | bundle 未启用；DSH 版本不匹配；客户端加载失败 | 在插件页启用；确认 DSH 在 `>=0.2.1-rc.2 <0.3.0`；看浏览器控制台/插件日志 |
| 搜索测试 401/403 | 实例需要认证；凭据未保存；Basic 格式不是 `username:password` | 在认证字段选择正确类型，保存密钥后重试；确认 `Source: env` 是否符合预期 |
| 搜索测试 429 | 实例/引擎限流 | 降低频率、换实例或引擎、增加缓存 TTL，或改用其他后端 |
| Local Direct 全部失败 | 网络无法访问引擎；引擎返回 202/403/429；解析为空 | 点 **测试搜索引擎** 看可达性；换网络/代理；稍后重试；必要时切回 SearXNG |
| Local Direct 结果偏少 | 只启用了单引擎；每站上限；引擎只返回首页 | 多启用一个引擎；提高 `maxPerHost`/`maxResults`；看 `Search notes` |
| 返回 0 条但测试“成功” | SearXNG 默认引擎被限流/CAPTCHA | 看 **Search notes**；指定 `engines`（例如 `bing`）或换实例 |
| JSON 解析失败 | 实例未启用 JSON 格式 | 在 SearXNG 的 `search.formats` 加 `json`；或把格式设为 `html` |
| `web_research is disabled` | 研究工具关闭 | 设置 → 研究 → 启用 `web_research` 并保存 |
| 研究只返回来源、不抓正文 | `fetch_top=0`；抓取失败；目标页面不可读 | 提高 `fetch_top`；看抓取详情的 `error`；手动在抓取页测试 URL |
| 内网 URL 抓取失败 | `readable-http` 的 DSH SSRF 防护；`jina-reader` 的公网目标守卫 | 预期行为；不要尝试绕过 |
| Jina 抓取返回 401/403 | API key 未配置/无效；匿名 IP 被限流 | 在设置页保存 `JINA_API_KEY`；或改用自托管 Reader |
| Jina 抓取返回 429 | Reader 限流 | 稍后重试、降低频率、配置 key/配额，或改回 `readable-http` |
| Jina 超时 | Reader 浏览器渲染慢；DSH `web_fetch` 30 秒工具超时先到 | 降低 `timeoutMs` 到 25 秒内，或调高 DSH `tool-web.fetchTimeoutMs` |
| 接管后内置搜索报错 | bundle 被停用/卸载但接管仍指向插件 | 重新启用插件，或关闭接管；设置页/插件详情页有恢复入口 |
| 配置保存冲突 | 配置在别处被修改；更高层覆盖 | 点 **重新加载**；检查 profile 的 `cordis.patch.yml` 和更高层 overlay |
| 配置只读 | 被更高层配置覆盖 | 页面顶部会说明；到覆盖层修改，或移除覆盖 |
| 实例 URL 重复 | 规范化后相同 | 去掉重复实例；URL 会去掉末尾 `/search`、query、hash |
| 代理配置报错 | 不是 http(s)，或包含用户名/密码 | 改成无凭据的 `http(s)://host:port` |
| 缓存结果不更新 | TTL 未到 | 在诊断页清空缓存，或等 TTL 过期 |
| 安装后没有 `web_research` | 研究工具关闭；模型未刷新工具列表 | 启用并保存；开新会话或重新加载页面 |
| npm 安装失败 | registry/网络/peer 不匹配 | 检查 DSH 版本、Node 版本、npm registry；用 tarball 离线安装 |

诊断顺序建议：

1. 看 **设置 → 诊断 → 最近错误**；
2. 看 **Search notes**（引擎级错误）；
3. 用 **测试** 复现，不要只看模型回合；
4. 确认是否开启了接管，以及当前 provider 是哪一个；
5. 最后再查 DSH 日志和实例日志。

## 已知限制

- **内置两个搜索 adapter 和三个抓取 adapter。** 搜索默认仍是 `searxng`；`local-direct` 是零部署的本地直连后端，默认查询 DuckDuckGo/Bing，并可选用 Brave Search / 360 公开 HTML，不执行 JS，也不保证引擎永不改版/限流。抓取默认 `readable-http`；`wire-http` 是本地浏览器级 HTTP + PDF 后端；`jina-reader` 是可选 Reader 后端。0.1.x 不附带 Google/Exa 等需要 API key 的商业搜索后端；Brave/360 走公开 HTML 页面，不需要 API key。
- **没有自动更新。** 升级需要卸载再安装。
- **外部 adapter contract 是实验性的。** 0.1.x 可能调整；生产集成请锁版本。
- **浏览器渲染只属于 `jina-reader`。** `readable-http` 和 `wire-http` 都不执行 JS；`wire-http` 强化的是 TLS/HTTP2 指纹、出口 pinning 和 PDF，不是浏览器引擎。Jina 需要公网 Reader（托管或自托管），且不处理登录态、付费墙或验证码。SPA 能渲染不等于能登录。
- **`readable-http` 只处理文本/HTML。** PDF、图片、音视频不会转成正文；`wire-http` 可本地提取 PDF 文本；需要 Office/浏览器渲染时用 `jina-reader`。Jina 的 PDF/Office 转换取决于 Reader 服务能力。
- **`web_research` 不是爬虫。** 只抓前 N 条结果，不做站点遍历、去重图谱或深度抓取。
- **研究提示词预览可复制。** 工具描述保持英文，与模型实际看到的内容一致；预览数值跟随当前草稿。
- **接管是全局 provider 切换。** 它会影响该 DSH profile 中所有使用 `ctx.web` 的调用，不只本插件。
- **缓存是进程内内存。** 重启后丢失；多进程不共享。
- **DSH preview 兼容范围窄。** 当前锁在 `0.2.1-rc.2` 线，升级 DSH 前先跑回归。

## 许可证与第三方组件

本项目使用 MIT 许可证，见 [LICENSE](https://github.com/ThzxxArt/dsh-search-tools/blob/main/LICENSE)。

第三方组件与许可证说明见 [THIRD_PARTY_NOTICES.md](https://github.com/ThzxxArt/dsh-search-tools/blob/main/THIRD_PARTY_NOTICES.md)。主要运行时依赖包括 `@mozilla/readability`、`linkedom`、`cheerio`、`turndown`、`@joplin/turndown-plugin-gfm`、`undici`、`lru-cache`、`normalize-url`、`ipaddr.js`、`impit`（Apache-2.0，平台原生二进制）和 `pdfjs-dist`（Apache-2.0）。`jina-reader` 只调用用户配置的 Reader 服务，不打包 Jina 的代码；自托管 Reader 的许可证与运行要求由 Reader 项目提供。`wire-http` 不打包浏览器、Reader 或 Jina 代码。

---

如果这个插件对你有帮助，欢迎在 GitHub 提 issue 或 discussion。安全问题请不要公开贴 token、实例密码或完整配置。
