XPath 详解
XPath(XML Path Language)是一种用于在 XML 和 HTML 文档中定位节点的查询语言。在网页爬虫中,XPath 是非常强大的元素定位工具,比 CSS 选择器更加灵活。
什么是 XPath?
XPath 使用路径表达式来选取 XML 或 HTML 文档中的节点或节点集。这些路径表达式和我们在常规电脑文件系统中看到的路径表达式非常相似。
XPath 的优势
| 特性 | 说明 |
|---|---|
| 灵活性 | 支持复杂的条件查询和逻辑运算 |
| 文本匹配 | 可以直接根据文本内容定位元素 |
| 层级导航 | 支持向上查找父元素、向前查找兄弟元素 |
| 函数支持 | 内置大量函数用于字符串、数值等处理 |
| 跨平台 | 被多种语言和工具支持(Selenium、Scrapy、lxml 等) |
XPath 与 CSS 选择器对比
| 功能 | XPath | CSS 选择器 |
|---|---|---|
| 按标签选择 | //div | div |
| 按属性选择 | //div[@class="content"] | div.content |
| 按文本选择 | //a[text()="登录"] | 不支持 |
| 按位置选择 | //li[1] | li:first-child |
| 查找父元素 | //div/.. | 不支持 |
| 查找兄弟元素 | //div/following-sibling::p | div ~ p |
基本语法
路径表达式
/ 从根节点选取
// 从匹配选择的当前节点选择文档中的节点,而不考虑它们的位置
. 选取当前节点
.. 选取当前节点的父节点
@ 选取属性
节点选择
# 选择所有 div 元素
//div
# 选择根元素 html
/html
# 选择 body 内的所有 p 元素
/html/body/p
# 选择所有 p 元素(无论在哪一层级)
//p
# 选择 id 为 main 的元素
//*[@id="main"]
# 选择 class 包含 content 的 div
//div[@class="content"]
# 选择所有带有 href 属性的 a 元素
//a[@href]
使用 Python 和 lxml 执行 XPath
from lxml import etree
# 从 HTML 字符串创建解析树
html = """
<html>
<body>
<div id="main">
<h1>标题</h1>
<ul class="list">
<li>项目1</li>
<li>项目2</li>
<li>项目3</li>
</ul>
</div>
</body>
</html>
"""
# 解析 HTML
tree = etree.HTML(html)
# 执行 XPath 查询
titles = tree.xpath('//h1/text()')
print(titles) # ['标题']
items = tree.xpath('//li/text()')
print(items) # ['项目1', '项目2', '项目3']
# 获取元素
div = tree.xpath('//div[@id="main"]')[0]
print(div.get('id')) # main
在 Selenium 中使用 XPath
from selenium import webdriver
from selenium.webdriver.common.by import By
driver = webdriver.Chrome()
driver.get('https://example.com')
# 使用 XPath 定位元素
element = driver.find_element(By.XPATH, '//div[@id="main"]')
# 获取多个元素
elements = driver.find_elements(By.XPATH, '//li')
# 通过文本定位
login_btn = driver.find_element(By.XPATH, '//a[text()="登录"]')
driver.quit()
在 Scrapy 中使用 XPath
import scrapy
class MySpider(scrapy.Spider):
name = 'my_spider'
def parse(self, response):
# 使用 xpath 方法
titles = response.xpath('//h1/text()').getall()
# 获取单个元素
title = response.xpath('//h1/text()').get()
# 获取属性
links = response.xpath('//a/@href').getall()
for link in links:
yield {'url': link}
谓语(Predicates)
谓语用于查找特定节点或包含特定值的节点,写在方括号 [] 中。
位置谓语
# 选择第一个 li 元素
//li[1]
# 选择最后一个 li 元素
//li[last()]
# 选择倒数第二个 li 元素
//li[last()-1]
# 选择前两个 li 元素
//li[position() < 3]
# 选择奇数位置的 li 元素
//li[position() mod 2 = 1]
属性谓语
# 选择 class 属性为 content 的 div
//div[@class="content"]
# 选择有 id 属性的所有元素
//*[@id]
# 选择 href 属性以 https 开头的 a 元素
//a[starts-with(@href, "https")]
# 选择 href 属性包含 example 的 a 元素
//a[contains(@href, "example")]
# 选择 class 属性包含多个值的情况
//div[contains(@class, "content") and contains(@class, "main")]
# 选择属性不等于某个值
//div[@class != "hidden"]
文本谓语
# 选择文本等于"登录"的 a 元素
//a[text()="登录"]
# 选择文本包含"登录"的 a 元素
//a[contains(text(), "登录")]
# 选择文本以"Python"开头的元素
//h2[starts-with(text(), "Python")]
# 选择文本以"教程"结尾的元素(需要使用 substring 函数)
//h2[substring(text(), string-length(text()) - 1) = "教程"]
# 选择包含非空文本的元素
//p[text() and string-length(text()) > 0]
组合谓语
# 选择第一个有 class 属性的 div
//div[@class][1]
# 选择 class 为 content 且有 data-id 属性的 div
//div[@class="content"][@data-id]
# 使用逻辑运算符
//div[@class="content" or @class="main"]
//div[@class="content" and @id="main"]
# 使用 not 函数
//div[not(@class="hidden")]
# 数值比较
//li[position() > 2]
//li[position() >= 2 and position() <= 5]
XPath 轴(Axes)
XPath 轴用于定义相对于当前节点的节点集。这是 XPath 相比 CSS 选择器最强大的功能之一。
轴的语法
轴名称::节点测试[谓语]
常用轴详解
child 子轴
# 选择当前节点的所有子元素
child::*
# 选择当前节点的所有 li 子元素
child::li
# 等价于
/li
parent 父轴
# 选择当前节点的父节点
parent::*
# 选择当前节点的父节点(如果父节点是 div)
parent::div
# 常用简写
..
ancestor 祖先轴
# 选择当前节点的所有祖先元素
ancestor::*
# 选择当前节点的所有 div 祖先
ancestor::div
# 选择当前节点的祖先中第一个 div
ancestor::div[1]
descendant 后代轴
# 选择当前节点的所有后代元素
descendant::*
# 选择当前节点的所有 p 后代
descendant::p
# 简写形式
.//p
following-sibling 后续兄弟轴
# 选择当前节点之后的所有兄弟节点
following-sibling::*
# 选择当前节点之后的所有 p 兄弟节点
following-sibling::p
# 选择紧邻的下一个兄弟节点
following-sibling::*[1]
preceding-sibling 前置兄弟轴
# 选择当前节点之前的所有兄弟节点
preceding-sibling::*
# 选择当前节点之前的所有 p 兄弟节点
preceding-sibling::p
# 选择紧邻的上一个兄弟节点
preceding-sibling::*[1]
following 后续轴
# 选择当前节点之后的所有节点(不包括后代)
following::*
# 选择当前节点之后的所有 p 节点
following::p
preceding 前置轴
# 选择当前节点之前的所有节点(不包括祖先)
preceding::*
# 选择当前节点之前的所有 p 节点
preceding::p
attribute 属性轴
# 选择当前节点的所有属性
attribute::*
# 选择 href 属性
attribute::href
# 简写形式
@href
轴的实际应用示例
from lxml import etree
html = """
<div id="container">
<div class="header">
<h1>网站标题</h1>
</div>
<div class="content">
<p class="intro">介绍段落</p>
<ul>
<li id="item1">项目1</li>
<li id="item2">项目2</li>
<li id="item3">项目3</li>
<li id="item4">项目4</li>
</ul>
<p class="footer">页脚段落</p>
</div>
</div>
"""
tree = etree.HTML(html)
# 示例1:选择项目2之后的所有兄弟 li
items = tree.xpath('//li[@id="item2"]/following-sibling::li/text()')
print(items) # ['项目3', '项目4']
# 示例2:选择项目4之前的所有兄弟 li
items = tree.xpath('//li[@id="item4"]/preceding-sibling::li/text()')
print(items) # ['项目1', '项目2', '项目3']
# 示例3:选择 li 元素的父元素
parent = tree.xpath('//li[@id="item1"]/parent::*')[0]
print(parent.tag) # ul
# 示例4:选择 li 元素的所有 div 祖先
ancestors = tree.xpath('//li[@id="item1"]/ancestor::div')
print([a.get('class') for a in ancestors]) # ['content', None]
# 示例5:选择包含 li 的 div 的所有 p 后代
paragraphs = tree.xpath('//div[.//li]/descendant::p/text()')
print(paragraphs) # ['介绍段落', '页脚段落']
常用函数
XPath 内置了大量函数,用于字符串处理、数值计算、布尔判断等。
字符串函数
# string() - 转换为字符串
string(//div)
# concat() - 连接字符串
concat(//h1/text(), " - ", //h2/text())
# substring() - 截取子串
substring(//h1/text(), 1, 5) # 从第1个字符开始,取5个
# substring-before() - 获取分隔符前的内容
substring-before(//a/@href, "?") # 获取 URL 中 ? 之前的部分
# substring-after() - 获取分隔符后的内容
substring-after(//a/@href, "?") # 获取 URL 中 ? 之后的部分
# string-length() - 字符串长度
string-length(//h1/text())
# normalize-space() - 去除首尾空白,压缩中间空白
normalize-space(//div/text())
# translate() - 字符替换
translate(//h1/text(), "abc", "ABC") # 将 abc 替换为 ABC
# upper-case() - 转大写(XPath 2.0)
upper-case(//h1/text())
# lower-case() - 转小写(XPath 2.0)
lower-case(//h1/text())
数值函数
# number() - 转换为数值
number(//span[@class="price"])
# sum() - 求和
sum(//li/@data-value)
# floor() - 向下取整
floor(3.7) # 3
# ceiling() - 向上取整
ceiling(3.2) # 4
# round() - 四舍五入
round(3.5) # 4
布尔函数
# boolean() - 转换为布尔值
boolean(//div)
# not() - 逻辑非
not(//div[@class="hidden"])
# true() - 返回 true
true()
# false() - 返回 false
false()
# lang() - 检查语言
lang("zh")
节点函数
# name() - 获取节点名称
name(//div) # div
# local-name() - 获取本地名称(忽略命名空间)
local-name(//div)
# position() - 获取节点位置
//li[position() < 3]
# last() - 获取最后一个位置
//li[last()]
# count() - 计算节点数量
count(//li)
# id() - 通过 id 选择
id("main")
实际应用示例
from lxml import etree
html = """
<div class="product">
<h2 class="title">Python 编程教程</h2>
<p class="price">¥59.90</p>
<p class="stock">库存: 100</p>
<ul class="tags">
<li>Python</li>
<li>编程</li>
<li>教程</li>
</ul>
</div>
"""
tree = etree.HTML(html)
# 使用 normalize-space 清理空白
title = tree.xpath('normalize-space(//h2[@class="title"]/text())')
print(title) # Python 编程教程
# 提取价格数字
price = tree.xpath('substring-after(//p[@class="price"]/text(), "¥")')
print(price) # 59.90
# 提取库存数字
stock = tree.xpath('substring-after(//p[@class="stock"]/text(), "库存: ")')
print(stock) # 100
# 计算标签数量
tag_count = tree.xpath('count(//ul[@class="tags"]/li)')
print(int(tag_count)) # 3
# 连接所有标签
tags = tree.xpath('concat(//ul[@class="tags"]/li[1]/text(), ", ", //ul[@class="tags"]/li[2]/text(), ", ", //ul[@class="tags"]/li[3]/text())')
print(tags) # Python, 编程, 教程
高级技巧
处理动态 class
很多网页的 class 是动态生成的,使用 contains 可以匹配部分 class:
# class 包含 "product-"
//div[contains(@class, "product-")]
# class 同时包含多个值
//div[contains(@class, "product") and contains(@class, "item")]
# class 以特定前缀开头
//div[starts-with(@class, "product-")]
# 使用 substring 匹配特定模式
//div[substring(@class, string-length(@class) - 5) = "-active"]
处理表格数据
from lxml import etree
html = """
<table id="data">
<tr>
<th>姓名</th>
<th>年龄</th>
<th>城市</th>
</tr>
<tr>
<td>张三</td>
<td>25</td>
<td>北京</td>
</tr>
<tr>
<td>李四</td>
<td>30</td>
<td>上海</td>
</tr>
</table>
"""
tree = etree.HTML(html)
# 获取表头
headers = tree.xpath('//table[@id="data"]//th/text()')
print(headers) # ['姓名', '年龄', '城市']
# 获取数据行
rows = tree.xpath('//table[@id="data"]//tr[td]')
for row in rows:
cells = row.xpath('./td/text()')
print(cells) # ['张三', '25', '北京'] ...
# 获取特定单元格
cell = tree.xpath('//table[@id="data"]//tr[2]/td[1]/text()')[0]
print(cell) # 张三
# 获取年龄大于 25 的行
rows = tree.xpath('//tr[td[2] > 25]')
相对 XPath
在解析时,可以先定位到一个容器元素,然后使用相对路径进一步查找:
from lxml import etree
html = """
<div class="article">
<h1>文章标题</h1>
<div class="content">
<p>段落1</p>
<p>段落2</p>
</div>
<div class="meta">
<span class="author">作者: 张三</span>
<span class="date">2024-01-01</span>
</div>
</div>
"""
tree = etree.HTML(html)
# 先定位到文章容器
article = tree.xpath('//div[@class="article"]')[0]
# 使用相对路径获取标题
title = article.xpath('.//h1/text()')[0]
print(title) # 文章标题
# 使用相对路径获取段落
paragraphs = article.xpath('.//div[@class="content"]/p/text()')
print(paragraphs) # ['段落1', '段落2']
# 使用相对路径获取作者
author = article.xpath('.//span[@class="author"]/text()')[0]
author = author.replace('作者: ', '')
print(author) # 张三
性能优化技巧
# 1. 避免使用 // 开头的绝对路径,尽量使用相对路径
# 慢
tree.xpath('//div[@class="article"]//p')
# 快
article = tree.xpath('//div[@class="article"]')[0]
article.xpath('.//p')
# 2. 使用更具体的路径减少搜索范围
# 慢
tree.xpath('//p')
# 快
tree.xpath('/html/body/div[@class="content"]/p')
# 3. 尽量使用属性选择器而非位置选择器
# 慢
tree.xpath('//div/div[3]/p')
# 快
tree.xpath('//div[@class="article"]/p')
# 4. 合并多个查询为一个
# 慢
titles = tree.xpath('//h1/text()')
authors = tree.xpath('//span[@class="author"]/text()')
# 快
data = tree.xpath('//h1/text() | //span[@class="author"]/text()')
常见问题解决
处理命名空间
from lxml import etree
# 某些 XML 或 XHTML 文档有命名空间
xml = """
<root xmlns="http://example.com/ns">
<item>内容</item>
</root>
"""
tree = etree.fromstring(xml.encode())
# 直接查询会失败
items = tree.xpath('//item')
print(items) # []
# 方法1:使用命名空间
ns = {'ns': 'http://example.com/ns'}
items = tree.xpath('//ns:item', namespaces=ns)
print(items) # [<Element item>]
# 方法2:使用 local-name 忽略命名空间
items = tree.xpath('//*[local-name()="item"]')
print(items) # [<Element item>]
处理编码问题
from lxml import etree
# 确保正确处理中文
html = """
<html>
<body>
<h1>中文标题</h1>
</body>
</html>
"""
# 方法1:指定编码
tree = etree.fromstring(html.encode('utf-8'))
# 方法2:使用 HTML 解析器(自动检测编码)
parser = etree.HTMLParser(encoding='utf-8')
tree = etree.fromstring(html.encode('utf-8'), parser)
title = tree.xpath('//h1/text()')[0]
print(title) # 中文标题
完整示例:新闻爬虫
from lxml import etree
import requests
def scrape_news(url):
"""使用 XPath 爬取新闻网站"""
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
response = requests.get(url, headers=headers)
response.encoding = response.apparent_encoding
tree = etree.HTML(response.text)
# 提取新闻列表
articles = []
# 定位到新闻列表容器
news_list = tree.xpath('//div[@class="news-list"]')[0]
# 使用相对路径提取每条新闻
for item in news_list.xpath('./div[@class="news-item"]'):
article = {}
# 提取标题
title_elem = item.xpath('.//h2[@class="title"]/a')
if title_elem:
article['title'] = title_elem[0].xpath('./text()')[0]
article['url'] = title_elem[0].xpath('./@href')[0]
# 提取摘要
summary = item.xpath('.//p[@class="summary"]/text()')
article['summary'] = summary[0] if summary else ''
# 提取时间
date = item.xpath('.//span[@class="date"]/text()')
article['date'] = date[0] if date else ''
# 提取作者
author = item.xpath('.//span[@class="author"]/text()')
article['author'] = author[0].replace('作者: ', '') if author else ''
# 提取标签
tags = item.xpath('.//div[@class="tags"]/a/text()')
article['tags'] = tags
articles.append(article)
return articles
# 使用示例
if __name__ == '__main__':
# 这里使用示例 URL,实际使用时替换为目标网站
# url = 'https://news.example.com'
# articles = scrape_news(url)
# 演示用模拟数据
html = """
<div class="news-list">
<div class="news-item">
<h2 class="title"><a href="/article/1">Python 3.12 正式发布</a></h2>
<p class="summary">Python 3.12 带来了许多新特性和性能改进...</p>
<span class="date">2024-01-15</span>
<span class="author">作者: 张三</span>
<div class="tags">
<a href="/tag/python">Python</a>
<a href="/tag/news">新闻</a>
</div>
</div>
<div class="news-item">
<h2 class="title"><a href="/article/2">机器学习入门指南</a></h2>
<p class="summary">本文介绍机器学习的基础概念和学习路径...</p>
<span class="date">2024-01-14</span>
<span class="author">作者: 李四</span>
<div class="tags">
<a href="/tag/ml">机器学习</a>
<a href="/tag/tutorial">教程</a>
</div>
</div>
</div>
"""
tree = etree.HTML(html)
news_list = tree.xpath('//div[@class="news-list"]')[0]
for item in news_list.xpath('./div[@class="news-item"]'):
title = item.xpath('.//h2[@class="title"]/a/text()')[0]
url = item.xpath('.//h2[@class="title"]/a/@href')[0]
summary = item.xpath('.//p[@class="summary"]/text()')[0]
date = item.xpath('.//span[@class="date"]/text()')[0]
print(f'标题: {title}')
print(f'链接: {url}')
print(f'摘要: {summary}')
print(f'日期: {date}')
print('---')
XPath 速查表
基本路径
| 表达式 | 说明 | 示例 |
|---|---|---|
/ | 从根节点选择 | /html/body |
// | 选择任意位置的节点 | //div |
. | 当前节点 | ./p |
.. | 父节点 | ../div |
@ | 属性 | @href |
谓语
| 谓语 | 说明 |
|---|---|
[n] | 第 n 个节点 |
[last()] | 最后一个节点 |
[@attr] | 有 attr 属性 |
[@attr="value"] | attr 属性等于 value |
[contains(@attr, "value")] | attr 属性包含 value |
[starts-with(@attr, "value")] | attr 属性以 value 开头 |
[text()="value"] | 文本等于 value |
[contains(text(), "value")] | 文本包含 value |
常用轴
| 轴 | 说明 |
|---|---|
child:: | 子节点(默认) |
parent:: | 父节点 |
ancestor:: | 祖先节点 |
descendant:: | 后代节点 |
following-sibling:: | 之后的兄弟节点 |
preceding-sibling:: | 之前的兄弟节点 |
following:: | 之后的所有节点 |
preceding:: | 之前的所有节点 |
attribute:: | 属性 |
常用函数
| 函数 | 说明 |
|---|---|
text() | 获取文本内容 |
string() | 转换为字符串 |
contains() | 包含判断 |
starts-with() | 开头判断 |
normalize-space() | 规范化空白 |
substring() | 截取子串 |
string-length() | 字符串长度 |
concat() | 连接字符串 |
position() | 节点位置 |
last() | 最后位置 |
count() | 节点计数 |
not() | 逻辑非 |
小结
本章我们学习了:
- XPath 基础 - 路径表达式和节点选择
- 谓语 - 位置、属性、文本条件
- XPath 轴 - 层级导航的强大工具
- 内置函数 - 字符串、数值、布尔函数
- 高级技巧 - 动态 class、表格、相对路径
- 实战应用 - 在 lxml、Selenium、Scrapy 中使用 XPath
XPath 是网页解析的利器,掌握它可以大大提高爬虫开发的效率。
练习
- 使用 XPath 从一个网页中提取所有链接
- 编写一个 XPath 表达式选择特定表格中的数据
- 使用轴查找某个元素之后的所有兄弟元素