CASE-004
Grep & Co. 数据侦探事务所
委托书
委托人
网店“郑市集”的郑老板
证物
会员注册申请记录,外加一个检查账号规则的 Python 函数。
破案条件
筛出所有不符合规则的账号,并查明检查器为什么会被突破。
快到中午时,郑老板抱着笔记本电脑进来了。“账号规则我明明定好了,可奇怪的账号还是一个劲儿地注册进来。”所长盯着屏幕看了好一会儿,只说了一句:“规则没错。问法错了。”
郑市集的账号规则和检查函数如下(规则原文是韩语,保留原样)。
아이디 규칙: 영문 소문자로 시작하고, 소문자·숫자·밑줄(_)만 써서 4~16자意思是:账号以英文小写字母开头,只能使用小写字母、数字和下划线(_),长度 4~16 个字符。
import re
ID = r"[a-z][a-z0-9_]{3,15}"
def is_valid_id(uid):
return re.search(ID, uid) is not None注册申请记录如下。
2026-10-18 14:02:51 signup id=jeong_fan
2026-10-19 03:11:02 signup id=ok_id!!!
2026-10-19 03:11:04 signup id=_admin
2026-10-19 03:11:05 signup id=ABC123
2026-10-19 03:11:07 signup id=a1
2026-10-19 09:40:13 signup id=minsu99先看看郑老板的检查器会放过什么。
print(is_valid_id("ok_id!!!")) # True ← 带感叹号却通过了
print(is_valid_id("_admin")) # True ← 以下划线开头却通过了
print(is_valid_id("ABC123")) # Falsere.search 问的是:“这个字符串里某个地方有没有符合规则的部分?”ok_id!!! 里有 ok_id,_admin 里有 admin,所以都成功了。
re.search(ID, "ok_id!!!") # <re.Match object; span=(0, 5), match='ok_id'>
re.search(ID, "!!!ok_id") # <re.Match object; span=(3, 8), match='ok_id'>输入校验要问的是另一个问题:“这个字符串整个都符合规则吗?”为此,必须在模式里写明“这里是开头,这里是结尾”。
方括号外面的 ^ 表示字符串的开头,$ 表示字符串的结尾。这类符号叫作锚点(anchor,船锚)。
re.search(r"^id", "id=kim") # <re.Match object; span=(0, 2), match='id'>
re.search(r"^id", "my id=kim") # None
re.search(r"kim$", "id=kim") # <re.Match object; span=(3, 6), match='kim'>
re.search(r"kim$", "id=kim!") # None^id 匹配到的仍然是 id 这两个字符。[^...] 搞混了。^ 在方括号最前面是取反,在方括号外面是开头。在检查器模式的前后加上 ^ 和 $,符合规则的部分就必须从头到尾连成一片。
证据分析室
re.search(r"", text)
返回值
即使不亲手写锚点,也可以通过选择函数来决定在哪里比对。
| 函数 | 问的是 | 用锚点写的话 |
|---|---|---|
re.search | 某个地方有没有符合的部分 | (没有锚点) |
re.match | 从最开头起有没有符合的部分 | \A模式 |
re.fullmatch | 整个都符合吗 | \A模式\Z |
\A 和 \Z 是和 ^、$ 相似的锚点,区别在 4-4 里看。
re.match(ID, "ok_id!!!") # <re.Match object; span=(0, 5), match='ok_id'>
re.match(ID, "!!!ok_id") # None
re.fullmatch(ID, "ok_id!!!") # None
re.fullmatch(ID, "ok_id") # <re.Match object; span=(0, 5), match='ok_id'>re.match 不像名字那样去检查“是否整个都符合”。只要开头部分符合就算成功,所以它会放过 ok_id!!!。re.fullmatch。就算忘了给模式加锚点也安全,还能避开下一节的陷阱。证据分析室
re.search(r"", text)
返回值
$ 藏着一条规则:它不光在字符串真正的结尾匹配,在最后一个换行符的紧前面也会匹配。所以 ^abc$ 连末尾带换行符的 "abc\n" 也会放行。
re.search(r"^abc$", "abc\n") # <re.Match object; span=(0, 3), match='abc'>
re.fullmatch(r"abc", "abc\n") # None
re.search(r"\Aabc\Z", "abc\n") # None\A 只在字符串的最开头匹配,\Z 只在字符串的最末尾匹配,没有换行符的例外。\z。在 Python 里它和 \Z 完全一样,是为了和其他语言的正则写法保持一致而加进来的。re.search(r"\Aabc\z", "abc") # <re.Match object; span=(0, 3), match='abc'>
re.search(r"\Aabc\z", "abc\n") # None多特的提醒
从文件里一行一行读出来的文字,末尾常常带着换行符。用 ^...$ 检查的话,它会对那个换行符装作没看见,直接放行。结果账号就带着末尾的换行符被存进去了。做校验的时候,请用 fullmatch,或者 \A...\Z。
第 3 课还留着一道作业:电话号码模式从 1234-5678-9012 正中间的 234 开始匹配的那次事故。这次需要的不是整个字符串的边界,而是在一长段文字里,能标出一个号码从哪儿开始、到哪儿结束的锚点。
\b 是单词边界。它匹配 \w(字母、数字、下划线)和 \W(其他字符)之间的位置,或者字符串开头、结尾处挨着 \w 的位置。\B 正好相反,匹配不是边界的位置。两者都不占字符。
re.findall(r"\bid\b", "id=kim userid=lee id_card") # ['id']
re.findall(r"\Bid\b", "id=kim userid=lee id_card") # ['id']
re.findall(r"\bid\B", "id=kim userid=lee id_card") # ['id']三个结果都是 ['id'],但找到的位置各不相同。
\bid\b:前后都是边界的,id=kim 里的 id\Bid\b:前面不是边界的,userid 里的 id\bid\B:后面是下划线(\w)、因而不是边界的,id_card 里的 id在电话号码前后加上 \b,就不能从数字中间开始了。因为 1 和 2 之间两边都是 \w,不是边界。
re.findall(r"\b\d{2,3}[-. ]?\d{3,4}[-. ]?\d{4}\b", phonebook)
# ['010-0000-1234', '010 0000 9876', '02-0000-5678', '010.0000.4321']有一点要当心:在 Python 里,韩文(以及汉字)也算 \w。所以紧贴在韩文后面的数字前面,是没有边界的。下面的 전화 就是韩语的“电话”。
re.findall(r"\b\d{2,3}-\d{3,4}-\d{4}\b", "전화010-0000-1234") # []
re.findall(r"\b\d{2,3}-\d{3,4}-\d{4}\b", "전화 010-0000-1234") # ['010-0000-1234']证据分析室
re.search(r"", text)
返回值
像注册记录这样,一个字符串里装着多行时,^ 只在第一行的开头匹配。想把每一行开头的日期都提取出来,就加上 re.MULTILINE(简写 re.M)标志。这样 ^ 和 $ 就会在每一行的开头和结尾匹配。
re.findall(r"^\d{4}-\d\d-\d\d", signups)
# ['2026-10-18']
re.findall(r"^\d{4}-\d\d-\d\d", signups, re.MULTILINE)
# ['2026-10-18', '2026-10-19', '2026-10-19', '2026-10-19', '2026-10-19', '2026-10-19']即使加了 re.M,\A 和 \Z 依然只在整个字符串的开头和结尾匹配。
re.findall(r"^\d", "1\n2", re.M) # ['1', '2']
re.findall(r"\A\d", "1\n2", re.M) # ['1']所有标志第 11 课再正式讲。今天只要记住:“有一个开关,能把 ^ 和 $ 切换成按行计算。”
证据分析室
re.search(r"", text)
标志
返回值
把检查器里的 re.search 换成 re.fullmatch。模式保持不变。
def is_valid_id(uid):
return re.fullmatch(r"[a-z][a-z0-9_]{3,15}", uid) is not None
for line in signups.splitlines():
uid = line.split("id=")[1]
if not is_valid_id(uid):
print(line)
# 2026-10-19 03:11:02 signup id=ok_id!!!
# 2026-10-19 03:11:04 signup id=_admin
# 2026-10-19 03:11:05 signup id=ABC123
# 2026-10-19 03:11:07 signup id=a1被筛出来的四条都违反了规则:ok_id!!! 带感叹号,_admin 以下划线开头,ABC123 用了大写,a1 太短。
结案报告 — 账号检查器用的是 re.search,“只要一部分符合就放行”。改成 re.fullmatch 后,拦下了 4 个违规注册。
可所长的手指顺着打印出来的四行时间一路往下点:03:11:02、03:11:04、03:11:05、03:11:07。“5 秒之内四次。凌晨 3 点。不是人手干的。”郑老板的脸色僵住了。这些假账号是在同一天凌晨一股脑涌进来的。是谁送来的,现在还没人知道。
第一部到此结束。一个字符、一个位置、次数、边界——“要找什么”的写法,你已经全部学会了。第二部里,我们要学怎样从找到的东西里取出需要的片段,一路追查这位凌晨来客。
re.match(r"\d{4}", "2026-10-19") 和 re.match(r"\d{4}", "#2026") 的结果,再运行看看。re.findall(r"\bcat\b", "cat concat cats") 和 re.findall(r"\Bcat", "cat concat cats") 分别会找到哪个 cat?所长的笔记本
^ 是开头,$ 是结尾。锚点匹配的不是字符,而是位置。
re.search 哪儿都行,re.match 只看最开头,re.fullmatch 看整体。输入校验用 fullmatch。
$ 在最后一个换行符前面也会匹配。要不带换行例外地看结尾,用 \Z(3.14 起 \z 也一样)。
\b 是 \w 和 \W 之间的边界,\B 是非边界的位置。韩文也是 \w。
加上 re.M,^ $ 就按行匹配。\A \Z 照旧以整个字符串为准。
订阅新课时和新课程通知
本课程新增课时或推出后续课程时,我们会发邮件通知你。
问答
关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。