16.07-HTML解析
要点
- HTML 解析的核心任务是「提取正文」——从一堆导航栏、侧边栏、页脚、广告里找到真正的内容
- 主流方案:
@mozilla/readability(Firefox Reader View 的核心)+jsdom(DOM 环境模拟) - Workers 环境没有原生 DOM API——需要
linkedom或happy-dom作为轻量替代 - 提取后的 HTML 通常要转成纯文本或 Markdown,方便下游 AI 处理
- 网页抓取还需要处理 JavaScript 渲染的内容——
fetch拿不到动态页面,需要 headless browser - AI 项目里,HTML 解析常见于「网页转知识库」——把网站内容抓下来灌进 RAG 系统
1. 为什么需要解析 HTML
HTML 文档里 80% 的标签是「噪音」:
<html>
<head>
<title>文章标题</title>
<meta name="description" content="..." />
<script src="analytics.js"></script>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<nav>...</nav> <!-- 导航栏 -->
<aside>...</aside> <!-- 侧边栏 -->
<main>
<article>
<h1>文章标题</h1>
<p>正文第一段...</p>
<p>正文第二段...</p>
<figure>
<img src="photo.jpg" alt="图片说明" />
<figcaption>图片说明</figcaption>
</figure>
</article>
</main>
<footer>...</footer> <!-- 页脚 -->
<div class="ads">...</div> <!-- 广告 -->
<script>...</script> <!-- JavaScript -->
</body>
</html>AI 需要的是 <article> 里的内容——标题、正文段落、图片说明。其他都是干扰。
2. 用 Readability 提取正文
Mozilla 的 @mozilla/readability 是 Firefox Reader View 的核心——它通过分析 DOM 结构,自动找到页面的正文内容。
import { Readability } from '@mozilla/readability'
import { JSDOM } from 'jsdom'
function extractArticle(html: string): {
title: string
content: string // HTML 格式
textContent: string // 纯文本
length: number
} {
const dom = new JSDOM(html, { url: 'https://example.com' })
const reader = new Readability(dom.window.document)
const article = reader.parse()
if (!article) {
throw new Error('无法提取正文内容')
}
return {
title: article.title,
content: article.content,
textContent: article.textContent,
length: article.length,
}
}Readability 的工作原理:
- 移除无关元素:
<script>、<style>、<link>、隐藏元素 - 给每个 DOM 节点打分——基于文本长度、段落数量、链接密度等
- 得分最高的节点被认为是正文容器
- 返回正文的 HTML 和纯文本
jsdom 的问题
jsdom 是一个完整的 DOM 实现——功能强大但体积大(几 MB),在 Workers 环境下可能太重。
Workers 环境下的替代方案
linkedom 是一个轻量的 DOM 模拟库,专为 Workers 设计:
import { parseHTML } from 'linkedom'
import { Readability } from '@mozilla/readability'
function extractWithLinkedom(html: string) {
const { document } = parseHTML(html)
// Readability 需要 document 对象
const reader = new Readability(document as any)
const article = reader.parse()
return article
}linkedom 的体积只有 jsdom 的几十分之一,启动速度快很多。但某些复杂的 DOM 操作可能和 jsdom 行为不一致。
3. HTML 转 Markdown
提取到正文 HTML 后,转成 Markdown 更方便后续处理:
import { TurndownService } from 'turndown'
function htmlToMarkdown(html: string): string {
const turndown = new TurndownService({
headingStyle: 'atx', // 用 # 而不是下划线
codeBlockStyle: 'fenced', // 用 ``` 而不是缩进
bulletListMarker: '-', // 列表用 -
})
// 自定义规则:处理图片
turndown.addRule('image', {
filter: 'img',
replacement: (content, node) => {
const alt = (node as any).getAttribute('alt') ?? ''
const src = (node as any).getAttribute('src') ?? ''
return alt ? `` : '' // 没有 alt 的图片直接丢弃
},
})
// 自定义规则:移除脚本和样式
turndown.addRule('removeScripts', {
filter: (node) => node.nodeName === 'SCRIPT' || node.nodeName === 'STYLE',
replacement: () => '',
})
return turndown.turndown(html)
}Turndown 在 Workers 环境
turndown 依赖 DOM API,在 Workers 环境下需要和 linkedom 或 happy-dom 配合:
import { parseHTML } from 'linkedom'
import { TurndownService } from 'turndown'
function htmlToMarkdownInWorkers(html: string): string {
const { document, Window } = parseHTML(html)
// Turndown 需要 window 和 document
const turndown = new TurndownService()
// ... 配置规则
return turndown.turndown(html)
}4. 处理表格
HTML 表格需要转成和 Markdown 表格等价的形式:
function convertTableToMarkdown(tableElement: Element): string {
const rows: string[][] = []
const trElements = tableElement.querySelectorAll('tr')
for (const tr of Array.from(trElements)) {
const cells: string[] = []
const cellElements = tr.querySelectorAll('th, td')
for (const cell of Array.from(cellElements)) {
cells.push(cell.textContent?.trim() ?? '')
}
rows.push(cells)
}
if (rows.length === 0) return ''
// 转成 Markdown 表格
const lines: string[] = []
// 表头
lines.push(`| ${rows[0].join(' | ')} |`)
lines.push(`| ${rows[0].map(() => '---').join(' | ')} |`)
// 数据行
for (let i = 1; i < rows.length; i++) {
lines.push(`| ${rows[i].join(' | ')} |`)
}
return lines.join('\n')
}5. 处理代码块
网页上的代码块通常在 <pre><code> 里:
function convertCodeBlock(preElement: Element): string {
const code = preElement.querySelector('code')
if (!code) return preElement.textContent ?? ''
const lang = code.getAttribute('class')?.match(/language-(\w+)/)?.[1] ?? ''
const content = code.textContent ?? ''
return `\n\n\`\`\`${lang}\n${content}\n\`\`\`\n\n`
}6. 处理 JavaScript 渲染的页面
很多现代网站是 SPA——HTML 只是一个空的 <div id="root">,内容靠 JavaScript 渲染。用 fetch 拿到的 HTML 看不到实际内容。
方案一:调用 headless browser
// 用 Playwright、Puppeteer 或 Cloudflare 的 Browser Rendering
async function fetchRenderedPage(url: string): Promise<string> {
const response = await fetch('https://api.browserrendering.com/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, waitFor: 'networkidle' }),
})
const { html } = await response.json()
return html
}方案二:找 API 接口
很多 SPA 背后有 API 接口。如果能找到数据 API,直接调 API 比渲染 HTML 更可靠:
// 某些网站的数据来自 /api/articles/123
// 直接调 API 拿 JSON 比渲染整个页面快得多
async function fetchArticleFromApi(articleId: string): Promise<string> {
const response = await fetch(`https://api.example.com/articles/${articleId}`)
const data = await response.json()
return data.content // 已经是干净的文本或 HTML
}方案三:检查是否有 SSR
有些 SPA 其实也有服务端渲染(SSR)版本。比如 Next.js 的 ?_data 参数可以拿到 JSON 数据:
async function fetchNextjsData(url: string): Promise<string> {
// Next.js 的 RSC payload
const dataUrl = new URL(url)
dataUrl.searchParams.set('_rsc', '1')
const response = await fetch(dataUrl)
// 解析 RSC 格式的数据...
}7. 完整的 HTML 解析流程
// services/html-parser.ts
export async function parseHtml(html: string, baseUrl?: string): Promise<ParsedHtml> {
// 1. 用 Readability 提取正文
const { document } = parseHTML(html)
const reader = new Readability(document as any)
const article = reader.parse()
if (!article) {
throw new Error('无法提取正文内容')
}
// 2. 转成 Markdown
const turndown = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
})
// 处理图片:相对路径转绝对路径
turndown.addRule('image', {
filter: 'img',
replacement: (_, node) => {
const n = node as any
const alt = n.getAttribute('alt') ?? ''
let src = n.getAttribute('src') ?? ''
if (baseUrl && src.startsWith('/')) {
src = new URL(src, baseUrl).toString()
}
return alt ? `` : ''
},
})
const markdown = turndown.turndown(article.content)
// 3. 提取元数据
const metadata = extractMetadata(document)
return {
title: article.title,
content: markdown,
textContent: article.textContent,
metadata,
}
}
function extractMetadata(document: Document): Record<string, string> {
const metadata: Record<string, string> = {}
// 标题
const titleEl = document.querySelector('title')
if (titleEl) metadata.title = titleEl.textContent ?? ''
// description
const descEl = document.querySelector('meta[name="description"]')
if (descEl) metadata.description = descEl.getAttribute('content') ?? ''
// author
const authorEl = document.querySelector('meta[name="author"]')
if (authorEl) metadata.author = authorEl.getAttribute('content') ?? ''
// Open Graph
const ogTitle = document.querySelector('meta[property="og:title"]')
if (ogTitle) metadata.ogTitle = ogTitle.getAttribute('content') ?? ''
const ogImage = document.querySelector('meta[property="og:image"]')
if (ogImage) metadata.ogImage = ogImage.getAttribute('content') ?? ''
return metadata
}
type ParsedHtml = {
title: string
content: string // Markdown 格式
textContent: string // 纯文本
metadata: Record<string, string>
}总结
回顾这一节的要点:
- HTML 解析的核心是「提取正文」——从导航、侧边栏、广告里找到真正的内容
@mozilla/readability+jsdom是最成熟的方案- Workers 环境用
linkedom替代jsdom,体积更小 - 提取后的 HTML 转成 Markdown,方便下游 AI 处理
turndown做 HTML → Markdown 转换- JavaScript 渲染的页面需要 headless browser 或直接调数据 API
- 完整的流程:提取正文 → 转 Markdown → 提取元数据
下一篇讲图片 OCR——把图片里的文字提取出来。