CASE-011
Grep & Co. 数据侦探事务所
委托书
委托人
韩星所长
证物
三封恐吓信的最后一行,以及事务所 4 楼的门禁记录。
破案条件
把三行接成一行正则,再把它拆开写得能读懂,查明这个正则到底在找什么。
夜里十一点,事务所只亮着一盏灯。所长坐在那把旧机械键盘前,把三封信的最后一行逐字敲进电脑。“写成一行的正则,只有写的人读得懂。过三个月,连写的人自己也读不懂了。”
下面是把三封信的尾巴依次誊下来的文件,以及所长一言不发调出来的事务所 4 楼门禁记录。因为正则碎片里出现了 door、4f、badge。
<<(?mi)^(\d{4})([-./])(\d\d)\2(\d\d)[ T]
(0[0-4]):(\d\d)\s+(?:door|gate)=4f\s+
badge=(f-0[12])\b(?!.*denied)>>2026-10-19 22:41 door=4F badge=F-01 result=ok
2026-10-20 02:13 door=4F badge=S-07 result=ok
2026-10-20 02:58 door=4F badge=F-02 result=denied
2026-10-20 03:02 door=4F badge=F-02 result=ok
2026.10.20 03:05 gate=4F badge=f-02 result=ok
2026-10-20 09:00 door=4F badge=F-01 result=ok正则最前面的 (?mi) 是第一条线索。它是在模式之外改变模式读法的开关,也就是标志。今天我们把标志一个个打开,解开这一行。
看门禁记录,只有后门(gate)那台机器把门禁卡号记成了小写的 f-02。加上 re.I(re.IGNORECASE)就不分大小写了。
import re
re.findall(r"badge=F-02", door_log) # ['badge=F-02', 'badge=F-02']
re.findall(r"badge=F-02", door_log, re.I) # ['badge=F-02', 'badge=F-02', 'badge=f-02']re.M(re.MULTILINE)在第 4 课只是浅尝了一下。本来 ^ 只在字符串的最开头、$ 只在最末尾匹配。打开 re.M 后,它们在每一行的开头和结尾都能匹配。这里的 door_log 就是上面那六行门禁记录。
re.findall(r"^\S+ 0[0-4]:\d\d", door_log) # []
re.findall(r"^\S+ 0[0-4]:\d\d", door_log, re.M)
# ['2026-10-20 02:13', '2026-10-20 02:58', '2026-10-20 03:02', '2026.10.20 03:05']
re.findall(r"\w+$", door_log) # ['ok']
re.findall(r"\w+$", door_log, re.M) # ['ok', 'ok', 'denied', 'ok', 'ok', 'ok']没有 re.M 的话,第一行是晚上 10 点,凌晨的记录一条也出不来。处理按行记录的日志时,re.M 几乎是标配。
证据分析室
re.search(r"", text)
标志
返回值
现在要把信的三行尾巴接成一行。抓住从 << 到 >> 的部分似乎就行,可什么也没找到。
m = re.search(r"<<(.*)>>", tails)
print(m) # None从第 1 课起多特就一直在说:. 跨不过换行。<< 后面的 .* 停在了第一行末尾,而那里没有 >>。
所长转过椅子,推开了窗户。“多特,出去看看。”打开 re.S(re.DOTALL),. 就成了连换行也包括在内的真正的任意字符。
m = re.search(r"<<(.*)>>", tails, re.S)
one_line = m.group(1).replace("\n", "")
print(one_line)
# (?mi)^(\d{4})([-./])(\d\d)\2(\d\d)[ T](0[0-4]):(\d\d)\s+(?:door|gate)=4f\s+badge=(f-0[12])\b(?!.*denied)多特踩着窗框跳了出去,又从隔壁的窗户钻回来,蹲到了桌上。三行变成了一行。
多特的提醒
窗户开啦!打开 re.S,我就能越过换行,跑到下一行、再下一行。不过要小心哦,.* 本来就是个贪心鬼,窗户一开,它会一口气冲到文本末尾。想在多行文本里抓短一点,就用 .*? 吧。
证据分析室
re.search(r"", text)
标志
返回值
re.M 和 re.S 名字相近,常常被搞混,可它们做的事完全不同。
re.M 改变 ^ 和 $ 能匹配的位置(每一行)。re.S 改变 . 能吃的字符(包括换行)。re.findall(r"^.+$", "a\nb", re.M) # ['a', 'b']
re.findall(r"^.+$", "a\nb", re.S) # ['a\nb']回溯者的正则只开了 (?mi) 而没开 s,也是有原因的。末尾的 (?!.*denied) 意思是“这一行后面不能有 denied”。要是开了 s,.* 就会越到下一行去,只要下面任何一行有 denied,都会被筛掉。
虽然成了一行,还是读不懂。打开 re.X(re.VERBOSE)后,模式里的空格和换行会被忽略,从 # 到行尾则成为注释。于是可以把模式分成多行来写,并加上说明。
所长把它一块一块拆开,重新敲了一遍。编号分组都起了名字,\2 换成第 10 课学过的 (?P=sep)。
DOOR = re.compile(r'''
^
(?P<year>\d{4}) (?P<sep>[-./]) (?P<month>\d\d) (?P=sep) (?P<day>\d\d) # 日期,分隔符只有一种
[ T] # 日期与时间之间:空格或 T
(?P<hour>0[0-4]) : (?P<minute>\d\d) # 凌晨 0 点到 4 点多
\s+ (?:door|gate) = 4f # 4 楼前门或后门
\s+ badge = (?P<badge>f-0[12]) \b # 门禁卡 F-01 或 F-02
(?! .* denied ) # 排除被拒绝的记录
''', re.I | re.M | re.X)
for m in DOOR.finditer(door_log):
print(m["hour"], m["minute"], m["badge"])
# 03 02 F-02
# 03 05 f-02还是同一个正则,现在却能一行一行读下来了。它要找的是:凌晨 0 点到 4 点多,通过 4 楼的门,由 F-01 或 F-02 门禁卡真正进门的记录。
re.X 也有例外。想找真正的空格或 #,就要转义或者放进字符类。字符类里的空格不会被删掉,上面模式里的 [ T] 就是这样。
re.fullmatch(r"\d{4} - \d\d", "2026-10", re.X) # <re.Match object; span=(0, 7), match='2026-10'>
re.fullmatch(r"a b", "a b", re.X) # None
re.fullmatch(r"a\ b", "a b", re.X) # <re.Match object; span=(0, 3), match='a b'>
re.fullmatch(r"a[ ]b", "a b", re.X) # <re.Match object; span=(0, 3), match='a b'>
re.fullmatch(r"a\#b", "a#b", re.X) # <re.Match object; span=(0, 3), match='a#b'>不用 re.X、只想在模式中间插一小段注释时,就用 (?#...)。括号里的内容会被整个忽略。下面注释里的韩文是“创始成员的门禁卡”。
re.findall(r"F-0[12](?#창립 멤버 출입증)", door_log, re.I)
# ['F-01', 'F-02', 'F-02', 'f-02', 'F-01']证据分析室
re.search(r"", text)
标志
返回值
所长盯着拆开的模式看了很久。“一个变量名都没有,全挤在一行。这种写注释的习惯……不对,是不写注释的习惯。那家伙也总是……”她没再说下去。她把扣在桌上的旧相框收进抽屉,手重新放回键盘上。“继续。”
第 2 课说过,str 模式里的 \w 包括韩文,\d 也包括阿拉伯数字 ٣。因为默认就是 Unicode。打开 re.A(re.ASCII)后,\w \W \d \D \s \S \b \B 就只看 ASCII 字符了。
re.findall(r"\w+", "café 한글 F_02") # ['café', '한글', 'F_02']
re.findall(r"\w+", "café 한글 F_02", re.A) # ['caf', 'F_02']
re.findall(r"\d", "٣3") # ['٣', '3']
re.findall(r"\d", "٣3", re.A) # ['3'](한글 就是“韩文”这个词本身。)
re.U(re.UNICODE)是 str 模式的默认值,不必特意写。留着它是为了兼容旧代码。re.L(re.LOCALE)只能用于 bytes 模式,结果还会随电脑的区域设置而变,不推荐使用。用在 str 模式上会报错。re.compile(r"\w", re.L)
# ValueError: cannot use LOCALE flag with a str pattern证据分析室
re.search(r"", text)
标志
返回值
标志用 | 组合。它的值是名为 re.RegexFlag 的枚举,可以像整数一样使用。还有表示“没有任何标志”的 re.NOFLAG(3.11 起,值为 0),很适合用作函数的默认值。
flags = re.I | re.M
print(flags) # re.IGNORECASE|re.MULTILINE
print(type(flags)) # <flag 'RegexFlag'>
print(int(flags)) # 10
def find_all(pattern, text, flags=re.NOFLAG):
return re.findall(pattern, text, flags)也可以像回溯者那样,在模式最前面写上 (?aiLmsux) 中需要的字母。(?im) 等同于 re.I | re.M。不在最前面就会报错。
re.findall(r"(?im)^\S+ 0\d:\d\d(?=.*f-02)", door_log)
# ['2026-10-20 02:58', '2026-10-20 03:02', '2026.10.20 03:05']
re.compile(r"badge=(?i)f-02")
# re.PatternError: global flags not at the start of the expression at position 6只想给模式的一部分加标志,就用 (?i:...) 这样的括号范围。(?-i:...) 只在该范围内关闭,(?i-s:...) 则打开 i、关闭 s。
re.findall(r"badge=(?i:f)-02", door_log) # ['badge=F-02', 'badge=F-02', 'badge=f-02']
re.findall(r"(?i)BADGE=(?-i:F)-02", door_log) # ['badge=F-02', 'badge=F-02']
re.fullmatch(r"(?s)a.(?i-s:b.)", "a\nB\n") # None
re.fullmatch(r"(?s)a.(?i-s:b.)", "a\nBc") # <re.Match object; span=(0, 4), match='a\nBc'>a、L、u 是决定字符范围的标志,可以在范围内打开,却不能关闭。
re.findall(r"(?a:\w+)", "café") # ['caf']
re.compile(r"(?-a:\w+)")
# re.PatternError: bad inline flags: cannot turn off flags 'a', 'u' and 'L' at position 4最后,re.DEBUG 会把 re 如何理解模式原原本本地打印出来。我们来对比回溯者的签名,和去掉外层重复后的模式。下面是输出的开头部分,再往下还有一串执行指令。
re.compile(r"(a+)+$", re.DEBUG)
# MAX_REPEAT 1 MAXREPEAT
# SUBPATTERN 1 0 0
# MAX_REPEAT 1 MAXREPEAT
# LITERAL 97
# AT AT_END
re.compile(r"a+$", re.DEBUG)
# MAX_REPEAT 1 MAXREPEAT
# LITERAL 97
# AT AT_END重复(MAX_REPEAT)里面又套着一个重复。第 8 课挑战书上的慢模式,正是这个形状。
就像所长把拆开写的模式装进了 DOOR 这个名字里一样,re.compile 会把模式变成 Pattern 对象。它拥有和模块函数同名的方法 search match fullmatch findall finditer sub split,还能额外接收搜索范围 pos 和 endpos。
p = re.compile(r"\d\d:\d\d")
line = "2026-10-20 03:02 door=4F"
print(p.search(line)) # <re.Match object; span=(11, 16), match='03:02'>
print(p.match(line)) # None
print(p.match(line, 11)) # <re.Match object; span=(11, 16), match='03:02'>
print(p.fullmatch(line, 11, 16)) # <re.Match object; span=(11, 16), match='03:02'>
print(p.findall(line, 0, 15)) # []
print(len(list(p.finditer(line, 11)))) # 1pos 和把字符串切开不是一回事。^ 不在 pos 的位置匹配,只在字符串真正的开头(开了 re.M 就是行首)匹配。
q = re.compile(r"^\d\d")
print(q.match(line, 11)) # None
print(q.match(line[11:])) # <re.Match object; span=(0, 2), match='03'>Pattern 对象里还存着关于自己的信息。str 模式的 flags 会自动加上 re.UNICODE(32)。
print(DOOR.groups) # 7
print(DOOR.groupindex)
# {'year': 1, 'sep': 2, 'month': 3, 'day': 4, 'hour': 5, 'minute': 6, 'badge': 7}
print(DOOR.flags) # 106
print(re.RegexFlag(DOOR.flags)) # re.IGNORECASE|re.UNICODE|re.MULTILINE|re.VERBOSE
print(re.compile("x").flags) # 32
print(re.compile(one_line).pattern == one_line) # Truere.search(模式, ...) 在内部也会编译。最近用过的模式会留在内部缓存里,不会重复编译。re.purge() 用来清空这个缓存(很少用得上)。re.compile 真正的好处与其说是速度,不如说是名字。起名叫 DOOR 的模式,在代码里是读得懂的。re.Pattern[str]、re.Match[str](bytes 模式就写 [bytes])。def night_entries(p: re.Pattern[str], log: str) -> list[re.Match[str]]:
return list(p.finditer(log))
print(len(night_entries(DOOR, door_log))) # 2用拆开写的模式重新读一遍门禁记录。
for m in DOOR.finditer(door_log):
print(f"{m['year']}-{m['month']}-{m['day']} {m['hour']}:{m['minute']} {m['badge'].upper()}")
# 2026-10-20 03:02 F-02
# 2026-10-20 03:05 F-02从信里取出的那一行原版正则,找到的也是一模一样的两行。意思相同,区别只在读不读得懂。
[m.group(7) for m in re.finditer(one_line, door_log)] # ['F-02', 'f-02']10 月 20 日凌晨,02:58 在前门被拒绝过一次的 F-02 门禁卡,4 分钟后又刷卡进了门,再过 3 分钟又经过了后门。回溯者发来这个正则,意思是:“我什么时候来过,读得懂就读读看。”
所长翻开门禁卡登记簿。“F-01 是我的。F-02 嘛……”登记簿的那一栏里没有名字,只划着两道横线。“十年前就该注销的。一直没舍得。”
结案报告 — 三封信的尾巴是同一个正则。拆开写后确认:2026-10-20 03:02~03:05,门禁卡 F-02 出入过事务所 4 楼。F-02 是创立时发放的门禁卡,即日起停用。
最后,所长把原版那一行正则留在屏幕上,说道:“同一个正则。一个只有写的人读得懂,一个谁都读得懂。我们写谁都读得懂的那种。”
re.X 把第 4 课的用户名规则 [a-z][a-z0-9_]{3,15} 拆开来写,并给每一块加上注释。re.compile(r"\d+") 的 pos,在 "no.17 at 03:02" 中只找出 at 后面的数字。re.compile(r"badge=(?i)f-02") 的错误之后,用范围标志 (?i:...) 改成同样的意思。回溯者的踪迹
(a+)+$
拆开写了啊。下一个,你们想拆也拆不开。明早九点,打开邮箱。
所长的笔记本
re.I 忽略大小写,re.M 让 ^ $ 按行匹配,re.S 让 . 包括换行。M 和 S 做的事不一样。
re.X:忽略空格和 # 注释。字符类里的空格和 \ (反斜杠+空格)会保留。(?#...) 在哪里都是注释。
re.A:让 \w \d \s \b 只认 ASCII。str 默认是 re.U,re.L 只用于 bytes,不推荐。
组合 re.I | re.M(re.RegexFlag),没有标志就用 re.NOFLAG。内联 (?im) 只能放在最前面。
范围标志 (?i:...) (?-i:...) (?i-s:...)。a L u 不能关闭。用 re.DEBUG 看模式的内部。
re.compile 是起名字的工具。可接收 pos endpos,但 ^ 只在真正的开头匹配。属性有 flags groups groupindex pattern。
订阅新课时和新课程通知
本课程新增课时或推出后续课程时,我们会发邮件通知你。
问答
关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。