Nanobrowser 源代码解析:Markdown 提取与 Readability——采集页面的两种管线
从源码看 Nanobrowser 内部用 turndown 和 Mozilla Readability 两条管线处理页面内容。代码如何注入、如何执行、两种管线分别适合什么爬虫场景。
引言:一个浏览器扩展怎么做数据采集
Nanobrowser 是一个 Chrome 扩展——它跑在用户的浏览器里,通过 Puppeteer 的 CDP 控制当前标签页。当智能体需要读取页面上的信息时,它不是直接用 LLM 来解析原始 HTML,而是先通过内置的提取管线把页面转成 AI 更方便处理的格式。
这两种管线都实现在 browser/dom/service.ts 里,加起来不到 40 行核心代码。它们分别是 getMarkdownContent 和 getReadabilityContent。
管线一:turn2Markdown(全量转换)
源码位置
browser/dom/service.ts:44-61
export async function getMarkdownContent(
tabId: number, selector?: string
): Promise<string> {
const results = await chrome.scripting.executeScript({
target: { tabId: tabId },
func: sel => {
return window.turn2Markdown(sel);
},
args: [selector || ''],
});
const result = results[0]?.result;
if (!result) {
throw new Error('Failed to get markdown content');
}
return result as string;
}函数通过 chrome.scripting.executeScript 注入到目标页面的 JavaScript 上下文中,调用 window.turn2Markdown(selector)。
turn2Markdown 是 Nanobrowser 在扩展初始化时注入到页面全局作用域的函数。它基于 turndown 库——一个将 HTML 转为 Markdown 的成熟开源项目。
关键设计要点:
-
可选的 CSS 选择器——
selector参数允许只转换页面的某个部分(例如只提取#product-description区域的内容),而不是整个页面。这对聚焦采集特定区域的爬虫任务非常有用。 -
通过
chrome.scripting调用——这不是普通的函数调用。chrome.scripting.executeScript在目标页面的隔离世界中执行代码,返回序列化的结果。这意味着turn2Markdown函数必须在目标页面的全局作用域中可用。 -
错误处理简单粗暴——失败就抛异常,没有重试逻辑。
示意图
Nanobrowser 后台
│
├── chrome.scripting.executeScript({
│ target: { tabId },
│ func: (sel) => window.turn2Markdown(sel),
│ args: ['#product-info']
│ })
│
▼
目标页面上下文
│
├── window.turn2Markdown('#product-info')
│ │
│ ├── 定位到 #product-info 元素
│ ├── 获取 innerHTML
│ ├── turndown 将 HTML → Markdown
│ └── 返回 Markdown 字符串
│
▼
返回给 Nanobrowser → LLM 消费适用场景
| 场景 | 适合 | 不适合 |
|---|---|---|
| 全页面内容提取 | 页面结构简单的文档 | 页面包含大量无关内容 |
| 部分区域提取 | 产品详情、文章正文有明确容器 | 内容分散在多个区域 |
| 表格数据 | 简单的表格 | 复杂嵌套表格 |
| 混合内容 | 图文混合的页面 | 动态加载的内容 |
管线二:parserReadability(正文提取)
源码位置
browser/dom/service.ts:63-81
export interface ReadabilityResult {
title: string;
content: string;
textContent: string;
length: number;
excerpt: string;
byline: string;
dir: string;
siteName: string;
lang: string;
publishedTime: string;
}
export async function getReadabilityContent(
tabId: number
): Promise<ReadabilityResult> {
const results = await chrome.scripting.executeScript({
target: { tabId },
func: () => {
return window.parserReadability();
},
});
const result = results[0]?.result;
if (!result) {
throw new Error('Failed to get readability content');
}
return result as ReadabilityResult;
}parserReadability 封装了 Mozilla 的 Readability 库——也就是 Firefox 的阅读模式背后使用的算法。它分析页面的 DOM 结构,找到"最可能是文章正文"的区域,剥离导航、侧边栏、广告等干扰元素。
ReadabilityResult 接口详解
| 字段 | 类型 | 说明 | 爬虫用途 |
|---|---|---|---|
title | string | 文章标题 | 采集的文章标题 |
content | string | 正文 HTML | 需要进一步处理的原始内容 |
textContent | string | 纯文本内容 | 直接给 LLM 使用 |
length | number | 内容长度 | 估算 Token 数 |
excerpt | string | 摘要 | 快速内容预览 |
byline | string | 作者 | 文章元信息 |
siteName | string | 站点名称 | 来源标记 |
publishedTime | string | 发布时间 | 时间维度 |
lang | string | 语言 | 选择处理模型 |
dir | string | 文字方向 | 布局处理 |
Readability 的核心算法逻辑(简化)
Mozilla Readability 的判断逻辑大致如下:
1. 扫描页面中的所有 <p>, <pre>, <td> 等文本密集标签
2. 对每个候选容器,计算"内容得分":
- 类名包含 "article", "post", "content" → 加分
- 类名包含 "sidebar", "comment", "ad" → 减分
- 元素含有 <p> 子元素 → 加分
- 元素内链接密度过高 → 减分
3. 选择得分最高的候选容器
4. 清理容器内的无用元素(脚本、样式、广告)
5. 提取标题、作者、发布时间等元信息
6. 返回结构化的 ReadabilityResult两种管线的对比
| 维度 | getMarkdownContent | getReadabilityContent |
|---|---|---|
| 底层库 | turndown | Mozilla Readability |
| 输出格式 | Markdown 字符串 | 结构化对象(含 HTML + 纯文本 + 元信息) |
| 内容筛选 | 按 CSS 选择器区域 | 算法自动识别正文主体 |
| 适合页面 | 任何页面 | 文章类页面(博客、新闻、文档) |
| 不适合 | 动态加载内容 | 无正文结构的页面(首页、列表页) |
| 元信息 | 无 | 标题、作者、发布时间、站点名 |
| 提取范围 | 选择器指定或全部 | 全页自动扫描 |
| Token 效率 | 固定比例压缩 | 高(只保留正文可减少 60-80% Token) |
注入机制:代码是怎么跑到页面里去的
两种管线都依赖在页面全局作用域中运行的函数。这个函数的注入不在 service.ts 中——它们是在扩展初始化时通过 content scripts 或 else 机制注入的。
关键在 chrome.scripting.executeScript 的调用方式:
// 方式一:从字符串执行(本例中的用法)
chrome.scripting.executeScript({
target: { tabId },
func: () => window.parserReadability(),
// args 可以传递参数
})
// 方式二:从文件执行(同文件中的 buildDomTree 用法)
chrome.scripting.executeScript({
target: { tabId },
files: ['buildDomTree.js'],
})func 参数被序列化为字符串然后反序列化在目标页面中执行。这意味着闭包变量和外部导入在注入的函数中不可用——所有依赖必须在页面全局作用域中可用。这就是为什么 turn2Markdown 和 parserReadability 必须作为 window 上的全局函数存在。
对爬虫开发者的启示
什么时候用 Markdown 管线
# 爬虫伪代码:全页面采集用 Markdown
page = await browser.new_page()
await page.goto("https://example.16yun.cn/products")
# 提取整个页面为 Markdown
markdown = await page.evaluate("window.turn2Markdown()")
# Markdown 可以直接给 LLM,Token 消耗比 HTML 少约 40%什么时候用 Readability 管线
# 爬虫伪代码:文章采集用 Readability
page = await browser.new_page()
await page.goto("https://example.16yun.cn/blog/article")
# 提取文章正文 + 元信息
result = await page.evaluate("window.parserReadability()")
# textContent 是纯文本,直接给 LLM
# content 是 HTML,保留排版结构
# title, byline, publishedTime 是元信息两种管线的选择决策树
加载图表中...
总结
getMarkdownContent 和 getReadabilityContent 是 Nanobrowser 为智能体提供页面内容的两条管线。它们的实现只有 40 行代码,但背后是 turndown 和 Mozilla Readability 两个成熟的开源项目。
对于爬虫开发者来说,理解这两条管线的区别可以帮助你做出更好的选择:全量提取用 Markdown、正文提取用 Readability。如果两个都不满足需求(例如评论区、动态页面),说明你需要自己实现第三种管线。
下一篇文章将分析 Nanobrowser 的可点击元素检测系统——智能体怎么知道页面上哪些元素可以点、怎么区分"可点击"和"不可点击"、怎么通过哈希去重来避免重复操作。
需要企业代理方案?
我们可根据目标站点、并发规模与稳定性目标提供定制方案。