前几天有个朋友发我一段代码说他用 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 classnews-list a href/news/20240612/123.html classtitle某地发布新政策/a a href/news/20240612/124.html classtitle某行业迎来重大变化/a /div用 Python 的 re 模块提取所有文章的 URL 和标题import re import requests url https://example.com/news html requests.get(url).text pattern ra 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 ra href(.*)(.*)/a这个正则匹配下来(.*)里的链接会把第二个、第三个 a 标签的href内容全吞进去因为正则引擎会尽可能匹配到最后一个/a之前。最终结果完全不是你想要的。改成这样才对pattern ra 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[classtitle]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 这三个字就行。这比classtitle更健壮因为很多页面的 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 classitem商品名在里面的h4中价格在span classprice中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 与 DevTools4.1 XPath Helper写路径时的效率神器如果你经常写 XPath强烈建议在 Chrome 里装一个 XPath Helper 插件。这个插件是一个叫 Thomas de Roo 的开发者做的在热搜里出现频率很高也确实好用。插件的使用方式很简单打开目标网页点击浏览器右上角的 XPath Helper 图标会出现一个黑色的控制台面板。按住 Shift 键鼠标移动到页面上面板里会自动显示当前鼠标所指元素的 XPath。在面板最下方的输入框里输入你自己的 XPath 表达式按回车匹配到的节点会高亮闪动。这个插件的价值在于即时反馈。你不用反复改代码、跑请求、看结果直接在真实页面上试 XPath看到高亮就说明路径写对了。我写爬虫的前期调试几乎一半时间都耗在这个面板里。4.2 为什么我不推荐直接用 DevTools 的 Copy XPathChrome 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 classarticle-list li a href/article/101.html爬虫基础教程从零开始/a span2024-06-12/span /li li a href/article/102.html正则表达式实战链接提取/a span2024-06-11/span /li li a href/article/103.htmlXPath入门定位的艺术/a span2024-06-10/span /li /ul目标提取每篇文章的标题、URL、日期。5.2 正则解法import re import requests url https://example.com/article-list html requests.get(url).text pattern ra 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 / GB2312requests 默认会用 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()验证你的路径再用小数据量把解析代码跑通最后才扩展到全量抓取。这个流程能帮你绕过我当年踩过的大部分坑。爬虫解析这件事耐心比技巧更重要一步步来你会发现它其实并不难。