之前有朋友问我,爬虫拿到HTML之后,怎么把里面的标题、链接、价格一行一行抠出来?我第一反应就是:你还没吃透正则表达式和XPath。这两个工具是解析网页最基础、也最实用的手段。很多人一开始觉得“正则表达式很难”“XPath是不是要学很多语法”,其实把它们放到爬虫场景里,拆开看就是一招一式的事。这篇文章就从实际解析网页的需求出发,把正则表达式和XPath的简单用法、选型思路、实操代码和排查技巧一次讲清楚,适合刚入门爬虫的朋友,也适合写了好几个月代码但还是靠复制粘贴提取数据的人。
1. 爬虫解析网页的整体思路与工具选型
1.1 从HTTP请求到数据提取:爬虫的三个环节
爬虫看起来高大上,本质上就是一条流水线:先把网页拿回来,再把里面有用的字段挑出来,最后存成表格或数据库。按我的习惯,整条链路可以拆成三个环节:请求、解析、存储。请求阶段用requests或httpx把HTML文本抓下来;解析阶段就是从这段HTML文本里找到我们需要的标题、链接、图片地址、价格这类信息;存储阶段则是把解析结果写进CSV、Excel或数据库里。
很多人把精力全放在“怎么模拟请求、怎么处理Cookie”上,结果拿到HTML后一脸懵,对着几万行标签不知道从哪里下手。说白了,解析才是决定爬虫能不能用的核心环节。因为无论你请求做得多漂亮,只要字段提取不准,后续所有工作全白做。而正则表达式和XPath,就是解析阶段最趁手的两把工具。
这里还要提醒一点:解析前一定要先搞清楚拿到的是什么。如果是Python的requests库发请求,通常用resp.text拿字符串,或者用resp.content拿字节流。HTML、XML这种结构化文档,更适合用XPath;而散落在JavaScript变量、JSON字符串、日志文本里的数据,用正则表达式更直接。
1.2 正则表达式和XPath的选型逻辑
好多新手会纠结“到底学正则还是学XPath”,其实这俩不是竞争关系,而是互补关系。正则表达式是基于文本模式匹配的,它对内容本身“长什么样”敏感,不关心内容在哪一层标签里。比如你从一段JavaScript代码里提取"name":"张三"这样的结构,或者从纯文本日志里匹配某个日期格式,用正则几乎是最优解。
XPath则完全不同,它面向HTML/XML的树形结构,通过节点路径来定位元素。你可以理解成:正则是在一堆乱麻里找特定花纹的线,XPath是你已经知道线在哪根经纬线上,直接顺着路径取出来。因此,只要网页结构是常规的HTML,用XPath会稳定很多,因为你不必关心标签之间的空格、换行、属性顺序等细节,结构没变就能提取成功。
我个人的选型逻辑很简单:能用XPath解决的结构化页面,就优先用XPath;只有遇到纯文本、字符串片段、动态拼接的数据,才会把正则拿出来。两者配合起来,基本能覆盖日常爬虫90%以上的解析需求。
2. 正则表达式解析网页的核心用法
2.1 正则基础语法:元字符、量词与分组
正则表达式看着晦涩,实际核心语法就那么几类。先记住四个最常用的概念:元字符、量词、字符类、分组。
元字符是正则的基本符号,比如.表示任意字符,\d表示数字,\w表示字母、数字、下划线,\s表示空白字符。量词控制出现次数,*表示0次或多次,+表示1次或多次,?表示0次或1次。比如\d+就能匹配连续的数字串,提取价格、ID这类数据特别好用。
分组用圆括号()表示,它能把匹配到的部分单独捕获出来。比如<a href="(.*?)">(.*?)</a>这个表达式,第一组(.*?)捕获链接地址,第二组(.*?)捕获链接文本。这里还有一个非常关键的技巧:.*?是“非贪婪匹配”,它会尽量短地匹配内容。如果写成.*,默认是贪婪的,会一直匹配到最后一个符合条件的结束标签,很容易把整段HTML吞掉。我见到的正则解析翻车案例,十有八九都是忘了用非贪婪模式。
2.2 用Python的re模块提取网页数据
Python里做正则解析,主要用内置的re模块,不需要装第三方库。最常用的三个函数是re.findall、re.search和re.sub。
re.findall(pattern, text):返回所有匹配结果,适合批量提取。re.search(pattern, text):在字符串里查找第一个匹配结果,返回Match对象,适合提取单个字段。re.sub(pattern, repl, text):替换匹配到的内容,可以用来清洗HTML标签。
举个例子,假设HTML片段如下:
<div class="content"> <a href="/article/101">爬虫入门指南</a> <a href="/article/102">正则表达式速查</a> </div>用正则提取所有链接和标题:
import re html = """ <div class="content"> <a href="/article/101">爬虫入门指南</a> <a href="/article/102">正则表达式速查</a> </div> """ pattern = r'<a href="(.*?)">(.*?)</a>' results = re.findall(pattern, html, re.S) print(results)运行结果是一个列表,每个元素是元组(链接, 标题):
[('/article/101', '爬虫入门指南'), ('/article/102', '正则表达式速查')]注意我在re.findall中加了第三个参数re.S,这个参数让.可以匹配换行。如果不加,一旦<a>标签之间出现换行,正则就会匹配失败。很多新手在这个地方卡半天,明明表达式看起来没问题,就是提取不到内容,其实就是换行搞的鬼。
2.3 正则解析的注意事项与常见误区
用正则解析网页,最怕的是“看似匹配了,实际匹配错了”。这里有三个我踩过的坑,提前帮你排掉。
第一个坑是HTML标签属性顺序不固定。比如有的页面是<a href="..." class="btn">,有的页面是<a class="btn" href="...">。如果你写死<a href=".*?">,第二种情况就匹配不到了。这是我强烈推荐XPath的原因之一,标签属性顺序变化在XPath里根本不影响。
第二个坑是转义字符。正则里(、)、[、]、.、*这些符号都有特殊含义,要匹配它们本身必须加反斜杠。比如你要匹配价格中的小数点,\d+\.\d+里的那个点前面必须加\。写正则时随手把原字符串里的特殊符号都过一遍,能省不少调试时间。
第三个坑是HTML实体。网页里的 、&、<这类字符,正则匹配时经常对不上。比如一个标题里显示的是“A & B”,HTML源码却是A & B。此时你可以先对字符串做一次HTML反转义,或者干脆在后续清洗时用re.sub把&替换成&。这些细节不处理,提取出来的数据总有奇怪字符,存进Excel里特别显眼。
3. XPath解析网页的核心用法
3.1 XPath基础:路径表达式、谓语与轴
XPath有点像文件路径,只是把文件夹换成了HTML标签。比如/html/body/div表示从根节点一层层往下找,//div则表示“不考虑层级,直接在整个文档里找所有div节点”。日常解析中用得最多的就是//开头,它灵活又简洁,不必关心元素嵌得有多深。
除了路径,谓语[...]是XPath里最强大的筛选功能。它可以按索引、属性、文本内容来筛选节点。举个例子:
//a[@class="title"]/@href这段表达式的意思是:找到所有<a>标签,并且class属性值等于title,然后取这些标签的href属性值。如果要按文本内容筛选,可以写//a[contains(text(),"爬虫")],意思是“链接文本里包含‘爬虫’两个字的a标签”。
XPath轴是进阶用法,比如following-sibling可以取当前节点后面的兄弟节点,parent可以取父节点。我平时用得不算多,但如果遇到“根据某个标签找相邻数据”的需求,轴表达式能优雅解决。先掌握//、@、[]、text()这四个就足够应付常见解析任务了。
3.2 用lxml结合XPath解析HTML
Python里用XPath,最佳搭档是lxml库。它底层是C语言实现,解析速度快,对HTML的容错能力也很强。安装命令很简单:
pip install lxml基本用法分两步:先把HTML字符串传入etree.HTML()得到一个Element对象,然后在这个对象上调用xpath()方法。下面是一个完整示例:
from lxml import etree html = """ <div class="content"> <a href="/article/101">爬虫入门指南</a> <a href="/article/102">正则表达式速查</a> </div> """ tree = etree.HTML(html) links = tree.xpath('//a[@class="content"]/@href')这里//a/@href表示获取所有<a>标签的href属性,返回值是一个列表。如果要同时拿链接和文本,可以用索引配合:
items = tree.xpath('//a[@class="content"]') for item in items: href = item.xpath('./@href')[0] text = item.xpath('./text()')[0] print(href, text)这里./表示相对当前节点继续查找,是XPath里非常实用的写法。还有一个小细节:如果某天你发现xpath取不到数据,先用print(etree.tostring(tree, encoding='unicode'))看看实际解析成的HTML是什么样的。因为很多网页源码虽然写在HTML文件里,但浏览器解析后会补全标签、修正格式,跟原始字符串并不完全一致。lxml会尽量按照浏览器的方式解析HTML,你debug时打印出它眼中的结构,往往能找到问题所在。
3.3 浏览器XPath Helper的调试技巧
光看代码有时候很难确定XPath表达式写对了没有,这时候浏览器插件能救命。我常用的是XPath Helper这个浏览器插件,就是Thomas de roo那个版本。安装后,在目标页面按快捷键开启插件,按住鼠标框选页面元素,它会自动生成一个XPath,你还能手动修改表达式并立刻看到匹配结果。
这个插件最实用的场景是:你想定位“网页上显示的第2条新闻链接”,但又不想反复改代码刷新看结果。直接在浏览器里用XPath Helper验证,确认表达式匹配到了预期元素,再把表达式复制到Python代码里,成功率会非常高。
要注意的是,浏览器插件生成的XPath往往很啰嗦,类似/html/body/div[2]/div[1]/div[3]/a。这种绝对路径一旦页面结构微调就会失效。我的习惯是:把插件生成的表达式当作参考,然后手动简化为相对路径,尽量用//和@class、contains(text())这类语义化条件。比如从//*[@id="news-list"]/li[1]/a改成//div[@id="news-list"]//a,既简洁又更能抗页面变动。
4. 实操案例:解析一个静态列表页并提取关键字段
4.1 目标页面分析与请求准备
纸上谈兵再多,不如跑一个完整案例。为了让你能直接复现,我用一段嵌入的HTML字符串来模拟一个静态列表页,这段源码模拟了博客文章列表,包含文章链接、标题和摘要。实际项目中你需要先用requests拿到页面内容,再进入解析环节。
import requests headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } resp = requests.get("https://example.com/blog", headers=headers) resp.encoding = resp.apparent_encoding html = resp.text这里我手动设置了resp.encoding = resp.apparent_encoding,目的是让requests能够从网页内容里自动猜测编码,避免中文乱码。实际开发中,你最好先打印resp.encoding看看默认值,再结合resp.headers.get('charset')判断。如果网页没有明确charset,apparent_encoding通常是最省事的兜底方案。
为了演示稳定,下面案例我直接把HTML字符串写成变量,省去网络请求的不确定性:
html = """ <html> <body> <div class="post-list"> <div class="post"> <h2><a href="/posts/python-regex">正则表达式入门</a></h2> <p class="summary">学会用正则提取网页数据</p> </div> <div class="post"> <h2><a href="/posts/xpath-basics">XPath基础教程</a></h2> <p class="summary">从HTML中定位节点</p> </div> <div class="post"> <h2><a href="/posts/spider-parse">爬虫解析网页实战</a></h2> <p class="summary">正则与XPath的配合使用</p> </div> </div> </body> </html> """目标很明确:提取每篇文章的链接、标题、摘要。
4.2 使用正则表达式提取数据
先用正则表达式来解。观察HTML结构,每篇文章都被包在<div class="post">里,链接在<a href="...">标题</a>,摘要在<p class="summary">...</p>。最直觉的写法是分别匹配三个字段:
import re links = re.findall(r'<a href="(.*?)">(.*?)</a>', html, re.S) summaries = re.findall(r'<p class="summary">(.*?)</p>', html, re.S) print(links) print(summaries)输出结果:
[('/posts/python-regex', '正则表达式入门'), ('/posts/xpath-basics', 'XPath基础教程'), ('/posts/spider-parse', '爬虫解析网页实战')] ['学会用正则提取网页数据', '从HTML中定位节点', '正则与XPath的配合使用']这种写法简单直接,但有个隐患:如果页面里还有别的<a>标签或别的<p class="summary">,匹配结果就会混入无关数据。更严谨的做法是先把每个<div class="post">作为一个整体切出来,然后分别提取内部字段:
post_blocks = re.findall(r'<div class="post">(.*?)</div>', html, re.S) for block in post_blocks: href = re.search(r'<a href="(.*?)">', block, re.S).group(1) title = re.search(r'<a href=".*?">(.*?)</a>', block, re.S).group(1) summary = re.search(r'<p class="summary">(.*?)</p>', block, re.S).group(1) print(href, title, summary)这样虽然麻烦了一点,但提取精准度更高。用re.search加上.group(1)提取第一个捕获组,是正则解析网页的典型套路。
4.3 使用XPath提取数据并与正则对比
同样的任务用XPath,代码会清爽很多。先加载HTML,再写XPath表达式:
from lxml import etree tree = etree.HTML(html) post_nodes = tree.xpath('//div[@class="post"]') for node in post_nodes: href = node.xpath('./h2/a/@href')[0] title = node.xpath('./h2/a/text()')[0] summary = node.xpath('./p[@class="summary"]/text()')[0] print(href, title, summary)输出结果和正则版本完全一样,但可以明显看出三个优势:
- 语义清晰。
./h2/a/@href直接表达“当前节点下h2里a标签的href”,不用写一长串转义字符。 - 不依赖属性顺序。哪怕
<a>标签里先写class再写href,表达式依然有效。 - 容错性更好。HTML标签嵌套即使有多余空格、换行,XPath依然能匹配,正则的空白字符处理则要谨慎得多。
如果页面结构稍微变化,比如标题外层多了一个<span>,正则写法很可能匹配不到,但XPath可以改成./h2//text(),用双斜杠忽略中间层。
4.4 完整可运行代码与输出效果
给你一个直接可运行的整合脚本,既跑正则又跑XPath,方便对照:
import re from lxml import etree html = """ <html> <body> <div class="post-list"> <div class="post"> <h2><a href="/posts/python-regex">正则表达式入门</a></h2> <p class="summary">学会用正则提取网页数据</p> </div> <div class="post"> <h2><a href="/posts/xpath-basics">XPath基础教程</a></h2> <p class="summary">从HTML中定位节点</p> </div> <div class="post"> <h2><a href="/posts/spider-parse">爬虫解析网页实战</a></h2> <p class="summary">正则与XPath的配合使用</p> </div> </div> </body> </html> """ print("=== 正则表达式方案 ===") post_blocks = re.findall(r'<div class="post">(.*?)</div>', html, re.S) for block in post_blocks: href = re.search(r'<a href="(.*?)">', block, re.S).group(1) title = re.search(r'<a href=".*?">(.*?)</a>', block, re.S).group(1) summary = re.search(r'<p class="summary">(.*?)</p>', block, re.S).group(1) print(f"{title} | {href} | {summary}") print("\n=== XPath 方案 ===") tree = etree.HTML(html) post_nodes = tree.xpath('//div[@class="post"]') for node in post_nodes: href = node.xpath('./h2/a/@href')[0] title = node.xpath('./h2/a/text()')[0] summary = node.xpath('./p[@class="summary"]/text()')[0] print(f"{title} | {href} | {summary}")输出效果如下:
=== 正则表达式方案 === 正则表达式入门 | /posts/python-regex | 学会用正则提取网页数据 XPath基础教程 | /posts/xpath-basics | 从HTML中定位节点 爬虫解析网页实战 | /posts/spider-parse | 正则与XPath的配合使用 === XPath 方案 === 正则表达式入门 | /posts/python-regex | 学会用正则提取网页数据 XPath基础教程 | /posts/xpath-basics | 从HTML中定位节点 爬虫解析网页实战 | /posts/spider-parse | 正则与XPath的配合使用如果你用这段代码去抓真实页面,只需要把html变量替换成requests.get(url).text,然后根据目标页面的实际结构调整XPath表达式即可。
5. 常见问题与排查技巧实录
5.1 编码问题导致乱码
爬虫解析网页时,最常遇到的就是中文乱码。这个坑的根源在于requests库默认会用HTTP响应头里的charset猜测编码,但很多网站并没有在响应头里明确编码,或者写错了。比如页面实际是UTF-8,响应头却写charset=ISO-8859-1,于是resp.text返回的就是一堆乱码。
我的排查顺序很固定:先打印resp.encoding,再打印resp.apparent_encoding,然后手动把resp.encoding设置为正确的编码。如果还不放心,可以直接用resp.content.decode('utf-8', errors='ignore'),其中errors='ignore'可以跳过无法解码的字节,避免抛异常。对于极少数编码混乱的页面,可以尝试gb18030、big5等常见中文编码。解析前搞定编码,后续正则和XPath才不会在乱码上做无用功。
5.2 HTML结构不规范导致的提取失败
真实网页常常会给你“惊喜”,比如标签没闭合、属性值带了单双引号、<li>标签散落各处。用正则表达式处理这些脏结构,很容易因为一个换行、一个多余空格就匹配失败。XPath虽然容错性好,但也会遇到取不到值的情况。
碰到这种情况,第一件事不是改表达式,而是先打印etree.tostring(tree, encoding='unicode')看看lxml解析后的结构。很多时候原始HTML和解析后的DOM树并不一致,lxml会自动修复部分标签嵌套错误。打印出来后,你会发现原本缺失的</div>被补上了,或者多出了<html>、<body>骨架。这种“先看树,再写表达式”的做法,能帮你少走很多弯路。
还有一种常见情况是动态内容。页面显示的数据其实不在HTML源码里,而是通过Ajax请求渲染出来的。此时正则和XPath都不可能提取到,你要么去抓接口返回的JSON,要么用Selenium等工具模拟浏览器执行JavaScript后再解析。判断方式很简单:在浏览器里右键查看页面源代码,搜一下你要提取的字符串,如果搜不到,那基本就是动态加载的。
5.3 动态内容与反爬策略的应对思路
这里需要强调一个原则:解析只是爬虫链条的一部分,真正决定爬虫“活不长久”的往往不是解析,而是请求策略。对于公开网站,至少要做到三点:设置合理的User-Agent、控制请求频率、避开明显的高峰时段。
很多反爬机制针对的是“一秒请求十几次”的机器行为,所以你在写爬虫时,哪怕只是练习,也要在循环里加上time.sleep(1),让请求间隔变得接近人工操作。更高级的反爬还包括登录校验、验证码、签名参数等,这些内容水很深,但我不建议初学者一上来就研究绕过手段。先拿无门槛的公开数据练手,把正则和XPath用到滚瓜烂熟,比什么都重要。
如果你自己维护网站,或者只写后端接口,担心被爬虫刷流量,核心思路其实是限流和校验。比如在Controller层对单位时间内的请求次数做限制,对频繁访问的IP做临时封禁,给接口增加签名参数等。这些属于Web防护的话题,但反过来想,理解了网站如何防护,你也就更清楚爬虫该遵守什么边界。
5.4 爬虫的合规边界与建议
最后说一说合规问题,这是每个爬虫学习者都绕不开的坎。我的建议很朴素:只抓公开数据,不抓需要登录才能看的隐私数据;请求前看一眼目标网站的robots.txt;抓数据后不要搞批量贩卖,更不要拿来做骚扰、诈骗之类的事。
在法律层面,不同国家和地区对爬虫的态度并不完全相同,但“未经授权大量抓取并商业使用他人数据”这件事,在任何地方都是有风险的。对于个人学习和技术研究,尽量使用公开的示例网站、测试页面,或者网站明确允许爬取的板块。学技术没错,但把技术用歪了,技术和人都容易走到死胡同。
具体到解析环节,合规还意味着“提取后不要保留过多无关字段”。比如抓一个商品列表,你只需要标题、价格、链接,那就只提取这三个字段,不要顺手把用户评价、卖家昵称、库存信息全部拉下来存起来。数据最小化,既是技术习惯,也是保护自己。
最后再说点个人经验
在我实际写了大量爬虫之后,个人体会是:正则表达式和XPath从来不是“二选一”的问题,而是“该用谁就用谁”的问题。解析HTML结构稳定的页面,我会毫不犹豫用XPath;处理网页源代码里嵌着的JSON字符串,或者从一堆乱糟糟的文本日志里捞数据,我还是会老老实实写正则。
另外,选工具的时候别死磕一个。有时候同一个数据,用正则三五行能搞定,用XPath反而绕来绕去;也有时候XPath一行表达式就能定位到的节点,正则写半天还容易出错。最好的状态是两个都会,然后根据场景自由切换。建议你从今天这个示例开始,把同一份HTML分别用两种方式解析一遍,体会它们各自的思考方式。练上几次,你会发现解析网页这件事,其实比想象中简单。