前几天有个朋友发我一段代码,说他用 requests 把页面抓下来了,但折腾了两天也没把数据提取干净。我打开一看,他全程在用 str.split 和 find 一层层切字符串,页面结构稍微变一下,程序就全线崩盘。这个场景我见过太多次了——很多刚接触爬虫的人,以为拿到 HTML 源码就等于抓到了数据,结果卡在最关键的解析环节。爬虫真正考验人的地方,从来不是发请求,而是怎么从一堆标签里稳定地取出你要的信息。
这篇内容我围绕爬虫解析网页展开,重点讲正则表达式和 XPath 这两套最常用的提取方案。网络爬虫原理说白了就是"请求—解析—存储"三步,其中解析决定你代码的存活率。适合已经会写基本 requests 请求、但面对 HTML 不知道如何下手的初学者,也适合想系统梳理正则与 XPath 用法的开发者。
1. 把HTML变成数据:解析这一步为什么绕不开
1.1 拿到网页源码不等于拿到数据
爬虫拿到的是服务端返回的 HTML 文本,这里面混着大量布局标签、样式属性、脚本代码,真正的数据只是其中的一小段。比如你在豆瓣、知乎、电商网站上看到的商品价格、文章标题、作者名字,在源码里都只是某个标签的属性值或文本内容。
程序本身是没有"视觉"的,它分不清哪段文字是标题、哪段是导航栏,它只能靠规则去匹配。所以爬虫解析的核心,就是设计一套规则,从半结构化的 HTML 文本里找出符合特征的内容。
这里有一个很重要的认知:HTML 本质上是一棵由标签节点组成的树。每一个标签都是一个节点,节点之间有父子、兄弟关系。理解了这一点,你就明白为什么会有两种完全不同的解析思路。
1.2 两种解析思路的本质差异
- 正则表达式:把 HTML 当纯文本处理,用模式匹配去刮内容。它不在乎标签之间的层级关系,只认字符串长得像不像。
- XPath:把 HTML 当树结构处理,通过节点路径来寻址。它认的是"在哪里",而不是"长什么样"。
我打一个比方。正则表达式像在一堆字里找特定的一句话,只要这句话出现就能抓到;XPath 像按照地图上的门牌号找房间,你必须知道路径,但一旦路径对了,定位非常精准。
对于标准的 HTML 页面,XPath 通常更稳,因为网页的结构是有规律的。但如果数据藏在 JavaScript 变量里、JSON 字符串里,或者 HTML 标签本身写得乱七八糟,正则反而更灵活。
这也解释了为什么搜索引擎热搜词里,"正则表达式语法"和"爬虫"总是形影不离。很多爬虫实战——网页抓取及信息提取的场景里,这两种工具往往是配合着用的,后面我会用一个完整案例来说明。
2. 正则表达式实战:抓链接、抓文本、抓数字的高频写法
2.1 爬虫开发中真正常用的正则语法其实没几个
很多人一学正则就被一大堆元字符劝退。其实做爬虫解析,你只需要先记住这些:
| 语法 | 含义 | 爬虫中的典型用途 |
|---|---|---|
\d | 匹配数字 | 提取价格、编号、日期中的数字 |
\w | 匹配字母、数字、下划线 | 匹配 ID、用户名 |
\s | 匹配空白字符 | 匹配空格、换行 |
. | 匹配任意字符(除换行) | 通配一段内容 |
* | 前一个字符出现 0 次或多次 | 匹配任意长度 |
+ | 前一个字符出现 1 次或多次 | 确保至少有一个字符 |
? | 前一个字符出现 0 次或 1 次,或转为非贪婪模式 | 匹配可选字符、控制贪婪 |
[abc] | 匹配方括号中任意一个字符 | 限定字符范围 |
[^abc] | 匹配不在方括号中的字符 | 排除某些字符 |
(.*?) | 非贪婪捕获任意内容 | 爬虫中最常用的提取组合 |
re.S | 让.可以匹配换行 | 抓取跨行的内容 |
re.I | 忽略大小写 | 匹配不区分大小写的文本 |
你不需要背完整本正则手册。爬虫里干来干去就三件事:抓链接、抓文本、抓数字。把上面这张表用熟,基本能覆盖八成的需求。
2.2 随手写一个正则提取链接和文本
假设有一个资讯列表页,源码片段长这样:
<div class="news-list"> <a href="/news/20240612/123.html" class="title">某地发布新政策</a> <a href="/news/20240612/124.html" class="title">某行业迎来重大变化</a> </div>用 Python 的 re 模块提取所有文章的 URL 和标题:
import re import requests url = "https://example.com/news" html = requests.get(url).text pattern = r'<a href="(/news/\d+/\d+\.html)"[^>]*>([^<]+)</a>' results = re.findall(pattern, html) for href, title in results: print(href, title)这里有两个关键细节值得说。
第一个是(/news/\d+/\d+\.html)这个括号:括号表示捕获组,findall 会把括号里的内容单独返回,这样就同时拿到了链接和标题。如果不用括号,findall 返回的是整个匹配的字符串。
第二个是[^>]*>这个写法。它表示"匹配任意不是>的字符,直到碰到>",这样能跳过<a>标签里的 class、id 等多余属性。用[^>]而不是.,是为了防止贪婪匹配把后面的内容也吞进去。
2.3 贪婪与懒惰:正则里最经典的坑
很多新手写正则,最常犯的一个错误是用.*而不是.*?。这两个区别非常大。
.*是贪婪匹配,它会尽可能多地匹配内容,直到匹配结束。.*?是懒惰匹配,它会尽可能少地匹配内容,匹配到第一次出现的地方就停下。
我用一个具体例子展示差异。假设要提取上面 HTML 中第一篇文章的标题:
# 贪婪写法:结果会出乎意料 pattern = r'<a href="(.*)">(.*)</a>'这个正则匹配下来,(.*)里的链接会把第二个、第三个 a 标签的href内容全吞进去,因为正则引擎会尽可能匹配到最后一个</a>之前。最终结果完全不是你想要的。
改成这样才对:
pattern = r'<a href="(.*?)">(.*?)</a>'在爬虫实战里,凡是遇到.*的地方,我基本都会下意识写成.*?。这是用血泪换来的经验,你可以直接记住这个习惯。
2.4 正则的适用边界
正则并不是解析 HTML 的银弹。遇到下面几种场景,我会优先考虑正则:
- 数据藏在
<script>标签的 JavaScript 变量里,比如var pageData = {"name":"xxx"},这时用正则抠 JSON 片段很方便。 - 页面结构混乱,嵌套层级深,XPath 不好写。
- 只是临时抓一把,不想引入额外的解析库。
但如果页面结构规整、需要批量提取同类节点,那 XPath 会是更好的选择。下面这部分,就是 XPath 的主场。
3. XPath基础:把网页当一棵树来查找节点
3.1 节点、路径与谓词:最常用的四类 XPath 表达式
XPath 的底层逻辑是:把 HTML 文档当成一棵树,根节点是<html>,往下是<body>、<div>、<a>等各级节点。定位信息的过程,就是在树上走路径。
下面这几类 XPath 表达式是爬虫中最高频的:
| 写法 | 含义 | 示例 |
|---|---|---|
/ | 从根节点选取 | /html/body/div |
// | 从任意位置选取,忽略层级 | //a |
. | 当前节点 | ./a |
.. | 父节点 | ../div |
@属性名 | 选取属性值 | //a/@href |
[条件] | 谓词,过滤节点 | //a[@class="title"] |
text() | 选取当前节点的文本 | //a/text() |
contains(属性, "值") | 属性包含指定内容 | //a[contains(@class, "title")] |
normalize-space() | 去除首尾空白并合并多个空格 | //a[normalize-space(text())="标题"] |
初学者最容易混淆的是/和//。//表示从任意位置开始找,而/必须严格按父子顺序一层层走下去。爬虫解析里,//用得最多,因为它不关心中间嵌套了多少层<div>。
3.2 配合 lxml 写第一个 XPath 解析脚本
先安装解析库:
pip install lxmllxml 的etree模块可以把 HTML 字符串解析成一棵树,然后调用xpath方法取节点。继续用上面的资讯列表页面:
from lxml import etree html = requests.get(url).text tree = etree.HTML(html) titles = tree.xpath('//a[contains(@class, "title")]/text()') hrefs = tree.xpath('//a[contains(@class, "title")]/@href') for t, h in zip(titles, hrefs): print(t, h)这一段代码的效果和前面正则版本的完整脚本一样,但写起来更清爽。注意contains(@class, "title")这种写法,它表示:只要 class 属性里包含 "title" 这三个字就行。这比@class="title"更健壮,因为很多页面的 class 会是"titlexxx"或"xxx title yyy"这种组合形式。
如果页面里只有一个目标节点,可以直接用tree.xpath('//xpath表达式')[0]来拿第一个匹配结果。但这里有一个必踩的坑:如果 XPath 没匹配到任何节点,xpath方法返回的是空列表,你直接取[0]会报 IndexError。所以我在真实项目里通常这样写:
matched = tree.xpath('//a[contains(@class, "title")]/text()') if not matched: print("没有匹配到任何节点,页面结构可能变了") return先判空,再取数。这个习惯能帮你省掉大量排查时间。
3.3 XPath 匹配不到节点时,从哪几个方向排查
我用 XPath 也经常遇到取不到数据的情况。按经验,排查顺序一般是这样的:
- 先看
tree是否解析成功。把html[:500]打印出来,确认请求到的内容真的包含目标标签。 - 看属性值是否包含大小写、前后空格。很多页面 class 写成
"Title",你用"title"就匹配不到。 - 看目标内容是不是由 JavaScript 动态生成的。如果是,
requests拿到的源码里根本没有这些节点,XPath 写得再正确也无济于事。 - 遇到
<html>标签带命名空间的情况,去掉命名空间再解析。有些 XML 风格的页面会声明xmlns,直接用 XPath 会找不准。
第四点很多人不知道。处理方法是在解析前做一次处理,或者直接用etree.HTML(html)解析,它会自动去掉一部分命名空间干扰。但如果页面是严格的 XML 或 XHTML,建议先用html.replace('xmlns="..."', '')把声明去掉。
3.4 XPath 的"相对路径"思路
爬虫解析时,我不太建议一上来就写超长的绝对路径,比如:
/html/body/div[3]/div[1]/div[2]/ul/li[1]/a这种路径在页面结构微调之后就废了,维护成本极高。更稳的做法是先定位到一个有辨识度的锚点节点,再从这个节点出发写相对路径。
举个例子。一个商品列表页,每个商品块是<div class="item">,商品名在里面的<h4>中,价格在<span class="price">中:
items = tree.xpath('//div[contains(@class, "item")]') for item in items: name = item.xpath('.//h4/text()') price = item.xpath('.//span[contains(@class, "price")]/text()')注意这里我在循环里用了.//h4,前面加了一个点,表示"从当前这个 item 节点内部去找 h4",而不是在整个文档里找。这是 XPath 相对路径的核心用法,写多节点抓取时非常常用。
4. 写XPath之前,先在浏览器里验证:XPath Helper 与 DevTools
4.1 XPath Helper:写路径时的效率神器
如果你经常写 XPath,强烈建议在 Chrome 里装一个 XPath Helper 插件。这个插件是一个叫 Thomas de Roo 的开发者做的,在热搜里出现频率很高,也确实好用。
插件的使用方式很简单:
- 打开目标网页,点击浏览器右上角的 XPath Helper 图标,会出现一个黑色的控制台面板。
- 按住 Shift 键,鼠标移动到页面上,面板里会自动显示当前鼠标所指元素的 XPath。
- 在面板最下方的输入框里输入你自己的 XPath 表达式,按回车,匹配到的节点会高亮闪动。
这个插件的价值在于"即时反馈"。你不用反复改代码、跑请求、看结果,直接在真实页面上试 XPath,看到高亮就说明路径写对了。我写爬虫的前期调试,几乎一半时间都耗在这个面板里。
4.2 为什么我不推荐直接用 DevTools 的 Copy XPath
Chrome DevTools 的 Elements 面板里,右键一个元素有 "Copy" 子菜单,里面能复制 XPath 和完整 XPath。看起来很方便,但有三个问题:
- DevTools 生成的 XPath 是绝对路径,特别长,像
/html/body/div[3]/div[1]/div[2]/div/ul/li[1]/a,页面加一个包裹层就失效。 - 浏览器会解析出
tbody节点,但实际 HTML 源码里可能根本没有tbody,导致 XPath 在代码里无法命中。 - 它生成的路径只针对当前这一个元素,没有考虑这个元素是否具有普适性,不适用于批量提取。
复制出来的路径最多只能当参考,适合用来确认"这个节点大概在哪一层",但不建议直接粘贴进爬虫代码。
4.3 在 Console 里用 $x() 随时验证路径
除了 XPath Helper,我还有一个更轻量的办法:在 Chrome DevTools 的 Console 面板里直接执行 XPath。
$x('//a[contains(@class, "title")]')Chrome 的$x()函数接受一个 XPath 表达式,返回匹配到的所有元素。你可以直接在 Console 里试路径,不需要装任何插件。
用$x()验证的好处是,它可以配合length属性快速确认匹配数量。比如:
$x('//a[contains(@class, "title")]').length如果返回 0,说明路径写错了或者页面上压根没有这个节点。如果返回一个很大的数字,说明你的路径太宽泛,需要加条件缩小范围。
这个环节看起来不起眼,但对爬虫开发效率的提升非常明显。很多 XPath 写不好的人,主要问题就是"闭门造车"——不先在浏览器里验证,直接写进代码,然后靠打印结果一遍遍猜。用过即时验证,你会彻底改掉这个习惯。
5. 同一个页面,正则与XPath两种解法对比
5.1 一个真实的抓取场景
为了让你更直观地感受正则和 XPath 的区别,我设计了一个具体的爬虫实战场景。
假设要抓取一个资讯列表页,页面结构如下:
<ul class="article-list"> <li> <a href="/article/101.html">爬虫基础教程:从零开始</a> <span>2024-06-12</span> </li> <li> <a href="/article/102.html">正则表达式实战:链接提取</a> <span>2024-06-11</span> </li> <li> <a href="/article/103.html">XPath入门:定位的艺术</a> <span>2024-06-10</span> </li> </ul>目标:提取每篇文章的标题、URL、日期。
5.2 正则解法
import re import requests url = "https://example.com/article-list" html = requests.get(url).text pattern = r'<a href="(/article/\d+\.html)">([^<]+)</a>\s*<span>([^<]+)</span>' results = re.findall(pattern, html) for href, title, date in results: print(title, href, date)这段代码能跑通,但注意它是依赖三个标签按特定顺序排列的。如果页面里某天改成了把<span>放到<a>前面,正则就完全失效了,需要改规则。
5.3 XPath 解法
from lxml import etree tree = etree.HTML(html) items = tree.xpath('//li[contains(., "爬虫") or contains(., "正则") or contains(., "XPath")]') # 更通用的写法:直接定位 li 下的 a 和 span items = tree.xpath('//ul[contains(@class, "article-list")]/li') for item in items: title = item.xpath('./a/text()')[0] href = item.xpath('./a/@href')[0] date = item.xpath('./span/text()')[0] print(title, href, date)这段代码的健壮性更好。即使页面在<ul>内部增加了几层包裹结构,只要<a>和<span>的关系没变,它就能正常工作。
5.4 到底选哪个:我的决策标准
| 对比维度 | 正则表达式 | XPath |
|---|---|---|
| 学习成本 | 语法多,入门的坑多 | 简单直观,半天就能上手 |
| 可读性 | 长正则很难读懂 | 路径语义清晰 |
| 抗页面变化能力 | 弱,结构一变就报废 | 相对强,可以靠锚点容错 |
| 性能 | 纯字符串匹配,通常较快 | 需要构建树,略慢但差距不大 |
| 适合场景 | 文本提取、JSON/JS变量抓取 | 标准 HTML/XML 结构解析 |
我在实际爬虫项目中,标准 HTML 页面基本都用 XPath,只有在拿 JSON 片段、处理 script 内嵌数据时才动用正则。两者不是二选一,更多时候是混着用。
6. 解析之外的三个坑:动态加载、编码、反爬
6.1 页面是动态加载的,requests 拿到的是空壳
XPath 和正则都是对"静态 HTML 文本"做解析。如果页面数据是 JavaScript 异步渲染的,requests 直接请求拿到的 HTML 里根本不含目标数据。怎么判断?
很简单。把拿下来的 HTML 字符串保存成一个 .html 文件,用浏览器打开,如果页面上没有你抓的内容,就说明数据是动态渲染的。
解决方案有两种。第一种是找数据接口:打开 DevTools 的 Network 面板,刷新页面,看有没有返回 JSON 数据的 XHR 请求,直接抓接口比抓 HTML 高效得多。第二种是用 Selenium / Playwright 这类浏览器自动化工具,让浏览器真实渲染后再提取。热搜里的"python selenium反爬虫""爬虫实战——网页抓取及信息提取"很大概率就是在说这类场景。
顺带提醒一句:爬虫请求要控制频率,别给目标站点造成压力,也别碰需要登录才可见的数据。抓公开信息、遵守 robots.txt、合理设置间隔,是每个爬虫开发者都应该有的底线。
6.2 编码乱码:解析全对,输出却是乱码
解析正则和 XPath 写得再好,如果编码不对,一切都是白费。中文网页很多是 UTF-8,但也有一些老站点用的是 GBK / GB2312,requests 默认会用 HTTP 头里的 charset 去做解码,有时候会猜错。
最稳妥的两行代码:
import requests response = requests.get(url) response.encoding = response.apparent_encoding html = response.textapparent_encoding是 requests 基于响应内容自动推断的编码,对中文页面准确率相当高。如果还不对,就手动指定response.encoding = "utf-8"或"gbk",试到不乱码为止。
6.3 解析出来的数据要清洗:这里最能体现实战经验
好不容易把数据抓出来了,很多人的第一版代码是这样的:直接把列表里的字符串拿来入库或写入 CSV。结果后面统计时发现,价格带了换行符,标题带了多余的空白,日期格式五花八门。
清洗数据的常见操作:
import re raw_title = " 爬虫基础教程 " clean_title = re.sub(r"\s+", "", raw_title) raw_price = "¥1,299.00" clean_price = re.sub(r"[^\d.]", "", raw_price) # 提取数字和点正则在这时候反而比 XPath 更好用,因为它适合做文本层面的精细处理。字符串去空格、去标签、统一格式,都是正则的强项。
6.4 结构变化后的快速修复思路
爬虫代码写完之后不要以为就万事大吉了。网页改版是常态,今天能跑通的 XPath,下个月可能就匹配不到。我的习惯是在代码里加一个"解析失败快速定位"的输出逻辑:
if not matched: print("解析失败,当前 HTML 前 1000 个字符:") print(html[:1000])这一小段代码能帮你快速判断是请求被拦了、页面结构变了,还是数据换了接口。比起翻日志、猜原因,这个方式直接有效。
最后分享一点个人体会
写爬虫这么多年,我对正则和 XPath 的使用已经形成了肌肉记忆:标准的 HTML 结构优先 XPath,因为它稳定、可读、容错性强;非结构化的文本和脚本内嵌数据优先正则,因为它灵活、精确。两个工具其实没有高下之分,关键是把各自用在合适的场景里。
如果真要说有什么给新手的建议,那就是:不要一上来就想着写一个万能的正则表达式,也不要抄一段看不太懂的 XPath 就完事。先打开浏览器,用 XPath Helper 或者 Console 里的$x()验证你的路径,再用小数据量把解析代码跑通,最后才扩展到全量抓取。这个流程能帮你绕过我当年踩过的大部分坑。爬虫解析这件事,耐心比技巧更重要,一步步来,你会发现它其实并不难。