正则表达式这玩意儿,刚接触Python的人十有八九觉得它像天书——一堆反斜杠加括号,看着比密码还难懂。但真在项目里跑过几轮爬虫、处理过几万条脏数据之后,你会发现自己根本离不开它。字符串查找、格式校验、数据清洗、日志分析,随便哪个场景都能看到它的身影。这篇就基于我自己多年的Python使用经验,把正则表达式这块掰开揉碎了讲清楚,从语法基础到爬虫实战、再到各种坑和排查思路,尽量让刚入门的朋友也能照着用起来。
我最早用正则,其实就是为了从一个网页里把商品价格抠出来。那时候还是用正则表达式硬匹配,后来换成XPath、CSS选择器,但正则依然是兜底方案——遇到结构不规则、标签嵌套混乱的HTML,正则反而最能打。所以不管你是要做爬虫、搞数据分析,还是写自动化脚本,正则都是绕不开的一门基本功。
1. 正则表达式到底解决什么问题
1.1 从一个真实需求说起
假设你现在拿到一个纯文本文件,里面有几千行这样的数据:
订单号:A20240001,金额:98.50元,时间:2024-03-15 14:30 订单号:B20240567,金额:1200.00元,时间:2024/03/16 09:12 订单号:C20240888,金额:66元,时间:2024.03.17你想把订单号、金额、时间三列分别提取出来,存成结构化数据。用普通的字符串方法做?split分割、strip清理、再判断格式,写出来的代码又长又脆,换一种格式就歇菜。这时候正则表达式就是最顺手的工具,一行匹配模式搞定:
import re text = "订单号:A20240001,金额:98.50元,时间:2024-03-15 14:30" pattern = r"订单号:(\w+),金额:([\d.]+)元,时间:([\d. /:-]+)" m = re.search(pattern, text) if m: print(m.groups()) # ('A20240001', '98.50', '2024-03-15 14:30')这就是正则的核心价值:它帮你用一段“模板”描述一类字符串的结构规律,而不是笨拙地逐个字符去判断。简单说,你告诉正则引擎“我要找什么样形状的东西”,它按你的规则去文本里扫描,找出所有符合条件的片段,或者帮你替换、校验、拆分。
1.2 正则的适用边界
这个必须开篇就说清楚:正则不是万能的。早期我犯过的最大错误,就是想用正则去解析HTML、解析JSON、解析各种嵌套结构。结果就是写了一坨超长的匹配模式,一遇到换行、注释、属性顺序变化就直接崩溃。HTML的标签可以嵌套,JSON的结构是递归的,这类数据用正则处理,本质上是“用错误的工具去解决错误的问题”。
正则真正擅长的是这几类任务:
- 格式校验:邮箱、手机号、身份证号、日期格式是否合法。
- 信息提取:从日志、网页文本、配置文件中抽取关键内容。
- 批量替换:把文本里的某类模式统一替换成另一格式。
- 字符串拆分:按多个分隔符统一切割。
- 文本过滤:找出包含特定关键词组合的段落。
而解析HTML、JSON、XML这类有层级嵌套的数据,正确做法是交给专门的解析库,比如解析HTML用BeautifulSoup、lxml,解析JSON直接json.loads。我见过不少新手拿正则生啃一个JSON字符串,最后把自己绕晕了,得不偿失。正则解决的是“线性的、平面的”文本模式问题,碰上“递归的、嵌套的”结构,果断换工具。
2. 核心语法与匹配逻辑拆解
2.1 字符匹配的基础单位
正则的本质是描述一组字符串的规则,它由普通字符和元字符组成。普通字符就是字面意思,比如匹配a就是找字母a。但正则的威力都在元字符上。我按自己的理解,把常用的元字符分成几组:
字符类与任意匹配
| 模式 | 含义 | 示例 |
|---|---|---|
. | 匹配除换行外的任意单个字符 | a.c匹配abc、a1c |
\d | 匹配一个数字,等价于[0-9] | \d\d匹配42 |
\w | 匹配字母、数字、下划线,等价于[a-zA-Z0-9_] | \w+匹配一个单词 |
\s | 匹配空白字符,包括空格、制表符、换行 | a\sb匹配a b |
[abc] | 字符集合,匹配a、b、c中任意一个 | [Pp]ython匹配Python或python |
[^abc] | 排除型字符集合 | [^0-9]匹配任意非数字字符 |
这里有个容易踩的坑:在字符集合内部,很多元字符会“退化”成普通字符。比如[.]就只匹配句点本身,而不是任意字符。所以如果你要匹配一个点号,写\.或者[.]都可以,但不要直接写.,不然它会匹配掉你不想匹配的东西。
位置锚定
| 模式 | 含义 |
|---|---|
^ | 匹配字符串开头 |
$ | 匹配字符串结尾 |
\b | 匹配单词边界 |
\B | 匹配非单词边界 |
位置锚定很常用,比如校验一个字符串是不是纯数字:^\d+$。如果忘了加^和$,\d+会在abc123def里照样匹配出123,结果就不符合“纯数字”的预期。这个细节在实际写校验正则时特别重要。
2.2 量词与贪婪模式的坑
量词决定一个模式重复多少次:
| 模式 | 含义 |
|---|---|
* | 重复0次或多次 |
+ | 重复1次或多次 |
? | 重复0次或1次 |
{n} | 重复n次 |
{n,} | 重复至少n次 |
{n,m} | 重复n到m次 |
比如\d{4}用来匹配四位年份,\d{1,3}匹配1到3位数字。手机号校验可以写成^1[3-9]\d{9}$,意思是以1开头,第二位是3到9之间的数字,后面跟9位数字,一共11位。
真正让人头大的是贪婪与非贪婪。默认情况下,量词是贪婪的,它会尽量匹配更多的字符。举个例子:
import re text = "<a>hello</a><b>world</b>" # 贪婪模式 print(re.findall(r"<.*>", text)) # 输出:['<a>hello</a><b>world</b>'] # 非贪婪模式 print(re.findall(r"<.*?>", text)) # 输出:['<a>', '</a>', '<b>', '</b>']同样的模式,只是加了个?,结果天差地别。贪婪模式会一路吃到最后一个>才停下;非贪婪模式则是找到一个最短的满足条件就停。实际提取HTML标签内容时,几乎都要用非贪婪模式,否则很容易把一大片内容全吞进去。这个坑我在爬虫里踩过无数次,后面专门有一节细说。
2.3 分组与反向引用
括号在正则里不只是为了“分组显示”,它还能捕获匹配到的内容。re.search之后,通过.group(1)、.group(2)就能取出对应分组的内容。比如提取订单号:
m = re.search(r"订单号:(\w+)", text) print(m.group(1)) # A20240001分组还有一个更高级的用法叫反向引用。匹配重复出现的单词时很管用,比如(\w+)\s+\1就能匹配hello hello这种连续重复的词。\1引用了第一个分组的匹配内容。
再比如非捕获分组(?:...),它只分组但不捕获,不会占用分组编号,适合用在不希望结果里多出无用分组的情况。比如匹配http或https,可以写(?:http|https)://,这样不会产生多余的分组干扰编号。
还有命名分组(?P<name>...),取数据时用m.group('name'),比记忆数字索引清晰得多。代码一长,group(3)和group(4)真的分不清谁是谁,命名分组能救你一命。举个例子:
text = "金额:98.50元" m = re.search(r"金额:(?P<amount>[\d.]+)元", text) print(m.group('amount')) # 98.502.4 常用元字符速查表
为了让你日常写的时候不用翻文档,我把最常用的整理成一张表放这里:
| 语法 | 作用 |
|---|---|
. | 匹配除换行外任意字符 |
^/$ | 匹配开头 / 结尾 |
\d/\D | 数字 / 非数字 |
\w/\W | 单词字符 / 非单词字符 |
\s/\S | 空白 / 非空白 |
\b/\B | 单词边界 / 非边界 |
*/+/? | 0次或多次 / 1次或多次 / 0次或1次 |
{n,m} | 重复n到m次 |
[...]/[^...] | 字符集合 / 排除集合 |
(...) | 分组捕获 |
(?:...) | 非捕获分组 |
(?P<name>...) | 命名分组 |
| | 或,匹配左边或右边 |
\b | 单词边界 |
3. Python正则的实操要点
3.1 re模块的核心API,别再只会search
Python的re模块是写正则的主要战场。核心函数就这几个,但每个都有自己的脾气:
re.match(pattern, string):从字符串开头开始匹配,开头不满足就直接返回None。它跟^作用类似,很多人刚学时分不清match和search,记住这一点就够了:match限定必须从头开始。re.search(pattern, string):扫描整个字符串,找到第一个满足模式的位置。re.findall(pattern, string):返回所有匹配结果的列表。注意,如果模式里有分组,返回的不再是字符串列表,而是元组列表。re.finditer(pattern, string):返回一个迭代器,每一项是一个Match对象。当匹配数量很大时,用finditer更省内存,而且可以通过m.group()取分组信息。re.sub(pattern, repl, string):替换所有匹配的文本。repl可以是一个字符串,也可以是一个函数,函数写法在动态替换时很实用。re.split(pattern, string):按照模式拆分字符串,比str.split()灵活得多,可以一次按多个分隔符切。
这里有个细节:findall遇分组时的行为。新手最懵的就是这个。比如:
re.findall(r"(\w+)@(\w+)", "test@example.com") # 输出 [('test', 'example')],注意是元组列表如果你想既拿到整体匹配、又拿到分组,用finditer更好。或者把整体也包成一个分组:((\w+)@(\w+))。
3.2 编译与性能,别反复造轮子
在许多示例代码里,大家习惯直接写re.findall(pattern, text)。这种写法最直观,但如果你在循环里调用几千次,每次Python都要重新编译一次正则表达式,性能损耗非常明显。正确做法是用re.compile预编译:
pattern = re.compile(r"\d{4}-\d{2}-\d{2}") for line in lines: m = pattern.search(line) ...编译后的Pattern对象可以直接复用search、findall等方法,速度有明显提升。特别是爬虫批量处理列表页、日志分析跑几十万行文本的场景,compile带来的收益不是玄学,是实打实的。
另外,re.compile还可以传一些有用的标志位:
re.I:忽略大小写。re.S:让.也能匹配换行符。这个在匹配跨多行的文本时几乎必用,因为默认.不匹配换行,一跨行就匹配失败。re.M:多行模式,让^和$匹配每一行的开头和结尾,而不只是整个字符串的开头结尾。re.X:忽略模式中的空白符,允许写注释,让复杂正则可读性提升不少。
这几个标志可以组合使用,比如re.compile(pattern, re.I | re.S)。用re.S匹配HTML多行内容是我的常规操作,不然.遇到换行就断,正则全废。
3.3 常用的正则模板,拿过去就能改
下面这些都是我在各种项目里反复用、验证过没问题的常见正则,可以直接抄:
# 邮箱(简化版,能覆盖绝大多数情况) email_pattern = r"[\w.+-]+@[\w-]+\.[\w.-]+" # 中国大陆手机号 phone_pattern = r"1[3-9]\d{9}" # 身份证号(18位) id_card_pattern = r"\d{17}[\dXx]" # IPv4地址 ipv4_pattern = r"((?:\d{1,3}\.){3}\d{1,3})" # 日期,支持 2024-03-15 / 2024/03/15 / 2024.03.15 date_pattern = r"\d{4}[-/.]\d{1,2}[-/.]\d{1,2}" # 匹配中文字符 chinese_pattern = r"[\u4e00-\u9fa5]+" # 提取HTML标签内的文本 html_text_pattern = r">([^<]+)<" # URL链接 url_pattern = r"https?://[\w./?=&%-]+"需要注意:这些是“够用就好”的实用型正则,不是终极完美版。像邮箱正则,真要严格按照RFC标准写,能写出一长串看似疯狂的模式,但实际业务里多数场景并不需要那么严格。符合业务需求、能处理你遇到的数据,就是好正则。
3.4 匹配中文的各种坑
处理中文时,正则有几个需要注意的点。首先,Python 3的字符串默认就是Unicode,直接用[\u4e00-\u9fa5]可以匹配中文字符。但如果你在Windows的CMD、老项目里遇到编码问题,字符串本身的编码就可能出问题,正则倒是次要的。其次,\w在Python 3的默认情况下是能匹配Unicode单词字符的,也就是说它也能匹配中文,这一点跟一些老教程里描述的不一样。自己写代码时,如果只想匹配英文单词,最好用[A-Za-z0-9_]显式指定,否则\w+可能把中文也吞进去,提取结果里多出一堆不想要的内容。
还有就是,匹配连续的中文不要写[\u4e00-\u9fa5]+以外花里胡哨的东西,这个范围覆盖了绝大多数常用汉字。如果遇到生僻字、扩展区字符,范围可能要扩大,比如加\u3400-\u4dbf(扩展A区),一般场景用不到,知道有这回事就行。
4. 爬虫场景中的正则实战
4.1 从HTML里提取信息的几种姿势
爬虫是正则表达式最典型的应用场景之一。虽然现在很多人用XPath和CSS选择器,但正则依然有它不可替代的位置,尤其是在处理接口返回的JSONP、嵌套严重的标签、或者是用XPath写了半天都定位不到的时候,正则往往一个模式就解决问题。
从HTML提取信息,常见的做法是先把整个页面文本拿到来,然后用re.search或re.findall去抠目标内容。比如:
html = """ <div class="product"> <h3>无线鼠标</h3> <span class="price">89.00</span> </div> <div class="product"> <h3>机械键盘</h3> <span class="price">299.00</span> </div> """ # 提取商品名称 names = re.findall(r"<h3>(.*?)</h3>", html) # 提取价格 prices = re.findall(r'<span class="price">(.*?)</span>', html) print(names) # ['无线鼠标', '机械键盘'] print(prices) # ['89.00', '299.00']看到.*?了吗?这就是前面说的非贪婪匹配。如果把?去掉,第一个<h3>(.*)</h3>会从第一个<h3>一路吞到最后一个</h3>,结果就变成整个列表连在一起。这个区别,写爬虫的人早晚会遇到一次。
从HTML提取数据,我不建议一上来就写超复杂的正则。我的习惯是先用re.findall快速验证模式,再回来看数据质量。如果HTML结构比较稳定,正则完全够用;如果结构经常变,那还是该上BeautifulSoup就上BeautifulSoup,别硬扛。
4.2 数据清洗与格式统一
爬下来的数据往往带着各种杂质——空格、换行、HTML实体、重复标点。这时候正则就是清洗利器。举几个我实际用过的例子:
# 去掉所有HTML标签 clean_text = re.sub(r"<[^>]+>", "", html_text) # 合并连续空白字符为单个空格 clean_space = re.sub(r"\s+", " ", text) # 去掉首尾空白 clean_trim = text.strip() # 把全角数字转半角(简化版) half_width = re.sub(r"[0-9]", lambda m: str(int(m.group(0))), text) # 手机号脱敏:改成 138****1234 masked = re.sub(r"(1[3-9]\d)\d{4}(\d{4})", r"\1****\2", text)这里重点说下替换函数。re.sub的第二个参数传函数,意味着你可以对每个匹配结果做任意处理。比如上面全角转半角的写法,每次匹配到一个全角数字,就调用函数转成半角。这种动态替换的能力,是简单的字符串replace做不到的。
再分享一个常见需求:把爬到的金额数字规范化。原始数据可能是"89.00 元"、"价格:89元"、"299.00"这些乱七八糟的格式,你想要统一转成浮点数:
def extract_amount(text): m = re.search(r"([\d,]+(?:\.\d+)?)", text.replace(",", "")) return float(m.group(1)) if m else None print(extract_amount("价格:89.00 元")) # 89.0 print(extract_amount("原价1,299元")) # 1299.0注意我先把,去掉了,因为千分位逗号会干扰数字匹配。这些细节,都是处理真实数据时一点点试出来的。
4.3 实战:从JSONP接口里抠数据
有些老接口返回的不是标准JSON,而是JSONP格式,比如:
callback({"status":1,"data":{"name":"李四","age":25}});如果直接用json.loads解析会直接报错,因为整段不是合法JSON。这时候先用正则把括号里的内容抠出来,再接json.loads:
import json resp_text = 'callback({"status":1,"data":{"name":"李四","age":25}});' m = re.search(r"callback\((.*)\);?", resp_text) if m: data = json.loads(m.group(1)) print(data["data"]["name"]) # 李四这也是正则的经典用法之一:从非结构化文本里,先把结构化片段剥离出来,再交给专业工具去解析。高阶一点的玩法是动态匹配回调函数名:(\w+)\((.*)\),然后m.group(1)就是函数名,m.group(2)就是JSON字符串,更加通用。
5. 常见问题与排查技巧实录
5.1 匹配不到:先排除这些低级错误
我最常被问到的问题就是“我这正则怎么啥都匹配不出来”。排查思路就一条:把模式拆到最细,逐层验证。常见的坑有:
- 忘加
re.S导致跨行匹配失败。HTML源码动辄几百行,中间必有换行。你的.*匹配不到换行,自然就失败了。解决:加上re.S,或者改用[\s\S]*。 - 在原始字符串里忘了处理反斜杠。强烈建议写正则时用
r"..."原始字符串,比如r"\d+",否则\d在普通字符串里会被当成转义,轻则报错,重则静默匹配错误。 - 字符集合里的特殊字符没转义。比如想匹配
a.b,你写a.b,结果.匹配了任意字符,axb也被匹配了。想匹配字面点号,要写a\.b。 - 中文与编码问题。用
[\u4e00-\u9fa5]没问题,但如果你把正则模式和源文本的编码搞混了,可能啥也匹配不到。先确保两边都是同样编码,一般UTF-8最稳妥。
我调试正则的土办法是:找一条最小样本,先把固定文字部分写出来,再逐步加入可变部分。每次只改一个点,验证一次。这样定位问题非常快。
5.2 匹配过多:贪婪模式惹的祸
正则“匹配过头”几乎都是贪婪模式的锅。前面说过.*会吃到底,这在提取多个结构相似的内容时是灾难。解决方法是:
- 能不用
.就不用,尽量用更精确的字符类,比如\d+、[\w-]+。 - 必须用
.时,后面加?变非贪婪,(.+?)。 - 用排除字符集代替
.,比如提取标题:<title>([^<]+)</title>,比<title>(.*?)</title>更稳,因为它永远不会跨过下一个<。
我在爬虫里最常用的是第三种写法。[^<]+这招在提取HTML标签内容时特别稳,因为内容是文本,里面不应该有<符号。用排除字符集,天然避免了贪婪问题,连非贪婪都不用加。
5.3 性能陷阱:灾难性回溯怎么写出来的
某些正则遇到特殊输入时,性能会急剧退化,甚至卡死。典型的例子是嵌套量词 + 模糊匹配组合,比如(a+)+这类模式。在处理长字符串时,正则引擎的回溯次数可能指数级增长,程序看起来像死循环。
实际写代码时,尽量避免“量词套量词”,比如(.+?)+、(\w*\d*)+。不要把每个字符都搞成可选。对性能敏感的大文本处理,建议:
- 用
re.compile预编译,不要在循环里反复构建。 - 能锚定的就锚定,
^和$能大幅减少扫描范围。 - 使用
finditer而非findall,大文本里一次性构建列表很耗内存。 - 实在不行就分开多次匹配,不要试图一个正则解决所有问题。
我处理几十MB的日志文件时,如果发现某个正则跑了几秒还没出结果,第一反应就是看有没有灾难性回溯风险。砍掉嵌套量词,性能立刻恢复。
5.4 调试小工具推荐
写复杂正则时,别光靠肉眼在代码里猜。我常用的调试方法是:
- 在Python里写一个小脚本,把正则、测试文本扔进去,定期打印结果。
- 在线正则测试工具(如regex101)能实时显示分组、匹配位置,还能解释每一步匹配过程,调试复杂正则非常省力。
- 自己写个
debug函数,把findall的结果逐条打印出来,看看是不是自己想要的。
有朋友还会用re.DEBUG标志来查看编译过程,但那个输出比较底层,一般调试用不上。先把模式逻辑理清楚,比啥工具都强。
6. 我在实际项目中的几点体会
正则这东西,刚学时觉得难,用多了觉得爽,真正深入之后反而会越来越谨慎。我现在写正则前会先问自己三个问题:这个需求正则是真是最适合的工具吗?模式里有没有潜在的性能陷阱?数据格式出现变化时,这个正则会不会静默出错?想清楚了再动手,能省下后面一堆排查时间。
有个经验特别想分享给新手:正则模式宁可写窄,不要写宽。写窄了匹配不到,你马上能发现;写宽了静默匹配到不该匹配的数据,可能藏在代码里跑好几个星期都没人发现,直到某天数据统计对不上账,才知道后悔。我见过太多因为.*太贪婪导致爬虫数据串位、分析结果混乱的案例了。要匹配一个数字,就写\d+,别写.;要匹配几个字符,就明确字符范围,别图省事用太模糊的模式。
还有一点,重要项目里别忘了给正则加注释。re.X模式允许你在正则里写空格和注释,比如:
pattern = re.compile(r""" (?P<year>\d{4}) # 年 [-/.] # 分隔符 (?P<month>\d{1,2}) # 月 [-/.] # 分隔符 (?P<day>\d{1,2}) # 日 """, re.X)这样几个月后回来看代码,你还能看懂自己在干什么。否则一长串正则放在那里,连自己都要琢磨半天,更别提接手你代码的同事了。
最后建议反正则表达式当成一个需要持续积累的“工具箱”——不需要背所有语法,但常用的元字符、贪婪与非贪婪的概念、分组引用的用法必须烂熟于心。下一回不管是写爬虫、处理日志还是做数据清洗,你都会感谢当初认真学了正则的自己。