文章

XPath匹配

XPath匹配

XPath 是用于在 XML/HTML 文档中导航和查询节点的语言。

一、XPath 基础语法

1. 节点选择

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 示例 XML
<?xml version="1.0" encoding="UTF-8"?>
<bookstore>
  <book category="cooking">
    <title lang="en">Everyday Italian</title>
    <author>Giada De Laurentiis</author>
    <year>2005</year>
    <price>30.00</price>
  </book>
  <book category="children">
    <title lang="en">Harry Potter</title>
    <author>J.K. Rowling</author>
    <year>2005</year>
    <price>29.99</price>
  </book>
  <book category="web">
    <title lang="en">XQuery Kick Start</title>
    <author>James McGovern</author>
    <year>2003</year>
    <price>49.99</price>
  </book>
</bookstore>
1
2
3
4
5
6
7
8
9
10
11
from lxml import etree
import requests

# 加载示例 XML
url = "http://example.com/books.xml"
# response = requests.get(url)
# tree = etree.fromstring(response.content)

# 或者使用本地文件
tree = etree.parse("books.xml")
root = tree.getroot()

2. 基本路径表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 选择所有节点
*                         # 所有元素节点
@*                        # 所有属性节点
node()                    # 所有类型节点

# 绝对路径(从根开始)
/bookstore                # 根元素 bookstore
/bookstore/book           # bookstore 的直接子 book 元素

# 相对路径(从当前节点)
./book                    # 当前节点下的 book 元素
../author                # 父节点下的 author 元素

# 任意位置(递归查找)
//book                    # 文档中所有 book 元素
//book/title             # 所有 book 下的 title 元素

二、谓词(条件筛选)

1. 位置索引

1
2
3
4
5
6
//book[1]                 # 第一个 book 元素
//book[last()]           # 最后一个 book 元素
//book[last()-1]         # 倒数第二个
//book[position()<3]     # 前两个 book 元素

# 注意:XPath 索引从 1 开始,不是 0

2. 属性筛选

1
2
3
4
5
//book[@category]                #  category 属性的 book
//book[@category='web']         # category="web"  book
//book[@category!='web']        # category 不等于 "web"
//book[@lang='en']              # lang="en"  book
//book[not(@category)]          # 没有 category 属性的 book

3. 内容筛选

1
2
3
4
5
6
7
//title[text()='Harry Potter']          # 文本等于
//title[contains(text(), 'Harry')]      # 文本包含
//title[starts-with(text(), 'Harry')]   # 文本以...开头
//title[ends-with(text(), 'Potter')]    # 文本以...结尾(XPath 2.0
//book[author='J.K. Rowling']           # 子元素内容等于
//book[price>35]                        # 数值比较
//book[year>2004]                       # 数值比较

4. 组合条件

1
2
3
4
//book[@category='web' and price>40]     # 与运算
//book[@category='web' or @category='cooking']  # 或运算
//book[price>20 and year>2004]           # 多个条件
//book[author='J.K. Rowling'][price>25]  # 链式筛选

三、获取节点内容

1. 获取文本

1
2
3
4
//title/text()                   # 所有 title 的文本内容
//book/author/text()            # 所有 author 的文本
//book[1]/title/text()          # 第一个 book  title 文本
string(//book[1])               # 第一个 book 的所有文本(拼接)

2. 获取属性

1
2
3
4
//book/@category                # 所有 book  category 属性
//title/@lang                   # 所有 title  lang 属性
//book[1]/@category            # 第一个 book  category
//book[@category='web']/@category  # 特定 book  category

3. 获取完整节点

1
2
//book                         # 返回 book 元素节点本身
//book[1]                      # 返回第一个 book 元素

四、轴(Axes) - 按关系选择

1. 常用轴

1
2
3
4
5
6
7
8
9
10
//book/child::title           # book 的子节点 title(等价于 //book/title
//title/parent::book          # title 的父节点 book
//book/ancestor::*            # book 的所有祖先节点
//book/descendant::*          # book 的所有后代节点
//book/following::*           # book 之后的所有节点
//book/preceding::*           # book 之前的所有节点
//book/following-sibling::*   # book 之后的同级节点
//book/preceding-sibling::*   # book 之前的同级节点
//book/self::*                # book 自身
//book/attribute::*           # book 的所有属性

2. 轴的实际应用

1
2
3
4
5
6
7
8
9
10
11
# 选择某个 author 的所有兄弟节点
//author[text()='J.K. Rowling']/following-sibling::*

# 选择某个节点的所有后代文本
//bookstore/descendant::text()

# 选择某个节点之前的所有 price 节点
//book[@category='web']/preceding::price

# 选择某个节点的所有属性
//book[1]/attribute::*

五、函数(Functions)

1. 字符串函数

1
2
3
4
5
6
7
concat(//author[1], ' - ', //title[1])  # 字符串连接
substring(//title[1], 1, 5)            # 子字符串
string-length(//title[1])              # 字符串长度
translate(//title[1], 'aeiou', 'AEIOU') # 字符替换
normalize-space(//title[1])            # 去除首尾空格并压缩内部空格
upper-case(//title[1])                 # 转为大写(XPath 2.0
lower-case(//title[1])                 # 转为小写(XPath 2.0

2. 数值函数

1
2
3
4
5
6
7
8
sum(//book/price)                     # 所有 price 的总和
avg(//book/price)                     # 平均值(XPath 2.0
min(//book/year)                      # 最小值(XPath 2.0
max(//book/year)                      # 最大值(XPath 2.0
count(//book)                         # book 的数量
floor(//book[1]/price)                # 向下取整
ceiling(//book[1]/price)              # 向上取整
round(//book[1]/price)                # 四舍五入

3. 节点集函数

1
2
3
4
5
6
count(//book)                         # 节点数量
position()                            # 当前节点位置
last()                                # 最后一个位置
name(//book[1])                       # 节点名称
local-name(//book[1])                 # 本地名称(无命名空间)
namespace-uri(//book[1])              # 命名空间 URI

六、运算符

1
2
3
4
5
6
7
8
//book[price > 20 and price < 40]     # 数值比较
//book[price = 29.99]                 # 等于
//book[price != 29.99]                # 不等于
//book[price >= 30]                   # 大于等于
//book[price <= 30]                   # 小于等于

//book[1] | //book[2]                 # 并集(两个节点集的合并)
//book/title | //book/author          # 合并 title  author

七、命名空间处理

1
2
3
4
5
6
<!-- 带命名空间的 XML -->
<root xmlns:bk="http://example.com/books">
  <bk:book>
    <bk:title>XML Guide</bk:title>
  </bk:book>
</root>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from lxml import etree

xml = '''<root xmlns:bk="http://example.com/books">
  <bk:book>
    <bk:title>XML Guide</bk:title>
  </bk:book>
</root>'''

root = etree.fromstring(xml)

# 方法1:使用命名空间前缀
ns = {'bk': 'http://example.com/books'}
titles = root.xpath('//bk:book/bk:title/text()', namespaces=ns)
print(titles)  # ['XML Guide']

# 方法2:使用本地名称
titles = root.xpath('//*[local-name()="book"]/*[local-name()="title"]/text()')
print(titles)  # ['XML Guide']

# 方法3:使用通配符加条件
titles = root.xpath('//*[name()="bk:book"]/*[name()="bk:title"]/text()')
print(titles)  # ['XML Guide']

八、Python lxml 中的 XPath 使用

1. 基本使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from lxml import etree

# 解析 XML
tree = etree.parse('books.xml')
root = tree.getroot()

# 执行 XPath 查询
# 返回元素列表
books = root.xpath('//book')
for book in books:
    print(book.tag, book.attrib)

# 返回文本列表
titles = root.xpath('//title/text()')
print(titles)  # ['Everyday Italian', 'Harry Potter', 'XQuery Kick Start']

# 返回属性列表
categories = root.xpath('//book/@category')
print(categories)  # ['cooking', 'children', 'web']

# 返回布尔值
has_web = root.xpath('boolean(//book[@category="web"])')
print(has_web)  # True

# 返回数字
book_count = root.xpath('count(//book)')
print(book_count)  # 3.0

# 返回字符串
first_title = root.xpath('string(//book[1]/title)')
print(first_title)  # 'Everyday Italian'

2. 编译 XPath 表达式(提高性能)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from lxml import etree

tree = etree.parse('books.xml')

# 编译常用表达式
find_books = etree.XPath('//book')
find_titles = etree.XPath('//title/text()')
find_expensive_books = etree.XPath('//book[price>35]')

# 使用编译后的表达式
books = find_books(tree)
titles = find_titles(tree)
expensive_books = find_expensive_books(tree)

print(f"Found {len(books)} books")
print(f"Titles: {titles}")

3. 使用变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from lxml import etree

tree = etree.parse('books.xml')

# 方法1:使用 XPath 类
find_books_by_category = etree.XPath('//book[@category=$cat]')
web_books = find_books_by_category(tree, cat='web')
print(f"Web books: {len(web_books)}")

# 方法2:使用 format(注意安全性)
category = 'web'
# 注意:这种方法有注入风险,如果 category 来自用户输入,请使用方法1
books = tree.xpath(f'//book[@category="{category}"]')

# 方法3:字符串拼接(不推荐,有注入风险)
category = 'web'
books = tree.xpath('//book[@category="' + category + '"]')

九、高级技巧和示例

1. 复杂查询示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 查找价格在 25-50 之间的书
//book[price>=25 and price<=50]

# 查找 2005 年出版的书,按价格降序
//book[year=2005] sortby(price descending)  # XPath 2.0

# 查找作者名包含 "James" 的书
//book[contains(author, 'James')]

# 查找第二本书的第三个兄弟节点
//book[2]/following-sibling::*[3]

# 查找所有有文本内容的节点
//*[text() and normalize-space(text())!='']

# 查找深度为3的节点
//*[count(ancestor::*) = 2]

# 查找属性值包含特定字符串
//book[contains(@category, 'child')]

2. 实际网页抓取示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from lxml import html
import requests

# 获取网页
url = 'https://books.toscrape.com/'
response = requests.get(url)
tree = html.fromstring(response.content)

# 提取所有书籍信息
books = []

# 方法1:分别提取每个字段
titles = tree.xpath('//h3/a/text()')
prices = tree.xpath('//div[@class="product_price"]/p[@class="price_color"]/text()')
availabilities = tree.xpath('//div[@class="product_price"]/p[@class="instock availability"]/text()[normalize-space()]')

for i in range(len(titles)):
    books.append({
        'title': titles[i].strip(),
        'price': prices[i].strip(),
        'availability': availabilities[i].strip() if i < len(availabilities) else 'N/A'
    })

# 方法2:一次提取每本书的所有信息
book_elements = tree.xpath('//article[@class="product_pod"]')
for book_el in book_elements:
    title = book_el.xpath('.//h3/a/text()')[0].strip()
    price = book_el.xpath('.//p[@class="price_color"]/text()')[0].strip()
    availability = book_el.xpath('.//p[@class="instock availability"]/text()[normalize-space()]')[0].strip()
  
    books.append({
        'title': title,
        'price': price,
        'availability': availability
    })

print(f"Found {len(books)} books")

3. 处理动态属性

1
2
3
4
5
6
7
8
9
10
11
12
# 假设 class 属性是动态生成的
# <div class="product-12345">...</div>
# <div class="product-67890">...</div>

# 使用 starts-with
elements = tree.xpath('//div[starts-with(@class, "product-")]')

# 使用 contains
elements = tree.xpath('//div[contains(@class, "product-")]')

# 使用 translate 忽略大小写
elements = tree.xpath('//div[contains(translate(@class, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), "product-")]')

十、调试和测试 XPath

1. 在浏览器中测试

  • Chrome/Edge:F12 → Console → $x('//your/xpath')
  • Firefox:F12 → Console → $x('//your/xpath')

2. 在线测试工具

3. Python 调试技巧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from lxml import etree

def debug_xpath(tree, xpath):
    """调试 XPath 表达式"""
    try:
        result = tree.xpath(xpath)
        print(f"XPath: {xpath}")
        print(f"结果类型: {type(result)}")
        print(f"结果数量: {len(result)}")
      
        if len(result) > 0:
            print("前5个结果:")
            for i, item in enumerate(result[:5]):
                if isinstance(item, etree._Element):
                    print(f"  {i+1}. 元素: {item.tag}, 属性: {item.attrib}")
                    if item.text and item.text.strip():
                        print(f"     文本: {item.text.strip()[:50]}...")
                else:
                    print(f"  {i+1}. {type(item).__name__}: {str(item)[:50]}...")
        else:
            print("未找到匹配项")
        print("-" * 50)
    except Exception as e:
        print(f"错误: {e}")

# 使用示例
tree = etree.parse('books.xml')
debug_xpath(tree, '//book[price>30]')
debug_xpath(tree, '//title[contains(text(), "Harry")]')

十一、性能优化

1. 避免低效表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#  低效:递归搜索两次
//bookstore//book//title

#  高效:一次定位
//book/title

#  低效:使用多个 // 开头
//div//p//span

#  高效:具体路径
//div[@id="content"]//span

#  低效:过度使用 *
//*[@class="price"]

#  高效:指定标签名
//span[@class="price"]

2. 使用索引优化

1
2
3
4
5
6
# 如果只需要第一个结果
(//book)[1]          # 先找到所有,再取第一个(低效)
//book[1]            # 直接找第一个(高效)

# 限制结果数量
//book[position() <= 10]  # 只取前10

十二、常见问题解决

1. 处理特殊字符

1
2
3
4
5
6
7
8
9
10
11
12
13
# XPath 中的引号转义
# 单引号包含双引号
elements = tree.xpath('//div[@class="foo"]')

# 双引号包含单引号
elements = tree.xpath("//div[@class='foo']")

# 包含两种引号
elements = tree.xpath('//div[contains(text(), \'"quote"\')]')
elements = tree.xpath("//div[contains(text(), \"'quote'\")]")

# 使用 concat 函数
elements = tree.xpath('//div[contains(text(), concat(\'"\', "quote", \'"\'))]')

2. 处理命名空间(简化版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 移除命名空间以便简化 XPath
def remove_namespaces(tree):
    """移除所有命名空间"""
    for elem in tree.iter():
        if elem.tag.startswith('{'):
            elem.tag = elem.tag.split('}', 1)[1]
        for attr in list(elem.attrib):
            if attr.startswith('{'):
                del elem.attrib[attr]
    return tree

# 使用
tree = etree.parse('with_namespace.xml')
clean_tree = remove_namespaces(tree)
# 现在可以使用简单的 XPath
titles = clean_tree.xpath('//book/title/text()')

3. 处理默认命名空间

1
2
3
4
5
6
<!-- XML 有默认命名空间 -->
<root xmlns="http://example.com">
  <book>
    <title>XML Guide</title>
  </book>
</root>
1
2
3
# 需要为默认命名空间分配前缀
ns = {'def': 'http://example.com'}
titles = tree.xpath('//def:book/def:title/text()', namespaces=ns)

十三、练习和测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 测试你的 XPath 技能
test_xml = '''
<catalog>
  <product id="1">
    <name>Laptop</name>
    <price>999.99</price>
    <category>Electronics</category>
    <stock>15</stock>
  </product>
  <product id="2">
    <name>Book</name>
    <price>19.99</price>
    <category>Education</category>
    <stock>100</stock>
  </product>
  <product id="3">
    <name>Phone</name>
    <price>699.99</price>
    <category>Electronics</category>
    <stock>25</stock>
  </product>
</catalog>
'''

tree = etree.fromstring(test_xml)

# 练习:
# 1. 选择所有电子产品
# 2. 选择价格超过 500 的产品
# 3. 选择库存小于 20 的产品
# 4. 选择产品名称包含 "oo" 的产品
# 5. 获取所有产品 ID
# 6. 计算所有产品的总价值(价格 × 库存)

掌握 XPath 需要练习,建议:

  1. 使用浏览器的开发者工具练习
  2. 尝试不同的网站,编写 XPath 提取数据
  3. 理解轴和谓词的组合使用
  4. 注意性能,编写高效的 XPath 表达式
本文由作者按照 CC BY 4.0 进行授权