🐍 正则进阶 — 分组、编译与实战
🕐 预计用时:2-3 小时 | 🎯 目标:掌握分组、编译正则、贪婪深入、邮箱/手机验证实战
📖 今日目录
1. 分组 () — 提取子匹配
圆括号 () 把正则分成"组"——不仅能整体匹配,还能单独提取每个部分。
import re
# 提取日期的年月日
text = "今天是 2024-01-15,明天是 2024-01-16"
pattern = r"(\d{4})-(\d{2})-(\d{2})"
for match in re.finditer(pattern, text):
print(f"完整匹配: {match.group(0)}") # 整个匹配
print(f" 年: {match.group(1)}") # 第1组
print(f" 月: {match.group(2)}") # 第2组
print(f" 日: {match.group(3)}") # 第3组
print()
# 输出:
# 完整匹配: 2024-01-15
# 年: 2024
# 月: 01
# 日: 15
#
# 完整匹配: 2024-01-16
# 年: 2024
# 月: 01
# 日: 16
# group() 的用法
match = re.search(r"(\d{4})-(\d{2})-(\d{2})", "日期: 2024-01-15")
print(match.group(0)) # '2024-01-15' 完整匹配
print(match.group(1)) # '2024' 第1组
print(match.group(2)) # '01' 第2组
print(match.group(3)) # '15' 第3组
print(match.groups()) # ('2024', '01', '15') 所有组的元组
# 实用:解析 URL
url = "https://www.example.com:8080/path/to/page"
pattern = r"(https?)://([^/:]+)(?::(\d+))?(.*)"
match = re.match(pattern, url)
if match:
print(f"协议: {match.group(1)}") # https
print(f"域名: {match.group(2)}") # www.example.com
print(f"端口: {match.group(3)}") # 8080
print(f"路径: {match.group(4)}") # /path/to/page
# 实用:解析 CSV 行(简易版)
line = "张三,25,北京,工程师"
pattern = r"^(\w+),(\d+),(\w+),(\w+)$"
match = re.match(pattern, line)
if match:
name, age, city, job = match.groups()
print(f"姓名: {name}, 年龄: {age}, 城市: {city}, 职业: {job}")
💡 group 编号规则:
group(0) = 整个匹配(永远存在)
group(1) = 第1个括号里的内容
group(2) = 第2个括号里的内容
groups() = 所有组的元组(不含 group(0))
2. 命名分组 (?P<name>)
给分组起名字——用名字代替数字,代码更清晰。
# 普通分组:用数字访问
match = re.search(r"(\d{4})-(\d{2})-(\d{2})", "2024-01-15")
print(match.group(1)) # 2024(哪个是年?不直观)
# 命名分组:用名字访问
pattern = r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})"
match = re.search(pattern, "2024-01-15")
print(match.group("year")) # 2024(一目了然!)
print(match.group("month")) # 01
print(match.group("day")) # 15
# 命名分组的字典
print(match.groupdict()) # {'year': '2024', 'month': '01', 'day': '15'}
# 实用:解析日志
log = "2024-01-15 08:30:15 [ERROR] 数据库连接失败"
pattern = r"(?P<date>\d{4}-\d{2}-\d{2}) (?P<time>\d{2}:\d{2}:\d{2}) \[(?P<level>\w+)\] (?P<msg>.+)"
match = re.match(pattern, log)
if match:
info = match.groupdict()
print(info)
# {'date': '2024-01-15', 'time': '08:30:15', 'level': 'ERROR', 'msg': '数据库连接失败'}
# 实用:解析键值对
config = "host=localhost port=8080 debug=true"
pattern = r"(?P<key>\w+)=(?P<value>\w+)"
config_dict = {m.group("key"): m.group("value") for m in re.finditer(pattern, config)}
print(config_dict) # {'host': 'localhost', 'port': '8080', 'debug': 'true'}
💡 命名分组的优势:
1. 代码可读性高(group("year") vs group(1))
2. groupdict() 直接生成字典
3. 正则模式变动时,不用改调用代码
3. 分组与 findall 的关系
findall 的行为取决于正则里有没有分组——这是个常见的坑!
# 没有分组:返回完整匹配的列表
result = re.findall(r"\d+", "a1b2c3")
print(result) # ['1', '2', '3']
# 有1个分组:只返回该分组的内容
result = re.findall(r"(\d+)", "a1b2c3")
print(result) # ['1', '2', '3'](和上面一样,但原因不同)
# 有多个分组:返回元组列表
result = re.findall(r"(\d+)-(\d+)", "1-2 3-4 5-6")
print(result) # [('1', '2'), ('3', '4'), ('5', '6')]
# ⚠️ 常见坑:想提取完整匹配但加了分组
result = re.findall(r"(\d{4})-(\d{2})-(\d{2})", "2024-01-15 2024-02-20")
print(result) # [('2024', '01'), ('2024', '02')] ← 只有分组内容!
# 期望是 ['2024-01-15', '2024-02-20'],但分组导致只返回组内容
# 解决方案:用非捕获分组 (?:) 或用 finditer
result = [m.group() for m in re.finditer(r"\d{4}-\d{2}-\d{2}", "2024-01-15 2024-02-20")]
print(result) # ['2024-01-15', '2024-02-20']
⚠️ findall 分组陷阱:
有分组 → 只返回分组内容(元组)
无分组 → 返回完整匹配(字符串)
想要完整匹配 + 分组 → 用 finditer
4. 非捕获分组 (?:)
# (?:...) 分组但不捕获——不占用 group 编号
# 普通分组:占用编号
match = re.search(r"(https?)://([^/]+)", "https://example.com")
print(match.group(1)) # https(第1组)
print(match.group(2)) # example.com(第2组)
# 非捕获分组:不占用编号
match = re.search(r"(?:https?)://([^/]+)", "https://example.com")
print(match.group(1)) # example.com(现在是第1组了!)
# (?:https?) 匹配了但不产生编号
# 实用场景:OR 分组但不需要捕获
text = "color: red, colour: blue"
# 想匹配 color/colour 但不需要捕获
result = re.findall(r"(?:colou?r): (\w+)", text)
print(result) # ['red', 'blue'](只有 (\w+) 的内容)
| | | |
|---|
| (...) | | |
| (?P<name>...) | | |
| (?:...) | | |
5. 贪婪/非贪婪深入
# 贪婪:尽可能多匹配(默认)
text = "<div>Hello</div><div>World</div>"
# .* 贪婪:匹配到最后一个 </div>
print(re.findall(r"<div>.*</div>", text))
# ['<div>Hello</div><div>World</div>'](吞掉了中间的标签)
# .*? 非贪婪:匹配到最近的 </div>
print(re.findall(r"<div>.*?</div>", text))
# ['<div>Hello</div>', '<div>World</div>'](分别匹配)
# +? 非贪婪:至少1个,但尽量少
text = "aabab"
print(re.findall(r"a+?", text)) # ['a', 'a', 'a'](每次只匹配1个 a)
print(re.findall(r"a+", text)) # ['aa', 'a'](每次尽量多匹配)
# 实际场景:提取 HTML 标签内容
html = '<p class="title">Python 教程</p><p>Day 27</p>'
# 提取所有 <p> 标签内容
contents = re.findall(r"<p[^>]*>(.*?)</p>", html)
print(contents) # ['Python 教程', 'Day 27']
# 提取标签属性
attrs = re.findall(r'<p\s+class="([^"]*)"', html)
print(attrs) # ['title']
# 实际场景:提取引号内容
text = 'name="张三" age="25" city="北京"'
pairs = re.findall(r'(\w+)="([^"]*)"', text)
print(pairs) # [('name', '张三'), ('age', '25'), ('city', '北京')]
print(dict(pairs)) # {'name': '张三', 'age': '25', 'city': '北京'}
💡 提取 HTML/引号内容的万能模式:
标签内容:<tag[^>]*>(.*?)</tag>
引号内容:attr="([^"]*)"
共同特点:用 [^X]* 排除法 + .*? 非贪婪
6. 编译正则 re.compile
同一个正则要多次使用时,先编译再用——更快、更清晰。
import re
# 不编译:每次都要解析正则字符串
re.findall(r"\d+", "abc123")
re.findall(r"\d+", "def456")
re.findall(r"\d+", "ghi789")
# 编译:只解析一次,多次复用
number_pattern = re.compile(r"\d+")
number_pattern.findall("abc123")
number_pattern.findall("def456")
number_pattern.findall("ghi789")
# 编译后的方法和 re 模块的方法一样
pattern = re.compile(r"(\d{4})-(\d{2})-(\d{2})")
# match
m = pattern.match("2024-01-15 hello")
print(m.groups()) # ('2024', '01', '15')
# search
m = pattern.search("date: 2024-01-15")
print(m.group()) # 2024-01-15
# findall
dates = pattern.findall("2024-01-15 to 2024-02-20")
print(dates) # [('2024', '01', '15'), ('2024', '02', '20')]
# sub
result = pattern.sub(r"\1/\2/\3", "date: 2024-01-15")
print(result) # date: 2024/01/15
# 编译时加标志位
pattern = re.compile(r"hello", re.IGNORECASE)
print(pattern.findall("Hello HELLO hello")) # ['Hello', 'HELLO', 'hello']
# 多个标志位用 | 连接
pattern = re.compile(r"^hello$", re.IGNORECASE | re.MULTILINE)
💡 何时用 compile?
1. 同一个正则要用 3 次以上 → 编译
2. 只用一两次 → 直接 re.xxx()
3. 编译不影响结果,只影响性能和可读性
7. re 标志位(flags)
| | |
|---|
re.IGNORECASE | re.I | |
re.MULTILINE | re.M | ^ |
re.DOTALL | re.S | . |
re.VERBOSE | re.X | |
import re
# re.I — 忽略大小写
print(re.findall(r"python", "Python PYTHON python", re.I))
# ['Python', 'PYTHON', 'python']
# re.M — 多行模式(^ 和 $ 匹配每行的开头和结尾)
text = """第一行
第二行
第三行"""
print(re.findall(r"^第.*行$", text, re.M))
# ['第一行', '第二行', '第三行']
# re.S — 让 . 也匹配换行符
text = "<div>\nHello\n</div>"
print(re.findall(r"<div>(.*)</div>", text)) # [](默认 . 不匹配换行)
print(re.findall(r"<div>(.*)</div>", text, re.S)) # ['\nHello\n']
# re.X — 详细模式(可以加注释)
phone_pattern = re.compile(r"""
^ # 开头
1 # 第一位是1
[3-9] # 第二位是3-9
\d{9} # 后面9位数字
$ # 结尾
""", re.VERBOSE)
print(phone_pattern.match("13800138000")) # 匹配成功
8. 实战:完整验证器
import re
class Validator:
"""数据验证器:用编译正则实现"""
# 编译所有正则(只执行一次)
PHONE = re.compile(r"^1[3-9]\d{9}$")
EMAIL = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
ID_CARD = re.compile(r"^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$")
URL = re.compile(r"^https?://[^\s/$.?#].[^\s]*$", re.I)
IP = re.compile(r"^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$")
DATE = re.compile(r"^(?:\d{4})-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|02-(?:0[1-9]|1\d|2[0-8]))$|^(?:\d{4})-(?:02)-(?:29)$")
USERNAME = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]{3,15}$")
PASSWORD = re.compile(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,20}$")
PLATE = re.compile(r"^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤川青藏琼宁][A-Z][A-HJ-NP-Z0-9]{4,5}[A-HJ-NP-Z0-9挂学警港澳]$")
@classmethod
def phone(cls, value):
"""验证手机号"""
return bool(cls.PHONE.match(str(value)))
@classmethod
def email(cls, value):
"""验证邮箱"""
return bool(cls.EMAIL.match(str(value)))
@classmethod
def id_card(cls, value):
"""验证身份证号"""
return bool(cls.ID_CARD.match(str(value)))
@classmethod
def url(cls, value):
"""验证 URL"""
return bool(cls.URL.match(str(value)))
@classmethod
def ip(cls, value):
"""验证 IP 地址"""
return bool(cls.IP.match(str(value)))
@classmethod
def username(cls, value):
"""验证用户名:字母开头,4-16位,字母数字下划线"""
return bool(cls.USERNAME.match(str(value)))
@classmethod
def password(cls, value):
"""验证密码:8-20位,含大小写+数字+特殊字符"""
return bool(cls.PASSWORD.match(str(value)))
@classmethod
def date(cls, value):
"""验证日期(含闰年)"""
return bool(cls.DATE.match(str(value)))
@classmethod
def validate_all(cls, data):
"""批量验证"""
validators = {
"phone": cls.phone,
"email": cls.email,
"id_card": cls.id_card,
"username": cls.username,
"password": cls.password,
}
results = {}
for field, value in data.items():
if field in validators:
results[field] = {
"value": value,
"valid": validators[field](value),
}
return results
# 测试
tests = [
("手机号", "13800138000", Validator.phone),
("手机号", "12345678901", Validator.phone),
("邮箱", "test@example.com", Validator.email),
("邮箱", "invalid@", Validator.email),
("身份证", "110101199003076531", Validator.id_card),
("URL", "https://www.example.com/path?q=1", Validator.url),
("IP", "192.168.1.100", Validator.ip),
("IP", "256.1.1.1", Validator.ip),
("用户名", "alice_123", Validator.username),
("用户名", "1abc", Validator.username),
("密码", "MyP@ss123", Validator.password),
("密码", "weak", Validator.password),
("日期", "2024-02-29", Validator.date),
("日期", "2023-02-29", Validator.date),
("车牌", "京A12345", Validator.plate if hasattr(Validator, 'plate') else lambda x: True),
]
print("🔍 验证测试结果:")
for label, value, func in tests:
status = "✅" if func(value) else "❌"
print(f" {status} {label:6s}: {value}")
# 批量验证
print("\n📋 批量验证:")
data = {
"username": "alice_123",
"password": "MyP@ss123",
"email": "alice@test.com",
"phone": "13800138000",
}
results = Validator.validate_all(data)
for field, info in results.items():
status = "✅" if info["valid"] else "❌"
print(f" {status} {field}: {info['value']}")
9. 今日小结
| |
|---|
| group(1) |
| (?P<name>...),group("name"),groupdict() |
| (?:...) |
| |
| .*贪婪 vs .*?非贪婪,提取 HTML 用非贪婪 |
| re.compile() |
| re.I忽略大小写 re.M多行 re.S匹配换行 re.X注释 |
🧠 记忆口诀:
圆括号分组,group 拿内容。
尖括号命名,groupdict 变字典。
问号冒号非捕获,分组不占编号位。
星号加号默认贪,问号一加变懒人。
compile 编译快,I M S X 标志位。
🔮 预告: Day 28 JSON 处理 — json.loads/dumps、嵌套结构、与文件交互、API 数据解析。数据交换的标准格式!