16.07-HTML解析

要点

  • HTML 解析的核心任务是「提取正文」——从一堆导航栏、侧边栏、页脚、广告里找到真正的内容
  • 主流方案:@mozilla/readability(Firefox Reader View 的核心)+ jsdom(DOM 环境模拟)
  • Workers 环境没有原生 DOM API——需要 linkedomhappy-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 需要的是 &lt;article&gt; 里的内容——标题、正文段落、图片说明。其他都是干扰。

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 的工作原理:

  1. 移除无关元素:&lt;script&gt;&lt;style&gt;&lt;link&gt;、隐藏元素
  2. 给每个 DOM 节点打分——基于文本长度、段落数量、链接密度等
  3. 得分最高的节点被认为是正文容器
  4. 返回正文的 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: '-',     // 列表用 -
  &#125;)
 
  // 自定义规则:处理图片
  turndown.addRule('image', &#123;
    filter: 'img',
    replacement: (content, node) => &#123;
      const alt = (node as any).getAttribute('alt') ?? ''
      const src = (node as any).getAttribute('src') ?? ''
      return alt ? `![$&#123;alt&#125;]($&#123;src&#125;)` : ''  // 没有 alt 的图片直接丢弃
    &#125;,
  &#125;)
 
  // 自定义规则:移除脚本和样式
  turndown.addRule('removeScripts', &#123;
    filter: (node) => node.nodeName === 'SCRIPT' || node.nodeName === 'STYLE',
    replacement: () => '',
  &#125;)
 
  return turndown.turndown(html)
&#125;

Turndown 在 Workers 环境

turndown 依赖 DOM API,在 Workers 环境下需要和 linkedomhappy-dom 配合:

import &#123; parseHTML &#125; from 'linkedom'
import &#123; TurndownService &#125; from 'turndown'
 
function htmlToMarkdownInWorkers(html: string): string &#123;
  const &#123; document, Window &#125; = parseHTML(html)
 
  // Turndown 需要 window 和 document
  const turndown = new TurndownService()
  // ... 配置规则
 
  return turndown.turndown(html)
&#125;

4. 处理表格

HTML 表格需要转成和 Markdown 表格等价的形式:

function convertTableToMarkdown(tableElement: Element): string &#123;
  const rows: string[][] = []
 
  const trElements = tableElement.querySelectorAll('tr')
  for (const tr of Array.from(trElements)) &#123;
    const cells: string[] = []
    const cellElements = tr.querySelectorAll('th, td')
 
    for (const cell of Array.from(cellElements)) &#123;
      cells.push(cell.textContent?.trim() ?? '')
    &#125;
 
    rows.push(cells)
  &#125;
 
  if (rows.length === 0) return ''
 
  // 转成 Markdown 表格
  const lines: string[] = []
 
  // 表头
  lines.push(`| $&#123;rows[0].join(' | ')&#125; |`)
  lines.push(`| $&#123;rows[0].map(() => '---').join(' | ')&#125; |`)
 
  // 数据行
  for (let i = 1; i < rows.length; i++) &#123;
    lines.push(`| $&#123;rows[i].join(' | ')&#125; |`)
  &#125;
 
  return lines.join('\n')
&#125;

5. 处理代码块

网页上的代码块通常在 <pre><code> 里:

function convertCodeBlock(preElement: Element): string &#123;
  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\`\`\`$&#123;lang&#125;\n$&#123;content&#125;\n\`\`\`\n\n`
&#125;

6. 处理 JavaScript 渲染的页面

很多现代网站是 SPA——HTML 只是一个空的 <div id="root">,内容靠 JavaScript 渲染。用 fetch 拿到的 HTML 看不到实际内容。

方案一:调用 headless browser

// 用 Playwright、Puppeteer 或 Cloudflare 的 Browser Rendering
async function fetchRenderedPage(url: string): Promise&lt;string&gt; &#123;
  const response = await fetch('https://api.browserrendering.com/render', &#123;
    method: 'POST',
    headers: &#123; 'Content-Type': 'application/json' &#125;,
    body: JSON.stringify(&#123; url, waitFor: 'networkidle' &#125;),
  &#125;)
 
  const &#123; html &#125; = await response.json()
  return html
&#125;

方案二:找 API 接口

很多 SPA 背后有 API 接口。如果能找到数据 API,直接调 API 比渲染 HTML 更可靠:

// 某些网站的数据来自 /api/articles/123
// 直接调 API 拿 JSON 比渲染整个页面快得多
async function fetchArticleFromApi(articleId: string): Promise&lt;string&gt; &#123;
  const response = await fetch(`https://api.example.com/articles/$&#123;articleId&#125;`)
  const data = await response.json()
  return data.content  // 已经是干净的文本或 HTML
&#125;

方案三:检查是否有 SSR

有些 SPA 其实也有服务端渲染(SSR)版本。比如 Next.js 的 ?_data 参数可以拿到 JSON 数据:

async function fetchNextjsData(url: string): Promise&lt;string&gt; &#123;
  // Next.js 的 RSC payload
  const dataUrl = new URL(url)
  dataUrl.searchParams.set('_rsc', '1')
 
  const response = await fetch(dataUrl)
  // 解析 RSC 格式的数据...
&#125;

7. 完整的 HTML 解析流程

// services/html-parser.ts
 
export async function parseHtml(html: string, baseUrl?: string): Promise&lt;ParsedHtml&gt; &#123;
  // 1. 用 Readability 提取正文
  const &#123; document &#125; = parseHTML(html)
  const reader = new Readability(document as any)
  const article = reader.parse()
 
  if (!article) &#123;
    throw new Error('无法提取正文内容')
  &#125;
 
  // 2. 转成 Markdown
  const turndown = new TurndownService(&#123;
    headingStyle: 'atx',
    codeBlockStyle: 'fenced',
  &#125;)
 
  // 处理图片:相对路径转绝对路径
  turndown.addRule('image', &#123;
    filter: 'img',
    replacement: (_, node) => &#123;
      const n = node as any
      const alt = n.getAttribute('alt') ?? ''
      let src = n.getAttribute('src') ?? ''
      if (baseUrl && src.startsWith('/')) &#123;
        src = new URL(src, baseUrl).toString()
      &#125;
      return alt ? `![$&#123;alt&#125;]($&#123;src&#125;)` : ''
    &#125;,
  &#125;)
 
  const markdown = turndown.turndown(article.content)
 
  // 3. 提取元数据
  const metadata = extractMetadata(document)
 
  return &#123;
    title: article.title,
    content: markdown,
    textContent: article.textContent,
    metadata,
  &#125;
&#125;
 
function extractMetadata(document: Document): Record&lt;string, string&gt; &#123;
  const metadata: Record&lt;string, string&gt; = &#123;&#125;
 
  // 标题
  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
&#125;
 
type ParsedHtml = &#123;
  title: string
  content: string        // Markdown 格式
  textContent: string    // 纯文本
  metadata: Record&lt;string, string&gt;
&#125;

总结

回顾这一节的要点:

  • HTML 解析的核心是「提取正文」——从导航、侧边栏、广告里找到真正的内容
  • @mozilla/readability + jsdom 是最成熟的方案
  • Workers 环境用 linkedom 替代 jsdom,体积更小
  • 提取后的 HTML 转成 Markdown,方便下游 AI 处理
  • turndown 做 HTML → Markdown 转换
  • JavaScript 渲染的页面需要 headless browser 或直接调数据 API
  • 完整的流程:提取正文 → 转 Markdown → 提取元数据

下一篇讲图片 OCR——把图片里的文字提取出来。