正则表达式:从一团乱文本里捞出结构化数据
以「清洗语音助手日历导出的乱文本」为任务实战 re 模块:字符与量词、锚点、分组捕获、findall/sub/split、常用标志与 VERBOSE 写法,讲贪婪与懒惰、原始字符串报错实录,附「别用正则做的事」判断清单与四类练习。
前置知识
正则不需要任何数学背景。它就是一种描述文本形状的小语言:像「三个数字加一个横线再加四个数字」这样的形状,用 \d{3}-\d{4} 一句话写完。本文全程用 Python 内置的 re 模块,零安装。
分工说明:本文管「会用」:语法、捕获、替换、标志、可读写法。正则引擎内部的回溯机制、灾难性回溯与防御(原子组、占有量词)、环视断言(lookaround)等深水内容在 正则进阶机制,本文用到时会给指引。
学习目标
读完本文你将能够:
- 用字符类、量词、锚点拼出常见形状:日期、时间、数量、编号;
- 用捕获组(含命名分组)把一段乱文本拆成结构化字段,并解释 findall / search / finditer 的返回差异;
- 用
re.sub做查找替换,包括用函数动态决定替换内容; - 会用 IGNORECASE、MULTILINE、VERBOSE 三个最常用标志,并用 VERBOSE 把长正则写成可读的多行;
- 拿到
re.error: missing ), unterminated subpattern与「匹配到空串」两类典型故障时能自己定位。
预计 50 到 70 分钟,含 3 组修改实验与 4 道练习。
1. 你现在要解决什么问题
你有一个语音助手小音箱(类似 FoloToy 那类 ESP32 玩具),它把你的口头日程记成了一坨自由文本,导出来长这样:
notes = [
"2026-10-03 09:30 提醒:给多肉浇水",
"提醒 10月7日 下午3点 交物业费!!",
"2026-10-12 14:00(注意全角空格)日语课",
"10/15 08:05 打疫苗",
"买菜", # 没有时间的普通待办
]你想要一个脚本,把它变成规整的结构化数据:
[{"date": "2026-10-03", "time": "09:30", "text": "给多肉浇水"}, ...]用字符串方法(split、find)写会发现分支爆炸:分隔符有横线、有斜杠、有「月」,日期在行首、在中间,还有全角空格。形状多变、规律杂糅的文本,正是正则的主场——它让你一次声明「我要的形状」,剩下的交给引擎。
2. 先不要看解释,先试试看
打开 REPL,从最简单的字面匹配开始:
>>> import re
>>> re.search("浇水", "2026-10-03 09:30 提醒:给多肉浇水")
<re.Match object; span=(20, 22), match='浇水'>search 在整段文本里找第一处,返回 Match 对象(.span() 是位置,.group() 是匹配到的文本)。字面量没意思,来第一个真正的模式——匹配时间「两位数字、冒号、两位数字」:
>>> m = re.search(r"\d{2}:\d{2}", "2026-10-03 09:30 提醒:给多肉浇水")
>>> m.group()
'09:30'\d 表示一个数字,{2} 表示恰好两次。你已经会正则的最核心一行了。注意模式串前面的 r——原始字符串,第 8 节讲它为什么不能省。
3. 六个积木:字符、类、量词、锚点
正则的全部表达能力由几块积木组合而来,逐块认识:
字符类(一对方括号 = 「这里可以是其中之一」):
[0-9] # 一个数字,等价 \d
[a-zA-Z] # 一个英文字母
[\u4e00-\u9fff] # 一个常用汉字
[^0-9] # 开头的 ^ 表示取反:一个非数字
. # 任意一个字符(换行符除外)预定义类:\d 数字、\w 字母数字下划线、\s 空白(空格、制表、全角空格都算——第 1 节那条全角空格记录会被它正常切开);大写取反:\D、\W、\S。
量词(跟在字符或字符类后面):
* # 0 次或多次
+ # 1 次或多次
? # 0 次或 1 次
{3} # 恰好 3 次
{2,5} # 2 到 5 次
{2,} # 至少 2 次锚点(不消耗字符,只占位置):
^ # 行首
$ # 行尾
\b # 单词边界(字母数字与非字母数字的分界)修改实验一:在 REPL 用 re.search 逐一验证:r"^\d{4}-\d{2}-\d{2}" 能否匹配第 1 节的四条记录?哪几条匹配失败、为什么?(答案:只有以横线日期开头的两条;10月7日 与 10/15 两种写法需要更宽的模式。)
贪婪与懒惰:量词默认贪婪——尽可能多吃。给量词加 ? 变懒惰——尽可能少吃:
>>> re.search(r"<.+>", "<a>和<b>")
<re.Match object; span=(0, 7), match='<a>和<b>'> # 贪婪:吞到最后一个 >
>>> re.search(r"<.+?>", "<a>和<b>")
<re.Match object; span=(0, 3), match='<a>'> # 懒惰:停在第一个 >处理「成对标记之间取内容」时,懒惰几乎总是你想要的。
4. 捕获组:把匹配拆成字段
模式里加圆括号 = 「这一段单独存下来」。给日历文本写日期与时间的完整模式:
pattern = r"(\d{4}[-/])?(\d{1,2})[-/月](\d{1,2})日?\s+(\d{1,2}):(\d{2})"拆开读:年份组可有可无(?),月份一到两位,分隔符接受横线、斜杠或「月」,日子后可带「日」,空白用 \s+,然后时、分。用 search 拿结果:
m = re.search(pattern, "提醒 10月7日 下午3点 交物业费!!")
m.groups() # (None, '10', '7', '3', ...) —— 等等,时间没对上先别急着觉得正则不可靠——这个「没对上」是真实的:下午3点 里根本没有冒号,模式只匹配到了别的片段。这暴露了正则的第一课:模式只负责形状,不负责语义。「下午 3 点」这种变体,要么扩模式,要么先做文本归一化。工程上的正确姿势是两步:先用宽松的模式兜住「时间大概在这」,再对捕获结果做程序化校验(int 之后检查范围)。
命名分组让可读性上一个台阶,值直接用名字取:
pattern = r"(?P<date>\d{4}-\d{2}-\d{2})\s+(?P<time>\d{2}:\d{2})\s*(?P<text>.*)"
m = re.search(pattern, notes[0])
m.groupdict() # {'date': '2026-10-03', 'time': '09:30', 'text': '提醒:给多肉浇水'}(?P<名字>...) 定义命名组,groupdict() 一次拿到全部字段的字典。这是「乱文本进、结构化数据出」的标准出口。
三个取值方法的分工要分清:
| 方法 | 返回 | 典型用途 |
|---|---|---|
re.search(p, s) | 第一个 Match 或 None | 判断有没有、取第一处 |
re.match(p, s) | 只从开头匹配 | 校验整个字符串形状(配合 $) |
re.fullmatch(p, s) | 必须整个字符串都匹配 | 表单校验首选 |
re.findall(p, s) | 所有匹配的字符串列表 | 批量捞值 |
re.finditer(p, s) | Match 对象的迭代器 | 批量捞值且要位置/分组 |
re.sub(p, repl, s) | 替换后的新字符串 | 清洗 |
新手高频坑:match 不是「全串匹配」而是「从开头匹配」——re.match(r"\d+", "123abc") 成功返回 123。想做全串校验用 fullmatch。
修改实验二:用 finditer + 命名分组遍历 notes,打印每条能匹配「日期 + 时间」的记录的 groupdict()。观察第 2、4 条因格式不同被漏掉,第 5 条完全没有时间。
5. sub 与 split:清洗与切分
re.sub 替换所有匹配,替换物可以是字符串(引用分组)或函数(动态决定):
import re
# 字符串替换:\g<1> 引用第 1 组,把 10/15 归一成 10-15
re.sub(r"(\d{1,2})/(\d{1,2})", r"\g<1>-\g<2>", "10/15 08:05 打疫苗")
# '10-15 08:05 打疫苗'
# 函数替换:把所有连续空白(含全角空格)压成单个半角空格
re.sub(r"\s+", " ", "2026-10-12 14:00 日语课")函数式替换的经典场景是打码或脱敏:
def mask(m: re.Match) -> str:
phone = m.group()
return phone[:3] + "****" + phone[-4:]
re.sub(r"1[3-9]\d{9}", mask, "联系 13812345678 或 15987654321")
# '联系 138****5678 或 159****4321're.split 按模式切分,比 str.split 强在「分隔符是形状而非字符」:
re.split(r"[,,;;\s]+", "浇水,施肥 ; 除虫 换盆")
# ['浇水', '施肥', '除虫', '换盆']6. 三个标志,一个 VERBOSE
标志以 re. 常量或 (?标志) 内联写法出现在编译时,最常用的三个:
re.IGNORECASE # 忽略大小写(缩写 re.I)
re.MULTILINE # 让 ^ 和 $ 匹配每一行的行首行尾,而不只是全文首尾(re.M)
re.DOTALL # 让 . 也匹配换行符(re.S)第四个强烈推荐养成习惯——re.VERBOSE,允许在模式里写空格与注释。第 4 节那条长模式,工程里应该长这样:
DATE_TIME = re.compile(
r"""
(?P<date>\d{4}[-/]\d{1,2}[-/]\d{1,2}|\d{1,2}[-/月]\d{1,2}日?)
\s+
(?P<time>\d{1,2}[::点时]\d{0,2}|上午|下午)?
\s*
(?P<text>.+)
""",
re.VERBOSE,
)注意:VERBOSE 模式里模式内的空格被忽略,真要匹配空格请写 \s、[ ] 或转义;# 开始注释,要匹配 # 字符本身需转义。
re.compile 把模式预编译成对象,重复使用时有性能收益,更重要的是给模式起了名字:DATE_TIME.search(line) 读起来是句子,魔法串不再散落各处。
7. 原始字符串:不写 r 会发生什么
第 2 节的 r"" 现在兑现解释。Python 字符串自己会先处理一遍反斜杠:"\d" 里 \d 不是合法转义,实际存进字符串的是字面的反斜杠加 d——目前碰巧能用,但 "\b" 不同:它是退格符(backspace),不是正则的单词边界!于是:
>>> re.search("\bcat\b", "a cat here") # 没写 r,\b 变成了退格符
>>> re.search(r"\bcat\b", "a cat here")
<re.Match object; span=(2, 5), match='cat'>第一行返回 None,排查半天——这是正则最著名的暗坑。规则记死一条:正则模式永远写原始字符串 r"..."。同理,Windows 路径、任何含反斜杠的文本都用 r 前缀,一劳永逸。
另一个报错实录:括号没配对。
>>> re.search(r"(\d{2}", "12")
re.error: missing ), unterminated subpattern at position 0re.error 会指出出错位置。遇到它别慌,从指出的下标开始数括号。
8. 别用正则做的事:判断清单
正则威力大,误用更常见。清单比语法更值钱:
- 能字符串方法解决的,不用正则。判断前缀
s.startswith("ERROR")、包含"ERROR" in s、简单替换s.replace()——更快、更直白、零学习成本。正则是「形状复杂到字符串方法表达不了」时的升级选项; - 不要用正则解析 HTML / JSON / 代码。它们是嵌套结构,正则(正则语言)在理论上处理不了任意深度的嵌套,实践里写出的是碰巧能跑的脆弱匹配。HTML 用 BeautifulSoup,JSON 用 json 模块;
- 不要在循环里重复编译同一模式。模块顶层
re.compile一次,循环里复用编译对象; - 警惕用户输入直接拼进模式。用户输入里的
(、*会破坏模式甚至引发回溯爆炸(机制见 正则进阶机制),必须先re.escape(user_input); - 校验电子邮箱这类「理论上无限复杂」的东西,用宽松正则过滤明显非法即可,终极手段是发验证邮件。
9. 什么时候应该 / 不应该
应该:从日志、导出文件、聊天记录里提取结构化字段;校验「形状固定」的输入(日期、编号、手机号粗校验);文本归一化(压空白、统一分隔符);配合 finditer 做批量打码。
不应该:解析嵌套格式(HTML/JSON/代码);能用字符串方法三行解决的事;把用户输入未转义地拼进模式;试图用一条巨型正则覆盖所有格式变体(先归一化,再匹配)。
10. 与之前和之后的知识的关系
- 往前:内置数据结构 的字符串方法是正则的「低成本替代」,先字符串方法后正则是默认顺序;函数详解 的函数作参数在 sub 的函数替换里直接用到;
- 往后:正则进阶机制 揭开引擎回溯的面纱,讲环视断言与灾难性回溯防御;网络爬虫 与日志分析里,本文的模式是提取层的日常工具;自动化脚本集 的批量改名、批量替换场景全部建立在本文之上。
11. 官方文档
- re 模块(标准库参考,语法速查在页内):https://docs.python.org/zh-cn/3/library/re.html
- 正则 HOWTO(官方教程向):https://docs.python.org/zh-cn/3/howto/regex.html
- 在线调试(写长模式必备):https://regex101.com/ (选 Python 方言)
12. 自我检查
- 能默写六个积木各自的符号与含义,并举一个自己写过的例子;
- 能解释 findall 与 finditer 返回类型的差别,以及什么时候必须用后者;
- 能说出 match / fullmatch / search 三者边界差异;
- 能复现「忘写 r 导致 \b 失效」的暗坑并解释原因;
- 给一个新需求,能先判断「字符串方法够不够」再决定上不上正则。
练习
预测题:re.findall(r"\d+", "a1b22c333") 返回什么?re.findall(r"a(\d)", "a1a2a3") 呢?先写再跑(注意第二个:有分组时 findall 返回分组内容)。
修改题:把第 6 节的 DATE_TIME 模式扩展到能吃下第 1 节第 4 条「10/15 08:05」,输出 groupdict() 中 date 为 10-15(归一化交给后续程序处理,模式只管捞)。
排错题:下面的函数想校验「全串必须是以字母开头的 5 到 12 位字母数字」,却对 User_99 返回了通过。指出问题并给出两种修法(提示:\w 含下划线;match 只锚头不锚尾):
def valid_username(s: str) -> bool:
return bool(re.match(r"[a-zA-Z]\w{4,11}", s))挑战题:写函数 extract_amounts(text: str) -> list[float],从「花了 12.5 元」「总价 3,800.00 元」「免费」这类文本里提取金额(支持千分位逗号,忽略不带数字的),并写三个自测断言。写完把金额模式换成 re.compile(r"\d{1,3}(?:,\d{3})*(?:\.\d+)?") 对照,想想 (?:...) 为什么不编号(进阶答案在下一篇)。
本章总结
正则是「描述文本形状」的小语言:字符类定字符、量词定次数、锚点定位置;贪婪默认,加 ? 变懒惰;圆括号捕获、(?P<名>) 命名、groupdict 一步到字典;search 找第一处、fullmatch 做全串校验、finditer 拿对象、sub 能接函数;模式永远写 r 前缀原始字符串,长模式配 VERBOSE 与 re.compile 起名;字符串方法能干的不用正则,嵌套结构不归正则管。
下一步
你已经会让正则「找到」,下一篇 正则进阶机制 讲它「怎么找」:引擎的回溯为什么有时会卡死机器、环视断言怎么「只看不吃」、3.11 的原子组怎么一招救命。