CASE-006
Grep & Co. 数据侦探事务所
委托书
委托人
网店“郑市集”的经营者 郑老板
证物
郑市集网站服务器的访问日志。正是假账号蜂拥而至的那天凌晨的记录。
破案条件
把一行日志拆成带名字的栏位(dict),找出凌晨 3 点访问管理页面(/admin)的 IP。
郑老板又来了。这次连笔记本电脑一起带来了。“假账号不是凌晨一股脑涌进来的嘛。听说那个时间段有人打开过管理后台。可我那会儿正在睡觉啊。”
所长扫了一眼屏幕。“网站服务器日志。一行大概有七个栏位。按编号叫容易乱。起个名字。”
203.0.113.7 - - [20/Oct/2026:02:58:40 +0900] "GET /products/42 HTTP/1.1" 200 5123
198.51.100.23 - - [20/Oct/2026:03:01:05 +0900] "POST /login HTTP/1.1" 302 0
203.0.113.66 - - [20/Oct/2026:03:02:11 +0900] "GET /admin HTTP/1.1" 200 8890
203.0.113.66 - - [20/Oct/2026:03:02:19 +0900] "GET /admin/users?page=2 HTTP/1.1" 200 12044
198.51.100.4 - - [20/Oct/2026:09:15:30 +0900] "GET /admin HTTP/1.1" 200 8890
203.0.113.7 - - [20/Oct/2026:09:16:02 +0900] "GET /cart HTTP/1.1" 200 2210一行从左到右依次是:访问的 IP、两个空栏(-)、方括号里的时刻、引号里的请求(方法·地址·协议)、响应码、发送的字节数。
第 5 课里我们用 group(1)、group(2) 这样的编号来叫分组。分组一旦超过七个,哪个编号是什么就记不住了。要给分组起名字,就在左括号后面紧接着写 ?P<名字>。
import re
line = '203.0.113.66 - - [20/Oct/2026:03:02:11 +0900] "GET /admin HTTP/1.1" 200 8890'
m = re.search(r"(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\]", line)
print(m.group("ip")) # 203.0.113.66
print(m["time"]) # 20/Oct/2026:03:02:11 +0900
print(m.group("ip", "time")) # ('203.0.113.66', '20/Oct/2026:03:02:11 +0900')
print(m.groupdict()) # {'ip': '203.0.113.66', 'time': '20/Oct/2026:03:02:11 +0900'}m.group("ip") 和 m["ip"] 是一样的。传入多个名字时返回元组。groupdict() 只把带名字的分组收集起来,返回 {名字: 文字} 的字典。一行日志就变成一个字典。- 都会报错。命名分组照样有编号。编号和第 5 课一样,按左括号的顺序来排。名字只是贴在编号上的外号而已。
print(m.group(1)) # 203.0.113.66
print(m[2]) # 20/Oct/2026:03:02:11 +0900
print(m.groups()) # ('203.0.113.66', '20/Oct/2026:03:02:11 +0900')证据分析室
re.search(r"", text)
返回值
多特的提醒
要是在别的语言里见过 (?<name>...) 这种写法,可得小心哦。在 Python 里漏掉 P,就会报 "unknown extension ?<n" 错误。同一个名字用两次,则会报 "redefinition of group name"。我的名字也只有一个呀。
现在给一行里的所有栏位都起上名字。栏位之间的空格、引号、方括号,都按原样写进模式。
LINE_RE = r'(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] "(?P<method>[A-Z]+) (?P<path>\S+) [^"]*" (?P<status>\d{3}) (?P<size>\d+)'
m = re.fullmatch(LINE_RE, line)
print(m.groupdict())
# {'ip': '203.0.113.66', 'time': '20/Oct/2026:03:02:11 +0900', 'method': 'GET', 'path': '/admin', 'status': '200', 'size': '8890'}r'...' 包起来。[^\]]+ 是“不是右方括号的若干字符”,[^"]* 是“不是引号的若干字符”。第 2 课的取反字符类帮我们卡住了栏位的边界。fullmatch。格式坏掉的行会变成 None,一下子就暴露出来。变成字典之后,剩下的就是普通的 Python 了。假设最上面那六行日志存放在字符串 access_log 里。
rows = [re.fullmatch(LINE_RE, l).groupdict() for l in access_log.splitlines()]
print(len(rows)) # 6
print(rows[1]["method"], rows[1]["path"], rows[1]["status"]) # POST /login 302值全都是字符串。想把字节数加起来,得像 int(row["size"]) 这样自己转换。
证据分析室
re.search(r"", text)
返回值
Match 对象不仅告诉你找到了什么,还会按分组告诉你在哪儿找到的。把分组编号或名字作为参数传进去,就能得到那个分组的位置。
m = re.fullmatch(LINE_RE, line)
print(m.start("path"), m.end("path")) # 51 57
print(m.span("status")) # (68, 71)
print(line[m.start("path"):m.end("path")]) # /admin可选的分组也可能没有匹配上,比如地址后面 ?page=2 这样的查询字符串。没有匹配的分组,文字是 None,位置是 -1。
P = r"(?P<path>/[^\s?]*)(?:\?(?P<query>\S+))?"
a = re.search(P, "GET /admin HTTP/1.1")
b = re.search(P, "GET /admin/users?page=2 HTTP/1.1")
print(a.group("query"), a.span("query")) # None (-1, -1)
print(b.group("query"), b.span("query")) # page=2 (17, 23)混进 None 的话,处理字符串时很容易出错。groups() 和 groupdict() 可以通过 default 参数指定一个值来代替 None。
print(a.groups()) # ('/admin', None)
print(a.groups(default="")) # ('/admin', '')
print(a.groupdict(default="-")) # {'path': '/admin', 'query': '-'}证据分析室
re.search(r"", text)
返回值
这些属性不常用,但知道了在调试时很顶用。
m = re.search(r"(?P<method>[A-Z]+) (?P<path>\S+)", line)
print(m.lastindex, m.lastgroup) # 2 path
print(m.pos, m.endpos, len(line)) # 0 76 76
print(m.string is line) # True
print(m.re) # re.compile('(?P<method>[A-Z]+) (?P<path>\\S+)')lastindex 是最后关闭的那个分组的编号,lastgroup 是那个分组的名字。没有名字或者没有分组匹配时,就是 None。((\d+)\.(\d+)) 的 lastindex 不是 3,而是 1。(?P<a>x)|(?P<b>y) 这样用 | 连起命名分组时,lastgroup 会告诉你匹配上的是哪一边。第 12 课就要利用这个性质来做分词器。pos、endpos 是搜索范围。用模块函数查找时,总是 0 和字符串长度。自己指定范围的方法,第 11 课学 re.compile 时再讲。string 是被搜索的原始字符串,re 是所用的模式对象。n = re.search(r"((\d+)\.(\d+))", "v3.14")
print(n.lastindex, n.lastgroup) # 1 None
n = re.search(r"(?P<a>x)|(?P<b>y)", "y")
print(n.lastindex, n.lastgroup) # 2 bMatch 对象是不可变的值,所以 copy.copy(m) 或 copy.deepcopy(m) 都可以用,得到的就是同一个对象本身。
最后,expand(template) 会把匹配结果填进模板,生成新的字符串。模板里的 \g<名字> 会被替换成那个分组的文字。这和第 7 课的替换字符串是完全一样的语法。下面例子里的 요청 是韩语的“请求”。
print(m.expand(r"\g<method> 요청: \g<path>")) # GET 요청: /admin现在把案件的条件放进模式。时刻栏 [20/Oct/2026:03:02:11 +0900] 里,日期后面第一个 : 之后的两位数字就是小时。用 \[[^:]+: 跳过日期,在那个位置写上 03,就成了“凌晨 3 点”这个条件。
03 或 :03: 去找的话,分钟或秒数是 03 的行也会被抓到。关键是把位置钉死,确定是哪一栏的 03。/admin\S*,以便抓住所有以 /admin 开头的页面。证据分析室
re.search(r"", text)
返回值
下面是参考答案之一。
(?P<ip>\S+) \S+ \S+ \[[^:]+:(?P<hour>03):[^\]]+\] "GET (?P<path>/admin\S*)把时刻拆成日·月·年·时·分·秒,全部起上名字,每一行都像字典一样取值,再加上条件。有了命名分组,条件语句读起来就像人话一样。
TIME_RE = (r'(?P<ip>\S+) \S+ \S+ '
r'\[(?P<day>\d\d)/(?P<mon>\w{3})/(?P<year>\d{4}):(?P<hour>\d\d):(?P<min>\d\d):(?P<sec>\d\d) [^\]]+\] '
r'"(?P<method>[A-Z]+) (?P<path>\S+)')
suspects = set()
for l in access_log.splitlines():
m = re.match(TIME_RE, l)
if m and m["hour"] == "03" and m["path"].startswith("/admin"):
print(m["hour"] + ":" + m["min"], m["ip"], m["path"])
suspects.add(m["ip"])
# 03:02 203.0.113.66 /admin
# 03:02 203.0.113.66 /admin/users?page=2
print(suspects) # {'203.0.113.66'}凌晨 3 点 2 分,203.0.113.66 打开了管理后台,8 秒后又把会员列表的第 2 页也下载走了。早上 9 点的 198.51.100.4 是郑老板办公室的 IP。郑老板的脸一下子白了。“会员列表?那顾客的信息……”
结案报告 — 10 月 20 日 03:02,外部 IP 203.0.113.66 访问管理页面并查看了会员列表。建议立即更改管理员密码并限制访问 IP。存在会员信息泄露的可能。
LINE_RE 读取日志,把响应码(status)为 200 的行的 size 全部加起来。别忘了 int()。(?P<y>\d{4})-(?P<m>\d\d)-(?P<d>\d\d) 找到 "2026-10-20" 之后,猜猜 m.expand(r"\g<d>/\g<m>/\g<y>") 的结果。re.search(r"(?P<a>\d)(?P<b>x)?", "7") 的 span("b")、groupdict(default="?")、lastgroup,再验证一下。所长的笔记本
用 (?P<name>...) 给分组起名字。m.group("name")、m["name"]、m.groupdict()。
命名分组也有编号。编号按左括号的顺序。
start(g)、end(g)、span(g)。没有匹配的分组是 None,位置是 -1。
用 groups(default=...)、groupdict(default=...) 拿别的值代替 None。
lastindex 是最后关闭的分组编号,lastgroup 是它的名字。能告诉你用 | 连起的命名分组中匹配的是哪一边。
m.pos、m.endpos、m.string、m.re。用 m.expand(r"\g<name>") 往模板里填值。
订阅新课时和新课程通知
本课程新增课时或推出后续课程时,我们会发邮件通知你。
问答
关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。