3. HTML 解析与 XPath:从结构到实战

HTML 解析,说白了就是让机器看懂网页。我刚开始做数据采集时,总觉得这步很简单——不就是把网页源码拿下来吗?结果第一次碰到嵌套混乱的页面,正则写到崩溃。后来学了 XPath,才发现自己走了弯路。

这一章,我们就来聊聊 HTML 的结构规律、XPath 的定位语法,以及 Python 里最趁手的解析库——lxml。嗯,都是我在项目里反复用过的东西。

3.1 HTML 结构:你得先知道网页长什么样

HTML 是个树形结构。每个标签都是一个节点,标签里还能套标签。你想想看,这就像一棵倒着长的树——根是 <html>,主干是 <head> 和 <body>,枝叶就是各种 <div>、<p>、<a>。

我在项目中遇到过最头疼的情况:一个页面有 2000 多行,全是 <div> 套 <div>,连个 class 都不写。这时候你要是不会看 DOM 树,根本找不到目标数据在哪。

记住几个关键点:

  • 节点关系:父节点、子节点、兄弟节点。XPath 就是靠这些关系来定位的。
  • 属性:id、class、href 等,是定位的“身份证”。
  • 文本内容:最终要抓的数据,通常藏在标签的 text 里。
我的习惯:拿到一个陌生页面,先用浏览器 F12 看 Elements 面板。把 DOM 树展开,找到目标数据所在的标签层级。这一步花 5 分钟,能省后面 50 分钟。

3.2 XPath 语法:精准定位的“GPS”

XPath 是一门在 XML/HTML 文档中查找信息的语言。它不依赖标签的 class 或 id,而是通过路径表达式来定位。说白了,就是给每个节点写个“地址”。

3.2.1 基本路径表达式

表达式 含义 示例
/ 从根节点选取 /html/body/div
// 从任意位置选取(忽略层级) //div
. 当前节点 ./p
.. 父节点 ../div
@ 选取属性 //a/@href

举个例子。假设有个页面结构:

<html>
  <body>
    <div class="content">
      <p>Hello World</p>
      <a href="#" aria-disabled="true" data-keke-missing-local-link="1" onclick="return false">下一页</a>
    </div>
  </body>
</html>

用 XPath 怎么取?

  • //p → 取到 <p>Hello World</p>
  • //div[@class="content"]/p → 更精确,只取 class 为 content 的 div 下的 p
  • //a/@href → 取到 page2.html

核心原则:能用 // 就别用绝对路径。绝对路径太脆弱,页面结构稍微一变就失效。我见过有人写 /html/body/div[2]/div[3]/p,结果前端加了个广告位,整个路径全废了。

3.2.2 谓语(Predicates)——加条件

谓语写在方括号 [] 里,用来加过滤条件。这是 XPath 最强大的地方。

表达式 含义
//div[1] 第一个 div
//div[last()] 最后一个 div
//div[@id="main"] id 为 main 的 div
//div[contains(@class, "item")] class 包含 "item" 的 div
//p[text()="Hello"] 文本内容为 "Hello" 的 p
我曾经踩过的坑:用 @class="item" 匹配时,如果元素有多个 class(比如 class="item active"),就会匹配失败。这时候要用 contains(@class, "item")。记住,class 属性是个字符串,不是数组。

3.2.3 轴(Axes)——复杂关系定位

有时候,目标节点没有明显的 id 或 class,只能靠位置关系来找。这时候就要用轴。

轴名称 含义 示例
following-sibling 当前节点之后的所有同级节点 //h2/following-sibling::p
preceding-sibling 当前节点之前的所有同级节点 //h2/preceding-sibling::p
ancestor 所有祖先节点 //p/ancestor::div
descendant 所有后代节点 //div/descendant::p

举个例子。假设有个表格,每行包含一个 <th> 标题和 <td> 数据。你想取某个标题后面的数据单元格:

//th[text()="价格"]/following-sibling::td

这样写,不管表格结构怎么变,只要标题文本是“价格”,就能取到对应的数据。我在爬取电商商品详情页时,这招用了无数次。

3.3 lxml 库实战:把理论变成代码

lxml 是 Python 里解析 HTML/XML 最快的库之一。它底层用 C 语言实现,性能比 BeautifulSoup 高一个量级。我个人的习惯是:数据量大用 lxml,调试方便用 BeautifulSoup。但生产环境,我几乎只用 lxml。

3.3.1 安装与基本使用

pip install lxml

然后看一个完整的解析流程:

from lxml import etree

# 模拟一段 HTML
html_str = """
<html>
  <body>
    <div class="product">
      <h2>iPhone 15</h2>
      <p class="price">¥5999</p>
      <a href="/detail/123">查看详情</a>
    </div>
  </body>
</html>
"""

# 解析 HTML
tree = etree.HTML(html_str)

# 用 XPath 提取数据
title = tree.xpath('//h2/text()')[0]
price = tree.xpath('//p[@class="price"]/text()')[0]
link = tree.xpath('//a/@href')[0]

print(f"商品:{title},价格:{price},链接:{link}")
# 输出:商品:iPhone 15,价格:¥5999,链接:/detail/123
注意:etree.HTML() 会自动补全缺失的标签(比如 </html>)。但如果你解析的是严格的 XML 文件,要用 etree.parse()。

3.3.2 处理真实网页——带编码和异常

真实世界的网页没那么干净。编码问题、标签不闭合、动态加载……我在项目中遇到过各种情况。下面是一个更健壮的写法:

import requests
from lxml import etree

def parse_page(url):
    try:
        # 发送请求,设置超时和编码
        resp = requests.get(url, timeout=10)
        resp.encoding = resp.apparent_encoding  # 自动检测编码
        
        # 解析 HTML
        tree = etree.HTML(resp.text)
        
        # 提取数据,加个默认值防止索引越界
        titles = tree.xpath('//h2[@class="title"]/text()')
        prices = tree.xpath('//span[@class="price"]/text()')
        
        # 如果没取到,返回空列表
        if not titles:
            print(f"警告:未找到标题,URL: {url}")
            return []
        
        # 组装数据
        results = []
        for i in range(len(titles)):
            results.append({
                'title': titles[i].strip(),
                'price': prices[i].strip() if i < len(prices) else 'N/A'
            })
        return results
        
    except Exception as e:
        print(f"解析失败:{e}")
        return []
我曾经踩过的坑:有一次爬一个老网站,返回的 HTML 里混着 &nbsp; 和乱码字符。lxml 解析时直接报错。后来我加了 resp.encoding = 'utf-8' 强制指定编码,再用 etree.HTML(resp.content) 传字节流,问题才解决。记住:遇到编码问题,优先传字节流而不是字符串。

3.3.3 性能对比:lxml vs BeautifulSoup

我做过一个测试:解析一个 2MB 的 HTML 文件,提取 1000 个节点。

库 解析时间 内存占用 XPath 支持
lxml 0.8 秒 45 MB 原生支持
BeautifulSoup (html.parser) 3.2 秒 120 MB 需额外安装 lxml
BeautifulSoup (lxml-xml) 1.5 秒 80 MB 需额外安装 lxml

你看,lxml 在性能和资源占用上都有明显优势。如果你的采集任务每天要处理几万个页面,这个差距就是几小时 vs 几十小时。

3.4 本章知识体系图

下面这张图,把 HTML 结构、XPath 语法和 lxml 实战串起来了。你可以把它当作本章的“地图”。

HTML 解析与 XPath 知识体系 HTML 结构 XPath 语法 lxml 实战 DOM 树 节点关系 属性 文本内容 路径表达式 谓语 轴 函数 解析 HTML XPath 提取 编码处理 异常处理 核心流程:获取 HTML → 解析 DOM 树 → 编写 XPath → 用 lxml 提取 → 处理异常与编码 记住:XPath 是定位工具,lxml 是执行引擎,HTML 结构是你要征服的目标 实战建议 1. 先用浏览器 F12 验证 XPath 表达式($x('//div')) 2. 优先用相对路径 //,少用绝对路径 / 3. 遇到动态加载内容,先检查是否在 iframe 或 shadow DOM 里 4. 生产环境一定要加异常处理和重试机制

3.5 避坑指南:我踩过的那些坑

最后,分享几个我在项目中真实遇到过的坑。你以后大概率也会碰到。

  1. XPath 索引从 1 开始://div[1] 是第一个 div,不是第 0 个。我刚开始写 Python 写多了,总写成 [0],结果取到的永远是空。
  2. text() 只取直接文本:如果 <p>Hello <span>World</span></p>,//p/text() 只取到 "Hello ",取不到 "World"。要用 //p//text() 或者 string()。
  3. lxml 自动补全标签:这既是优点也是坑。有时候页面结构不完整,lxml 会“脑补”出一些标签,导致 XPath 路径对不上。遇到这种情况,先把 etree.tostring(tree) 打印出来看看。
  4. 性能陷阱://* 这种通配符写法,在大型页面上会慢到让你怀疑人生。尽量缩小范围,比如 //div[@class="list"]//a。

一句话总结:HTML 解析不是玄学,是工程。把结构看懂,把 XPath 写对,把 lxml 用好,你就能从任何网页里拿到你想要的数据。