CASE-010
Grep & Co. 数据侦探事务所
委托书
委托人
姜队长
证物
寄给姜队长的三封恐吓信,外加昨天又来的一封。鉴证组把四封信逐行誊抄了下来。
破案条件
分辨出哪几封出自同一个人,并用模式把依据摆出来。
一个下雨的傍晚,姜队长把四个塑料证物袋摊在桌上。“恐吓信。三封是上周开始陆续寄来的,还有一封昨天才到,就在报纸登出报道之后。”所长扶了扶眼镜。“也就是说,混进了一个模仿的家伙。证据会留下模式。同一只手写的东西,带着同样的习惯。”
这是鉴证组把四封信逐行誊抄的记录。正文(msg=)是一行简短的英文,意思大概是“周五服务器会垮掉”“没人读得懂我”“终点见”“快交钱”。
#1 2026.10.18 key='K-17' tel=(010)-0000-1018 msg=the server server falls on friday
#2 2026-10-19 key="K-17" tel=010-0000-1019 msg=no one can can read me
#3 2026/10/20 key='K-17' tel=(010)-0000-1020 msg=see you At at the end
#4 2026-10/21 key="K-17' tel=(010-0000-1021 msg=pay now or else乍一看,四封信都差不多。今天我们用反向引用来找习惯,这种语法问的是:“和前面抓到的一模一样的东西,有没有再次出现?”
所长指着第一封信。“server server,同一个词敲了两遍。第二封也有 can can。”看起来像是手误,可如果三封都有,那就是习惯了。
第 5 课学过分组编号。这个编号在模式内部也能调用。\1 的意思是“和第 1 组刚刚抓到的文字完全相同的文字”。这叫作反向引用(backreference)。
import re
line = "msg=the server server falls on friday"
m = re.search(r"\b(\w+)\s+\1\b", line)
print(m.group()) # server server
print(m.group(1)) # server(\w+) 一旦抓到 server,从那一刻起 \1 就变成了 server 这串文字。\1 并不是把模式 \w+ 再写一遍。所以它不会匹配两个不同的单词。re.search(r"(\w+)\s+(\w+)", "the server").groups() # ('the', 'server')
re.search(r"(\w+)\s+\1", "the server") # None前后的 \b 也很重要。去掉的话,连 can cannot 里的 can can 也会被当成“重复的单词”。
re.search(r"(\w+)\s+\1", "can cannot") # <re.Match object; span=(0, 7), match='can can'>
re.search(r"\b(\w+)\s+\1\b", "can cannot") # None证据分析室
re.search(r"", text)
返回值
看分析室的结果,有个地方不对劲:第三封信的 At at 没被找出来。因为 A 和 a 是不同的字符。加上 re.I(忽略大小写,第 11 课会正式讲),反向引用在比较时也会忽略大小写。
re.findall(r"\b(\w+)\s+\1\b", letters) # ['server', 'can']
re.findall(r"\b(\w+)\s+\1\b", letters, re.I) # ['server', 'can', 'At']
print(re.search(r"(a)\1", "aA", re.I)) # <re.Match object; span=(0, 2), match='aA'>这里的 letters 是装着上面四行信件内容的字符串。从结果看,同一个词敲两遍的习惯只出现在第 1、2、3 封信里。
第 5 课里所长搁下了一个问题。用 [-./] 来挑分隔符的话,像 2026-10/20 这种混用分隔符的日期也会通过,因为字符类在每个位置上是各挑各的。用反向引用先抓住第一个分隔符,再要求第二个位置是同一个字符就行了。
re.fullmatch(r"\d{4}[-./]\d\d[-./]\d\d", "2026-10/21") # <re.Match object; span=(0, 10), match='2026-10/21'>
re.fullmatch(r"\d{4}([-./])\d\d\1\d\d", "2026-10/21") # None
re.fullmatch(r"\d{4}([-./])\d\d\1\d\d", "2026/10/20") # <re.Match object; span=(0, 10), match='2026/10/20'>命名分组(第 6 课)在模式内部也能调用,语法是 (?P=名字)。分组一多,名字比编号好读。
DATE = r"(?P<y>\d{4})(?P<sep>[-./])(?P<m>\d\d)(?P=sep)(?P<d>\d\d)"
m = re.search(DATE, "#3 2026/10/20 key='K-17'")
print(m.groupdict()) # {'y': '2026', 'sep': '/', 'm': '10', 'd': '20'}(?P=sep) 的位置写 \2 也一样。(?P=sep),替换字符串里用 \g<sep>(第 7 课),Match 对象上用 m["sep"]。证据分析室
re.search(r"", text)
返回值
来看 key= 后面的值。第 1、3 封用单引号 'K-17',第 2 封用双引号 "K-17"。只有第 4 封是 "K-17',用双引号开头、单引号结尾。把开引号抓成分组,收尾时要求 \1,就能知道引号是否配对。
QUOTED = r'(["\']).*?\1'
for line in letters.splitlines():
m = re.search(QUOTED, line)
print(line[:2], m.group() if m else None)
# #1 'K-17'
# #2 "K-17"
# #3 'K-17'
# #4 None\'。在正则里,\' 就是普通的 '。.*?(懒惰量词,第 8 课)是有原因的。写成 .* 的话,会从第一个引号一口气吃到最后一个同样的引号。re.search(r'(["\']).*\1', '"a" and "b"').group() # '"a" and "b"'
re.search(r'(["\']).*?\1', '"a" and "b"').group() # '"a"'证据分析室
re.search(r"", text)
返回值
可是把同一个模式放进 findall,结果却很奇怪。
re.findall(QUOTED, letters) # ["'", '"', "'"]
[m.group() for m in re.finditer(QUOTED, letters)] # ["'K-17'", '"K-17"', "'K-17'"]这正是第 5 课学过的规则:只要有一个分组,findall 就只返回那个分组。而这次也没法改成 (?:...),因为那样 \1 要调用的分组就没了。
多特的提醒
我一开始也用 findall 去收集配对的引号,结果只叼回来一大把引号字符。反向引用离不开分组,所以需要整个匹配时,就用 finditer 取 m.group()。也可以把整个模式再用括号包一层,不过那样编号会往后挪,\1 就变成 \2 了。
来看电话号码 tel=。第 1、3 封像 (010)-0000-1018 这样把区号用括号括起来,第 2 封没有括号。第 4 封是 (010-0000-1021,只有左括号。“有左括号,就必须有右括号”这条规则,用条件模式来写。
(?(1)yes|no) 的意思是:“如果第 1 组参与了匹配,就用 yes 模式,否则用 no 模式。”no 的部分可以省略。(?(1)yes) 就是“有那个分组就要求 yes,没有就什么都不要求”。
PHONE = r"(\()?\d{2,3}(?(1)\))-\d{3,4}-\d{4}"
for tel in ["(010)-0000-1018", "010-0000-1019", "(010-0000-1021", "010)-0000-1021"]:
print(tel, bool(re.fullmatch(PHONE, tel)))
# (010)-0000-1018 True
# 010-0000-1019 True
# (010-0000-1021 False
# 010)-0000-1021 False(\()?:把左括号抓成第 1 组,没有也可以。(?(1)\)):第 1 组抓到了就要求右括号,没抓到就直接通过。命名分组则用名字而不是编号来设条件,语法是 (?(名字)yes|no)。下面的规则是:列表编号写成 (1) 的要以右括号结尾,像 2. 这样没有括号的要以句点结尾。
ITEM = r"(?P<p>\()?\d+(?(p)\)|\.)"
for s in ["(1)", "2.", "(3.", "4)"]:
print(s, bool(re.fullmatch(ITEM, s)))
# (1) True
# 2. True
# (3. False
# 4) False证据分析室
re.search(r"", text)
返回值
就算破案条件全部通过,看分析室里第 4 封信那一行,010-0000-1021 依然被标了出来。search 和 finditer 可以从任何位置开始匹配,所以它跳过左括号,从后面开始了。在前面加上 tel=,把起始位置钉住。
re.search(PHONE, "tel=(010-0000-1021").group() # '010-0000-1021'
re.search(r"tel=" + PHONE + r"\b", "tel=(010-0000-1021") # None反向引用的编号变成两位数时就容易搞混。模式里的 \10 不是“第 1 组后面跟字符 0”,而是第 10 组。没有第 10 组就报错,也不会被当作八进制字符来读。
re.compile(r"(a)\10")
# re.PatternError: invalid group reference 10 at position 4
p = re.compile(r"(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)\10")
print(p.fullmatch("abcdefghijj")) # <re.Match object; span=(0, 11), match='abcdefghijj'>\1[0] 这样把 0 放进字符类,隔开。\g<1>0(第 7 课)在模式里不能用。在模式里,\g 是未知的转义,会报错。\0 开头或者正好三位数字时,才是八进制字符(\101 是 A)。第 12 课还会再看到。print(re.fullmatch(r"(a)\1[0]", "aa0")) # <re.Match object; span=(0, 3), match='aa0'>把四种习惯写成规则,数一数每封信符合几条。
RULES = {
"반복": re.compile(r"\b(\w+)\s+\1\b", re.I),
"날짜": re.compile(r"\d{4}([-./])\d\d\1\d\d"),
"따옴표": re.compile(r'key=(["\']).*?\1'),
"괄호": re.compile(r"tel=(\()?\d{2,3}(?(1)\))-\d{3,4}-\d{4}\b"),
}
for line in letters.splitlines():
hits = [name for name, rx in RULES.items() if rx.search(line)]
print(line[:2], hits)
# #1 ['반복', '날짜', '따옴표', '괄호']
# #2 ['반복', '날짜', '따옴표', '괄호']
# #3 ['반복', '날짜', '따옴표', '괄호']
# #4 []规则名是韩文:반복 是“重复”,날짜 是“日期”,따옴표 是“引号”,괄호 是“括号”。
第 1、2、3 封的四种习惯完全一致:同一个词敲两遍,分隔符只用一种,引号和括号一次都没配错。第 4 封只模仿了外表,习惯一个也没有。
姜队长咂了咂嘴。“这么说,第 4 封是看了报道来捣乱的,真货是那三封。”所长点点头。“真货很细致。是个连一对括号都不会配错的家伙。”
结案报告 — 恐吓信第 1、2、3 封出自同一人(回溯者)之手。重复的单词、一致的日期分隔符、引号与括号的配对全部吻合。第 4 封判定为模仿信,另行移交姜队长。
正要合上报告,所长又拿起了信件原件。有几行是鉴证组没有誊抄的:三封真信的最底下,各有一行只由括号和反斜杠组成的怪字符。
0000 这样同一个数字连续出现四次的地方。提示:\1 后面也可以加量词。<b>bold</b> <i>x</b> 里,只找出开始标签和结束标签名字相同的部分。结果应该只有 <b>bold</b> 一个。< 开头,就必须以 > 结尾。<kim@example.com>、kim@example.com 要通过,<kim@example.com 要失败。回溯者的踪迹
(a+)+$
三封信的最后一行。按顺序接起来,就成了一行。——如果你们读得懂的话。
所长的笔记本
模式里的 \1 是和第 1 组实际抓到的文字完全相同的文字,不是把模式再写一遍。
重复单词 \b(\w+)\s+\1\b,统一日期分隔符 \d{4}([-./])\d\d\1\d\d。
命名分组用 (?P=name) 再次调用。替换里用 \g<name>,Match 上用 m['name']。
引号配对 (["']).*?\1。findall 只返回引号,要整体就用 finditer。
条件模式 (?(1)yes|no)、(?(name)yes|no)。no 可以省略:(?(1)yes)。
re.I 下反向引用也忽略大小写。\10 是第 10 组,第 1 组后面跟 0 要写 \1[0]。
订阅新课时和新课程通知
本课程新增课时或推出后续课程时,我们会发邮件通知你。
问答
关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。