🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

网页检索抓取套件 liuda1999/dsh-web-kit

DeepSeek Harness
liuda199924 天前有提交已提供中文文档扫描:含敏感能力

免密钥搜索网页并安全抓取正文,可建本地索引

30/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/10 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add liuda1999/dsh-web-kit
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:需留意
是什么
dsh 原生插件 · browser
装得上吗
实装验证未通过(unknown),装前请到仓库确认最近更新与 issue
L4 · 真实安装等级说明 →
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 24 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
⚠︎ 实装验证未通过(unknown · 2026/11/3) ——可能是验证环境差异,装前建议到 GitHub 仓库确认最近更新与 issue。
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

读写/删除本地文件发起外部网络请求
查看扫描证据(共 13 处,展示前 13 条)
  • 发起网络请求
    scripts/check.mjs:78
    const { value, error, ms } = await timed(() => provider.fetch({ url: FETCH_URL }))
  • 读取本地文件
    scripts/extract-report.mjs:19
    const load = (name) => readFileSync(fileURLToPath(new URL(name, fixtures)), 'utf8')
  • 读取本地文件
    scripts/push-github.mjs:34
    for (const entry of readdirSync(dir)) {
  • 读取本地文件
    scripts/push-github.mjs:37
    if (statSync(full).isDirectory()) walk(full, out)
  • 请求外部主机api.github.com
    scripts/push-github.mjs:44
    const response = await fetch(`https://api.github.com${path}`, {
  • 发起网络请求
    scripts/push-github.mjs:44
    const response = await fetch(`https://api.github.com${path}`, {
  • 读取本地文件
    scripts/push-github.mjs:73
    const content = readFileSync(file)
  • 读取本地文件
    scripts/release-github.mjs:21
    const { version } = JSON.parse(readFileSync(ROOT + 'package.json', 'utf8'))
  • 请求外部主机api.github.com
    scripts/release-github.mjs:98
    const response = await fetch(`https://api.github.com${path}`, {
  • 发起网络请求
    scripts/release-github.mjs:98
    const response = await fetch(`https://api.github.com${path}`, {
  • 读取本地文件
    scripts/release-github.mjs:127
    const asset = readFileSync(assetPath)
  • 请求外部主机uploads.github.com
    scripts/release-github.mjs:128
    const upload = await fetch(`https://uploads.github.com/repos/${OWNER}/${REPO}/releases/${release.id}/assets?name=dsh-web-kit-${version}.tgz`, {
  • 发起网络请求
    scripts/release-github.mjs:128
    const upload = await fetch(`https://uploads.github.com/repos/${OWNER}/${REPO}/releases/${release.id}/assets?name=dsh-web-kit-${version}.tgz`, {
扫描于 2026/11/4(0 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库
涉及外部主机:api.github.com、uploads.github.com

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

功能亮点

  • 免密钥搜索网页
  • 安全抓取正文
  • 建本地BM25索引
展开完整介绍(144 字)↓

面向 Node.js 和 TypeScript 的独立 web_search / web_fetch,从 DeepSeek Harness 中抽离。无需 API key:默认抓取 Bing,带真实浏览器兜底,还有防 SSRF 的抓取、分层内容过滤,以及针对已抓内容的本地 BM25 索引。

README

由 DeepSeek 最新模型翻译生成
dsh-web-kit 面向 Node.js / TypeScript 的独立 web_search / web_fetch 能力,从 DeepSeek Harness(deepseek-harness v0.2.1-alpha.1,MIT)中提取。 没有 Cordis。没有 DeepSeek 运行时。服务商就是普通对象,运行时就是普通类,工具就是 JSON Schema 加一个处理函数,任何智能体框架都能注册。 每个文件来自哪里、改了什么,见 ATTRIBUTION.md。 安装 from the packed tarball (single machine, no registry) npm install ./dsh-web-kit-0.8.0.tgz or drop the folder in and link it npm install /path/to/dsh-web-kit optional: enables browser rendering. Uses Edge/Chrome if installed. npm install playwright Node ^22.19 || >=24。仅支持 ESM。playwright 是可选依赖——不装也能跑,只是少了浏览器后端。
展开完整文档(README 剩余 1 节)
dsh-web-kit

面向 Node.js / TypeScript 的独立 web_search / web_fetch 能力,从 DeepSeek Harness(deepseek-harness v0.2.1-alpha.1,MIT)中提取。

没有 Cordis。没有 DeepSeek 运行时。服务商就是普通对象,运行时就是普通类,工具就是 JSON Schema 加一个处理函数,任何智能体框架都能注册。

每个文件来自哪里、改了什么,见 ATTRIBUTION.md。

安装

from the packed tarball (single machine, no registry)
npm install ./dsh-web-kit-0.8.0.tgz

or drop the folder in and link it
npm install /path/to/dsh-web-kit

optional: enables browser rendering. Uses Edge/Chrome if installed.
npm install playwright

Node ^22.19 || >=24。仅支持 ESM。playwright 是可选依赖——不装也能跑,只是少了浏览器后端。自己打包 tarball:

npm install && npm run build && npm pack # -> dsh-web-kit-0.8.0.tgz

快速开始

import { createWebKit } from 'dsh-web-kit'

// Zero config: scrape Bing over plain HTTP, backed by Bing in a real browser if
// HTTP ever stops answering. No API key, no server, and no Playwright install
// needed to work.
const kit = createWebKit()

// Once you run SearXNG locally, switch to it — it is the stable free option. // const kit = createWebKit({ search: { kind: 'searxng', options: { baseUrl: 'http://localhost:8080' } } })

const [search, fetch] = kit.tools

const out = await search.handler({ queries: ['deepseek harness web capability'] })
console.log(out.text)

const page = await fetch.handler({ url: 'https://example.com' })
console.log(page.text)

注册到你的框架:

for (const def of kit.tools) {
myAgent.registerTool({
name: def.name,
description: def.description,
schema: def.parameters,
run: def.handler, // (args, { signal }) => Promise<ToolOutcome>
})
}

ToolOutcome 是 { text, isError, value?, meta?, code? }。失败时 text 为 Error: <message>,code 带上可机器路由的 WebError 错误码。

把信任段落加进你的系统提示词——正是它们拦住抓回来的文本,不让它被当成指令读:

import { webToolPromptSections } from 'dsh-web-kit'
const sections = webToolPromptSections({ search: true, fetch: true })

嵌进你自己的智能体——每个进程一个 kit,每条退出路径都调 dispose(),取消,转成 OpenAI 兼容的 tools,写到哪里,还有那些坑:见 EMBEDDING.md。可运行的宿主在 examples/:minimal-host.mjs(离线)、openclaw-tool-host.mjs(真实搜索 + 抓取 + 生命周期)、openai-function-calling.mjs。

哪些引擎真的能用(2026-10-10 实测,中国大陆网络)

下面每个后端都为这个包做过实机探测——HTTP 状态、响应大小、结果块能不能在服务端渲染的 HTML 里活下来,还有重复查询是否还能继续用。「可用」的标准是:解析器提取到了真实结果,带真实目标 URL,而不只是请求返回了 200。

| 引擎 | 实测结果 | 本包中的状态 |
| Bing cn.bing.com | 200,9–10 条结果,约 25 次请求、3 分钟以上保持稳定。中英文查询都能用。 | ✅ bing-html — 默认 |
| 360 搜索 www.so.com | 前约 5 次查询返回 200 和 3–6 条结果(data-mdurl 给出真实 URL,无需跳转)——之后每次请求都返回 6 KB 的 访问异常页面 限流页;3 分钟后仍然被封,带上 Referer 也一样。 | ⚠️ so360-html — 仅作兜底 |
| 百度 www.baidu.com(纯 HTTP) | 200,但只能提取 4 个标题,全部藏在 baidu.com/link?url= 跳转包装后面;类名是构建哈希(title_4tuoE、cosc-title),每次前端发版都会变;加上 &rn=20 后页面塌缩到 1,438 B,零结果。 | ❌ 没有浏览器就没法用 |
| 百度 www.baidu.com(真实浏览器) | 8–11 条结果,带真实目标 URL——每一行都在 mu 属性里带着真实目标。没有 mu 的行不会被选中,这能去掉大部分推广位——但不是全部:见下面的推广位注意事项。 | ✅ browser(engine: 'baidu') |
| 搜狗 PC www.sogou.com | 302 直接跳到 /antispider。 | ❌ |
| 搜狗 移动 m.sogou.com | 200,340 KB——但零外部锚点;结果藏在 JSON/JS 数据块里,需要渲染器。 | ❌ |
| 头条搜索 so.toutiao.com | 200,2 MB——但 0 个 、0 个结果锚点;JS 渲染。 | ❌ |
| 神马 m.sm.cn | 200,有一些真实锚点,但结果标记藏在 JS 模板和「未找到相关结果」分支里;稳定性未验证。 | ❌ |
| 夸克 quark.sm.cn | 200,但只有约 6 条结果,时间筛选控件占满了标记。 | ❌ |
| Yandex | 跳转到 SmartCaptcha。 | ❌ |
| Mojeek / Startpage / Brave / searx.be | UND_ERR_CONNECT_TIMEOUT——当前网络不可达。 | ❌ |
| DuckDuckGo html.duckduckgo.com | UND_ERR_CONNECT_TIMEOUT——当前网络不可达。 | ⚠️ ddg-html 保留给非中国用户 |
| SearXNG(自托管) | 这里没部署。它是唯一既不靠抓取也不付费的方案。 | ✅ searxng — 长期推荐 |

同样的引擎走真实浏览器

浏览器只对一个引擎改变了局面,仅此一个。用 Playwright 驱动 Edge 重新实测,JS 已执行、cookie 已设置:

| 引擎 | 纯 HTTP | 真实浏览器 |
| Bing cn.bing.com | 9–10 条结果,可用 | 四个不同查询都是 10/10,每一行都有标题 + URL + 摘要 |
| 百度 www.baidu.com | 4 行,全部是跳转包装 | 8–11 行,真实 URL(例如 https://github.com/deepseek-free/...) |
| 搜狗 www.sogou.com | 302 → /antispider | 仍然 → /antispider,即使先访问了首页也一样 |
| 360 www.so.com | 约 5 次查询后被限流 | 跳转到 qcaptcha.so.com |
所以浏览器会买下 Baidu,而纯 HTTP 根本用不了它。Sogou 和 360 无论如何都被挡着——它们是没实现,而不是发出去就是坏的。

浏览器路径的成本,实测

两条路径都是抓 Bing,所以有意思的问题是:浏览器买到了什么,又付出了什么代价。各跑五条查询,同一台机器,同一个网络:

| 路径 | 成功 | 中位延迟 | 与另一条路径在首位结果上一致 |
| bing-html(HTTP) | 5/5 | 149 ms | 5 条中 4 条 |
| browser:bing | 5/5 | 5,558 ms | 5 条中 4 条 |

慢了 37 倍,答案却没更好。 这就是默认用 http-first 的原因:HTTP 能用就用 HTTP,抓取坏了再退回浏览器。模型在等工具调用时,每一毫秒都要付。

Baidu 版位的坑

Baidu 的行靠 mu 属性来选,没有 mu 的行根本不会被选中——所以「版位在结构上被排除」以前是对的。现在不对了。用 browser:baidu 采样五条查询,http://28608.recommend_list.baidu.com 五次里有三次排第一:那个推荐位确实有 mu,而且它指向 recommend_list 子域,不是 baidu.com/s? 也不是 /link?,所以现有两道检查都放它过去,看起来跟正常来源一模一样。这些主机和 Baidu 的广告技术主机现在都被 isEngineLink 丢掉了,所以 browser:baidu 返回的行比以前少两行——是版位走了,不是结果丢了。

结论: 在中国大陆做单机个人部署,Bing 是唯一一个实测既好解析又扛得住、还不需要任何凭据的后端,而且有两条独立路径(纯 HTTP 和真实浏览器),丢一条不会让你一无所有。真实浏览器能在它之上再加一个 Baidu,坑如上所述。so360-html 能短时间用,值得串在后面当兜底。其余实测过的,要么被挡,要么需要 JavaScript,要么根本连不上。

推荐:串起来

import { createWebKit, BingHtmlSearchProvider, So360HtmlSearchProvider, withFallback } from 'dsh-web-kit'

const kit = createWebKit({
search: {
kind: 'custom',
provider: withFallback(
new BingHtmlSearchProvider(),
new So360HtmlSearchProvider({ timeoutMs: 15_000 }),
),
},
})

withFallback 按顺序试服务商,把「返回零个来源」当成失败——抓取悄悄坏掉时正是这个表现,所以往坏处读才是对的。

浏览器自动化

浏览器后端驱动真实浏览器(Playwright),不用 HTTP 客户端。它存在的理由只有一个,而且是实测出来的:走纯 HTTP,Baidu 只给你 baidu.com/link?url= 这种跳转壳,只有真实浏览器能解开。 第二个好处是扛得住——浏览器拿到的页面跟人拿到的一样,所以 JS 渲染的结果布局不成问题。

import { createWebKit } from 'dsh-web-kit'
// 默认:通过 HTTP 抓取 Bing,如果哪天不再响应,就回退到浏览器。
// 等同于 { kind: 'http-first' }。
const kit = createWebKit()

// 显式选择:
createWebKit({ search: { kind: 'browser', options: { engine: 'baidu' } } })
createWebKit({ search: { kind: 'http-first', options: { engine: 'baidu' } } }) // 以 Baidu 为兜底
createWebKit({ search: { kind: 'browser-first' } }) // 更慢;见下方说明

代价,直说:

- Playwright 是可选的。 没安装 → 浏览器服务商抛出类型化的 WEB_PROVIDER_UNAVAILABLE,http-first 回退到浏览器。
其他部分不受影响。
- 浏览器解析顺序是 msedge → chrome → Playwright 自带的 Chromium。在 Windows 上你几乎总是已经有 Edge,所以没什么要下载的。如果没有:npx playwright install chromium。
- 延迟:差距不是一点点。在中国网络下,用两条路径各采样五个查询:通过 HTTP 抓取 Bing 的中位响应时间是 149 ms,同样的查询走浏览器是 5,558 ms —— 37 倍。五个查询里有四个,两者给出的首位结果一致。正是这个测量结果,让默认配置把 HTTP 放在前面,把浏览器留作回退。
- Baidu 的推广位:大部分是结构性的,剩下的需要一条规则。 行会把真实目标放在 mu 属性里,没有 mu 的行永远不会被选中 —— 大多数广告和所有 baidu.com/link?url= 包装链接,就是这样在过滤器运行之前就消失了。本来故事到这里就结束了,直到做了采样:用浏览器驱动 Baidu 跑五个查询,http://28608.recommend_list.baidu.com 五次里有三次排在第一。它有 mu,而 mu 指向的地方既不是 baidu.com/s? 也不是 /link?,于是它顺利通过了两道现有检查,看起来完全像一个来源。这些推荐子域名和 Baidu 的广告技术主机,现在由 isEngineLink 丢弃(见回归测试),所以 engine: 'baidu' 返回的行数比以前少两行 —— 少的是推广位,不是结果。
ts
import { BrowserSearchProvider } from 'dsh-web-kit'

const provider = new BrowserSearchProvider({ engine: 'baidu', timeoutMs: 60_000 })
const { sources } = await provider.search({ query: 'deepseek 本地部署', maxResults: 5 })
await provider.dispose() // closes the browser this provider owns

内容过滤

抓取到的结果页和拉取到的文章,都带着不是答案的材料。内置两个过滤器,默认都开启 —— 而且都刻意做得很弱,因为丢掉一个真实来源,代价比看到一个差来源更大。

没有内置的站点黑名单

值得直说,因为「过滤」通常意味着有这么个东西:

- blockedHosts、blockedKeywords、allowedHosts 默认都是空的。
你不点名,就什么都不拦。它们是你的策略,不是我们的。
- 唯一硬编码的方向,是九条匹配搜索引擎自身的重定向与导航路径,以及它的位置和广告技术主机(主机和路径都参与匹配),例如 baidu.com/link?url=… 和 28608.recommend_list.baidu.com 会被丢弃,而 baike.baidu.com/item/… 会保留。
- 页面提取有一份 27 个词的 class/id 片段列表,但它在默认级别下不运行——见下文。

其余内容要么是结构上确定的(抓取器标记出的广告行),要么是软性的:记录下来,排在最后,并报告给你。

结果过滤——三种结果,不是两种

每条规则解析为 keep、demote 或 drop。被降级的行仍会到达模型,只是排在所有干净行之后——所以默认级别在糟糕的日子里只会让你损失排名,绝不会让你损失内容。随后 maxResults 截断会先丢弃被降级的行,而它们从未被隐藏过。

| 规则 | off | loose (默认) | balanced | strict |
| invalid-url | keep | drop | drop | drop |
| sponsored(抓取器标记为付费的行) | keep | drop | drop | drop |
| engine-link(重定向包装 / 自身导航) | keep | drop | drop | drop |
| duplicate-url(剥离 utm_ 等之后) | keep | drop | drop | drop |
| duplicate-title(≥ 8 字符;更短永不匹配) | keep | demote | demote | drop |
| no-title | keep | keep | demote | drop |
| short-snippet、host-cap、low-score | keep | keep | demote | drop |
| blocked- / not-allowed-host(仅当你设置它们时) | keep | drop | drop | drop |
ts
import { filterSources, survived } from 'dsh-web-kit'

const outcome = filterSources(rows) // level 'loose'
outcome.kept // clean rows
outcome.demoted // suspicious rows — still returned, behind the clean ones outcome.dropped // removed, each with a reason
survived(outcome) // kept ++ demoted, i.e. exactly what the model will see

没有任何内容被静默移除:withFilter(provider) 会在结果的 content 字段中报告各项计数。静默移除会与「网上无话可说」无法区分——而这正是本包要避免的失败模式。

想只反对某一条规则而不改变级别: ts
filterSources(rows, { level: 'strict', rules: { sponsored: 'demote' } })

scoreSource 通常只对行排序;在你设置 minScore 或切换到 balanced/strict 之前,它不会移除任何内容。英文关键词按词边界匹配,所以关键词 ad 不会命中单词 "read"。

页面过滤——四种模式,以及防止过度裁剪的防护

每种模式决定哪些类别的内容可以被丢弃。除非该决定允许,否则不会移除任何内容,所以更严格的模式只会丢失更宽松模式本就愿意丢失的内容。

| 模式 | 移除 | 裁剪 | class/id 猜测 | 图片 | 表单 | 媒体 | maxLossRatio |
| full | 仅非渲染标签 | 否 | 否 | keep | keep | keep | — |
| gentle (默认) | + + 广告位 | 是 | 否 | 保留 | 保留 | 保留 | 0.65 |
| standard | 同上 | 是 | 是 | 保留 | 保留 | 丢弃 | 0.65 |
| aggressive | 同上 | 是 | 是 | 丢弃 | 丢弃 | 丢弃 | 0.9 |

最后三列之所以存在,是因为有些页面类型把内容放在文本节点之外,剥掉标签就等于把内容本身抹掉:

- 表单 —— 、、:一份设置参考手册用控件渲染出来,主体基本都是表单。
- 图片 —— :一张图唯一的说明。
- 媒体 —— SVG 的 <text> 标签、、、canvas 的兜底文本:架构图和嵌入式示例就靠这些传递信息。

以前,一个完全由表单控件搭成的页面,返回的只是几个孤零零的段落,所有值都不见了 —— 而且每一种模式都这样,包括 full。full 明明宣称「不裁剪、不猜测」,却用一份写死的丢弃清单把它们删掉了。这个问题已经修好:full 现在真的只删那些无法渲染文本的标签。
ts
extractMainContent(html) // gentle: keeps Related Work
extractMainContent(html, { mode: 'standard' }) // drops it
extractMainContent(html, { mode: 'full' }) // nothing cropped, ever
extractMainContent(html, { mode: 'aggressive', includeForms: true }) // but keep the values

隐藏输入框永远不恢复 —— 是框架存放 CSRF token 的地方,读它会把密钥同时复制进模型上下文和本地索引。密码字段同理。详见 UNREADABLE_INPUT_TYPES。

npm run report 会把每一种模式跑遍 tests/fixtures/complex-.html 里的每个 fixture,并写出 EXTRACT-REPORT.md。

丢失防护。 当胜出的区块所持有的文本,不足页面非链接文本的 1 - maxLossRatio 时,就彻底放弃裁剪;你拿到的是清洗后的页面,外加 fallback: 'loss-ratio' 说明原因。丢掉样板是优化;丢掉正文是 bug。

在真实页面上实测(raw 是截断后的 HTML,cleaned 是清洗后留下的内容,gentle 是最终送到模型的内容):

| 页面 | raw | cleaned | gentle | retained | picked |
| juejin.cn 文章 | 100,000 chars | 6,540 | 5,803 | 0.97 | article |
| developer.baidu.com 文章 | 100,000 chars | 7,530 | 7,164 | 0.99 | main |
| example.com | 577 chars | 171 | 171 | 1.00 | fell back* |

测量过程中发现两个真实缺陷:

- 一段 50 KB 的 window.__NUXT__= 载荷被当成正文读了。 的内容里含有 <、&&,还有 a<b 这类比较,会扰乱标签栈;更糟的是,100 KB 的抓取上限经常把它们从中间截断,于是根本没有闭合标签。现在原始文本子树改用专门的正则清扫,包括未闭合的情况(<(script…)>…$)。juejin 那篇文章的 full 输出从 58,737 字符降到 6,540 字符,retained 也从荒唐的 0.10 变成真实的 0.97。
- 丢失防护从未触发。 扫描的总数算错了——一份平衡的文档没有任何东西可以累加——导致分母为零,Math.max(1, …) 把它变成了「100% 保留」。加一个哨兵根帧就能修好,防护现在真正会触发了(有一个回归测试,用的是一张链接密集的页面,密度启发式在那里会选错)。

本地索引(搜索你已经抓取过的内容)

启用 index 后,每个抓取过的页面都会被存下来,离线也能用 BM25 搜索。不需要服务,不需要 API,什么都不离开本机。
ts
import { createWebKit } from 'dsh-web-kit'

const kit = createWebKit({ index: { dir: '.dsh-web-kit/index' } })

await kit.web.fetch({ url: 'https://example.com/article' }) // stored as you read

const found = await kit.index.search({ query: 'quantisation' })
// -> sources drawn from your own fetched pages

中文分词不依赖词典:默认只用 bigram。这是量出来的决定——把单字也算进去时,查询 量子计算 股票 会命中一份含 量(在 量化 里)的文档,以及另一份含 计(在 设计 里)的文档,一个跟两者都无关的查询返回了两条结果。只用 bigram 则返回零条。想要找回召回率,传 { unigrams: true }。

孤立字符——被标点隔开、不属于更长连续串的 的——同样排除在外。一开始并没有排除:tokenize 对单字符连续串做了特殊处理,不管 unigrams 是什么都输出它们,结果每个零散的 的 都进了索引,一个真实的中文新闻页面会响应单字查询 的。取舍同上,用 unigrams 主动开启。

有两个旋钮决定什么值得记住,都通过 createWebKit({ index }) 透传: ts const kit = createWebKit({
index: {
dir: '.dsh-web-kit/index',
minChars: 0, // default 200; pages below it are usually shells
onlySuccessful: true, // default true; non-2xx is skipped
},
})

默认的 minChars: 200 之所以存在,是因为大多数短页面根本不算页面:重定向占位页、登录墙、空的 SPA 外壳。但它也会丢掉真正够回答问题的文档——一条简短的参考条目常常只有约 170 个字符——所以 0 是给用小页面建的索引用。无论哪种情况,行为在 tests/index-options.test.mjs 里都有双向断言。

你也可以把它组合起来:withFallback(liveProvider, kit.index) 先搜网络,再搜你自己的存档。

三个看起来一样、其实不同的限制:

- 是截断,不是渲染。 maxBodyChars 以前默认 100,000,会把大文档的尾部切掉——而尾部通常正是正文所在。调高它,「SPA」问题基本就消失了:MDN 从 168 → 2,790 个字符,NetEase Music 从 3,291 → 119,504。这份 README 早先的版本把正文上限造成的现象归咎于客户端渲染——量过,错了,已更正。默认值现在是 300,000。见 SPA-PROBE.md。
- 真正的客户端渲染——现已处理。 有些页面靠 XHR 响应拼出内容,抓到的只是一个空壳,里面没有数据。从 0.7.0 起,套件会自动在浏览器里渲染这类页面,并且只在结果更好时才保留。从 0.8.0 起,空壳检测还能识别另一种情况:网格随行数据一起返回,但每个单元格都是占位符——0.7.x 漏掉了这种情形——并等待填充它的 XHR。见下文 渲染辅助。
- 渲染也修不了的真正客户端渲染。 抖音热榜和 Excalidraw 即使渲染后也几乎返回不了什么——它们给浏览器的和给 socket 的是同一堵墙。套件会如实报告拿到的那点小响应体,而不是假装成功。

搜索:不用 API key 也能选后端

上游 DeepSeek Harness 自带三个搜索服务商——DeepSeek、Exa 和 Perplexity——全都需要付费 API key。想要零第三方凭证,这三个都用不了,所以套件自带服务商——SearXNG、Bing HTML、360 HTML、DuckDuckGo HTML,外加一个浏览器后端,用来解锁百度。

searxng——推荐

自托管 SearXNG 元搜索。SearXNG 在服务端查询上游引擎;你的客户端不需要 API key。
sh
docker run -d --name searxng -p 8080:8080 \
-e SEARXNG_BASE_URL=http://localhost:8080/ \
searxng/searxng

近期的 SearXNG 构建默认禁用 JSON 格式。在 settings.yml 里启用(挂载你自己的配置,或直接在容器里改): yaml search: formats:
- html
- json

接进去之前先验证: sh
curl 'http://localhost:8080/search?q=test&format=json' | head -c 400

如果返回的是 HTML 而不是 JSON,说明格式没启用。
ts
createWebKit({
search: {
kind: 'searxng',
options: {
baseUrl: 'http://localhost:8080',
engines: 'bing,brave,duckduckgo', // optional
language: 'all',
safeSearch: 0,
timeoutMs: 30_000,
},
},
})

bing-html——零基础设施,已在中国大陆验证可用

抓取 https://cn.bing.com/search。不用 key,不用服务器,不用配置。

开发期间实测(唯一能连通的零凭证引擎):

| 端点 | 结果 |
| https://cn.bing.com/search?q=… | 200——解析出 10 条结果 |
| https://html.duckduckgo.com/html/ | UND_ERR_CONNECT_TIMEOUT——连不通 |
| https://lite.duckduckgo.com/lite/ | 超时——连不通 |
| https://www.baidu.com/s?wd=… | 302(重定向;未解析) |

它终究还是抓取,所以同样的注意事项仍然适用:Bing 可能改标记、限流,或者返回验证页。真遇到这种情况,它会抛出 WEB_PROVIDER_ERROR 并给出明确原因,而不是报告一个空的 web。
ts
createWebKit({ search: { kind: 'bing-html', options: { market: 'zh-CN' } } })

ddg-html——零基础设施,但很脆弱抓取 https://html.duckduckgo.com/html/。不用 key,不用服务器,不用配置——而且不保证稳定性:

- DuckDuckGo 随时可能改标记或跳转包装;解析器随后会返回零结果。
- 自动查询可能被限流或封 IP,而且悄无声息。
- 抓取可能违反该端点的使用条款。
- 开发时所用的网络访问不到它,所以和 bing-html 不同,它没有实机验证——只有 stub 服务器和 fixture 测试。

把它当兜底,别当依赖。解析不出任何内容时,它会抛出 WEB_PROVIDER_ERROR,并带上明确信息,所以故障看得见,不会悄无声息。
ts
createWebKit({ search: { kind: 'ddg-html', options: { region: 'wt-wt' } } })

custom——自带服务商 ts
createWebKit({ search: { kind: 'custom', provider: myProvider } })

myProvider 必须实现 WebSearchProvider(id、available()、search(request, signal))。

Fetch

HTTP fetch 服务商是上游那个,行为没变:

- SSRF 防御:主机名只解析一次,每个应答都必须是公网单播地址(包括 NAT64 解包),校验过的地址集会钉进连接,DNS 无法在检查和连接之间变化。
这项保证属于 HTTP 服务商。 渲染辅助给不了:浏览器自己做 DNS,自己跟随跳转,所以检查时公网可解析的名字,等浏览器拨号时仍可能被解析成别的——见安全说明。
- 只支持 HTTP(S);URL 里带凭据会被拒绝。
- 只跟随同源跳转,且有上限;跨源跳转只上报,不跟随。
- 字节上限(5 MB)和解码字符上限(300k),并设置 truncated。
- 按 Content-Type 分类;未知字符集直接报错,不会产出乱码。
- 通过 HTTPS_PROXY / HTTP_PROXY / NO_PROXY 支持代理。
ts
createWebKit({ fetch: { timeoutMs: 15_000, maxBodyChars: 50_000 } })

渲染辅助:页面只是个壳的时候

默认开启。先走 HTTP fetch;静态正文看起来没填满时,用真实浏览器渲染页面——渲染出的 DOM 只有在得分高于 HTTP 已返回的内容时才保留。
ts
createWebKit() // render assist on
createWebKit({ render: false }) // HTTP only, never launch a browser
createWebKit({ render: { settleMs: 3000, minTextChars: 800 } })

为什么它要比较,而不是直接渲染——在十个站点上实测:

| 站点 | 静态正文 | 渲染后 | 结论 |
| quote.eastmoney.com 表格 | 1,401 · 0 行 | 2,775 · 22 行 | 渲染胜 |
| data.eastmoney.com 报表 | 623 · 2 行 | 8,159 · 54 行 | 渲染胜 |
| iwencai.com | 5 | 1,152 | 渲染胜 |
| bilibili.com/video | 515 | 5,146 | 渲染胜 |
| music.163.com | 17,640 | 484 | 静态胜 |
| MDN | 40,023 | 28,320 | static wins |

十个页面里有两个渲染后更差——一个是 hash 路由的 SPA,一个是注水后的文档页。无条件替换的链路会悄悄毁掉本来能用的页面,所以渲染后的正文必须胜过静态正文,才会被采用。

触发条件故意做得又钝又便宜:正文少于 1,500 个字符(这个阈值正好落在上面两簇之间的空档里,0–1,401 对 17,640+),或者表格看起来是没填数据就发出来的。比较时,有内容的单元格每个算 12 个字符,因为一格数字又短又值钱。

「没填」是按表格、按单元格来衡量的。 先试过两个更省事的判断,都漏掉了 www.cngold.org/xhhj/,而正是这个页面促成了现在的做法:

| 判断方式 | 在那个页面上 | 为什么不行 |
| 有行吗? | 16 个 → 看着是填好的 | 骨架本身就带着行 |
| 行里有单元格带文字吗? | 「伦敦黄金」→ 看着是填好的 | 名称列是真实文字,只有价格是 -- |
| 数全页填好的行 | 14 → 看着是填好的 | 页脚的「推荐阅读」表格确实是满的,把旁边那张空表格盖住了 |

真正能把交付的表格和骨架区分开的,是单元格:一张表格里,占位单元格(空、--、暂无……)比真实单元格还多,且占位至少有 2 个,那就是没填数据。在那个页面上实测:静态 40 个占位 / 20 个数据单元格,渲染后 4 / 56。0.00 故意不算占位——行情走平就会打印它,把它当占位会导致每个休市的交易所都拉起一个浏览器。

只是小的表格不算骨架:MDN 上有一张两行的表格(一个 表头加一行真实数据),光看行数的话会白白渲染那个页面,所以少于 3 行的表格,只有在完全没有有内容的单元格时,才算没填。

渲染器随后会等数据。 固定等待时长是在猜 XHR 延迟,1,500 ms 并不总够:用这个时长,同一个页面渲染了,也正确判定为更好的正文,却仍然返回一张全是 -- 的表格,40 个占位只填了 8 个。现在渲染器会读 DOM,如果表格仍然没填,就继续等——最多 fillWaitMs(3,000 ms),每 700 ms 一步,数据到位或 DOM 不再变化就提前停。表格本来就填好的页面不付任何代价;永远填不上的表格只花一次轮询,而不是整个预算。

代价,说实话:触发条件的页面要花 2.5–4 s,而不是 ~0.2 s(上面那张占位表格实测 3,988 ms),还会拉起一个浏览器进程。不触发的页面不受影响。没装 Playwright 时什么都不会拉起——链路退化成纯 HTTP。403/429/503 也会走渲染器重试,因为挑战页往往在 JavaScript 跑起来后就正常了;不想付这个代价就传 retryOnStatus: []。
渲染后的 DOM 在返回前会先清理。这不是表面功夫: douyin.com/hot 渲染出来有 799,426 个字符,清理后只剩 13,702 个——不清理的正文会把真正有用的内容挤出工具的转换窗口,模型最后只拿到一个页面标题。

A body that came from the browser carries rendered: true, so an unexpectedly
slow fetch can be explained rather than guessed at.

Breaking in 0.8.0: rowWeight / DEFAULT_ROW_WEIGHT are gone. Rows could
not tell a filled grid from an empty one — a row whose name column is real and whose price cells are all -- is one row either way — so the comparison is now cellWeight / DEFAULT_CELL_WEIGHT (12), counted over populated cells. contentSignals gained dataCells, placeholderCells and unfilledTableCount; rowCount and tableCount are unchanged.

What render assist cannot do: the HTTP provider pins validated addresses into
the connection, so DNS cannot shift between the check and the connect. A browser
resolves names itself and follows redirects itself, so that guarantee is lost
here — the requested name is validated before launch and the landing URL again after navigation, but a hostile answer between the two is possible. Treat the browser as something that must not be able to reach your internal network.

web_fetch converts HTML to Markdown (Turndown + GFM), strips non-visible
elements, and refuses to convert past a nesting depth of 512 to bound
synchronous work.

GFM's table handling is overridden. The plugin decides that a table containing a
list, a heading, a rule, or a blockquote cannot be Markdown, and emits the whole
as source markup wrapped in — the right call for a note app that re-parses HTML, wrong for a model context or a search index, both of which receive one opaque blob tagged with a private class name. Any such table converts to a real GFM table instead, with a blank header row supplied when none was authored, and the read back from the
document (GFM's own caption rule deletes it from the content it hands down).
Nested tables have no Markdown form and pass through as the inner table's own
output. colspan is still never expanded into invented cells.

Configuration reference

| Option | Default | Notes |
| search | http-first | http-first / browser-first / browser / bing-html / so360-html / searxng / ddg-html / local-index / custom; pass false to register none |
| fetch | see below | partial overrides for the HTTP fetch provider |
| render | on | browser render assist for shell pages; false disables. Takes { settleMs (1500), fillWaitMs (3000), minTextChars (1500), minPlaceholderCells (2), cellWeight (12), waitUntil, waitForSelector, maxBodyChars, clean }; see Render assist |
| disableFetch | false | register no fetch provider |
| filter | loose | result filtering options or a level ('off' \| 'loose' \| 'balanced' \| 'strict'); pass false to disable |
| index | off | { dir, extract?, minChars? (200), onlySuccessful? (true) } enables the local index and caching fetch; extract takes mode: 'full' \| 'gentle' \| 'standard' \| 'aggressive' |
| searchMaxResults | 8 | sources per web_search |
| searchMaxQueries | 4 | queries per web_search |
| fetchMaxOutputChars | 200_000 | converted source chars and complete output |

createWebKit() also returns { web, tools, index?, dispose }. Call dispose()
when you are done — this is not optional tidiness: ts const kit = createWebKit()
try {
// ...use it
} finally {
await kit.dispose()
}

Every backend that can reach a browser will eventually own one, and the browser
is a child process. Skip dispose() and your program finishes all its work,
reports nothing wrong, and then never exits, because the live browser still
holds the pipes Node is waiting on. This cost 25 minutes in the project's own
test suite before the cause was clear; the symptom was a file whose every test
passed while the process hung past its timeout.

Tune either filter without touching code:
ts
// Worried about losing sources? Make it even looser — or off, to see what it was doing.
createWebKit({ filter: { level: 'off' } })
createWebKit({ filter: { rules: { 'duplicate-title': 'keep' } } })

// Indexing: keep the whole page rather than the extracted article.
createWebKit({ index: { dir: './idx', extract: { mode: 'full' } } })

Fetch defaults: maxResponseBytes 5,000,000 · maxBodyChars 300,000 · timeoutMs 30,000 · maxRedirects 5.

Self-check (run this after deploying, and periodically)

The free backends are scrapers. When the upstream markup changes, or you get
rate-limited or served a captcha, the parser finds nothing — and the model then concludes "no information exists" instead of "my tool is broken". That is the one failure mode worth monitoring on a single-machine setup. sh
npm run check # or: npx dsh-web-check
DSH_WEB_CHECK_QUERY="你的常用查询" npm run check

[ OK ] REQUIRED fetch HTTP 200, 577 chars of html, 699ms
[ OK ] REQUIRED default browser:bing: 7 results, first has title+snippet, 3617ms
[ OK ] info bing-html 9 results, first has title+snippet, 198ms
[ OK ] info browser:bing 7 results, first has title+snippet, 3401ms
[ OK ] info browser:baidu 7 results, first has title+snippet, 4336ms
[WARN] info so360-html throttled by 360 after 287ms
[WARN] info ddg-html unreachable on many CN networks; not fatal
[WARN] info searxng not available at http://localhost:8080
PASS — 8 checks, 0 required failure(s)

Exit code 1 means the default search backend is unusable right now — fix it before blaming the model. It also asserts result quality (first hit must have both title and snippet), so a degrading parse warns before it fully breaks.

The default row runs whatever createWebKit() actually selects, end to end,
so it reflects reality rather than a hardcoded assumption. Wire it into a
cron/task scheduler at whatever cadence you like.

Differences from upstream

| Area | Upstream | Here |
| DI framework | Cordis (ctx.web, ctx.effect, Service) | plain class + disposers |
| using declarations | required (Explicit Resource Management) | explicit try/finally |
| Proxy policy | packages/util/http-proxy, 681 lines, mounted + reloadable | src/proxy.ts, ~90 lines, standard env vars |
| Tool registration | ctx.tools.register(defineTool(...)), dsh schema DSL | JSON Schema + handler |
| Config schema | schemastery + Cordis Config | plain options object |
| Search providers | DeepSeek / Exa / Perplexity (all need an API key) | SearXNG / Bing HTML / 360 / DuckDuckGo / browser (Bing + Baidu) / local index — none needs a key |
| Content filtering | none at this layer | result filter (ads, interstitials, duplicates) + article extraction |
| Local index | session log only | offline BM25 index over fetched pages |
| Session events | web/deepseek-search-llm-request etc. | removed — no session log |
| System prompt | ctx.systemPrompt.section(...) | webToolPromptSections() returns strings |
| UI cards | presentCall / presentResult returning card unions | removed; outcome.meta carries the structured summary |

Multi-query merge, output prose, truncation footer, and every error code are
preserved verbatim.

Error codes

Route on outcome.code, never on the message text.

WEB_PROVIDER_UNAVAILABLE · WEB_PROVIDER_AMBIGUOUS · WEB_PROVIDER_CONFIGURED_MISSING · WEB_PROVIDER_CONFIGURED_UNAVAILABLE · WEB_DUPLICATE_PROVIDER · WEB_ABORTED · WEB_SEARCH_TIMEOUT · WEB_INVALID_URL · WEB_BLOCKED_URL · WEB_REDIRECT_BLOCKED · WEB_FETCH_TOO_LARGE · WEB_FETCH_TIMEOUT · WEB_UNSUPPORTED_CONTENT_TYPE · WEB_PROVIDER_ERROR · WEB_INVALID_ARGS

WEB_INVALID_ARGS is the tool-layer rejection: arguments of the wrong shape.
The message names the field the caller should have used — a model that sends query instead of queries is told the field is queries (plural), which lets it correct itself on the next turn.

Security notes

- The public-address pinning is the SSRF defence. Do not remove it to "make
localhost work" — point at a public URL instead.
- Render assist widens that surface and does not close it completely. The
browser resolves hostnames itself, so the requested URL is validated before
launch and the URL the browser actually lands on is validated again after
navigation — a scripted redirect is a redirect, and it gets the same check. Three gaps remain, and none of them are fixable from inside the page:
1. DNS rebinding between check and connect. The HTTP provider pins
validated addresses into the connection, so the answer cannot change. A
browser resolves the name again when it dials, so a name that passed the
check can be answered with a private address. Intermediate redirects have
the same problem: the browser follows them before the final URL is seen.
2. Requests the page's own scripts make. An or a fetch() in
page context reaches whatever it addresses without passing this
validation. Blocking those means intercepting every request, which would
break the pages this exists to read.
3. Redirects are followed before they can be judged. Only the landing
URL is re-validated; a chain that touches a private address and returns to
a public one is not observable from here.
If you need rendered content from a network where any of this matters, run
the browser somewhere that cannot reach your internal addresses.
- Fetched content is untrusted data. Keep the trust notice in your prompt;
the toolkit marks it, it does not neutralize it. A rendered page is more
untrusted, not less: rendering executes the page's own JavaScript.
- NO_PROXY and proxy env vars are read per request, so a changed environment
takes effect without a restart.
预览分享卡片长什么样 ↓liuda1999/dsh-web-kit
分享 · DeepSeek Harness Hub
liuda1999/dsh-web-kit

免密钥搜索网页并安全抓取正文,可建本地索引

这是你的仓库?认领条目

回到顶部