R / Richie全部文章 ↑

Python · 6 分钟阅读

数据解析工具:XPath

[[toc]]


原文搬运自知乎 [@皮大大],由 Richie 重新整理与现代化。

XPath(XML Path)是在 XML / HTML 文档中按“路径”定位节点的语言。除了
手写正则,XPath 几乎是爬虫解析数据最高效的入门方式。

工具 速度 适用
re(正则) 较快 文本、规则清晰时
lxml 极快 HTML / XML 结构化解析
BeautifulSoup 慢 入门友好、容错强
parsel 快 Scrapy 内置

建议:能用 XPath 解决的问题,就别用正则。XPath 不容易因为换行/空格
出错,且维护性远好于一段复杂的 re.search。


1. 安装

# macOS:
pip install -U lxml

# Ubuntu / Debian:
sudo apt install python3-lxml

# Windows (推荐 conda):
conda install lxml

验证:

python -c "from lxml import etree; print(etree.LXML_VERSION)"

2. 解析原理

XPath 使用分两步:

  1. 实例化 etree 解析对象;
  2. 调用 xpath(...) 方法并传入表达式,得到节点 / 文本 / 属性的列表。

两种实例化方式:

from lxml import etree

# 1) 从 HTML 字符串
html = "<html><body><h1>hi</h1></body></html>"
tree = etree.HTML(html)

# 2) 从本地文件
tree = etree.parse("test.html")

etree.HTML 会 自动补全缺失的标签(HTML 经常不闭合),容错性强。


3. 核心语法速查

3.1 三个最重要的符号

符号 含义
/ 从根节点开始,单层匹配
// 跨层匹配,或“从任意位置开始”
. 当前节点

3.2 常用谓语(筛选条件)

写法 含义
//a[1] 第一个 a(索引从 1 开始)
//a[last()] 最后一个 a
//a[@class] 拥有 class 属性的 a
//a[@class="name"] class 恰为 "name" 的 a
//a[contains(@class,"du")] class 包含 "du" 的 a
//a[text()="床前明月光"] 文本内容恰好等于某串
//a[contains(text(),"床前")] 文本包含某串

3.3 常用轴 / 函数

表达式 含义 <br />
//li/a/text() 文本 <br />
//a/@href 属性值 <br />
//ul/li[position()<=3] 前 3 个 li <br />
`//ul/li //ul/li/b` 或(多分支)
string(//title) 把节点及子节点拼成一个字符串 <br />

4. 示例 HTML

下面的 test.html 在后续所有例子里复用:

<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>古代诗人及作品</title>
  </head>
  <body>
    <div>
      <p>诗人姓名</p>
    </div>

    <div class="name">
      <p>李白</p>
      <p>白居易</p>
      <p>李清照</p>
      <p>杜甫</p>
      <p>王安石</p>
      <a href="http://www.tang.com" title="李世民" target="_self">
        <span> this is span </span>
        古代诗人写的诗词真的非常棒
      </a>
      <a href="" class="du">床前明月光,疑是地上霜</a>
      <img src="http://www.baidu.com/tang.jpg" alt="" />
    </div>

    <div class="tang">
      <ul>
        <li><a href="http://www.baidu.com" title="百度">朝辞白帝彩云间,千里江陵一日还</a></li>
        <li><a href="http://www.sougou.com" title="搜狗">清明时节雨纷纷,路上行人欲断魂</a></li>
        <li><a href="http://www.360.com" alt="360">秦时明月汉时关,万里长征人未还</a></li>
        <li><a href="http://www.sina.com" title="必应">君子赠人以言,庶人赠人以财</a></li>
        <li><b>苏轼</b></li>
        <li><i>苏洵</i></li>
        <li><a href="http://www.google.cn" id="谷歌">欢迎使用谷歌浏览器</a></li>
      </ul>
    </div>
  </body>
</html>

5. 常见操作

5.1 获取单个 / 多个节点

from lxml import etree

tree = etree.parse("test.html")

# 节点(Element 对象列表)
titles = tree.xpath("/html/head/title")
print(titles)        # [<Element title at 0x...>]
print(titles[0].text)

# 文本
title_text = tree.xpath("/html/head/title/text()")[0]
print(title_text)    # 古代诗人及作品

⚠️ Xpath 返回 列表。[0] 取出第一个;想取唯一值时务必确认结果个数。

5.2 跨层 + 任意位置

# 所有 <p>
ps = tree.xpath("//p")
print(len(ps))       # 6

5.3 属性定位

# 找 class="name" 的 div
name_div = tree.xpath('//div[@class="name"]')

# 取 class="name" 下所有 <p> 文本
names = tree.xpath('//div[@class="name"]/p/text()')
print(names)          # ['李白', '白居易', '李清照', '杜甫', '王安石']

# 索引定位(第 3 个 <p>)
print(tree.xpath('//div[@class="name"]/p[3]/text()'))   # ['李清照']

5.4 取属性值

# 拿某个 <a> 的 href
href = tree.xpath('//div[@class="tang"]//li/a[1]/@href')[0]

# 多个
hrefs = tree.xpath('//div[@class="tang"]//li/a/@href')
print(hrefs)

5.5 拿一列 <li> 下的全部文本

# 第 1 个 <li> 下 a/b/i 全部合并
items = tree.xpath(
    '//div[@class="tang"]//li/a/text() | '
    '//div[@class="tang"]//li/b/text() | '
    '//div[@class="tang"]//li/i/text()'
)
print(items)

注意 | 是 XPath 的“或”运算符,优先级低于 /,必要时用括号分组。


6. 实战:从小说网站抓取古龙作品列表

import requests
import pandas as pd
from lxml import etree


def fetch(url: str) -> str:
    """很多老站点默认 GBK 编码,先按 gbk 解码。"""
    resp = requests.get(
        url,
        headers={"User-Agent": "Mozilla/5.0"},
        timeout=10,
    )
    resp.raise_for_status()
    return resp.content.decode("gbk", errors="replace")


def parse(html: str) -> pd.DataFrame:
    tree = etree.HTML(html)

    hrefs = tree.xpath("//tbody/tr//a/@href")
    names = tree.xpath("//tbody/tr//a/text()")
    base = "https://www.kanunu8.com/book"
    return pd.DataFrame({
        "name": names,
        "url": [base + h for h in hrefs],
    })


if __name__ == "__main__":
    url = "https://www.kanunu8.com/zj/10867.html"
    df = parse(fetch(url))
    print(df.head())
    df.to_excel("gulong.xlsx", index=False)

⚠️ 实际抓取时务必:

  • 遵守目标网站的服务条款与 robots.txt;
  • 限速(一般 ≤ 1 QPS);
  • 站点结构常变,XPath 选择器要可配置,别写死。

7. 在浏览器里抄 XPath

  1. 打开 DevTools(F12)→ Elements 面板。
  2. 选中目标节点,右键 → Copy → Copy XPath。
  3. 浏览器复制的 XPath 通常是 /html/body/div[3]/...,过于脆弱。改成
    //div[@class="xxx"]/... 这种“语义化”版本。

Chrome DevTools 的 $x("//p") 控制台命令可以直接跑 XPath,结果会高亮。


8. lxml 的 CSS 选择器(可选)

lxml 也支持 cssselect:

pip install cssselect
from lxml import etree

tree = etree.parse("test.html")
# 等价于 //div[@class="name"]/p
for p in tree.cssselect("div.name > p"):
    print(p.text)

习惯 CSS 的人会觉得这个更顺手。


9. 常见问题

  • 结果总是空? 先 print(etree.tostring(tree)[:200]) 看 HTML 是否成功加载。
  • HTML 不规范? etree.HTML 会补全;但有些残缺页面连 lxml 也救不了,
    上 BeautifulSoup 兜底。
  • 想拿整段 HTML(含子节点)? 用 etree.tostring(node, pretty_print=True).decode()。
  • xpath(...) 返回 <Element> 还是字符串? 看表达式里有没有 text() /
    @attr —— 没有就返回节点。
  • XPath 1.0 vs 2.0? lxml 只支持 XPath 1.0(部分 2.0 扩展)。够用。

10. 速查表

需求 XPath 表达式
所有 a 节点 //a
第一个 a //a[1]
class 含 name 的 div //div[contains(@class, "name")]
取文本 //a/text()
取属性 //a/@href
多条件 //a[@class="x" and text()="y"]
多分支 //a
在某个节点下找子节点 ./p / .//p
最后一个 //li[last()]