【Python系列课程】Python正则表达式(下):环视、命名分组与日志实战
阅读时长25分钟 | 关键词正则表达式进阶、Lookahead、Lookbehind、实战案例、日志解析引言当你需要更聪明的匹配上一篇文章中我们学完了正则表达式的核心语法——元字符、字符集、量词、分组、基础函数。这些已经能解决 80% 的正则需求。但实际工作中你会遇到这样的需求匹配后面跟着password的单词但不包含password本身匹配前面是$的数字但不包含$本身匹配不在引号内的逗号用于 CSV 解析匹配重叠的字符串这些需求用基础语法很难优雅地解决。今天这篇文章我们来讲进阶特性让你能写出更精准、更强大的正则表达式。一、环视Lookahead Lookbehind匹配位置而不是字符1.1 什么是环视普通的正则匹配是吃掉字符的——匹配到了就把字符消耗掉后续匹配从下一个字符继续。环视Lookaround不同——它只检查某个位置的前面或后面是否符合条件但不消耗字符也不会把条件中的内容纳入匹配结果。[图1普通匹配 vs 环视匹配对比图]建议配图左侧展示普通匹配foo的过程——foo逐一被消耗匹配指针逐字符后移右侧展示环视(?o)的过程——指针在f后检查但不消耗o指针位置不变。1.2 四种环视语法类型语法含义记忆口诀正向先行环视(?...)后面必须跟着...“前面看有则行”负向先行环视(?!...)后面不能跟着...“前面看无则行”正向后行环视(?...)前面必须是...“后面看有则行”负向后行环视(?!...)前面不能是...“后面看无则行”1.3 正向先行环视(?...)后面必须跟着importre textfoo bar fooish food# 匹配后面跟着 bar 的 foopatternrfoo(? bar)resultsre.findall(pattern,text)print(results)# 输出[foo]# 解释只有第一个 foo 后面跟着 bar第二个 foo 后面是 ish不匹配关键点(? bar)是条件不参与匹配结果的输出。1.4 负向先行环视(?!...)后面不能跟着importre# 匹配不是以 bar 结尾的单词简化示例textfoobar fooish barr# 匹配 foo 但后面不能跟着 barpatternrfoo(?!bar)resultsre.findall(pattern,text)print(results)# 输出[foo]# 解释第一个 foo 后面跟着 bar不匹配第二个 foo 后面是 ish匹配1.5 正向后行环视(?...)前面必须是importre text$100 €200 ¥300# 匹配前面有 $ 的数字patternr(?\$)\dresultsre.findall(pattern,text)print(results)# 输出[100]# 解释只有 100 前面是 $其他数字前面不是不匹配注意后行环视中的内容必须是固定长度的Python 的re模块限制。a)可以a)不行。1.6 负向后行环视(?!...)前面不能是importre textuser: admin, user: guest# 匹配 user: 但前面不能是 admin, patternr(?!admin, )user:resultsre.findall(pattern,text)print(results)# 输出[user:]# 解释第二个 user: 前面是 admin, 不匹配第一个前面不是匹配1.7 综合案例提取带特定前缀的单词importre textpreheat prehistoric postpone preposition# 提取以 pre 开头的单词用后行环视patternr\b(?pre)\wresultsre.findall(pattern,text)print(results)# 输出[preheat, prehistoric, preposition]二、命名的分组引用2.1 给分组起名字基础语法中分组用()定义用\1\2引用在正则内部或用group(1)group(2)提取在 Python 中。但数字引用很难维护——改一个分组后面全要改。命名分组语法(?Pname...)importre text2024-05-31# 数字分组pattern1r(\d{4})-(\d{2})-(\d{2})m1re.search(pattern1,text)print(m1.groups())# (2024, 05, 31)print(m1.group(1))# 2024# 命名分组推荐pattern2r(?Pyear\d{4})-(?Pmonth\d{2})-(?Pday\d{2})m2re.search(pattern2,text)print(m2.group(year))# 2024print(m2.group(month))# 05print(m2.group(day))# 31# 也可以在正则内部用 \Pname 引用pattern3r(?Pword\w)\s\Pwordtext2hello hello world worldprint(re.findall(pattern3,text2))# [hello][图2数字分组 vs 命名分组对比表]建议配图用表格对比两种写法标注可维护性差异。左侧是(\d{4})→group(1)的数字引用方式右侧是(?Pyear\d{4})→group(year)的命名引用方式用绿色 ✓ 标注右侧的可读性优势。2.2 在替换中使用命名分组importre text2024-05-31# 把 YYYY-MM-DD → DD/MM/YYYYpatternr(?Pyear\d{4})-(?Pmonth\d{2})-(?Pday\d{2})replacementr\Pday/\Pmonth/\Pyearresultre.sub(pattern,replacement,text)print(result)# 输出31/05/2024三、标志位Flags改变匹配的性格3.1 常用标志位速查表标志位简写作用re.IGNORECASEre.I忽略大小写re.MULTILINEre.M^和$匹配每一行的开头/结尾re.DOTALLre.S.也能匹配换行符\nre.VERBOSEre.X允许正则写成多行 添加注释re.ASCIIre.A\w\d\s只匹配 ASCII不匹配中文re.LOCALEre.L根据本地设置匹配少用3.2re.I忽略大小写importre textHello HELLO hellopatternrhelloprint(re.findall(pattern,text))# [hello]print(re.findall(pattern,text,re.I))# [Hello, HELLO, hello]3.3re.M多行模式importre textfirst line second line third line# 没有 M 标志^ 和 $ 只匹配整个字符串的开头/结尾pattern1r^\wprint(re.findall(pattern1,text))# [first]# 有 M 标志^ 和 $ 匹配每一行的开头/结尾pattern2r^\wprint(re.findall(pattern2,text,re.M))# [first, second, third][图3单行模式 vs 多行模式匹配范围对比图]建议配图一段三行文本的示意图标注在单行模式下^只匹配第一行的开头在多行模式下^匹配每一行的开头用三种颜色标注三行各自的匹配范围。3.4re.SDOTALL让.也能匹配换行符importre textdiv\nhello\n/div# 默认. 不匹配 \npattern1rdiv.*/divprint(re.findall(pattern1,text))# [] 匹配不到# DOTALL. 也匹配 \npattern2rdiv.*/divprint(re.findall(pattern2,text,re.S))# [div\nhello\n/div]3.5re.XVERBOSE把正则写成诗当正则表达式很长时一行写下来完全没法维护。re.X允许你换行和空格会被忽略用\或[ ]来匹配真正的空格#后面是注释importre text姓名张三电话13800138000# 不用 VERBOSE一行难读pattern1r姓名(\w)电话(\d{11})# 用 VERBOSE多行 注释可读性强pattern2r ^姓名(?Pname\w) # 捕获姓名 电话(?Pphone\d{11}) # 捕获电话 mre.search(pattern2,text,re.VERBOSE|re.I)ifm:print(m.group(name))# 张三print(m.group(phone))# 13800138000四、实战日志文件解析这是正则表达式在真实工作中最常见的用途之一。4.1 日志格式分析假设我们有如下格式的日志文件[2024-05-31 10:23:45] [INFO] user_login: user_id1001, ip192.168.1.1 [2024-05-31 10:24:01] [ERROR] db_connect_failed: timeout5000ms [2024-05-31 10:25:13] [WARNING] slow_query: query_time3.2s [2024-05-31 10:26:00] [INFO] user_logout: user_id10014.2 用正则解析每条日志importre log_line[2024-05-31 10:23:45] [INFO] user_login: user_id1001, ip192.168.1.1patternr ^\[ (?Pdate\d{4}-\d{2}-\d{2}) # 日期 \s (?Ptime\d{2}:\d{2}:\d{2}) # 时间 \]\[ (?Plevel\w) # 日志级别 \]\] \s (?Pmessage.?) # 日志消息非贪婪 $ mre.search(pattern,log_line,re.VERBOSE)ifm:print(f日期{m.group(date)})print(f时间{m.group(time)})print(f级别{m.group(level)})print(f消息{m.group(message)})# 输出# 日期2024-05-31# 时间10:23:45# 级别INFO# 消息user_login: user_id1001, ip192.168.1.14.3 批量解析日志文件importre patternre.compile(r ^\[(?Pdate\d{4}-\d{2}-\d{2})\s(?Ptime\d{2}:\d{2}:\d{2})\] \s\[(?Plevel\w)\] \s(?Pmessage.?)$ ,re.VERBOSE)# 模拟日志内容log_data [2024-05-31 10:23:45] [INFO] user_login: user_id1001 [2024-05-31 10:24:01] [ERROR] db_connect_failed: timeout [2024-05-31 10:25:13] [WARNING] slow_query: query_time3.2s # 统计各级别日志数量level_count{}forlineinlog_data.strip().split(\n):mpattern.search(line)ifm:levelm.group(level)level_count[level]level_count.get(level,0)1print(日志级别统计,level_count)# 输出日志级别统计 {INFO: 1, ERROR: 1, WARNING: 1}五、实战从 HTML 中提取结构化数据5.1 提取所有链接importre html a hrefhttps://example.comExample/a a href/aboutAbout/a img srclogo.png patternr(?href|href)(?Purl[^])(?|)urlsre.findall(pattern,html)print(urls)# 输出[https://example.com, /about]5.2 更健壮的链接提取处理双引号/单引号importre html a hrefhttps://example.com classlinkExample/a a href/about classlinkAbout/a # 匹配 href... 或 href...patternrhref\s*\s*[\](?Purl[^\]*)[\]urlsre.findall(pattern,html)print(urls)# 输出[https://example.com, /about]六、常见坑与最佳实践6.1 贪婪 vs 非贪婪一个字符之差importre textdivcontent1/divdivcontent2/div# 贪婪默认尽量多匹配print(re.findall(rdiv.*/div,text))# 输出[divcontent1/divdivcontent2/div]# 解释.* 从第一个 div 匹配到最后一个 /div# 非贪婪加 ?尽量少匹配print(re.findall(rdiv.*?/div,text))# 输出[divcontent1/div, divcontent2/div]# 解释.*? 每个 div 匹配到最近的 /div[图4贪婪匹配 vs 非贪婪匹配过程对比动画示意图]建议配图同一段文本的匹配过程左侧贪婪模式用红色高亮从第一个标签到最后一个标签的整个范围右侧非贪婪模式用绿色高亮每个标签对的独立范围用箭头标注匹配指针的移动路径。6.2.不匹配\n别忘了re.S这是新手最容易踩的坑importre texttag\ncontent\n/tagprint(re.findall(rtag.*/tag,text))# []匹配不到print(re.findall(rtag.*/tag,text,re.S))# [tag\ncontent\n/tag]6.3 括号的转义\(而不是(importre# 想匹配字符串中的 (123) 这种格式text函数返回了 (123) 这个结果patternr\(\d\)# 正确转义括号print(re.findall(pattern,text))# [ (123)]# pattern r(\d) # 错误这变成了捕获分组6.4 最佳实践速查表实践推荐做法原因复杂正则用re.VERBOSE多行书写 注释可维护性分组引用用命名分组(?Pname...)可读性与可维护性重复使用用re.compile()预编译性能匹配中文用[\u4e00-\u9fff]或\w需确认准确性HTML 解析不要用正则用BeautifulSoup正则无法处理嵌套标签七、动手练习练习 1密码强度校验用正则表达式校验密码是否同时满足至少 8 位包含大小写字母包含数字包含特殊字符[emailnbsp;#$%^*]importredefis_strong_password(pwd): 返回 True 如果密码满足所有条件 提示用多个正则分别校验或用一个复杂正则 # 在这里写你的代码pass# 测试tests[Abc123!,password,ABC123!!,Short1!]fortintests:print(f{t}:{is_strong_password(t)})练习 2提取 Markdown 中的图片链接从 Markdown 文本中提取所有图片的 URLimportre md_text 这是一篇文章。  一些内容。  结束。 # 写正则提取所有图片 URLpatternryour_pattern_hereurlsre.findall(pattern,md_text)print(urls)# 期望输出[images/pic1.png, https://example.com/img2.jpg]练习 3解析 Nginx/Apache 访问日志Common Log Format 格式127.0.0.1 - - [31/May/2024:10:23:45 0800] GET /api/users HTTP/1.1 200 1234用正则解析出IP、时间、请求方法、路径、协议、状态码、响应大小。小结今天这篇文章我们深入了正则表达式的进阶特性知识点关键内容环视(?)先行、(?)后行不消耗字符命名分组(?Pname...)\Pname可维护性强标志位re.I忽略大小写re.M多行re.SDOTALLre.XVERBOSE贪婪/非贪婪默认贪婪*加?变非贪婪*??实战日志解析、HTML 提取、密码校验一句话总结正则表达式是处理文本的强大武器但记住——如果你用正则解析 HTML说明你已经有 2 个问题了。下一篇文章我们将进入数据分析双雄的第一位NumPy——Python 科学计算的基石一切数组运算的起点。本文是「Python从入门到数据分析」系列的第 15 篇共 24 篇。关注我不错过后续更新。