CSS 选择器 vs XPath:完整指南
使用 query() 方法时,您有两种强大的选择器语言可供选择:CSS 选择器和 XPath。了解何时以及如何使用每种语言对于有效的元素定位至关重要。
根本差异
| 方面 | CSS 选择器 | XPath |
|---|---|---|
| 语法 | 简单,类似 CSS | XML 路径语言 |
| 性能 | 更快 (浏览器原生支持) | 稍慢 |
| 方向 | 只能向下和横向遍历 | 可以向任何方向遍历 |
| 文本匹配 | 有限 (伪选择器) | 强大的文本函数 |
| 复杂性 | 最适合简单到中等的情况 | 擅长处理复杂关系 |
| 可读性 | Web 开发人员更直观 | 学习曲线更陡峭 |
何时使用 CSS 选择器
CSS 选择器是以下情况的理想选择:
- 通过 ID、类或标签进行简单的元素选择
- 直接的父子关系
- 具有简单模式的属性匹配
- 对性能要求严格的场景
- 在 DOM 中向下遍历时
# 简洁高效的 CSS 示例
await tab.query("#login-form")
await tab.query(".submit-button")
await tab.query("div.container > p.intro")
await tab.query("input[type='email'][required]")
await tab.query("ul.menu li:first-child")
何时使用 XPath
XPath 是以下情况的理想选择:
- 复杂的文本匹配和部分文本搜索
- 向上遍历到父元素
- 查找相对于兄弟元素的元素
- 选择器中的条件逻辑
- 复杂的 DOM 关系
# 强大的 XPath 示例
await tab.query("//button[contains(text(), 'Submit')]")
await tab.query("//input[@name='email']/parent::div")
await tab.query("//td[text()='John']/following-sibling::td[2]")
await tab.query("//div[contains(@class, 'product') and @data-price > 100]")
CSS 选择器语法参考
基本选择器
# 元素选择器
await tab.query("div") # 第一个 <div> 元素
await tab.query("div", find_all=True) # 所有 <div> 元素
await tab.query("button") # 第一个 <button> 元素
# ID 选择器
await tab.query("#username") # id="username" 的元素
# 类选择器
await tab.query(".submit-btn") # 第一个 class="submit-btn" 的元素
await tab.query(".submit-btn", find_all=True) # 所有带该类的元素
await tab.query(".btn.primary") # 第一个同时具有这两个类的元素
# 通用选择器
await tab.query("*", find_all=True) # 所有元素
组合器
# 后代组合器 (空格)
await tab.query("div p") # <div> 内的第一个 <p>
await tab.query("div p", find_all=True) # <div> 内的所有 <p> (任何深度)
# 子组合器 (>)
await tab.query("div > p") # <div> 直接子元素中的第一个 <p>
await tab.query("div > p", find_all=True) # 所有作为直接子元素的 <p>
# 相邻兄弟组合器 (+)
await tab.query("h1 + p") # 紧跟 <h1> 后的 <p>
# 通用兄弟组合器 (~)
await tab.query("h1 ~ p") # <h1> 后的第一个 <p> 兄弟元素
await tab.query("h1 ~ p", find_all=True) # <h1> 后的所有 <p> 兄弟元素
属性选择器
# 属性存在
await tab.query("input[required]") # 第一个带 'required' 的 input
await tab.query("input[required]", find_all=True) # 所有带 'required' 的 input
# 属性等于
await tab.query("input[type='email']") # 第一个 email input
await tab.query("input[type='email']", find_all=True) # 所有 email input
# 属性包含单词
await tab.query("div[class~='active']") # 第一个 class 中包含 'active' 的 div
# 属性以...开头
await tab.query("a[href^='https://']") # 第一个 HTTPS 链接
await tab.query("a[href^='https://']", find_all=True) # 所有 HTTPS 链接
# 属性以...结尾
await tab.query("img[src$='.png']") # 第一个 PNG 图像
await tab.query("img[src$='.png']", find_all=True) # 所有 PNG 图像
# 属性包含子字符串
await tab.query("a[href*='example']") # 第一个 href 中包含 'example' 的链接
await tab.query("a[href*='example']", find_all=True) # 所有 href 中包含 'example' 的链接
# 不区分大小写匹配
await tab.query("input[type='text' i]") # 不区分大小写匹配
伪类
# 结构伪类
await tab.query("li:first-child") # 作为第一个子元素的第一个 <li>
await tab.query("li:last-child") # 作为最后一个子元素的第一个 <li>
await tab.query("li:nth-child(2)") # 作为第二个子元素的第一个 <li>
await tab.query("li:nth-child(odd)", find_all=True) # 所有奇数位置的 <li>
await tab.query("li:nth-child(even)", find_all=True) # 所有偶数位置的 <li>
await tab.query("li:nth-child(3n)", find_all=True) # 每第 3 个 <li>
# 类型伪类
await tab.query("p:first-of-type") # 兄弟元素中的第一个 <p>
await tab.query("p:last-of-type") # 兄弟元素中的最后一个 <p>
await tab.query("p:nth-of-type(2)") # 兄弟元素中的第二个 <p>
# 状态伪类
await tab.query("input:enabled") # 第一个启用的 input
await tab.query("input:enabled", find_all=True) # 所有启用的 input
await tab.query("input:disabled") # 第一个禁用的 input
await tab.query("input:checked") # 第一个选中的 checkbox/radio
await tab.query("input:focus") # 当前获得焦点的 input
# 其他有用的伪类
await tab.query("div:empty") # 第一个空元素
await tab.query("div:empty", find_all=True) # 所有空元素
await tab.query("div:not(.exclude)") # 第一个没有 'exclude' 类的 div
await tab.query("div:not(.exclude)", find_all=True) # 所有没有 'exclude' 类的 div
XPath 语法参考
基本路径表达式
# 绝对路径 (从根开始)
await tab.query("/html/body/div") # 处于该精确路径的第一个 div
# 相对路径 (从任何地方开始)
await tab.query("//div") # 第一个 <div> 元素
await tab.query("//div", find_all=True) # 所有 <div> 元素
await tab.query("//div/p") # 任何 <div> 内的第一个 <p>
await tab.query("//div/p", find_all=True) # 任何 <div> 内的所有 <p>
# 当前节点
await tab.query("./div") # 相对于当前的第一个 <div>
# 父节点
await tab.query("..") # 当前节点的父节点
属性选择
# 基本属性匹配
await tab.query("//input[@type='email']") # 第一个 email input
await tab.query("//input[@type='email']", find_all=True) # 所有 email input
await tab.query("//div[@id='content']") # id='content' 的 div
# 多个属性
await tab.query("//input[@type='text' and @required]") # 第一个匹配项
await tab.query("//input[@type='text' and @required]", find_all=True) # 所有匹配项
await tab.query("//div[@class='card' or @class='panel']") # 第一个 card 或 panel
# 属性存在
await tab.query("//button[@disabled]") # 第一个 disabled button
await tab.query("//button[@disabled]", find_all=True) # 所有 disabled button
XPath 轴 (方向导航)
XPath 的真正威力来自于它能够在 DOM 树中向任何方向导航。
轴参考表
| 轴 | 方向 | 描述 | 示例 |
|---|---|---|---|
child:: |
向下 | 仅直接子元素 | //div/child::p |
descendant:: |
向下 | 所有后代 (任何深度) | //div/descendant::a |
parent:: |
向上 | 直接父元素 | //input/parent::div |
ancestor:: |
向上 | 所有祖先 (任何深度) | //span/ancestor::div |
following-sibling:: |
横向 | 当前元素之后的所有兄弟元素 | //h1/following-sibling::p |
preceding-sibling:: |
横向 | 当前元素之前的所有兄弟元素 | //p/preceding-sibling::h1 |
following:: |
向前 | 当前节点之后的所有节点 | //h1/following::* |
preceding:: |
向后 | 当前节点之前的所有节点 | //h1/preceding::* |
ancestor-or-self:: |
向上 | 祖先 + 当前节点 | //div/ancestor-or-self::* |
descendant-or-self:: |
向下 | 后代 + 当前节点 | //div/descendant-or-self::* |
self:: |
当前 | 仅当前节点 | //div/self::div |
attribute:: |
属性 | 当前节点的属性 | //div/attribute::class |
简写语法
//div是//descendant-or-self::div的简写//div/p是//div/child::p的简写@id是attribute::id的简写..是parent::node()的简写
实用轴示例
# 导航到父元素
await tab.query("//input[@name='email']/parent::div")
await tab.query("//span[@class='error']/..") # 简写
# 查找祖先元素
await tab.query("//input/ancestor::form") # 第一个祖先 <form>
await tab.query("//button/ancestor::div[@class='modal']")
# 兄弟元素导航
await tab.query("//label[text()='Email:']/following-sibling::input")
await tab.query("//h2/following-sibling::p[1]") # <h2> 后的第一个 <p>
await tab.query("//h2/following-sibling::p", find_all=True) # <h2> 后的所有 <p>
await tab.query("//button/preceding-sibling::input[last()]")
# 复杂关系
await tab.query("//tr/td[1]/following-sibling::td[2]") # 第一行中的第 3 个单元格
await tab.query("//tr/td[1]/following-sibling::td[2]", find_all=True) # 所有行中的第 3 个单元格
XPath 函数
文本函数
# 精确文本匹配
await tab.query("//button[text()='Submit']")
# 包含文本
await tab.query("//p[contains(text(), 'welcome')]")
# 以...开头
await tab.query("//a[starts-with(@href, 'https://')]")
# 文本规范化 (移除多余的空白)
await tab.query("//button[normalize-space(text())='Submit']")
# 字符串长度
await tab.query("//input[string-length(@value) > 5]")
# 字符串连接
await tab.query("//div[concat(@data-first, @data-last)='JohnDoe']")
数字函数
# 位置匹配
await tab.query("//li[position()=1]") # 第一个 <li>
await tab.query("//li[position() > 3]", find_all=True) # 第 3 个之后的所有 <li>
await tab.query("//li[last()]") # 最后一个 <li>
await tab.query("//li[last()-1]") # 倒数第二个
# 计数
await tab.query("//ul[count(li) > 5]") # 第一个包含超过 5 个 li 的 <ul>
await tab.query("//ul[count(li) > 5]", find_all=True) # 所有包含超过 5 个 li 的 <ul>
# 数值运算
await tab.query("//div[@data-price > 100]") # 第一个 price > 100 的 div
await tab.query("//div[@data-price > 100]", find_all=True) # 所有
await tab.query("//div[number(@data-stock) = 0]") # 第一个 stock = 0 的
布尔函数
# 布尔逻辑
await tab.query("//div[@visible='true' and @enabled='true']") # 第一个匹配项
await tab.query("//input[@type='text' or @type='email']") # 第一个 text 或 email
await tab.query("//input[@type='text' or @type='email']", find_all=True) # 所有
await tab.query("//button[not(@disabled)]") # 第一个启用的 button
await tab.query("//button[not(@disabled)]", find_all=True) # 所有启用的 button
# 存在性检查
await tab.query("//div[child::p]") # 第一个有 <p> 子元素的 div
await tab.query("//div[child::p]", find_all=True) # 所有有 <p> 子元素的 div
await tab.query("//div[not(child::*)]") # 第一个空 div
await tab.query("//div[not(child::*)]", find_all=True) # 所有空 div
XPath 谓词 (Predicates)
谓词使用方括号 [] 中的条件来过滤节点集。
# 位置谓词
await tab.query("(//div)[1]") # 文档中的第一个 <div>
await tab.query("(//div)[last()]") # 文档中的最后一个 <div>
await tab.query("//ul/li[3]") # <ul> 中的第一个第 3 个 <li>
await tab.query("//ul/li[3]", find_all=True) # 每个 <ul> 中的所有第 3 个 <li>
# 多个谓词 (AND 逻辑)
await tab.query("//input[@type='text'][@required]") # 第一个匹配项
await tab.query("//div[@class='product'][position() < 4]", find_all=True) # 前 3 个
# 属性谓词
await tab.query("//div[@data-id='123']")
await tab.query("//a[contains(@class, 'button')]") # 第一个匹配的链接
await tab.query("//input[starts-with(@name, 'user')]") # 第一个匹配的 input
真实世界示例:复杂元素查找
让我们使用一个真实的 HTML 结构来演示高级选择器。
示例 HTML 结构
<div class="dashboard">
<header>
<h1>User Dashboard</h1>
<nav class="menu">
<a href="/home" class="active">Home</a>
<a href="/profile">Profile</a>
<a href="/settings">Settings</a>
</nav>
</header>
<main>
<section class="products">
<h2>Available Products</h2>
<table id="products-table">
<thead>
<tr>
<th>Product Name</th>
<th>Price</th>
<th>Stock</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr data-product-id="101">
<td>Laptop</td>
<td class="price">$999</td>
<td class="stock">15</td>
<td>
<button class="btn-edit">Edit</button>
<button class="btn-delete">Delete</button>
</td>
</tr>
<tr data-product-id="102">
<td>Mouse</td>
<td class="price">$25</td>
<td class="stock">0</td>
<td>
<button class="btn-edit">Edit</button>
<button class="btn-delete" disabled>Delete</button>
</td>
</tr>
<tr data-product-id="103">
<td>Keyboard</td>
<td class="price">$75</td>
<td class="stock">8</td>
<td>
<button class="btn-edit">Edit</button>
<button class="btn-delete">Delete</button>
</td>
</tr>
</tbody>
</table>
</section>
<section class="user-form">
<h2>User Information</h2>
<form id="user-form">
<div class="form-group">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
<span class="error-message" style="display:none;">Invalid username</span>
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" id="email" name="email" required>
<span class="error-message" style="display:none;">Invalid email</span>
</div>
<div class="form-group">
<input type="checkbox" id="newsletter" name="newsletter">
<label for="newsletter">Subscribe to newsletter</label>
</div>
<button type="submit" class="btn-primary">Save Changes</button>
<button type="button" class="btn-secondary">Cancel</button>
</form>
</section>
</main>
</div>
挑战 1:查找活动的导航链接
目标:找到当前活动的导航链接。
# CSS 选择器
active_link = await tab.query("nav.menu a.active")
# XPath
active_link = await tab.query("//nav[@class='menu']//a[@class='active']")
# 获取其文本
text = await active_link.text
print(text) # "Home"
挑战 2:查找特定产品的编辑按钮
目标:找到产品 "Mouse" 的编辑按钮 (不知道其行位置)。
# XPath (推荐用于此情况)
edit_button = await tab.query(
"//tr[td[text()='Mouse']]//button[contains(@class, 'btn-edit')]"
)
# 备选方案:使用 following-sibling
edit_button = await tab.query(
"//td[text()='Mouse']/following-sibling::td//button[@class='btn-edit']"
)
为什么这里使用 XPath?
CSS 选择器无法向上遍历找到行,然后再向下找到按钮。XPath 在 DOM 中自由移动的能力使这变得微不足道。
挑战 3:查找所有价格超过 $50 的产品
目标:获取价格大于 $50 的所有表格行。
# 带有数值比较的 XPath
expensive_products = await tab.query(
"//tr[number(translate(td[@class='price'], '$,', '')) > 50]",
find_all=True
)
# 更易读的版本:对于更简单的情况使用 contains
# 这会查找价格包含特定金额的产品
products = await tab.query("//tr[contains(td[@class='price'], '$75')]", find_all=True)
文本到数字的转换
translate() 函数移除了 $ 和 , 字符,然后 number() 将其转换为数值进行比较。
挑战 4:查找所有缺货产品
目标:找到所有库存为 0 的产品。
# XPath
out_of_stock = await tab.query(
"//tr[td[@class='stock' and text()='0']]",
find_all=True
)
# 备选方案:查找所有行并检查库存
rows = await tab.query("//tbody/tr[td[@class='stock']/text()='0']", find_all=True)
挑战 5:通过标签查找输入字段
目标:首先定位其标签,然后找到 email 输入字段。
# XPath 使用 label 的 'for' 属性
email_input = await tab.query("//label[text()='Email:']/following-sibling::input")
# 备选方案:使用 for 属性
email_input = await tab.query("//input[@id=(//label[text()='Email:']/@for)]")
# 更通用的:按标签文本查找
username_input = await tab.query(
"//label[contains(text(), 'Username')]/following-sibling::input"
)
挑战 6:查找 Email 字段旁的错误消息
目标:获取出现在 email 输入字段旁边的错误消息 span。
# XPath - 查找 email input 的错误兄弟元素
error_span = await tab.query(
"//input[@id='email']/following-sibling::span[@class='error-message']"
)
# 备选方案:从父 div 导航
error_span = await tab.query(
"//input[@id='email']/parent::div//span[@class='error-message']"
)
# 检查可见性
is_visible = await error_span.is_visible()
挑战 7:查找提交按钮 (而不是取消按钮)
目标:找到提交按钮,排除取消按钮。
# CSS 选择器 (简单)
submit_button = await tab.query("button[type='submit']")
submit_button = await tab.query("button.btn-primary")
# 带文本的 XPath
submit_button = await tab.query("//button[text()='Save Changes']")
# 排除其他的 XPath
submit_button = await tab.query(
"//button[@type='submit' and not(@class='btn-secondary')]"
)
挑战 8:查找所有必填的表单字段
目标:获取表单中所有必填的 input 字段。
# CSS 选择器 (简洁)
required_fields = await tab.query(
"#user-form input[required]",
find_all=True
)
# XPath
required_fields = await tab.query(
"//form[@id='user-form']//input[@required]",
find_all=True
)
# 验证
for field in required_fields:
field_name = await field.get_attribute("name")
print(f"Required: {field_name}")
挑战 9:查找第一个未禁用的删除按钮
目标:找到第一个未被禁用的删除按钮。
# CSS 选择器
first_enabled_delete = await tab.query("button.btn-delete:not([disabled])")
# XPath
first_enabled_delete = await tab.query(
"//button[contains(@class, 'btn-delete') and not(@disabled)]"
)
# 获取所有启用的删除按钮
all_enabled = await tab.query(
"//button[@class='btn-delete' and not(@disabled)]",
find_all=True
)
挑战 10:按多个条件查找表格行
目标:查找库存 > 0 且价格 < $100 的产品。
# 具有复杂逻辑的 XPath
available_affordable = await tab.query(
"""
//tr[
number(td[@class='stock']) > 0
and
number(translate(td[@class='price'], '$', '')) < 100
]
""",
find_all=True
)
# 对于每个匹配的产品
for row in available_affordable:
cells = await row.query("td", find_all=True)
product_name = await cells[0].text
print(f"Available: {product_name}")
挑战 11:导航复杂关系
目标:从删除按钮获取同一行中的产品名称。
# 从删除按钮开始
delete_button = await tab.query("//tr[@data-product-id='101']//button[@class='btn-delete']")
# 导航到父行,然后到第一个单元格
product_name_cell = await delete_button.query("./ancestor::tr/td[1]")
product_name = await product_name_cell.text
print(product_name) # "Laptop"
# 备选方案:首先获取整行
row = await delete_button.query("./ancestor::tr")
product_id = await row.get_attribute("data-product-id")
print(product_id) # "101"
挑战 12:同时查找复选框及其标签
目标:找到 newsletter 复选框并验证其标签。
# 查找复选框
checkbox = await tab.query("#newsletter")
# 使用 'for' 属性获取关联的标签
label = await tab.query("//label[@for='newsletter']")
label_text = await label.text
print(label_text) # "Subscribe to newsletter"
# 备选方案:从复选框导航到标签
label = await checkbox.query("//following::label[@for='newsletter']")
# 检查是否选中
is_checked = await checkbox.is_checked()
高级模式:动态构建选择器
处理动态内容时,您可能需要以编程方式构建选择器:
async def find_product_by_name(tab, product_name: str):
"""通过名称动态查找产品行。"""
# 转义产品名称中的引号以防止 XPath 注入
safe_name = product_name.replace("'", "\\'")
xpath = f"//tr[td[text()='{safe_name}']]"
return await tab.query(xpath)
async def find_table_cell(tab, row_text: str, column_index: int):
"""通过行内容和列位置查找特定单元格。"""
xpath = f"//tr[td[contains(text(), '{row_text}')]]/td[{column_index}]"
return await tab.query(xpath)
# 用法
product_row = await find_product_by_name(tab, "Laptop")
price_cell = await find_table_cell(tab, "Laptop", 2)
price = await price_cell.text
print(price) # "$999"
性能比较
import asyncio
import time
async def benchmark_selectors(tab):
"""比较 CSS 与 XPath 的性能。"""
# 预热
await tab.query("#products-table")
# 基准测试 CSS
start = time.time()
for _ in range(100):
await tab.query("#products-table tbody tr", find_all=True)
css_time = time.time() - start
# 基准测试 XPath
start = time.time()
for _ in range(100):
await tab.query("//table[@id='products-table']//tbody//tr", find_all=True)
xpath_time = time.time() - start
print(f"CSS: {css_time:.3f}s")
print(f"XPath: {xpath_time:.3f}s")
print(f"CSS is {xpath_time/css_time:.2f}x faster")
# 典型结果:对于简单选择器,CSS 快 1.2-1.5 倍
性能 vs 可读性
虽然 CSS 选择器通常更快,但对于单个查询,差异通常可以忽略不计(毫秒级)。请选择使您的代码更具可读性和可维护性的选择器,特别是对于 XPath 擅长的复杂关系。
选择器最佳实践
1. 优先使用稳定的选择器
# 好的:使用语义属性
await tab.query("#user-email")
await tab.query("[data-testid='submit-button']")
await tab.query("input[name='username']")
# 避免:基于结构的脆弱选择器
await tab.query("div > div > div:nth-child(3) > input")
await tab.query("body > div:nth-child(2) > form > div:first-child")
2. 使用能工作的最简单的选择器
# 好的:简单高效
await tab.query("#login-form")
await tab.query(".submit-button")
# 避免:在不必要时过度复杂化
await tab.query("//div[@id='content']/descendant::form[@id='login-form']")
3. 适当组合 find() 和 query()
# 使用 find() 进行简单的属性匹配
username = await tab.find(id="username")
submit = await tab.find(tag_name="button", type="submit")
# 使用 query() 处理复杂模式
active_link = await tab.query("nav.menu a.active")
error_msg = await tab.query("//input[@name='email']/following-sibling::span[@class='error']")
4. 为复杂的选择器添加注释
# 查找包含产品 "Laptop" 的行中的 "Edit" 按钮
# XPath: 导航到带有 "Laptop" 文本的行, 然后查找编辑按钮
edit_button = await tab.query(
"//tr[td[text()='Laptop']]//button[@class='btn-edit']"
)
结论
通过理解 CSS 选择器和 XPath,以及它们各自的优势和用例,您可以创建出健壮且可维护的浏览器自动化,以处理现代 Web 应用程序的复杂性。请记住:
- 使用 CSS 选择器 进行简单的、对性能要求严格的选择
- 使用 XPath 处理复杂关系、文本匹配和向上导航
- 编写选择器时,选择稳定性 而非简洁性
- 注释复杂的查询 以保持代码的可读性
有关 Pydoll 内部如何使用这些选择器的更多信息,请参阅 FindElements Mixin 文档。