☰
Python正则表达式实战:从基础语法到爬虫数据清洗
2026/10/6 4:25:01 网站建设 项目流程

正则表达式这玩意儿,刚接触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.50

2.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 匹配不到:先排除这些低级错误

我最常被问到的问题就是“我这正则怎么啥都匹配不出来”。排查思路就一条:把模式拆到最细,逐层验证。常见的坑有:

  1. 忘加re.S导致跨行匹配失败。HTML源码动辄几百行,中间必有换行。你的.*匹配不到换行,自然就失败了。解决:加上re.S,或者改用[\s\S]*。
  2. 在原始字符串里忘了处理反斜杠。强烈建议写正则时用r"..."原始字符串,比如r"\d+",否则\d在普通字符串里会被当成转义,轻则报错,重则静默匹配错误。
  3. 字符集合里的特殊字符没转义。比如想匹配a.b,你写a.b,结果.匹配了任意字符,axb也被匹配了。想匹配字面点号,要写a\.b。
  4. 中文与编码问题。用[\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)

这样几个月后回来看代码,你还能看懂自己在干什么。否则一长串正则放在那里,连自己都要琢磨半天,更别提接手你代码的同事了。

最后建议反正则表达式当成一个需要持续积累的“工具箱”——不需要背所有语法,但常用的元字符、贪婪与非贪婪的概念、分组引用的用法必须烂熟于心。下一回不管是写爬虫、处理日志还是做数据清洗,你都会感谢当初认真学了正则的自己。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询