CASE-007
Grep & Co. 数据侦探事务所
委托书
委托人
追踪泄露事件的吴记者
证物
匿名爆料人发来的一份客户名单文件,看起来是郑市集的会员列表。每一行的分隔符都不一样。
破案条件
为了能登上明早的报道:遮住电话号码的中间四位,把日期改成 2026-10-20 的格式,再把每一行拆成一个个字段。
晚上 9 点,事务所的门被猛地推开。吴记者气喘吁吁地打开笔记本电脑。“有人爆料了,是郑市集的客户名单。明早报道就要发,可总不能把个人信息原样登出去吧。”
所长凑近屏幕看了看。正是第 6 课找到的那份凌晨 3 点的会员列表。“找的部分已经结束了。今天的活儿是改和切。”
id, name; phone | joined
1001,kim,010-0000-5678,20/10/2026
1002; lee; 010-0000-1234; 19/10/2026
1003 | park | 010-0000-9012 | 03/09/2026
1004, choi;010-0000-3456 |28/02/2026
# (a+)+$最后一行有几个奇怪的字符,但吴记者被截稿时间追着,看都没多看一眼。
re.sub(模式, 替换字符串, 原文) 会把模式匹配到的地方全部替换,返回一个新字符串,原文保持不变。因为 Python 的字符串是不可变的值。
import re
re.sub(r"\d{4}", "****", "010-0000-5678") # '010-****-****'四位数字有两处,所以连后四位也被遮住了。想保留前三位和后四位、只遮中间,就先把要保留的部分用分组抓住,再在替换字符串里把它叫回来。\1 就是第 1 组抓到的文字。
re.sub(r"(\d{3})-\d{4}-(\d{4})", r"\1-****-\2", "010-0000-5678") # '010-****-5678'替换字符串里也有反斜杠,所以和模式一样用 r"..." 来写。
证据分析室
re.search(r"", text)
返回值
名单上的日期是 20/10/2026 这种日/月/年的顺序,报道里得写成 2026-10-20。把分组的顺序调换一下就行。
re.sub(r"(\d\d)/(\d\d)/(\d{4})", r"\3-\2-\1", "20/10/2026") # '2026-10-20'
re.sub(r"(\d\d)/(\d\d)/(\d{4})", r"\g<3>-\g<2>-\g<1>", "20/10/2026") # '2026-10-20'\3 和 \g<3> 意思相同。第 6 课学的命名分组用 \g<名字> 来叫。不用记顺序,出错的机会也就少了。
re.sub(r"(?P<d>\d\d)/(?P<m>\d\d)/(?P<y>\d{4})", r"\g<y>-\g<m>-\g<d>", "20/10/2026") # '2026-10-20'\g<0> 代表整个匹配。想把找到的东西原样保留、只在外面包一层时就用它。
re.sub(r"\d{3}-\d{4}-\d{4}", r"[\g<0>]", "1001,kim,010-0000-5678") # '1001,kim,[010-0000-5678]'如果想在组号后面紧接着写数字,就要小心了。\10 不是“第 1 组后面跟个 0”,而是第 10 组。这时要像 \g<1>0 这样,用尖括号把组号封起来。
re.sub(r"(\d+)", r"\g<1>0", "kim 5") # 'kim 50'
re.sub(r"(\d+)", r"\10", "kim 5") # re.PatternError: invalid group reference 10 at position 1在替换字符串里,\n 同样会变成换行符。而像 \q 这种没有意义的字母转义则会报错。
re.sub(r",", r"\n", "a,b") # 'a\nb'
re.sub(r",", r"\q", "a,b") # re.PatternError: bad escape \q at position 0re.PatternError 是从 3.13 开始使用的名字,和旧名字 re.error 是同一个异常。第 12 课会详细讲。
证据分析室
re.search(r"", text)
返回值
替换字符串的位置上也可以传一个函数。每出现一次匹配,re.sub 就把那个 Match 对象交给函数,再用函数返回的字符串来替换。我们来试试只留下名字的第一个字母,其余按长度打上星号。这是固定的模板做不到的事。
def hide(m):
name = m.group(1)
return "," + name[0] + "*" * (len(name) - 1) + ","
re.sub(r",([a-z]+),", hide, "1003,park,010-0000-9012,03/09/2026") # '1003,p***,010-0000-9012,03/09/2026'
re.sub(r"[a-z]+", lambda m: m[0].upper(), "kim and lee") # 'KIM AND LEE'TypeError。count 决定从前往后最多替换几处。默认值 0 表示“全部”。re.subn 会把替换后的字符串和替换次数放在一个元组里返回。假设最上面那份名单存在名为 leak 的字符串里。
re.sub(r"\d{3}-\d{4}-\d{4}", "***", leak, count=1).splitlines()[1:3]
# ['1001,kim,***,20/10/2026', '1002; lee; 010-0000-1234; 19/10/2026']
re.subn(r"\d{3}-\d{4}-\d{4}", "***", leak)[1] # 4这样报道里就能写上准确的数字:“已遮盖 4 个电话号码”。
名单每一行的分隔符都不同:逗号、分号、竖线混在一起,前后的空格也多少不一。str.split 只能接收一种分隔符,而 re.split 是按模式来切。
re.split(r"\s*[,;|]\s*", "a, b;c |d") # ['a', 'b', 'c', 'd']
re.split(r"\s*[,;|]\s*", "a, b;c |d", maxsplit=2) # ['a', 'b', 'c |d']\s* 把空格也当作分隔符的一部分吞掉,切出来的字段就很干净。maxsplit=2 只切两次,剩下的部分原样留在最后一个字段里。如果模式里有捕获分组,切掉的分隔符也会被插进结果列表。需要记录用的是哪种分隔符时就这么写。不需要的话,就写成 (?:...)。
re.split(r"\s*([,;|])\s*", "a, b;c |d") # ['a', ',', 'b', ';', 'c', '|', 'd']不占任何字符的空匹配也能用来切。\b 只匹配单词边界的位置,所以每个单词和空格之间都会被切开。两端也有边界,于是会出现空字符串。
re.split(r"\b", "a b") # ['', 'a', ' ', 'b', '']证据分析室
re.search(r"", text)
返回值
re.sub 的第四个位置是 count。如果想加标志,随手把 re.I 写在第四个位置,它进去的身份就不是标志,而是次数。因为 re.I 的实际值是 2。
re.sub("a", "b", "aAaA", re.I) # 'bAbA' ← 没有忽略大小写,只替换了两次
re.sub("a", "b", "aAaA", flags=re.I) # 'bbbb'这个错误太常见了,所以从 Python 3.13 开始,以位置参数传入 count、maxsplit、flags 时会发出 DeprecationWarning。上面第一行就会给出 'count' is passed as positional argument 的警告。re.split 的 maxsplit、flags 也一样。请始终像 count=1、maxsplit=2、flags=re.I 这样写上名字。
多特的提醒
跟我说“坐下”,我就坐下;光喊一声数字 2,我可不知道你要我干嘛。函数也是一样哦。re.sub("a", "b", s, re.I) 它会听成“只换两次”。请叫出名字:flags=re.I。
把遮盖、改日期、切分依次串起来。去掉标题行和最后一行,只切客户数据的行。
masked = re.sub(r"(\d{3})-\d{4}-(\d{4})", r"\1-****-\2", leak)
fixed = re.sub(r"(?P<d>\d\d)/(?P<m>\d\d)/(?P<y>\d{4})", r"\g<y>-\g<m>-\g<d>", masked)
for row in fixed.splitlines()[1:-1]:
print(re.split(r"\s*[,;|]\s*", row))
# ['1001', 'kim', '010-****-5678', '2026-10-20']
# ['1002', 'lee', '010-****-1234', '2026-10-19']
# ['1003', 'park', '010-****-9012', '2026-09-03']
# ['1004', 'choi', '010-****-3456', '2026-02-28']吴记者接过表格正要起身,所长抬起了手。“等等。最后一行。”
print(fixed.splitlines()[-1]) # # (a+)+$这不是名单数据,而是有人故意留下的正则表达式。要按字面找到这串字符,就得像第 1 课学的那样,把元字符一个个关掉。
print(re.escape("(a+)+$")) # \(a\+\)\+\$
re.search(r"\(a\+\)\+\$", leak) # <re.Match object; span=(177, 183), match='(a+)+$'>证据分析室
re.search(r"", text)
返回值
“这是什么?”吴记者问。所长沉默了好一会儿才开口:“回溯陷阱。读到它的正则引擎会在同一条路上来回折返无数次,最后卡死。明天的报道里把它删掉。”
吴记者走后,所长从抽屉里取出一个褪了色的相框,是事务所开张头一年拍的照片。她盯着看了很久,然后把相框扣在了桌上。
结案报告 — 已遮盖泄露名单中 4 个电话号码的中间四位,并把日期统一为年-月-日,交给记者。在名单末尾发现被故意植入的正则 (a+)+$。来源不明,需另行调查。
1001~1004 改成 #1001 这样,在前面加上 #。用 \g<0> 一行就能搞定。re.split(r"(\s*)([,;|])(\s*)", "a, b") 的结果。分组有三个的话,会插进几个字段?"price 1500 and 300" 里的数字全部变成两倍。别忘了函数必须返回字符串。所长的笔记本
re.sub(模式, 替换字符串, 原文) 返回新字符串,原文不变。
替换字符串里 \1 = \g<1>,命名分组用 \g<name>,整个匹配用 \g<0>。后面紧跟数字时写 \g<1>0(\10 是第 10 组)。
替换字符串里的 \n 是换行,\q 这种不认识的转义会报错。
函数替换:接收 Match、返回字符串的函数。count 限制次数,re.subn 返回 (结果, 次数)。
re.split 按模式切分。可用 maxsplit;有捕获分组时,分隔符也会进入结果。
count、maxsplit、flags 用关键字传。位置参数从 3.13 起发出 DeprecationWarning,还会出现 re.I 被当成 count=2 的事故。
订阅新课时和新课程通知
本课程新增课时或推出后续课程时,我们会发邮件通知你。
问答
关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。