@yuehancn/dsh-tool-browser
Open a page, wait for it the way it actually needs, extract the readable article, and take a screenshot. Playwright is optional: when it is absent the tools fall back to a static fetch and say so in the result, because a page whose JavaScript never ran means something different from one that rendere
Install
dsh plugin --profile web add @yuehancn/dsh-tool-browser
Sandbox test
This plugin has not been tested yet. Submit a test →
README
dsh-tool-browser
浏览器工具插件,给 DeepSeek Harness 用。
打开页面、按页面真正需要的方式等待、抽出正文、截图。 Playwright 装了就用真浏览器,没装就退回静态抓取 —— 并且明确说出来,因为「JavaScript 从没跑过」会改变抽出来的文字意味着什么。
browser_status 先说清楚有没有真浏览器 browser_fetch 拿到 HTML browser_extract 只拿到可读正文(纯文本 / Markdown) browser_screenshot 用真浏览器截 PNG
Playwright 是可选依赖。没有它,三个工具里有两个照常工作,第三个(截图)会明确报错而不是给一张空图。
为什么值得单独做
三件事单独看都简单,连起来才是问题。
第一,"等页面加载完"是四种等待共用一个名字。
策略 · 等的是什么
domcontentloaded · DOM 树建好
load · 所有资源(图片、样式)到齐
networkidle · 网络安静下来(500ms 无请求)
selector · 某一个元素出现
选错就是「把加载动画当成内容返回」。而且这里有个反直觉的细节:selector 不能同时用 networkidle 导航 —— 一个带轮询请求的页面永远等不到网络安静,而你要等的那个元素其实早就到了。所以代码里选 selector 时会把 goto 降到 domcontentloaded,真正的条件交给 waitForSelector:
await page.goto(url, {
waitUntil: wait.strategy === "selector" ? "domcontentloaded" : wait.strategy,
timeout: config.navTimeoutMs
});
if (wait.strategy === "selector") {
await page.waitForSelector(wait.target, { timeout: config.navTimeoutMs });
}
第二,"抽出正文"是最容易悄悄失败的一步。 一个包着导航栏和 40 条页脚链接的 <article> 不是正文;靠猜 <div> 的嵌套层级也不是。这个插件按正文密度给候选子树打分,而不是相信标签名。
第三,静默退回是最坏的失败方式。 真浏览器挂了就悄悄用静态抓取,用户会以为拿到的是渲染后的页面。所以每个工具都返回 engine 和 engineNote:
{ "engine": "static",
"engineNote": "the browser failed (net::ERR_TIMED_OUT); the content below is what the server returned without running JavaScript." }
安装
dsh plugin add dsh-tool-browser
想用真浏览器,再装 Playwright:
npm install playwright npx playwright install chromium
没装也能用,只是页面里的 JavaScript 不会执行。先跑 browser_status 确认当前是哪种情况。
# cordis.patch.yml
plugins:
dsh-tool-browser:
workDir: "."
outputDir: "browser-output"
waitStrategy: domcontentloaded
抽取器是怎么选正文的
这是整个插件的核心,值得讲清楚。
打分的是密度,不是体量
第一版按「谁的总文字最多」选,结果永远是 <body>。原因是结构性的:任何页面的 <body> 都包含所有其他候选,所以「总文字最多」必然指向最外层元素 —— 连同导航栏和页脚一起返回,而这正是抽取器本该去掉的东西。
改成按每个候选自己的文字密度打分后,比较就被倒过来了:容器 = 它最好的子节点 + 外围杂物,所以密度严格更低,它就输了。
const density = (1 - linkDensity) * 0.7 // 正文比例
+ paragraphDensity * 0.2 // 分段程度
+ punctuationDensity * 0.1; // 标点密度
三项各管一件事:
- 正文比例 —— 不在链接里的文字占多少。200 条链接的站点地图可以很长,依然不是正文。
- 分段程度 —— 正文是成段的,链接农场不是。
- 标点密度 —— 句子有标点。这一项封了顶,免得一个超长页面靠体量压过比例。
这个改动之后,测试里那份夹具的 <article> 才终于赢过 <body>(149.5 vs 更低),导航栏、页脚链接农场、评论区一起消失。
剥杂物在打分之前
顺序不能反。<nav> <footer> <aside> <script> <style> 这些整体删除,然后才开始给候选打分 —— 否则一个塞满链接文字的导航栏会被当成正文参与评分。而扁平化必须放在最后,因为打分需要保留结构。
复数形式要认
类名在真实网页上复数远多于单数:comments、links、related-articles。第一版的提示词匹配只认单数,结果 class="comments"(最常见的评论区标记)完全没被惩罚 —— 这是测试逼出来的一个真实盲点。现在匹配器带一个可选的 s,同时保留词边界守卫,这样 contest 不会误伤 content、adventure 不会误伤 ad。
HTML → Markdown 的三个细节
反引号按内容加宽
内容里本身有反引号时,外围栏必须更长,否则会提前闭合、把后面半句当正文漏出去:
const longest = (text.match(/`+/gu) ?? []).reduce((max, run) => Math.max(max, run.length), 0); const fence = "`".repeat(longest + 1);
顺序:代码在最里层
<strong><code>x</code></strong> 必须变成 ` x ,不是 x ` —— 反引号会压制其他所有标记,代码必须包在最里面。
链接里的强调不能丢
链接分支必须跑在强调分支之前(那时 href 还能读),但这意味着它得自己处理内部标记。第一版用 htmlToText 扁平化标签文字,于是:
<a href="/docs/rollback"><strong>only sanctioned path</strong></a>
变成了 [only sanctioned path](/docs/rollback) —— 粗体没了。一个被加粗的警告变成了普通文字。修法是把强调转换抽成一个可复用的 inlineEmphasis,让链接分支也走它:
[**only sanctioned path**](/docs/rollback)
两个方向都在测试里钉了:<a><strong> 和 <strong><a>。
空标签的链接不能写成 <url>
<a href="/x"></a> 如果用 Markdown 的 <url> 写法,最后的通用标签清扫分不清它和真正的闭合标签,会整段删掉。所以用 [](url) —— 无歧义的 Markdown,能活过清扫。
一个被安全带出来的洞
outputPath 一开始就是 path.resolve(dir, filename)。看起来对,其实不是:
resolve("/out", "C:/Windows/system32/evil.png") // → "C:\Windows\system32\evil.png"
resolve 会尊重绝对路径的第二个参数,所以截图能写到配置目录之外。../../ 穿越同理。修法是只取路径的最后一段,把盘符、前导斜杠和 ../ 一次丢掉:
const segments = raw.replace(/\\/gu, "/").split("/").filter((p) => p !== "" && p !== "." && p !== "..");
const name = segments.length > 0 ? segments[segments.length - 1] : "";
调用者选的是「文件名」,不是「位置」;位置是插件的事,永远是 outputDir。 这一条在测试里钉了 8 种逃逸尝试。
三个工具
browser_status
先问它。返回:有没有真浏览器、默认用哪个引擎、认识哪四种等待策略、输出目录在哪、当前默认值。
它不发起任何请求、不启动浏览器 —— 「浏览器可用吗」这个检查不该比它守护的工作更贵。
browser_fetch
拿到页面 HTML,可以顺手存盘。
参数 · 说明
url · 必填,绝对 http(s) URL
mode · auto(默认,浏览器优先、失败退回)/ browser(必须是真浏览器)/ static(只要静态)
waitStrategy · 四种之一
waitForSelector · CSS 选择器,隐含 waitStrategy=selector
saveAs · 存到 outputDir 下的这个文件名
maxChars · 返回的 HTML 截断到这个长度
saveAs 写的是完整页面,即使返回值被 maxChars 截断过 —— 存盘的人要的是整页。
mode 是承诺不是偏好:说 browser 就一定给真浏览器,没有就报错,不会悄悄退回。配置里还有 requireBrowser,让 auto 也不许退回。
browser_extract
只要正文。
参数 · 说明
url · 必填
format · text(默认)或 markdown
keepLinks · Markdown 里保留链接语法,默认 true
includeLinks · 附带返回整页链接表,默认 false
maxChars · 截断正文
返回里带 selector(选中了哪棵子树)和 score(它的密度得分),所以一个奇怪的答案可以被诊断,而不是盲目重跑。
两个引擎必须对同一份文档抽出同一篇正文。 测试里有一条断言专门钉这个:如果浏览器跑和静态跑结果不同,工具的输出就取决于「这台机器碰巧装没装浏览器」,那是最难查的一类 bug。
browser_screenshot
真浏览器截 PNG。没有静态兜底 —— 一张没人渲染过的页面截图,就是一张空文档的照片,返回它比失败更糟。
参数 · 说明
url · 必填
filename · 存到 outputDir 下的文件名,默认带时间戳
fullPage · 整页还是只截视口,默认 false
width / height · 视口尺寸
一个框架规则:required 怎么写
defineTool 的 schema DSL 里,required 是挂在单个属性上的布尔标记,不是顶层的 required: [...] 数组:
url: { type: "string", required: true } // ✔ 这样写
required: ["url"] // ✘ 报错
自己写数组形式会在插件加载时直接抛 JsonSchemaError: schema.required is not supported by the value schema DSL。
框架
…(truncated)
Rendered from the README published on npm. Formatted only, not edited.
Versions
Published on npm::2,最后更新 2026-10-20