# ben-dsh-crawl4ai
基于 [Crawl4AI](https://github.com/unclecode/crawl4ai) 的 DeepSeek Harness 网页抓取提供方,
注册到 web 能力接缝 `ctx.web`。
Crawl4AI 会真正启动浏览器,所以普通 HTTP 抓取只能拿到一个空的 `
` 的页面,
在这里会变成渲染后的 Markdown —— 包括只有 JavaScript 执行后才存在的代码块、表格和懒加载正文。
这是一个**实现型**插件:它只向 `ctx.web` 注册提供方,**不**注册面向模型的工具。`web_fetch`
由 `@deepseek-ai/dsh-tool-web` 拥有(它的 schema、提示词和结果卡片),安装本插件就是让那个
既有工具改用 Crawl4AI 抓取。
| | |
|---|---|
| 提供方 id | `ben-crawl4ai` |
| 注册 | `ctx.web.registerFetchProvider(...)` |
| 配置位置 | 插件页 → 本插件 → `ben-crawl4ai` 行 → 配置 |
| 依赖 | 一个可访问的 Crawl4AI 服务(已对 0.9.x 验证) |
[English](README.md) | 中文
## 安装
```bash
dsh plugin --profile <配置档> add github:you/ben-dsh-crawl4ai # 或绝对路径
```
本包声明了 `dsh.bundle`,所以安装的同时就已启用:它的 bundle patch 会插入提供方行,并在
`web` 行上选中它。
### 就地开发(`link:` 安装需要额外一步)
`dsh plugin add <绝对路径>` 会把依赖记作 `link:<路径>`,于是配置档里的
`node_modules/ben-dsh-crawl4ai` 是指回你工作目录的**目录联接(junction)**。Node 解析模块的
导入标识符时使用的是模块的**真实路径**,因此向上查找 `@deepseek-ai/…` 时从你的工作目录开始,
永远到不了配置档的解析根目录 —— 而从 npm 或压缩包安装的插件由 pnpm 实体化到配置档**内部**,
不存在这个问题。
一条命令即可补齐这份可达性:
```bash
node scripts/link-harness-deps.mjs
```
它把 `peerDependencies` 里声明的每个包,从 harness 自身的安装位置联接进本目录的
`node_modules`,并优先取配置档那一层,好让本插件与 harness **共用同一个模块实例**而不是副本。
改动 `peerDependencies` 后重新执行即可。默认根目录是 `$DSH_PROFILE_DIR/../node_modules`
(harness 会为子进程设置该变量),否则用 `--root ` 指定。
## 配置
全部在**插件页 → 本插件 → `ben-crawl4ai` 行 → 配置**里完成。页面先把输入暂存,点**保存**
才落盘,并且走 shell 自带的设置通道(`ctx.configForms`),所以一次修改对下一次 `web_fetch`
立即生效,无需重启。
| 字段 | 含义 |
|---|---|
| **Crawl4AI 地址** | 服务根地址,例如 `http://crawl4ai.internal:11235`。为空或不是 `http(s)` 时提供方报告为不可用,`web_fetch` 会直接说明,而不是每次调用都失败。 |
| **验证 Header 名称** | 例如 `Authorization` 或 `X-API-Key`。留空则不带验证请求头。 |
| **验证 Header 值** | 作为该请求头的值发送。界面以掩码显示;**留空则不带验证请求头** —— 名称与值必须成对生效。 |
两个请求头字段彼此独立,因此任何请求头名称、或者完全不带验证,都能工作。
这些值存放在当前配置档的补丁文件
(`$DSH_HOME/profiles/<配置档>/cordis.patch.yml`)里,是**明文**。它是抓取后端读取自己的令牌,
不是某个用户的凭据;掩码控件只是不让它出现在屏幕上,而不是不让它出现在文件里。另有两个字段
不出现在页面上,因为那是部署层面的取舍,需要时在 YAML 里改:
| 键 | 默认 | 含义 |
|---|---|---|
| `timeoutMs` | `60000` | 单次抓取的资源兜底超时。请保持在面向模型的工具预算(`tool-web` 的 `timeoutMs`)之下,这样服务变慢会表现为提供方失败。 |
| `maxContentChars` | `200000` | 单页返回的 Markdown 上限;超出时结果会置 `truncated`。 |
补丁行会**整体替换**它命中的那一行的 `config`,所以要保留的键都必须重述。
## 必须显式选中本提供方
bundle patch 会设置 `web.fetchProvider: ben-crawl4ai`,这不是客套:
`@deepseek-ai/dsh-web-fetch-http` 与本插件同时挂载,而它的 `available()` 恒为 `true`,于是
**同时存在两个可用**的抓取提供方。接缝只在恰好一个可用时才自动选择,否则每次调用都会以
`WEB_PROVIDER_AMBIGUOUS` 失败。
补丁会整体替换它命中那一行的 `config`,而配置档补丁在所有 bundle 层之后应用 —— 所以如果你
也在 `web` 行上钉了 `searchProvider`,请在那里一并重述 `fetchProvider: ben-crawl4ai`。
## 服务端契约
一次抓取就是 `POST /crawl`,请求体 `{"urls": [""]}` 加配置的请求头。应答是
Crawl4AI 的 `{ success, results: [ … ] }`;取第一条结果的最终地址(优先 `redirected_url`,
其次 `url`)、目标自身的 `status_code`,以及它的 `markdown` 对象 —— 其中优先 `fit_markdown`,
而当 `fit_markdown` 为**空字符串**时回退到 `raw_markdown`(未请求 fit 过滤时服务端就返回
`fit_markdown: ""`)。
对于不提供 `/crawl` 的服务端(较老的版本,或只暴露部分 API 的反向代理),会得到 404/405,
此时自动改用 `/md` 重试一次:内容相同,但没有目标状态码和最终地址。
**目标页面返回非 2xx 属于结果而不是错误** —— 状态码是被抓取资源的一部分,会连同 Crawl4AI
抽到的正文一起返回。只有"根本没能取到页面"才抛错。
## 错误
失败都是 `WebError`,由工具层渲染进工具结果。
| 情形 | 代码 |
|---|---|
| 调用方取消 | `WEB_ABORTED` |
| `timeoutMs` 用尽 | `WEB_FETCH_TIMEOUT` |
| 请求的 URL 不是绝对的 `http(s)` | `WEB_INVALID_URL` |
| 未配置 Crawl4AI 地址 | `WEB_PROVIDER_ERROR`(且 `available()` 为假) |
| 服务端拒绝(401/403) | `WEB_PROVIDER_ERROR`,并指明验证请求头 |
| 服务端不可达、5xx,或返回非 JSON | `WEB_PROVIDER_ERROR` |
| 服务端没有产出结果或正文 | `WEB_PROVIDER_ERROR` |
## 测试
```bash
node --test test/crawl4ai.test.mjs test/client.test.mjs # 离线
```
`crawl4ai.test.mjs` 用桩 `fetch` 覆盖 HTTP 契约与映射。`client.test.mjs` 用桩模块加载器执行
浏览器端 bundle,因此多余的 `@deepseek-ai/*` 导入、写错的组件名、或与插入行不一致的 slot key
都会在这里失败,而不是弄坏整个 Web UI。
另外两个套件需要真实服务,缺省时跳过:
```bash
$env:CRAWL4AI_URL='https://ca.example:1443'
$env:CRAWL4AI_AUTH_HEADER_NAME='Authorization'
$env:CRAWL4AI_AUTH_HEADER_VALUE='Bearer …'
node --test test/seam.test.mjs # 注册进真实的 ctx.web 并抓取
node --test test/crawl4ai.test.mjs # 含一次真实抓取
```
`seam.test.mjs` 是端到端的那个:它挂载 `@deepseek-ai/dsh-web`,应用本插件,并通过接缝派发一次
抓取,覆盖按 id 选择、不可用门禁,以及"一次 volatile 配置修改对紧接着的下一次调用就生效"。
## 已知限制
- **只返回 Markdown。** 接缝的正文类型只有 `html` 与 `text`;本提供方始终返回 `text`,于是
`dsh-tool-web` 的 HTML→Markdown 那一步被跳过了,而不是被重复执行。
- **不暴露抓取调参。** `crawler_config` 没有开放:扫描深度、缓存、过滤、截图都是 Crawl4AI 的
能力,而接缝的请求结构没有地方放它们。
- **页面缓存由 Crawl4AI 服务端掌握。** `cache` 保持默认,因此重复抓取可能命中服务端缓存。
- **`/md` 回退路径丢失目标状态码。** 走回退时,成功转换一律按 HTTP 200 报告。
## 许可
MIT