R

ReflexKR

Reflex 官方文档

看得见的正则表达式

·

课程概览

1

消失的支付记录:按原样查找文字

2

肇事逃逸车辆:字符类

3

撕破的电话簿:量词

4

虚假注册:锚点与校验

5

五花八门的日期:分组与选择

6

凌晨 3 点的访问:命名分组与 Match 对象

7

见报前夜:替换与分割

8

回溯者的挑战书:贪婪、懒惰与占有

9

保险箱的规则:前后查找

10

同样的手法:反向引用与条件模式

11

读不懂的正则:标志与编译

12

回溯者的真面目:总复习与分词器

看得见的正则表达式
第6课

时间 4:10–5:00

凌晨 3 点的访问:命名分组与 Match 对象

CASE-006

Grep & Co. 数据侦探事务所

委托书

凌晨 3 点的访问

委托人

网店“郑市集”的经营者 郑老板

证物

郑市集网站服务器的访问日志。正是假账号蜂拥而至的那天凌晨的记录。

破案条件

把一行日志拆成带名字的栏位(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、两个空栏(-)、方括号里的时刻、引号里的请求(方法·地址·协议)、响应码、发送的字节数。

6-1. 用名牌代替编号:(?P...)(10 分钟)

第 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() 只把带名字的分组收集起来,返回 {名字: 文字} 的字典。一行日志就变成一个字典。
  • 名字要遵守 Python 标识符的规则。以数字开头或含有 - 都会报错。

命名分组照样有编号。编号和第 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)

返回值

切换到 finditer,看看第一个匹配栏里的 m.group("ip") 和 groupdict()。如果把 (?P<time>...) 改成普通的 (...),字典里会少掉什么?

多特的提醒

要是在别的语言里见过 (?<name>...) 这种写法,可得小心哦。在 Python 里漏掉 P,就会报 "unknown extension ?<n" 错误。同一个名字用两次,则会报 "redefinition of group name"。我的名字也只有一个呀。

6-2. 把一整行日志变成字典(10 分钟)

现在给一行里的所有栏位都起上名字。栏位之间的空格、引号、方括号,都按原样写进模式。

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 课的取反字符类帮我们卡住了栏位的边界。
  • 整行都得是这个样子,所以用第 4 课学的 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)

返回值

模式停在了请求地址(path)。把协议、响应码(status)、字节数(size)都接上,让整行都能 fullmatch。末尾被截断的最后一行不能通过。

6-3. 在哪儿:start、end、span(6 分钟)

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)

返回值

用 finditer 把每行的地址和查询字符串分开。先在第一个匹配栏里确认 query 是 None,再删掉文本的前两行,看看 page=2 能不能被抓到。如果删掉开头的 [A-Z]+,连 HTTP/1.1 里的 /1.1 也会被当成地址。

6-4. Match 对象的其余属性(6 分钟)

这些属性不常用,但知道了在调试时很顶用。

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 b

Match 对象是不可变的值,所以 copy.copy(m) 或 copy.deepcopy(m) 都可以用,得到的就是同一个对象本身。

最后,expand(template) 会把匹配结果填进模板,生成新的字符串。模板里的 \g<名字> 会被替换成那个分组的文字。这和第 7 课的替换字符串是完全一样的语法。下面例子里的 요청 是韩语的“请求”。

print(m.expand(r"\g<method> 요청: \g<path>"))   # GET 요청: /admin

6-5. 凌晨 3 点的管理页面(8 分钟)

现在把案件的条件放进模式。时刻栏 [20/Oct/2026:03:02:11 +0900] 里,日期后面第一个 : 之后的两位数字就是小时。用 \[[^:]+: 跳过日期,在那个位置写上 03,就成了“凌晨 3 点”这个条件。

  • 只用 03 或 :03: 去找的话,分钟或秒数是 03 的行也会被抓到。关键是把位置钉死,确定是哪一栏的 03。
  • 地址写成 /admin\S*,以便抓住所有以 /admin 开头的页面。

证据分析室

re.search(r"", text)

返回值

现在的模式连早上 9 点郑老板本人的访问也抓了进来。只留下时刻栏里小时为 03 的 /admin 访问。如果再用命名分组把 ip 和 hour 一起抓住,就更好了。

下面是参考答案之一。

(?P<ip>\S+) \S+ \S+ \[[^:]+:(?P<hour>03):[^\]]+\] "GET (?P<path>/admin\S*)

破案(10 分钟)

把时刻拆成日·月·年·时·分·秒,全部起上名字,每一行都像字典一样取值,再加上条件。有了命名分组,条件语句读起来就像人话一样。

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。存在会员信息泄露的可能。

练习

  1. 用 LINE_RE 读取日志,把响应码(status)为 200 的行的 size 全部加起来。别忘了 int()。
  2. 用命名分组 (?P<y>\d{4})-(?P<m>\d\d)-(?P<d>\d\d) 找到 "2026-10-20" 之后,猜猜 m.expand(r"\g<d>/\g<m>/\g<y>") 的结果。
  3. 先猜猜 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>") 往模板里填值。

← 上一课

第5课 · 五花八门的日期:分组与选择

下一课 →

第7课 · 见报前夜:替换与分割

订阅新课时和新课程通知

本课程新增课时或推出后续课程时,我们会发邮件通知你。

收集项目:电子邮箱 · 用途:本课程的新课时和上线通知 · 保存期限:直到你要求删除。不会用于其他用途,也不会提供给他人。回复任意通知邮件,我们会立即删除。

问答

关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。

以纯文本显示,不支持链接和格式。

R

ReflexKR

动手学 Python 和 Reflex。所有示例均已在 Reflex 0.10.0 上实际运行验证。

ReflexKR 免费,没有横幅广告。如果对你有帮助,欢迎请我们喝杯咖啡。 ☕ 请我们喝杯咖啡

联盟链接与赞助说明
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex