上一篇文章里我把 Python 里最常用的 XPath 基础写法过了一遍/与//的区别、如何用 lxml 的 etree 解析 HTML、怎么用text()和属性去拿节点数据。留言里有不少朋友已经能写出简单的表达式但真正一上真实页面就开始露馅列表定位忽上忽下、提取的文本带一堆换行和空格、页面里肉眼能看到的数据XPath 却怎么都查不到。这期接着上篇把进阶表达式、调试方法和排错套路一次讲完配合requests lxml走一个完整的列表抓取流程。适合刚看完入门内容的朋友也适合已经写过几个小脚本、但每次写 XPath 都靠试错的人。1. 从读得懂到写得准XPath 进阶要解决的三个痛点入门阶段大家写的 XPath 基本都能跑通因为样例页面结构太干净了。换成真实页面后“XPath 不生效”的情况十有八九来自三个痛点命中范围不准、提取结果带脏数据、表达式在结构相似但细节不同的页面之间不稳定。进阶阶段的所有内容基本都是在跟这三件事较劲。先说命中范围不准。最常见的就是写了一个//div[contains(class, product)]以为只匹配外层容器结果连内层一堆类名带 product 的节点全部命中。随后你在循环里处理每个 item 时又用了绝对路径//h2于是每个循环体拿到的都是全页面第一组标题而不是当前节点下的标题。这类问题不是语法不会而是对 XPath 的“搜索起点”理解不到位。再说提取结果带脏数据。HTML 源码里的文本节点常常被换行、缩进、注释和嵌套标签拆得七零八落直接//text()出来的是一个列表你还要处理拼接和空白。更麻烦的是有些字段在一个节点下既有文字又有子标签比如“价格 199限时优惠”被拆成三段文本取第一段会丢信息取全部又会带多余内容。最后是不稳定问题。同一个页面今天能解析明天字段没变XPath 却失效多半是写得太“死”——把一个层级关系绑死在具体位置上结果某处多包了一层 div 就全线崩掉。下面我先把进阶表达式里最实用的几类讲清楚再拿一个列表页从头到尾走一遍最后集中列排错清单。1.1 先分清//和.//避免全页重复命中这是我在带新人时必问的问题。XPath 里//表示从整个文档任意位置搜索而.//表示从当前节点出发搜索所有后代。lxml 的 xpath 方法如果不带.即使你是在某个 item 节点上调用的它依然会从文档根开始找。# 错误示例在 item 里继续用 // 找标题拿到的往往是第一组匹配 titles item.xpath(//h2[classname]/text()) # 正确示例只找当前节点内部的标题 titles item.xpath(.//h2[classname]/text())这个坑在“先定位容器、再逐字段提取”的两段式写法里极其常见。我在实际项目里总结过一个笨办法只要进了循环内部表达式全部统一加.//宁可多写一个点也不要让程序悄悄从文档根重新搜一遍。调试时如果发现每个 item 拿到的内容都一样先看这里大概率能解决。1.2 lxml 和其它解析方案的取舍很多人喜欢拿 XPath 和 BeautifulSoup 对比其实它们在项目里不冲突关键看场景。我自己的选择标准很简单页面结构相对规整、需要批量提取大量重复节点时用lxml XPath页面杂乱到标签对不上、缺闭合、充满非法字符时优先 BeautifulSoup。核心原因是 lxml 底层是 C 实现的 libxml2解析和查询速度比纯 Python 滚动循环快得多而 XPath 表达式能直接表达“某个节点的父节点”“后面相邻的兄弟节点”这类关系代码量比find_parent()加find_previous_sibling()这种链式调用直观得多。方案解析速度复杂节点定位对破损 HTML 容忍度适合场景lxml XPath快C 实现强支持节点关系和函数中上etree.HTML 会做容错修复结构规整、数据量大、批量任务BeautifulSoup较慢中靠 find/find_all 组合高内置多策略解析页面乱、一次性脚本lxml cssselect快中依赖 CSS 语法中上习惯 CSS 选择器、且定位不复杂如果你的任务是抓一个动态渲染严重的页面XPath 再强也拿不到源码里不存在的内容那种情况要先考虑找页面背后的数据接口或者用浏览器自动化兜底而不是硬写解析表达式。2. 必须吃透的 XPath 高频写法很多教材把 XPath 讲成“路径表达式”导致大家只记住了/、//、[]遇到复杂需求就开始瞎猜。这节我把实战里出现频率最高的几类写法集中过一遍每一个都是可以直接抄的。2.1 谓语里的小技巧别把所有条件都堆在一条路径里谓语就是方括号里的过滤条件但它的玩法比大多数人以为的多。提取第几个节点、找包含某个文本的元素、按属性筛选这些都能在谓语里完成。//li[1] # 第一个 li //li[last()] # 最后一个 li //li[position() 2] # 从第二个开始的所有 li //a[contains(text(), 下载)] # 文本中包含“下载”的 a 标签 //span[contains(class, price)]/text() # 类名中含 price 的 span 文本这里最值得说的是 class 匹配。HTML 里一个元素可以同时有多个 class比如div classproduct list-item active。直接写classproduct大概率匹配不上写contains(class, product)又容易误伤product-card、product-detail这类相似类名。XPath 1.0 没有 CSS 的~操作符我常用的替代写法是//div[contains(concat( , normalize-space(class), ), product )]思路是把类名字符串前后各补一个空格再把目标类名也补上空格这样只有完整的类名才能匹配上。表达式看起来长但它的误伤率比裸写 contains 低很多。真正的实用心法是谓语不是越多越好能用一个谓语锁定到容器层级就够了内部字段尽量通过.//在容器内相对定位不要把十几个条件叠在一条路径里。2.2 轴axis突破“从上往下”的限制XPath 最常见的写法是往下找子节点但真实页面经常需要“往上找父节点”或“找兄弟节点”。这时候就要用到轴语法格式是节点/轴名::标签[条件]。# 找当前节点后面第一个兄弟节点 //span[contains(text(), 价格)]/following-sibling::span[1] # 找当前节点前面相邻的兄弟节点 //span[contains(text(), 原价)]/preceding-sibling::span[1] # 往上找祖先节点中 class 为 mod 的容器 //h2[contains(text(), 参数)]/ancestor::div[contains(class, mod)] # 父节点的简写 //span[contains(text(), 价格)]/parent::div # 等价于 //span[contains(text(), 价格)]/..轴语法的威力在表格类页面里体现得最明显。比如一个商品参数表左侧是字段名右侧是值直接把“价格”后面的值节点写成//span[contains(text(), 价格)]/following-sibling::span[1]/text()一次就能抓准。很多人遇到这种需求会退回去写 BeautifulSoup 的find_next_sibling()其实 XPath 一行就能完成。强调一点lxml 支持的是 XPath 1.0轴方向是有限的following-sibling、preceding-sibling这种按顺序查找的性能在小范围内没问题但别拿它在大文档里做全局大规模匹配会慢。2.3 文本提取三件套text()、string()、normalize-space()文本提取是网页解析里噪声最大的一块。//div/text()得到的是直接子文本节点列表如果一个节点下面还套了span、a等子标签文本会被拆散。string()可以把整个节点内的所有文本拼接成字符串而normalize-space()能把连续空白压缩成一个空格再去掉首尾空白。# text() 返回列表需要自己拼接 node.xpath(.//text()) # string() 返回当前节点内部完整文本 node.xpath(string(.//h2[contains(class, name)])) # normalize-space 压缩多余空白常用于清洗 # 注意 lxml 的 xpath 支持该函数可以直接写在表达式里 node.xpath(normalize-space(string(.//span[contains(class, price)])))我在实际脚本里最常见的做法是短字段用string()一步到位再在外面用 Python 的strip()做兜底长文本字段用.join(node.xpath(.//text()))拼起来后用re.sub(r\s, , text)清理。建议不要在表达式里强行塞入太多格式化函数XPath 负责定位和取数清洗交给 Python 层这样出问题时能快速定位到底是“没取到”还是“取到了没处理好”。3. 实操记录requests lxml 抓取一个列表页理论说再多不如完整跑一遍。我这次用一个虚构的列表页作为模拟项目 X某商品列表页每件商品被包在div classproduct-item里里面有一个带product-name的标题一个带price的价格一个跳详情页的链接。目标是把所有商品名称、价格、链接抓下来存成 CSV。3.1 环境准备与页面抓取先装依赖pip install requests lxml然后用 requests 抓页面交给 lxml 解析。环境上有一个常被忽略的点很多页面返回的编码声明与实际内容不一致乱码会导致后续所有 XPath 文本匹配失败。我会先设置resp.encoding resp.apparent_encoding再取文本。import requests from lxml import html url https://example.com/list headers {User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)} resp requests.get(url, headersheaders, timeout10) resp.encoding resp.apparent_encoding or utf-8 # 注意这里是 html.fromstring不是 etree.fromstring前者对破损 HTML 更友好 tree html.fromstring(resp.text)解析里最容易踩到的坑是“用了 XML 解析器去解析 HTML”。如果你看到类似Opening and ending tag mismatch的报错基本就是用了etree.XML或者etree.fromstring。换成lxml.html.fromstring后缺闭合标签、错位引号这类问题会被内部容错机制兜住。3.2 写 XPath 前的结构探查拿到tree之后先别急着写完整表达式先做一轮“探针”确认页面结构和肉眼看到的一致。我会先用 count 或 len 验证目标容器数量。print(len(tree.xpath(//div[contains(class, product)])))如果没有匹配先怀疑类名写错了。打开浏览器开发者工具在目标元素上检查实际 class 值再在控制台里用$x(//div[contains(class, product)])验证一遍表达式。这里有一个我在实际项目中总结的经验浏览器控制台里验证通过不代表 Python 端一定通过因为浏览器拿的是渲染后的 DOM而 requests 拿到的是原始 HTML。尤其是动态加载的内容页面上能看到源码里却不存在。为保险起见我常把原始 HTML 存一份到本地解析瓶颈期反复用避免每次都重新发请求with open(debug.html, w, encodingutf-8) as f: f.write(resp.text)这样后面做表达式迭代时可以直接把debug.html读回来构造树网络结构变了也不受影响。3.3 逐字段写出表达式并落地 CSV探针通过后按“先容器、后字段”的顺序写表达式。容器表达式只需要锁定到包含所有商品的父节点层级字段表达式一律以.//开头确保在容器内查找。items [] for item in tree.xpath(//div[contains(class, product-item)]): name item.xpath(string(.//h2[contains(class, product-name)])).strip() price item.xpath(string(.//span[contains(class, price)])).strip() link_nodes item.xpath(.//a[contains(class, product-link)]/href) link link_nodes[0] if link_nodes else items.append({name: name, price: price, link: link}) import csv with open(result.csv, w, encodingutf-8-sig, newline) as f: writer csv.DictWriter(f, fieldnames[name, price, link]) writer.writeheader() writer.writerows(items)写入 CSV 时用utf-8-sig编码Excel 打开就不会出现乱码这个细节是很多人在最后一步才发现的。关于string()的几个易错点需要提醒下。如果容器内部不存在某个字段item.xpath(string(...))返回空字符串不会报错这是好事但如果你之前用的是.//text()得到的空列表会在后续取[0]时直接 IndexError。所以字段提取建议统一用string()再用.strip()兜底代码会稳不少。价格字段如果带“”、逗号这类符号清理逻辑我习惯放在字段提取之后统一处理不要在每个 XPath 表达式里反复做字符串替换清洗层与解析层分离后续维护更轻松。4. 写 XPath 最容易翻车的 6 个场景与排查思路这部分是我从过往项目里攒下来的问题实录每一条都对应过真实的“抓不到数据”工单。整理成速查表放在这里遇到问题先对照看能省下大量试错时间。现象大概率原因处理思路xpath 返回空列表表达式写错、页面结构与肉眼看到的不一致、目标在动态渲染里先保存 HTML在本地验证用 count() 层层定位循环里每个 item 提取到全页相同内容容器内部用了//而不是.//统一改成.//文本带大量换行、空格多个文本节点或嵌套标签用string()取整段文本再re.sub压缩空白多 class 元素匹配不上或误匹配class写死了完整值或 contains 误伤相似类名用 concat 加 normalize-space 的完整类名匹配写法页面能打开requests 抓下来却没有目标节点编码错误、登录校验、内容被 JS 接管检查编码看页面源码找数据接口或用浏览器自动化lxml 报 HTML 标签错位警告页面标签不规范改用lxml.html.fromstring不要用 XML 模式4.1 输出全是空列表时的通用排查链路先别急着改表达式按顺序做几步第一步确认“页面真的抓到了”。打印len(resp.text)和len(tree.xpath(//body))如果文本长度异常短说明请求本身就有问题先解决抓取。第二步确认“表达式在浏览器端能匹配”。用开发者工具控制台执行同样的$x()看目标节点是否高亮。如果浏览器能匹配而 Python 里不行考虑是不是 lxml 对 HTML 的容错把结构改了用etree.tostring(tree, pretty_printTrue)打印标准化后的结构来看。第三步做表达式拆半调试。从最宽的//div开始打印匹配数量然后逐步加条件print(len(tree.xpath(//div))) print(len(tree.xpath(//div[contains(class, product)]))) print(len(tree.xpath(//div[contains(class, product)]//h2)))看到哪一步数量变成 0问题就锁定在哪一步的类名、标签名或层级关系上。我见过太多人拿着一个长表达式反复改却不知道断头在哪里拆开看是最快的。第四步警惕网页里的“隐藏副本”。有些页面会把模板数据放在隐藏容器里同一类名在源码中出现两次可能导致匹配数量翻倍。处理方式要么加更精确的层级定位要么先在表达式里用count()观察数量再决定。4.2 排查 XPath 之外的两个隐藏因素很多“XPath 失效”其实根本不是表达式的问题而是页面内容来源问题。比如页面主体是 JS 动态填充的requests 拿到的 HTML 里根本没有目标节点你再怎么调整 XPath 也无解。这种页面我的处理思路是打开开发者工具的“网络”面板找页面加载时请求的数据接口直接解析 JSON 通常比解析 DOM 更稳如果接口找不到只能走浏览器自动化再把渲染后的 DOM 交给 lxml 继续处理。另一个隐藏因素是文章开头提到的动态列表翻页。很多列表页第一页是服务端渲染的第二页开始就是异步加载。这种情况下同一个 XPath 在第一页有效在第二页失效不是表达式写错而是页面结构在变。遇到分页抓取先把所有页码的真实返回 HTML 存下来做对比再做表达式设计不要拿着第一页的表达式批量跑完整站。我个人现在写 XPath 的习惯是先用最简表达式锁定一个大容器再在容器内部用.//提取字段所有长表达式都会配一个“计数探针”也就是先打印len()再继续往下做无论代码多简单抓取前都会把原始 HTML 存一个副本。这样看起来多写了几行实际上能在结构和编码出现变化时把排查时间从几小时压缩到几分钟。XPath 说到底是个定位语言真正难的不是记住语法而是学会验证“你自以为在搜的东西和页面里实际存在的结构是不是同一个”。把这一步想透后续再复杂的页面都不会太费力。
阅读完成 · 觉得有帮助?