自动化实战:CSS 定位怎么写才稳定?
从稳定属性、属性匹配、范围控制到影刀里的文字定位、唯一性验证和排错路线,整理网页自动化中更可维护的 CSS 定位方法。
刚开始学自动化,CSS 定位很容易被讲成“背几个语法”:#id、.class、[name="xxx"]。但真实项目里,真正难的不是写出一个能用的选择器,而是写出一个稳定、可读、出错后好排查的选择器。
这篇文章不再停留在 CSS 入门。目标是让实习生能从“会写选择器”,进一步走到“知道为什么这样写、什么时候不用这样写、怎么验证它是不是可靠”。
先命中元素,再提高匹配精度,最后把范围限定到弹窗、表格行或业务卡片里,并验证结果是否唯一。
button[data-action="submit"]先找到大致目标,避免坐标点击。
[class*="comments--"]用等于、包含、开头和结尾匹配处理动态属性。
.dialog .confirm先锁定弹窗、表格行或卡片,再找里面的按钮。
len(elements) == 1不是“能找到”就结束,而是确认只找到目标元素。
本文默认场景是网页自动化,尤其是影刀编码版里常见的 browser.find_by_css()、browser.find_all_by_css() 和 browser.find_by_xpath()。如果只是临时点一次页面,写得能用就行;如果要交给 RPA 长期跑,就必须把定位当成可维护代码来写。
先把定位分成三层能力
CSS 定位可以先分成三层,不要一上来就陷入语法细节。
| 层级 | 解决什么问题 | 典型写法 |
|---|---|---|
| 基础命中 | 通过标签、id、class、属性找到元素 | #submit、.primary、input[name="keyword"] |
| 精准匹配 | 对属性做等于、包含、开头、结尾等匹配 | [data-id^="order-"]、[class*="comments--"] |
| 范围控制 | 先限定弹窗、表格行、卡片,再找内部按钮 | .dialog .confirm、tr[data-id="1"] button |
入门阶段只要知道第一层。真正做自动化项目,至少要掌握第二层和第三层。
先快速过一遍基础:这些够你开始写定位
基础选择器不需要背很多,先记住它们分别适合什么场景。
| 写法 | 例子 | 适合什么时候用 |
|---|---|---|
| 标签 | button、input | 只做大范围候选,不建议单独点击 |
| id | #submit-order | id 稳定且页面唯一 |
| class | .primary、.button.primary | class 有业务含义,不是随机样式名 |
| 属性 | input[name="username"] | 自动化里最常用,优先看 name、data-*、aria-label |
| 组合 | input.form-input[name="keyword"] | 单个条件不唯一时,再加必要条件 |
| 范围 | .dialog .confirm | 页面有多个同名按钮时,先限定弹窗、表格行或卡片 |
一个登录输入框可能长这样:
<input
id="username"
class="login-input"
name="username"
placeholder="请输入用户名"
type="text"
/>
如果 name="username" 稳定,就优先写:
input[name="username"]
不优先写:
input[placeholder="请输入用户名"]
原因很简单:name 更像程序字段,placeholder 更像给用户看的提示文案,后者更容易被产品文案调整影响。
基础部分先记住一句话:优先找能表达功能的稳定属性,不要一上来复制浏览器生成的完整路径。
属性匹配:从入门到中等水平的关键
很多页面没有干净的 id,也没有专门给自动化准备的 data-testid。这时只会写 [name="xxx"] 还不够,需要掌握属性匹配。
| 写法 | 含义 | 适合场景 |
|---|---|---|
[attr="value"] | 属性值完全等于 | 精确匹配 name、type、data-action |
[attr^="value"] | 属性值以某段开头 | id 或 class 有固定前缀 |
[attr$="value"] | 属性值以某段结尾 | 文件名、链接、状态后缀 |
[attr*="value"] | 属性值包含某段文本 | 动态 class 有稳定片段 |
[attr~="value"] | 属性值按空格拆分后包含某个词 | class 这类空格分隔属性 |
| `[attr | =“value”]` | 属性值等于 value 或以 value- 开头 |
可以先按这个判断:
| 真实场景 | 优先写法 | 例子 |
|---|---|---|
| 属性完全稳定 | 精确匹配 | [data-action="submit"] |
| 前缀稳定,后面变化 | 开头匹配 | [id^="order-"] |
| 后缀稳定 | 结尾匹配 | [href$=".xlsx"] |
| 中间有稳定片段 | 包含匹配 | [class*="comments--"] |
| class 是多个独立词 | 空格词匹配 | [class~="primary"] |
越模糊的匹配,越要验证数量。尤其是 *=,它不是“更高级”,只是“更宽松”。
精确匹配
input[name="keyword"]
button[data-action="submit"]
这是最推荐的属性写法。只要属性稳定,精确匹配最清楚,也最不容易误伤。
开头匹配
tr[data-order-id^="202607"]
表示 data-order-id 以 202607 开头。它适合批量找某一类元素,但不适合直接点击,因为它通常会匹配多个。
自动化里更常见的用法是先找一组候选:
rows = browser.find_all_by_css('tr[data-order-id^="202607"]', timeout=10)
然后再根据文本、状态或其它属性继续筛选。
结尾匹配
a[href$=".xlsx"]
img[src$=".png"]
这种写法适合下载链接、图片、文件类型判断。但如果页面里同类链接很多,仍然要先限定范围。
包含匹配
[class*="comments--"]
这类写法常用于前端框架生成的动态 class。
例如页面里出现:
<div class="comments--ChxC7GEN">评价区</div>
后半段 ChxC7GEN 看起来像随机字符,但 comments-- 是稳定语义,就可以用包含匹配:
div[class*="comments--"]
不过要注意:*= 是模糊匹配,范围容易变大。能用 data-*、name、aria-label 时,不要优先用它。
空格词匹配
button[class~="primary"]
它表示 class 列表里有一个完整的 primary 词。下面两个元素都能匹配:
<button class="button primary large">提交</button>
<button class="primary">提交</button>
但这个不会匹配:
<button class="primary-button">提交</button>
这和 [class*="primary"] 不一样。*= 只要包含文本就行,~= 要求是独立词。
关系选择器:控制范围的核心
CSS 选择器不只是“找某个元素”,还可以表达元素之间的关系。
| 写法 | 含义 | 示例 |
|---|---|---|
A B | A 内部任意层级的 B | .dialog button |
A > B | A 的直接子元素 B | .menu > li |
A + B | A 后面紧挨着的 B | label + input |
A ~ B | A 后面同级的所有 B | .title ~ button |
后代选择器:最常用
.dialog .confirm
表示在 .dialog 内部找 .confirm。弹窗、卡片、表格行、表单区域都常用这种思路。
直接子元素:避免层级过深误匹配
.menu > li
只找 .menu 下面第一层的 li,不会继续往更深层级找。
相邻兄弟:根据前后结构找元素
label[for="password"] + input
它表示找到 for="password" 的 label 后,紧挨着它的 input。
这种写法在后台表单里偶尔有用,但依赖结构,页面布局调整后可能失效。能用 name 还是优先用 name。
伪类选择器:能用,但要知道风险
伪类选择器可以表达状态或位置,但自动化里不要滥用。
| 写法 | 含义 | 自动化建议 |
|---|---|---|
:nth-child(n) | 第 n 个子元素 | 最后再用,容易受数据变化影响 |
:first-child | 第一个子元素 | 适合固定结构,不适合动态列表 |
:last-child | 最后一个子元素 | 适合固定结构,不适合分页表格 |
:not(...) | 排除某类元素 | 可用于排除禁用项或隐藏项 |
:checked | 被选中的复选框或单选框 | 适合状态判断 |
:disabled | 禁用状态 | 适合判断按钮是否可用 |
:has(...) | 包含某个子元素 | 需按运行环境验证,不要默认所有环境都稳定 |
:not() 用于排除干扰项
button:not([disabled])
表示找没有 disabled 属性的按钮。实际项目里通常还要加范围:
.order-dialog button.submit:not([disabled])
:has() 可以表达“包含关系”,但要谨慎
tr:has([data-status="待处理"]) button.process
这个选择器的意思是:找包含 data-status="待处理" 的表格行,再找这一行里的处理按钮。
它表达能力很强,但自动化项目里要注意两点:
- 影刀内置浏览器或实际运行环境是否支持,需要运行验证;
- 如果可以通过
tr[data-order-id="..."]直接定位,就不要为了炫技使用:has()。
中等水平不是会用更复杂的语法,而是知道什么时候不用复杂语法。
自动化里的 CSS 定位四步法
写 CSS 时不要一上来复制浏览器生成的超长路径。先按下面四步判断,定位通常会更短,也更容易维护。
页面主体 / 弹窗 / 表格 / 商品卡片
id / name / data-* / aria-label
用尽量少的条件唯一定位
匹配结果是 0 个 / 1 个 / 多个
- 1数据属性 / name / aria-label
- 2稳定且有意义的 id
- 3稳定且有意义的 class
- 4标签 + 属性组合
- 5nth-child,最后再考虑
这张图只保留方法,不再重复每一种语法。实际写代码时,可以把它当成检查顺序:先看范围,再看稳定特征,再组合,最后验证数量。
CSS、XPath、get_text() 怎么选?
这一步要提前讲,因为真实页面并不是所有元素都适合用 CSS。
有稳定属性
↓
优先使用 CSS
没有稳定属性,但按钮文字明确
↓
使用 XPath 按文字定位
需要先限定弹窗、表格或卡片范围,再判断文字
↓
CSS 找候选元素,再用 get_text() 筛选
元素在 iframe、弹窗或动态加载区域里
↓
先处理页面上下文和加载状态,再谈选择器
也就是说,CSS 是自动化里的主力定位方式,但不是唯一方式。遇到“只有文字稳定”的按钮时,硬凑 CSS 反而会把问题复杂化。
按文字定位:不要写 :contains()
标准 CSS 不能根据元素内部显示的文字进行匹配。下面这种写法并不是有效的 CSS:
button[text="提交订单"]
button:contains("提交订单")
在影刀编码版中,如果按钮文字稳定,可以直接用 XPath:
button = browser.find_by_xpath(
'//button[normalize-space(.)="提交订单"]',
timeout=10,
)
button.click()
如果需要先限定弹窗范围,再按文字筛选,可以先用 CSS 拿候选元素:
buttons = browser.find_all_by_css(".order-dialog button", timeout=10)
for button in buttons:
if button.get_text().strip() == "提交订单":
button.click()
break
else:
raise RuntimeError('未找到文本为“提交订单”的按钮')
这里的关键点是:CSS 负责缩小范围,文字判断发生在 Python 代码里。
怎么在浏览器里查看和验证?
第一步:打开开发者工具
在 Chrome 或 Edge 中按 F12,或者在目标元素上点击右键,选择“检查”。
第二步:观察元素特征
重点看这些内容:
- 标签名;
- id;
- class;
- name;
- placeholder;
- data 属性;
- aria-label。
不要看到一大段 HTML 就慌。先问自己:有没有稳定 id?有没有能表达功能的属性?是否需要先限定一个区域?
第三步:在 Console 验证
document.querySelectorAll('input[name="username"]')
重点看返回数量:
| 数量 | 说明 | 下一步 |
|---|---|---|
| 0 个 | 没找到,或者元素还没有加载 | 检查语法、页面状态和 iframe |
| 1 个 | 通常是理想结果 | 再确认它是不是目标元素 |
| 多个 | 定位范围太大 | 增加区域或稳定条件 |
在影刀里也可以用 find_all_by_css() 做类似验证:
elements = browser.find_all_by_css('input[name="username"]', timeout=10)
if len(elements) != 1:
raise RuntimeError(f"定位结果不唯一,当前数量={len(elements)}")
elements[0].input("test_user")
一个好的定位不能只看“现在能找到”,还要看“以后是否容易失效”。
选择器明明对,为什么还是找不到?
这部分是新手最容易卡住的地方。很多时候不是 CSS 写错了,而是页面状态不对。
| 现象 | 常见原因 | 排查方向 |
|---|---|---|
| Console 能找到,影刀找不到 | 页面还没加载完成 | 先等待页面加载、弹窗出现或列表渲染完成 |
| CSS 写法没问题,但结果是 0 个 | 元素在 iframe 里 | 先确认当前操作的是不是正确 iframe 或页面对象 |
| 只在点击后才出现 | 元素是弹窗、下拉框或异步区域 | 先触发页面动作,再查找元素 |
| 明明看得见,代码找不到 | 元素可能在 Shadow DOM、虚拟列表或特殊控件里 | 需要单独探测页面结构,不要直接猜选择器 |
find_by_css() 报匹配多个 | 选择器范围太大 | 改用 find_all_by_css() 看数量,再增加范围条件 |
| 昨天能跑,今天失效 | id、class 或页面结构动态变化 | 改用稳定属性、包含匹配或业务范围定位 |
使用 :has() 不稳定 | 运行环境支持不确定 | 在影刀实际运行环境验证,不能只看浏览器 Console |
影刀里排查定位问题时,我通常先用 find_all_by_css() 看数量,而不是直接 find_by_css():
selector = '.order-dialog button.submit:not([disabled])'
elements = browser.find_all_by_css(selector, timeout=10)
if len(elements) == 0:
raise RuntimeError(f"未找到元素:{selector}")
if len(elements) > 1:
raise RuntimeError(f"定位不唯一:{selector},数量={len(elements)}")
elements[0].click()
这样写虽然多几行,但报错信息会更清楚。后续排查时,能马上知道是“没找到”,还是“找到太多”。
实战案例:从“能找到”到“可维护”
案例一:登录输入框
<input type="text" name="username" placeholder="请输入账号">
推荐:
input[name="username"]
不优先使用:
input[placeholder="请输入账号"]
原因是 name="username" 更像程序字段,placeholder 更像给用户看的提示文案,后者更容易因为产品文案调整而变化。
案例二:弹窗确认按钮
<div class="dialog">
<button class="cancel">取消</button>
<button class="confirm">确认</button>
</div>
推荐:
.dialog .confirm
不要只写:
.confirm
因为页面其它区域也可能有确认按钮。自动化里经常不是元素本身难找,而是同名元素太多。
案例三:表格某一行的处理按钮
<tr data-order-id="20260713001">
<td>20260713001</td>
<td>待处理</td>
<td><button class="process-button">处理</button></td>
</tr>
推荐:
tr[data-order-id="20260713001"] .process-button
这个写法的重点不是 .process-button,而是前面的 tr[data-order-id="20260713001"]。先定位业务记录,再操作这一行内部按钮,这比直接找第几个按钮稳定得多。
案例四:动态 class 的评价区
<div class="comments--ChxC7GEN">评价内容</div>
如果确认 comments-- 是稳定前缀,可以写:
div[class*="comments--"]
但这类选择器必须验证数量:
areas = browser.find_all_by_css('div[class*="comments--"]', timeout=10)
if len(areas) != 1:
raise RuntimeError(f"评价区定位不唯一,当前数量={len(areas)}")
这就是中等水平和入门水平的差别:入门只看“能不能找到”,中等水平会继续确认“是不是只找到了一个”。
案例五:把坏定位改造成好定位
很多新手会从浏览器里复制出这种选择器:
body > div:nth-child(3) > div > div:nth-child(2) > button:nth-child(2)
它的问题不是“不能用”,而是太依赖页面结构。只要页面多一个弹窗、多一层容器,或者按钮顺序变化,就可能点错。
如果页面结构是这样:
<div class="order-dialog">
<button class="btn">取消</button>
<button class="btn" data-action="confirm">确认</button>
</div>
更好的写法是:
.order-dialog button[data-action="confirm"]
如果没有 data-action,但弹窗范围和按钮文字稳定,就不要硬凑 CSS,可以用 CSS 加 get_text():
buttons = browser.find_all_by_css(".order-dialog button", timeout=10)
for button in buttons:
if button.get_text().strip() == "确认":
button.click()
break
else:
raise RuntimeError("未找到弹窗里的确认按钮")
这个案例要记住的不是某个固定答案,而是改造方向:少依赖页面层级,多依赖业务范围、稳定属性和可验证结果。
给实习生的练习
假设页面 HTML 如下:
<div class="login-panel">
<input name="account" placeholder="请输入账号">
<input name="password" type="password">
<button class="login-button" data-action="login">登录</button>
</div>
先自己写,再展开答案:
查看参考答案
input[name="account"]
input[name="password"]
button[data-action="login"]再加一个中等难度练习:
<div class="order-dialog">
<div class="row" data-order-id="20260713001">
<span class="status pending">待处理</span>
<button class="btn btn-primary">处理</button>
</div>
<div class="row" data-order-id="20260713002">
<span class="status done">已完成</span>
<button class="btn btn-primary">查看</button>
</div>
</div>
目标:点击订单 20260713001 这一行里的按钮。
查看参考答案
.order-dialog .row[data-order-id="20260713001"] button.btn-primary这里分成三层:先限定弹窗 .order-dialog,再找到对应订单行 .row[data-order-id="20260713001"],最后操作这一行里的按钮。