
在 HTML 文档中,很多关键信息并不是规整地包裹在带有 id 或 class 的元素里,而是以“纯文本 + 兄弟元素”的方式排列。比如一个描述性文字后面紧跟着一个操作按钮,或者一段标签名称旁边跟着一个需要提取的数值。面对这类结构,直接通过文本内容定位到紧随其后的元素,就成了 XPath 必须解决的问题。理解 XPath 如何将文本节点视为 DOM 树的一部分,是实现这类定位的关键。文本节点虽然在视觉上和其他元素混合在一起,但在 XPath 的数据模型里,它们和元素节点是兄弟关系,共享同一个父节点。
文本节点与元素节点的兄弟关系
在 XPath 眼中,HTML 结构不仅仅是标签的嵌套,还包括文本、注释等节点类型。考虑下面这段简单的 HTML:
<div> 价格:<span class="value">199</span> </div>
这里的“价格:”这部分纯文本,在 div 的内部会形成一个独立的文本节点。而 span 元素是这个文本节点的下一个兄弟节点。如果直接使用 //div/text(),返回的只是文本节点本身,无法直接获取到后续的 span 元素。许多人会尝试 //div/text()[contains(., '价格')],但它依然停留在文本节点层面。要定位到 span,就需要在文本节点定位后,使用 XPath 的轴来导航到兄弟元素。
具体来说,following-sibling 轴可以从当前节点出发,选取所有后续兄弟节点。假如我们已经确定了文本节点,就可以写成 //div/text()[contains(., '价格')]/following-sibling::span[1]。其中的 [1] 表示紧邻的第一个 span。这里文本节点作为上下文节点,following-sibling 会筛选出所有后续节点中类型为 span 的,并取第一个。需要注意的是,如果文本和 span 之间还存在其他空白文本节点或注释节点,它们也会被 counted,但我们的筛选条件已经限定了节点类型为 span,所以只会匹配元素。
但在实际页面中,文本内容往往不是完整匹配的,可能包含空格、换行等干扰。此时可以使用 normalize-space() 函数来去除多余空白,使匹配更可靠。例如://div/text()[normalize-space(.)='价格']/following-sibling::span[1]。这样即使文本节点中包含换行和前后空格,也能准确命中。
使用 preceding-sibling 定位之前的元素
如果需求反过来,想根据后面的文本定位前面的元素,原理也是一样。比如页面中有一个图标元素,后面紧跟着一个文本标签,我们需要通过文本来选取图标。可以使用 preceding-sibling 轴:
//div/text()[normalize-space(.)='价格']/preceding-sibling::img[1]
此外,preceding-sibling 轴包含的是当前节点之前的所有兄弟节点(按文档顺序逆序)。因此 [1] 表示最靠近文本节点的那个 img 元素。这种写法在处理图标与文本的组合时非常有用,很多 UI 框架生成的表格或卡片都采用这种排列方式。
有一点需要特别留意:preceding-sibling 和 following-sibling 都是基于节点在父元素内部的顺序,而不是兄弟元素之间的视觉顺序。比如 CSS 可能会通过浮动或定位改变视觉顺序,但 DOM 树中的顺序才是 XPath 判断的依据。在实际开发中,务必通过浏览器开发者工具查看元素在 DOM 中的真实位置,而不是只看页面渲染效果。
复杂嵌套结构下的文本定位技巧
当文本和兄弟元素不在同一个直接父容器下,而是有多层嵌套时,直接的兄弟轴就不再适用。例如:
<div>
<span class="label">用户名</span>
<div class="input-wrapper">
<input type="text"/>
</div>
</div>这里“用户名”所在的 span 和目标 input 并不共享同一个父节点,它们是表兄弟关系。此时可以先定位到公共祖先 div,然后通过文本内容筛选出包含特定文本的孙子元素,再回到其兄弟分支去定位目标。常见的写法是:
//div[span[normalize-space(text())='用户名']]/div[@class='input-wrapper']/input
这里利用了 span[normalize-space(text())='用户名'] 作为谓词,找到包含特定文本子的父 div,再向下定位到 input。这种方法比直接依赖相邻兄弟更稳健,适用于大多数表单布局。
另一种情况是文本和兄弟元素嵌套在同一个父元素的不同子元素下,但文本可能被更深层次的标签打断,比如:<em>价格</em>:<span class="value">199</span>。此时文本被 em 包裹,不再是单纯的文本节点。这种情况下可以直接用元素的 text() 方法和 following-sibling://em[normalize-space(text())='价格']/following-sibling::span[1]。因为 em 后面紧跟着的是一个文本节点“:”,而 span 是这个文本节点的下一个兄弟,但我们的 XPath 是从 em 出发寻找后面的 span,这其实跳过了中间的文本节点。XPath 的 following-sibling 轴选取的是当前节点之后的兄弟元素节点,所以能直接命中 span。这种用法在文本被标签包裹时非常高效。
为了进一步提高精确性,当文本可能存在多种变体时,可以结合 contains() 和 normalize-space()://*[contains(normalize-space(.), '价格')]/following-sibling::span[1]。不过这种通配符 * 的做法可能导致性能损耗,建议限定明显的父容器范围。
避免常见陷阱
第一个常见错误是误认为 //text() 返回的是包含该文本的元素。实际上它返回的是文本节点,文本节点不能直接使用大部分元素节点的轴,比如 //text()[contains(.,'价格')]/span 是无效的,因为文本节点没有 span 子节点。文本节点的后续兄弟要用 /following-sibling::* 来选择。
第二个错误是直接在 //div[text()='价格'] 这样的谓词中使用 text(),它会匹配那些恰好第一个子节点为文本且内容等于“价格”的 div。如果文本被其他元素打断,或者存在前后空白,这种写法则会失败。推荐在谓词中使用 normalize-space() 包裹 string() 或直接用 . 点号表示当前节点字符串值。
第三个坑是 XPath 版本问题。在浏览器自动化工具如 Selenium 中,默认的 XPath 引擎支持 XPath 1.0,上述所有方法均适用。但如果使用 XPath 2.0 或更高版本的处理器,某些函数如 matches() 也可以用来进行正则匹配,使文本筛选更加灵活。不过在跨平台方案中,最好还是坚持使用 XPath 1.0 兼容的写法,以保证最大兼容性。
通过将文本节点与兄弟轴的组合运用,可以轻松突破那些看似不规则的 DOM 结构,实现精准的元素选取,大幅提升自动化脚本的稳定性和可维护性。