前置知识: Python

正则表达式:从一团乱文本里捞出结构化数据

10 min中级

以「清洗语音助手日历导出的乱文本」为任务实战 re 模块:字符与量词、锚点、分组捕获、findall/sub/split、常用标志与 VERBOSE 写法,讲贪婪与懒惰、原始字符串报错实录,附「别用正则做的事」判断清单与四类练习。

前置知识

正则不需要任何数学背景。它就是一种描述文本形状的小语言:像「三个数字加一个横线再加四个数字」这样的形状,用 \d{3}-\d{4} 一句话写完。本文全程用 Python 内置的 re 模块,零安装。

分工说明:本文管「会用」:语法、捕获、替换、标志、可读写法。正则引擎内部的回溯机制、灾难性回溯与防御(原子组、占有量词)、环视断言(lookaround)等深水内容在 正则进阶机制,本文用到时会给指引。

学习目标

读完本文你将能够:

  1. 用字符类、量词、锚点拼出常见形状:日期、时间、数量、编号;
  2. 用捕获组(含命名分组)把一段乱文本拆成结构化字段,并解释 findall / search / finditer 的返回差异;
  3. 用 re.sub 做查找替换,包括用函数动态决定替换内容;
  4. 会用 IGNORECASE、MULTILINE、VERBOSE 三个最常用标志,并用 VERBOSE 把长正则写成可读的多行;
  5. 拿到 re.error: missing ), unterminated subpattern 与「匹配到空串」两类典型故障时能自己定位。

预计 50 到 70 分钟,含 3 组修改实验与 4 道练习。

1. 你现在要解决什么问题

你有一个语音助手小音箱(类似 FoloToy 那类 ESP32 玩具),它把你的口头日程记成了一坨自由文本,导出来长这样:

python
notes = [
    "2026-10-03 09:30 提醒:给多肉浇水",
    "提醒 10月7日 下午3点 交物业费!!",
    "2026-10-12 14:00(注意全角空格)日语课",
    "10/15 08:05 打疫苗",
    "买菜",                      # 没有时间的普通待办
]

你想要一个脚本,把它变成规整的结构化数据:

python
[{"date": "2026-10-03", "time": "09:30", "text": "给多肉浇水"}, ...]

用字符串方法(split、find)写会发现分支爆炸:分隔符有横线、有斜杠、有「月」,日期在行首、在中间,还有全角空格。形状多变、规律杂糅的文本,正是正则的主场——它让你一次声明「我要的形状」,剩下的交给引擎。

2. 先不要看解释,先试试看

打开 REPL,从最简单的字面匹配开始:

python
>>> import re
>>> re.search("浇水", "2026-10-03 09:30 提醒:给多肉浇水")
<re.Match object; span=(20, 22), match='浇水'>

search 在整段文本里找第一处,返回 Match 对象(.span() 是位置,.group() 是匹配到的文本)。字面量没意思,来第一个真正的模式——匹配时间「两位数字、冒号、两位数字」:

python
>>> m = re.search(r"\d{2}:\d{2}", "2026-10-03 09:30 提醒:给多肉浇水")
>>> m.group()
'09:30'

\d 表示一个数字,{2} 表示恰好两次。你已经会正则的最核心一行了。注意模式串前面的 r——原始字符串,第 8 节讲它为什么不能省。

3. 六个积木:字符、类、量词、锚点

正则的全部表达能力由几块积木组合而来,逐块认识:

字符类(一对方括号 = 「这里可以是其中之一」):

python
[0-9]        # 一个数字,等价 \d
[a-zA-Z]     # 一个英文字母
[\u4e00-\u9fff]  # 一个常用汉字
[^0-9]       # 开头的 ^ 表示取反:一个非数字
.            # 任意一个字符(换行符除外)

预定义类:\d 数字、\w 字母数字下划线、\s 空白(空格、制表、全角空格都算——第 1 节那条全角空格记录会被它正常切开);大写取反:\D、\W、\S。

量词(跟在字符或字符类后面):

python
*      # 0 次或多次
+      # 1 次或多次
?      # 0 次或 1 次
{3}    # 恰好 3 次
{2,5}  # 2 到 5 次
{2,}   # 至少 2 次

锚点(不消耗字符,只占位置):

python
^      # 行首
$      # 行尾
\b     # 单词边界(字母数字与非字母数字的分界)

修改实验一:在 REPL 用 re.search 逐一验证:r"^\d{4}-\d{2}-\d{2}" 能否匹配第 1 节的四条记录?哪几条匹配失败、为什么?(答案:只有以横线日期开头的两条;10月7日 与 10/15 两种写法需要更宽的模式。)

贪婪与懒惰:量词默认贪婪——尽可能多吃。给量词加 ? 变懒惰——尽可能少吃:

python
>>> 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. 捕获组:把匹配拆成字段

模式里加圆括号 = 「这一段单独存下来」。给日历文本写日期与时间的完整模式:

python
pattern = r"(\d{4}[-/])?(\d{1,2})[-/月](\d{1,2})日?\s+(\d{1,2}):(\d{2})"

拆开读:年份组可有可无(?),月份一到两位,分隔符接受横线、斜杠或「月」,日子后可带「日」,空白用 \s+,然后时、分。用 search 拿结果:

python
m = re.search(pattern, "提醒 10月7日 下午3点 交物业费!!")
m.groups()      # (None, '10', '7', '3', ...) —— 等等,时间没对上

先别急着觉得正则不可靠——这个「没对上」是真实的:下午3点 里根本没有冒号,模式只匹配到了别的片段。这暴露了正则的第一课:模式只负责形状,不负责语义。「下午 3 点」这种变体,要么扩模式,要么先做文本归一化。工程上的正确姿势是两步:先用宽松的模式兜住「时间大概在这」,再对捕获结果做程序化校验(int 之后检查范围)。

命名分组让可读性上一个台阶,值直接用名字取:

python
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 替换所有匹配,替换物可以是字符串(引用分组)或函数(动态决定):

python
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 日语课")

函数式替换的经典场景是打码或脱敏:

python
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 强在「分隔符是形状而非字符」:

python
re.split(r"[,,;;\s]+", "浇水,施肥 ; 除虫 换盆")
# ['浇水', '施肥', '除虫', '换盆']

6. 三个标志,一个 VERBOSE

标志以 re. 常量或 (?标志) 内联写法出现在编译时,最常用的三个:

python
re.IGNORECASE   # 忽略大小写(缩写 re.I)
re.MULTILINE    # 让 ^ 和 $ 匹配每一行的行首行尾,而不只是全文首尾(re.M)
re.DOTALL       # 让 . 也匹配换行符(re.S)

第四个强烈推荐养成习惯——re.VERBOSE,允许在模式里写空格与注释。第 4 节那条长模式,工程里应该长这样:

python
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),不是正则的单词边界!于是:

python
>>> 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 前缀,一劳永逸。

另一个报错实录:括号没配对。

python
>>> re.search(r"(\d{2}", "12")
re.error: missing ), unterminated subpattern at position 0

re.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. 官方文档

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 只锚头不锚尾):

python
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 的原子组怎么一招救命。