XPath完整笔记:从语法轴函数到移动端元素定位实战

发布时间:2026/10/27 16:38:49
XPath完整笔记:从语法轴函数到移动端元素定位实战 做爬虫和自动化测试这些年我电脑里存得最多的不是脚本而是一堆 XPath 表达式。原因很简单CSS 选择器在页面结构一变就废正则表达式在嵌套标签面前基本靠运气只有 XPath 能一路从兄弟节点摸到祖先再从祖先拐到隔壁分支把“我要的那个元素”从几千个 div 里精准揪出来。这篇内容就是我这些年攒下来的一份 XPath 完整笔记从最基础的节点路径一路写到轴、函数、条件组合和性能取舍最后拿一个本地生活服务类 APP下称“示例 APP”的界面元素匹配做落地演练——移动端的节点树和网页完全是两套东西写法上有很多反直觉的地方我会把踩过的坑一个个摊开讲。不管你是刚学爬虫、正在写 UI 自动化脚本还是做数据采集的工程师只要手上要跟 HTML 或 XML 打交道这篇都能直接拿去用。1. 我为什么还在用 XPath场景判断与方案取舍1.1 XPath 到底在解决什么问题先把定位说清楚。XPath 是一门在 XML/HTML 文档里“寻址”的语言它把整个文档看成节点树然后你用一条路径表达式去描述“从哪儿出发、走哪条路、到哪个节点停”。它的输出可以是单个节点、一组节点也可以是字符串、数字、布尔值。这一点很关键很多人以为 XPath 只能“选元素”其实它本身还能做计算比如count(//li)直接返回列表项数量string-length(normalize-space(//h1))能返回标题长度这些在做页面结构校验的时候特别好用。我在实际项目里用 XPath 主要干四件事一是数据采集时定位目标内容块二是 UI 自动化里定位按钮、输入框、列表项三是做页面结构断言比如“列表至少渲染出 20 条才算加载成功”四是在数据清洗阶段对 XML/接口返回报文做筛选。这四类场景对表达式的诉求差别很大抓数据要求“容错”自动化要求“唯一且稳定”断言要求“能计数”清洗要求“能按属性精确过滤”所以没有一个万能写法得分开设计。理解这一点之后你就不会陷入“背语法”的误区。XPath 的语法其实很少真正难的是针对具体页面判断该用哪种路径描述方式以及如何让这条路径在页面小改版之后依然活着。这部分经验只能靠一次次定位失败攒出来我后面会把方法拆成可复用的套路。1.2 XPath、CSS 选择器、正则三种方案的真实对比新手最常见的困惑是明明 CSS 选择器写起来更短为什么还要学 XPath我一般让人先看一张表把三者的能力边界摆出来选型时直接对号入座。能力维度XPathCSS 选择器正则表达式按文本内容定位原生支持text()登录不支持支持但脆弱向上找父节点/祖先支持parent::、ancestor::不支持不支持定位兄弟节点支持following-sibling::仅相邻兄弟、~不支持按位置序号取值支持[2]、[last()]、[position()3]支持:nth-child()不支持逻辑运算组合and、or、not()部分支持支持字符串函数contains/starts-with/normalize-space极少强大但需自行拼接执行性能中等//全树扫描较慢较快浏览器原生优化好取决于模式复杂度移动端原生控件支持支持依赖引擎实现基本不支持不适用结论很清楚只要涉及“按文字找元素”“往上找父级”“找同级兄弟”CSS 直接出局这不是写法问题而是能力缺失。移动端更极端原生控件树的定位基本只有 XPath、ID、accessibility 标识几条路可走CSS 选择器在那里几乎不存在。正则的问题不在能力在稳定性。我见过太多人用re.findall(rdiv classprice(.*?)/div, html)抓价格页面一加个换行、一改成span嵌套就全线崩。正则适合处理“已经拿到的一段纯文本”不适合处理“结构化的节点树”。正确的分工是XPath 负责从树里把节点取出来正则负责对取出来的文本做二次清洗。顺序千万别反。提示如果目标只是“给某个 class 的元素加样式”或“取一层子元素”用 CSS 更省事一旦判断里出现文本、父级、兄弟任一要素直接上 XPath别浪费时间绕。1.3 各引擎对 XPath 的支持差异别踩版本坑这一条新手最容易忽略但踩了会非常痛苦不同环境里跑的 XPath 版本不一样语法不是完全通用的。浏览器Chrome、Firefox 的document.evaluate和 Selenium 走的是XPath 1.0。Python 的lxml也是1.0。这意味着matches()、lower-case()、ends-with()、replace()这些 2.0 才有的函数统统不可用写了就报Invalid expression。我当初写了个ends-with(href,.pdf)本地测试一直报错查了半天才发现是版本问题。移动端的引擎差异更大。Android 侧基于 UIAutomator 的定位XPath 是由驱动层自己解析的能力是 1.0 的子集而且对轴的完整支持有限跨平台框架里同一个表达式在 Android 和 iOS 上因为控件类名体系不同一边是android.widget.TextView一边是XCUIElementTypeStaticText几乎不可能复用。这类差异后面第 4 章会展开讲。实践中我的处理办法是把 XPath 表达式当成“带方言的代码”来管理建立一份兼容性底线清单——只用 1.0 的核心函数不用matches、不用ends-with需要“以某串结尾”就用substring(href, string-length(href)-3).pdf这种等价写法绕过。这条经验值一次踩坑的时间。2. 基础语法把节点路径这件事说透2.1 七种节点类型与文档树模型学 XPath 之前必须先接受一个观念文档不是字符串是树。HTML 里每一个标签、每一个属性、每一段文字甚至注释都是树上的一个节点。XPath 定义了七种节点类型元素节点、属性节点、文本节点、命名空间节点、处理指令节点、注释节点、文档节点根节点。日常打交道最多的是前三种。这里有个高频误解div classprice29.9/div里div是元素节点class是属性节点29.9是div的子文本节点。所以//div[classprice]/text()能拿到价格而//div[classprice]拿到的是整个元素。很多人写//div[classprice]然后打印出一堆标签就是因为节点类型没对上。还有一个坑文本节点包含空白。格式化过的 HTML 里span 价格 /span的文本节点是价格带前后空格直接和价格比较会失败。这个问题在移动端尤其严重因为控件树里的 text 属性经常带不可见空白。解决方案后面会讲标准做法是normalize-space()包一层。至于“根节点”/表示文档根//是/descendant-or-self::node()/的缩写意思是“从当前节点往下任意深度找”。这两个符号的区别是很多人写错路径的根源绝对路径以/开头脆得一碰就断相对路径以//或.开头容错高得多。我默认只用//开头。2.2 路径表达式与四大轴的关系路径表达式里的每一段本质上是“轴 节点测试 谓词”的缩写。比如child::div[1]完整写法是“子轴 div 元素 位置 1”缩写成div[1]。日常写的//div[idapp]完整形式是descendant-or-self::node()/child::div[attribute::idapp]。XPath 一共有 13 条轴但常用的就四五条轴名含义典型用途child直接子节点默认轴常规层级下降descendant/descendant-or-self所有后代//的展开形式全局搜索parent直接父节点从内容回到容器ancestor所有祖先从文本回溯到某层容器取属性following-sibling之后的所有同级从标签跳到相邻的值preceding-sibling之前的所有同级反向取值attribute属性节点class的完整形式一开始我不建议背全 13 条就记下面四条缩写.是 self..是 parent是 attribute//是 descendant-or-self。这四条覆盖了 90% 的日常写法。等遇到“取完价格还要回到整个卡片去拿标题”这种需求时再去查ancestor::那时候你自然会记住它。2.3 谓词方括号里的门道比想象中多谓词是 XPath 的灵魂也是新手翻车最多的地方。先看几个最容易错的点。第一索引从 1 开始不是 0。//li[1]是第一个 li不是第二个。这个和绝大多数编程语言相反写 Python 的人第一次几乎必错。第二索引的优先级是“同一父节点下的位置”。//ul/li[1]取的是每个 ul 下的第一个 li而不是“所有 li 里的第一个”。如果你想要“文档里所有 li 的第一个”必须加括号(//ul/li)[1]。这个括号区别我强调三遍都不为过因为它直接影响你会不会取到 500 条重复数据的首条。第三谓词可以写逻辑和函数。//div[classitem and position()5]取前五条//div[not(data-hidden)]取没有 hidden 属性的//div[contains(class,active)]用于 class 是多值的情况。说到 class 多值这里有个必须讲的坑。div classitem item-active extra这个元素写//div[classitem]是选不到的因为属性值精确匹配要求完全相等。正确写法是//div[contains(concat( , normalize-space(class), ), item )]——前后补空格再匹配避免item误命中item-active。这个写法看起来啰嗦但它是判断“某个类名是否真的存在于 class 列表中”的标准解法我在正式的采集脚本里一律用它而不是裸用contains。2.4 通配符、多路径与运算符基础最后一块是三类实用语法。通配符*匹配任意元素*匹配任意属性node()匹配任意类型节点。//div/*就是 div 的所有子元素。这个在“结构固定但标签名不固定”的场景很好用比如前端框架换组件库时标签从span变成em用*能扛过去一段时间但我不建议长期依赖因为它会让表达式失去语义。多路径选择用|//h1 | //h2同时取两种标题返回结果是并集且按文档顺序排列。做内容提取时把正文里可能的标题层级一次性捞出来很实用。运算符方面记住这些 - * div mod做算术注意除号是div不是/ ! 做比较and or做逻辑|做并集。比较运算里有个细节在 1.0 里对节点集用是“存在任意一个相等即真”//div[classa or classb]和//div[classa | classb]的语义完全不同前者是过滤后者是合并结果集再过滤容易写出效果诡异的表达式。看一段可以拿来练手的结构后面章节的例子都基于它div classgoods-list div classgoods-item>// 1. 取所有商品标题文本 $x(//div[contains(concat( ,normalize-space(class), ), goods-item )]//h3[classtitle]/text()) // 2. 取第一个商品的价格注意括号 $x((//span[classprice])[1]/text()) // 3. 取月售里数字大于 1000 的商品标题1.0 无正则只能先取后过滤 $x(//div[data-id][number(substring-after(span[classsales],月售 ))1000]/h3/text())第三条里的number()加substring-after()是个很实用的组合遇到“月售 1200”这种带中文前缀的数字先用substring-after截掉前缀再用number()转数值就能在表达式层做数值比较。这个技巧在数据采集里省掉了很多 Python 侧的清洗代码。3. 高级用法从“能选中”到“选得稳”3.1 轴的真实用法回溯父级与跨级取兄弟基础够用之后真正的分水岭是会不会用轴。轴解决的是“元素之间没有直接父子关系但语义上有关联”的问题。最典型的需求列表里每个卡片有一个“状态标签”我需要根据标签文字判断整个卡片该不该抓。这时候从标签出发用ancestor::回到卡片容器拿>// 找到文字为已下架的标签向上回到最近的商品卡片取它的>dl classdetail dt配送费/dtdd3/dd dt起送价/dtdd20/dd /dl要拿到“配送费”对应的值就用following-sibling::$x(//dt[normalize-space()配送费]/following-sibling::dd[1]/text())这里的[1]是必须的因为following-sibling::dd会返回后面所有dd不取下标的第一个会得到一个集合。这个坑我踩过写成了following-sibling::dd/text()结果把“配送费”和“起送价”两个值都拿回来了还纳闷为什么数据对不上。反过来“值在标签前面”的情况用preceding-sibling::。这两个轴配合使用基本能解决所有“表格状结构无 table 标签”的页面。第三种是“取完当前元素还想看后面 N 个同级”用following-sibling::*[position()3]取后三个。做分页区域解析的时候很顺手。注意following::和preceding::这两个轴不带 sibling扫描范围是整棵树的后续/前置节点性能极差且结果常出人意料。我基本只用 sibling 版本除非明确需要跨层级。3.2 函数家族文本清洗与条件判断的主力函数是 XPath 保留项目里最能提升效率的部分。按用途我把它分成三组。字符串组做文本清洗的主力函数作用我常用的场景normalize-space()去首尾空白并把连续空白压成一个空格几乎所有文本比较前都包一层contains(a,b)是否包含子串class 多值、URL 部分匹配starts-with(a,b)是否以某串开头按前缀区分同类元素substring-before/after(a,b)按分隔符截取“月售 1200”取数字string-length()长度过滤掉过短的占位文本translate(a,b,c)字符级替换模拟大小写无关比较concat()拼接拼出完整 URL1.0 没有lower-case()如果要忽略大小写比较标准写法是用translate把大写字母全部映射成小写$x(//div[contains(translate(class,ABCDEFGHIJKLMNOPQRSTUVWXYZ,abcdefghijklmnopqrstuvwxyz),card)])这行丑陋但有效我把它存成代码片段用了很多年。真要写的时候把class换成目标属性即可。数值组count()数节点、sum()求和、number()强制转数值、floor/ceiling/round取整。count()在断言里非常好用比如判断“搜索结果至少 10 条才继续处理”。逻辑与位置组not(expr)取反position()当前序号last()最后一个boolean()转布尔。[last()]取最后一条很常用[position() mod 2 1]取奇数位置在需要交错处理时有用。有个细节值得单独说text()和.的区别。//div/text()返回 div 的直接文本子节点不包含子元素里的文字//div//text()返回所有后代文本而string(//div)或//div/.在语义上会做“字符串化”把整个元素所有后代文本拼起来。取一整段富文本内容时normalize-space(string(.))是最好用的写法一行拿到干净文本。3.3 构造“最短唯一路径”的四步法高级用法最终要落在一个目标上写出一条足够短、足够唯一、足够稳的表达式。我总结了一套四步流程基本能应付 95% 的页面。第一步找锚点。优先找“语义稳定”的东西固定文案如“确认提交”、业务属性>// 写法一绝对路径一改版就死 $x(/html/body/div[2]/div/div[3]/button) // 写法二依赖自动生成 class构建一变就死 $x(//button[classcss-k8x2ba]) // 写法三文案锚点 祖先定容器 相对下探 $x(//div[contains(class,order-panel)]//button[normalize-space()提交订单])第三种明显最稳。文案偶尔会变但“订单面板里的提交按钮”这个语义关系是产品层面的不会随前端重构消失。这就是那套四步法的核心思想让定位表达式表达业务语义而不是表达 DOM 结构。3.4 性能取舍//到底有多贵很多人不知道//是有成本的。//展开是descendant-or-self::node()后接节点测试意味着要从当前节点遍历整棵子树。在几千个节点的页面上几十条这样的表达式跑下来耗时能到几百毫秒——在爬虫里不算什么在 UI 自动化里就是肉眼可见的卡顿。优化思路有三个。第一缩小起点。能写成//div[idlist]//span就别写成//span前者只在 list 子树里扫。第二优先过滤属性而不是文本。属性比较是字符串直接比对文本比较要遍历所有文本节点后者更慢。第三谓词尽量前置//div[classx][data-id1]比//div[classx and data-id1]在执行顺序上没本质差别但把能快速排除的条件写在前面有助于减少候选集这是实践中的经验值。在移动端这条经验要乘以十。移动端 XPath 的引擎实现通常是“把整个控件树序列化出来再在应用侧匹配”有的驱动版本一次 XPath 查询要几十到几百毫秒一个页面几十条 XPath 会让整个测试套慢到离谱。所以移动端我的原则是能不用 XPath 就不用必须用的时候只写短路径 唯一属性。4. 落地实操移动端界面元素匹配的完整演练4.1 移动端没有 HTMLXPath 为什么还能用先把一个常见困惑解开网页里 XPath 是对 HTML 文档树寻址APP 里没有 HTML凭什么还能用原因是移动端自动化框架会把当前屏幕的控件层级序列化成一份类似 XML 的结构Android 侧基于无障碍节点信息iOS 侧基于可访问性树这份结构和 HTML 在形态上高度相似——同样有根节点、有层级、有属性text、resource-id、class、content-desc、bounds。XPath 引擎匹配的就是这份序列化结果所以语法和网页基本一致但属性名和类名体系完全不同。这个背景直接决定了两件事。第一网页上练出来的classbtn在 APP 里完全不适用安卓的 class 是全限定名比如android.widget.Button、androidx.recyclerview.widget.RecyclerView。第二属性优先级要重新排移动端最稳定的是resource-id开发人员手写的标识其次是text再次是content-desc最不可靠的是class和bounds坐标会随屏幕尺寸变。提示APP 的控件树需要用相应的自动化工具抓取后才能写 XPath。写之前先抓一份当前页面的层级结构保存成文件对着文件写比一边跑一边猜效率高得多。4.2 解剖一个列表页从源码到表达式下面是我在某本地生活服务类 APP示例 APP里抓到的一段简化控件结构为了避免暴露具体业务我把类名和属性做了通用化处理但结构形态和真实情况一致hierarchy node classandroid.widget.FrameLayout resource-idcom.example.app:id/root node classandroid.widget.LinearLayout resource-idcom.example.app:id/search_bar node classandroid.widget.EditText text搜索商家、商品 resource-idcom.example.app:id/search_input / /node node classandroidx.recyclerview.widget.RecyclerView resource-idcom.example.app:id/feed_list node classandroid.widget.FrameLayout resource-idcom.example.app:id/feed_item node classandroid.widget.TextView text手工牛肉面 / node classandroid.widget.TextView text28.0 / node classandroid.widget.TextView text月售 1200 / /node node classandroid.widget.FrameLayout resource-idcom.example.app:id/feed_item node classandroid.widget.TextView text番茄鸡蛋盖饭 / node classandroid.widget.TextView text18.5 / node classandroid.widget.TextView text月售 860 / /node /node node classandroid.widget.Button text提交订单 resource-idcom.example.app:id/submit_btn / /node /hierarchy对着这段结构我先给结论这个页面里最稳的两条表达方式是resource-id和text其他都属于凑合。下面逐个演示。点击搜索框用 iddriver.find_element(AppiumBy.XPATH, //*[resource-idcom.example.app:id/search_input])注意这里用*而不是具体类名因为类名可能因为组件库升级从EditText换成androidx.appcompat.widget.AppCompatEditText而 id 不会变。点击列表里第 2 个卡片的标题用位置 条件driver.find_element( AppiumBy.XPATH, (//*[resource-idcom.example.app:id/feed_item])[2]//android.widget.TextView[1] )这里的括号很重要原因和网页一样不加括号[2]会被理解成“每个 feed_item 下的第二个子节点”加了括号才是“所有 feed_item 里的第二个”。点击“提交订单”按钮用文案兼容多语言时慎用driver.find_element(AppiumBy.XPATH, //*[normalize-space(text)提交订单])normalize-space必须加。移动端控件的 text 属性经常莫名其妙带前后空格或者全角空格我在三个不同版本的驱动上都遇到过不加就偶发失败而且失败率不到 10%最难排查。按文案找到卡片再取卡片 id反向定位driver.find_element( AppiumBy.XPATH, //*[text手工牛肉面]/ancestor::*[resource-idcom.example.app:id/feed_item][1] )这是我在列表页做“按名称找条目”的标准写法比用位置索引稳得多因为列表顺序会变名称不会。4.3 六个可复用的匹配套路与对照表把上面的写法抽象一下实践中我固定用这六套模板遇到新页面直接往里套目标推荐写法不推荐写法原因点固定按钮//*[resource-idxxx]//android.widget.Button[3]id 不随布局调整变化点带文案的入口//*[normalize-space(text)文案]//*[contains(text,文案)]contains 容易命中多个取列表第 N 项(//*[resource-iditem])[N]//*[resource-iditem][N]后者取的是每父级下第 N 个按名称找卡片//*[textX]/ancestor::*[resource-iditem][1]//*[resource-iditem][2]顺序会变语义不会取卡片内字段//*[resource-iditem][1]//android.widget.TextView[2]全页面//android.widget.TextView[2]限定在容器内避免误命中无 id 无文案//*[contains(content-desc,X)]依赖bounds坐标坐标随分辨率变表格里第一条和最后一条是一对能用 id 绝不用别的方式能退到 content-desc 绝不退到坐标。中间几条的核心思想是“先锚定容器再在容器内找相对位置”这样即使页面整体加了一层或换了个容器表达式也不用改。真实操作时我还会做一件事把表达式里的*换成更具体的类名之前先用*跑通跑通了再决定是否收紧。因为*的匹配范围广能先验证“路径逻辑是否成立”等确认无误再收紧到具体类名获得性能收益。反过来的顺序会让你在“路径错”和“类名错”两件事里反复横跳。4.4 参数化与可维护性把 XPath 从代码里抽出来写到最后真正决定项目能不能维护的不是表达式本身而是表达式放在哪。我早期的脚本里 XPath 直接写死在每一行定位代码中页面一改我得全文搜索find_element逐个改。后来统一改成“定位表 模板函数”的结构情况好很多LOCATORS { search_input: //*[resource-id{pkg}:id/search_input], feed_item: //*[resource-id{pkg}:id/feed_item], submit_btn: //*[normalize-space(text)提交订单], } def item_by_index(i, pkgcom.example.app): return f({LOCATORS[feed_item].format(pkgpkg)})[{i}] def item_by_name(name, pkgcom.example.app): return f//*[text{name}]/ancestor::*[resource-id{pkg}:id/feed_item][1]这套结构有三个好处。包名抽成变量换渠道包时只改一处位置索引和名称索引分成两个函数调用方语义清晰单个定位变了只改字典里一行。我实测过一个 200 多条定位的脚本改成这种结构后一次改版维护时间从两三个小时压到二十分钟以内。再补一个细节text是用户可见文案做多语言或活动文案改动时会变。如果你的应用有多语言需求别用 text 定位回到resource-id或content-desc。这个选择要在写表达式的第一分钟就定下来改起来成本很高。5. 常见问题与排查实录5.1 十类高频错误的成因与解法这一节按我实际被问到的频率排序每一条都是真实踩过的。第一类表达式返回空列表但页面上明明有。九成是三个原因之一文本里带空白导致精确匹配失败iframe或WebView里的内容不在当前文档树页面还没渲染完。排查顺序就是先normalize-space再切换上下文最后加显式等待。第二类Invalid selector或Invalid expression报错。通常是三种情况用了 2.0 函数matches、ends-with在 1.0 引擎上跑引号嵌套错乱比如//div[title他说好]括号不配对。引号问题最隐蔽建议外层统一双引号、内层单引号或者用concat拼。第三类取到了但取的是第一个而不是想要的那个。检查[N]前有没有括号。这是最高频的一个错误没有之一。第四类contains(class,x)命中了不该命中的元素。contains是子串匹配x会命中xx、x-active。换成contains(concat( ,normalize-space(class), ), x )。第五类表达式在浏览器控制台能跑在 Selenium 里失败。检查是不是用了$x()里的写法直接搬到 Python 字符串里忘了转义引号或者浏览器里页面已经渲染完而脚本执行时机太早。第六类移动端偶发定位不到重试又能过。动画、转场、列表懒加载都会造成这个问题。别用固定 sleep用“等待元素存在且可见”的显式等待条件本身也用 XPath 表达。第七类同一句 XPath 在 Android 能用iOS 全部失效。控件类名体系不同android.widget.TextView在 iOS 上根本不存在。跨平台脚本必须把类名从表达式里拿掉只用属性和层级。第八类坐标相关写法在不同分辨率下全乱。bounds属性是像素值换设备就变。除非做图像级操作否则永远别用坐标定位。第九类表达式太长了改起来痛苦。这是设计问题不是语法问题回到 3.3 的四步法用容器切分把一条长表达式拆成“容器表达式 局部表达式”。第十类明明唯一第二天跑就命中多个了。典型是列表里出现了同名条目或者动态插入了一条广告位。解决办法是给表达式加上业务属性的约束比如带上>

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询