# dsh-web-search-firecrawl

[English](README.md) | 中文

由 [Firecrawl](https://firecrawl.dev) 支持的搜索提供方，用于 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的 web 能力 seam（`ctx.web`）：让内置的 `web_search` 工具走 Firecrawl 搜索 API，而不是随产品附带的 DeepSeek 路由。

本插件是一个 Cordis 函数插件，向 seam 注册 `WebSearchProvider`（`id: firecrawl`）——它不拥有 `ctx.web`，也不注册面向模型的工具（工具 schema 仍属于 `@deepseek-ai/dsh-tool-web`）。它调用 Firecrawl 的 `POST /v1/search` 端点，把扁平 `data[]` 映射为 seam 规范化的 `WebSearchResult`。

## 环境要求

- DeepSeek Harness 安装在官方包 `0.1.0-rc.6` 线上（本插件 peer 依赖 `@deepseek-ai/dsh-web ^0.1.0-rc.6` 等——当前发布线）。更旧线上 pnpm 会在 profile 中嵌套安装第二份 seam 包，错误分类会降级（跨副本 `WebError`）；可在 profile 目录执行 `npm ls @deepseek-ai/dsh-web` 检查。
- Firecrawl API 密钥。提供方从 `$FIRECRAWL_API_KEY` 或下方 `apiKey` 配置读取（见 [API 密钥](#api-密钥)）。可在 [firecrawl.dev](https://firecrawl.dev) 获取。

## 安装

以下步骤假设 `dsh` 命令在 PATH 上（例如通过 `npm install -g @deepseek-ai/dsh` 安装）。如果你用其他方式运行 dsh——不安装、直接用 npx，或从源码仓库运行——请把下文所有 `dsh ...` 换成对应的调用方式；CLI、profile 与插件必须来自同一个安装：

| `dsh` 的来源 | 安装本插件的命令 |
|---|---|
| 全局安装（`npm install -g @deepseek-ai/dsh`） | `dsh plugin --profile web add -w @yangzhe1003/dsh-web-search-firecrawl` |
| 不安装，通过 `npx` 运行 | `npx @deepseek-ai/dsh plugin --profile web add -w @yangzhe1003/dsh-web-search-firecrawl` |
| deepseek-harness 源码 checkout | `pnpm dsh plugin --profile web add -w @yangzhe1003/dsh-web-search-firecrawl`（在仓库根目录执行） |

1. **安装 bundle**（需要 PATH 上有 pnpm；pnpm 9.x 到 11.x 均可，原因见下方 `-w` 说明）：

   ```sh
   dsh plugin --profile web add -w @yangzhe1003/dsh-web-search-firecrawl
   ```

   `dsh plugin` 会在 profile 目录内转发给 pnpm，并自动把 bundle 追加到 profile 的层叠列表。profile 是单包 pnpm workspace（其 `pnpm-workspace.yaml` 声明 `packages: [.]`），因此 `-w` 明确指向 profile 自己的 manifest：pnpm <10 会以 `ERR_PNPM_ADDING_TO_ROOT` 拒绝不带 `-w` 的 `add`，pnpm ≥10 两种写法都接受。该 bundle patch 插入 `web-search-firecrawl` 插件行，并把 `web` seam 行切换为 `searchProvider: firecrawl`（替换 base bundle 的 `deepseek-official`）。

2. **提供 API 密钥**——临时或持久化，见 [API 密钥](#api-密钥)。

3. **重启 `dsh web`**——bundle 层在启动时组合，运行中的实例不会加载新安装的 bundle。请完全退出当前 `dsh web` 进程，然后用与安装时相同的调用方式重新启动（`dsh web`、`npx @deepseek-ai/dsh web` 或 `pnpm dsh web`）：

   ```sh
   dsh web
   ```

4. **验证**——在会话中让 agent 执行一次网页搜索，请求会走 Firecrawl；也可用 `dsh --profile web --dump-config` 查看组合后的配置树。密钥缺失时搜索会以 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 失败。

想保留 DeepSeek 搜索为默认、仅按实例启用 Firecrawl：在你自己的 profile `cordis.patch.yml` 中改写 `web` 行即可——每行最后一次写入生效。base bundle 固定了 `searchProvider: deepseek-official`，因此 `$DSH_WEB_SEARCH_PROVIDER` 只在 `web` 行未设置 `searchProvider` 时生效：用环境变量选择时把 `web` 行改写为空 config（`- id: web` + `config: {}`），要固定 DeepSeek 则写 `searchProvider: deepseek-official`。

## API 密钥

二选一即可：

**临时**——启动 `dsh` 前在 shell 中 export，仅当前终端会话有效：

```sh
export FIRECRAWL_API_KEY=fc-...
dsh --profile web
```

**持久化**——写入 `$DSH_HOME/.env`（默认 `~/.dsh/.env`），每行一个裸 `KEY=value`（不带 `export` 前缀）；`dsh` 每次启动都会读取，重启后依然有效。请保持文件私密：

```sh
echo 'FIRECRAWL_API_KEY=fc-...' >> ~/.dsh/.env
chmod 600 ~/.dsh/.env
```

## 配置

| 配置键 | 默认值 | 含义 |
|---|---|---|
| `apiKey` | `$FIRECRAWL_API_KEY` | Firecrawl API 密钥。为空或缺失时提供方不可用。 |
| `baseURL` | `https://api.firecrawl.dev` | 端点基址；追加 `/v1/search`。无法解析时提供方不可用。 |
| `limit` | （未设置） | 请求不含 `maxResults` 时使用的默认结果数。未设置时不发送默认值。必须是正整数。 |
| `maxSnippetChars` | `600` | 将单个 `description` 映射为 `snippet` 时保留的字符上限。必须是正整数。 |

```yaml
# profile cordis.patch.yml
- id: web-search-firecrawl
  name: '@yangzhe1003/dsh-web-search-firecrawl'
  config:
    apiKey: !!js process.env.FIRECRAWL_API_KEY
```

## 映射

Firecrawl 返回扁平 `data[]`，不返回生成答案，因此省略 `content`。每项条目映射为 `WebSearchSource`：`url` ← `url`、`title` ← `title`、`snippet` ← 修剪并按 `maxSnippetChars` 限长的 `description`（没有非空 description 的条目仍保留可引用的仅 URL 来源）。请求的 `maxResults` 优先于已配置的默认 `limit`，并作为 Firecrawl `limit` 发送，以优化成本和延迟；最终上限由 seam 强制执行。提供方失败（HTTP 错误、网络失败、响应体无法解析或结构不符、`success: false` 信封）以 `WebError` `WEB_PROVIDER_ERROR` 呈现；中止请求以 `WEB_ABORTED` 呈现。HTTP 重定向会在访问 `Location` 指向的目标之前被拒绝。

## 模型体验

通过 `@deepseek-ai/dsh-tool-web` 间接影响：模型看到经 `maxResults` 限制的 URL、标题与限长后的描述，以及消费方错误包装层内的 `Firecrawl search aborted`、`Firecrawl search request failed: <error>` 和 `Firecrawl returned an unprocessable response body: <error>`。生成答案与提供方私有字段不进入上下文。

## 已知限制

- **snippet 是 Firecrawl 的 `description` 摘录，而非搜索引擎摘要**：可能是较长的页面 markdown，因此提供方将其限制为 `maxSnippetChars`；没有 description 的条目完全不带 snippet（仅 URL）。
- **只公开 `limit`／`maxSnippetChars`**：Firecrawl 的其他控制项（语言、国家／地区、时效、位置、定向、抓取选项）等待 `@deepseek-ai/dsh-web` 中提供方无关的 Service Definition 字段。
- **按错误形状分类中止**：只有 `DOMException` 且名为 `AbortError` 时才映射为 `WEB_ABORTED`；携带自定义原因的中止会呈现为 `WEB_PROVIDER_ERROR`。

## 开发

```sh
pnpm install
pnpm run typecheck
pnpm test                            # 单元测试（不联网）
FIRECRAWL_API_KEY=... pnpm run test:e2e  # 真实 API 冒烟，无密钥时自动跳过
pnpm run build                       # 产出 lib/（ESM）+ lib/types/（d.ts）
```

### 发布

推送 `v<semver>` 格式的 tag 会自动把该提交发布到 npm（`.github/workflows/publish.yml`）：tag 名即发布版本，因此

```sh
git tag v0.1.3 && git push origin v0.1.3
```

会构建、测试并发布 `0.1.3`（带 SLSA provenance）。发布使用 npm 的 **Trusted Publishing**（OIDC，无需保存任何 npm token）。需要先在 npmjs.com 的包设置页（`https://www.npmjs.com/package/@yangzhe1003/dsh-web-search-firecrawl` → Settings → **Trusted Publisher**）添加信任发布方：

- CI/CD 提供方：**GitHub Actions**
- Organization or user：`yangzhe1003`
- Repository：`dsh-web-search-firecrawl`
- Workflow filename：`publish.yml`
- Environment name：`publish`
- Allowed actions：`npm publish`

工作流引用的 GitHub environment `publish` 会在首次运行时自动创建。Trusted Publishing 要求 npm CLI ≥ 11.5.1、Node ≥ 22.14——工作流的 Node 24 运行器自带 npm 11.13，开箱即满足。

## 许可证

MIT
