15、Python3 正则表达式应用

作者: Brinnatt 分类: python 术 发布时间: 2023-03-30 10:20

15.1、概述

正则表达式,Regular Expression,缩写为 regex、regexp、RE 等。

正则表达式是文本处理极为重要的技术,用它可以对字符串按照某种规则进行检索、替换。

1970 年代,Unix 之父 Ken Thompson 将正则表达式引入到 Unix 文本编辑器 ed 和 grep 命令中,由此正则表达式普及开来。

1980 年后,perl 语言对 Henry Spencer 编写的库,扩展了很多新的特性。1997 年开始,Philip Hazel 开发出了 PCRE(Perl Compatible Regular Expressions),它被 PHP 和 HTTPD 等工具采用。

正则表达式应用极其广泛,shell 中处理文本的命令、各种高级编程语言都支持正则表达式。

参考 https://www.w3cschool.cn/regex_rmjc/

15.2、分类

  1. BRE

    基本正则表达式,grep、sed、vi 等软件支持。vim 有扩展。

  2. ERE
    扩展正则表达式,egrep ( grep -E)、sed -r 等。

  3. PCRE
    几乎所有高级语言都是 PCRE 的方言或者变种。Python 从 1.6 开始使用 SRE 正则表达式引擎,可以认为是 PCRE 的子集,见模块 re。

15.3、基本语法

15.3.1、元字符

代码 说明 举例
. 匹配除换行符外任意一个字符 .
[abc] 字符集合,只能表示一个字符位置。匹配所包含的任意一个字符。 [abc] 匹配 plain 中的 a
[^abc] 字符集合,只能表示一个字符位置。匹配除去集合内字符的任意一个字符 [^abc] 可以匹配 plain 中的p、l、i 或者 n
[a-z] 字符范围,也是个集合,表示一个字符位置。匹配所包含的任意一个字符。 常用[A-Z] [0-9]
[^a-z] 字符范围,也是个集合,表示一个字符位置。匹配除去集合内字符的任意一个字符。
\b 匹配单词的边界 \bb在文本中找到单词为b开头的b字符
\B 不匹配单词的边界 t\B包含t的单词,但是不以t结尾的t字符,例如write;
\Bb不以b开头的含有b的单词,例如able
\d [0-9]匹配1位数字
\D [^0-9]匹配一位非数字
\s 匹配1位空白字符,包括换行符、制表符、空格
[\f\r\n\t\v]
\S 匹配1位非空白字符
\w 匹配[a-zA-Z0-9_],包括中文的字
\W 匹配\w之外的字符

15.3.2、转义

凡是在正则表达式中有特殊意义的符号,如果想使用它的本意,请使用 \ 转义。反斜杠自身,得使用 \\

\r\n 还是转义后代表回车、换行。

15.3.3、重复

代码 说明 举例
* 表示前面的正则表达式会重复0次或多次 e\w*单词中e后面可以有非空白字符
+ 表示前面的正则表达式会重复至少1次 e\w+单词中e后面至少有一个非空白字符
? 表示前面的正则表达式会重复0次或1次 e\w?单词中e后面至多有一个非空白字符
{n} 重复固定的n次 e\w{1}单词中e后面只能有一个非空白字符
{n,} 重复至少n次 e\w{1,}等价e\w+
e\w{0,}等价e\w*
e\w{0,1}等价e\w?
{n,m} 重复n到m次 e\w{1,10}单词中e后面至少1个,至多10个非空白字符

15.3.4、捕获断言

代码 说明 举例
x|y 匹配x或者y wood took foot food
使用 w|food 或者 (w|f)ood
(pattern) 使用小括号指定一个子表达式,也叫分组。
捕获后会自动分配组号从1开始。
可以改变优先级。
\数字 匹配对应的分组 (very) \1匹配very very,但捕获的组group是very
(?:pattern) 如果仅仅为了改变优先级,就不需要捕获分组 (?:w|f)ood
industr(?:y|ies) 等价 industry|industries
(?<name>exp)
(?'name'exp)
分组捕获,但是可以通过 name 访问分组。
Python语法必须是(?P<name>exp)
(?=exp) 零宽度正预测先行断言
断言exp一定在匹配的右边出现,也就是说断言后面一定跟个exp
f(?=oo) f后面一定有oo出现
(?<=exp) 零宽度正回顾后发断言
断言exp一定在匹配的左边出现,也就是说前面一定有个exp前缀
(?<=f)ood、(?<=t)ook
ood前一定有f,ook前一定有t
(?!exp) 零宽度负预测先行断言
断言exp一定不会出现在右侧,也就是说断言后面一定不是exp
\d{3}(?!\d)匹配3位数字,断言3位数字后面一定不能是数字;
foo(?!d) foo后面一定不是d
(?<!exp) 零宽度负回顾后发断言
断言exp一定不能出现在左侧,也就是说断言前面一定不能是exp
(?<!f)ood ood的左边一定不是f
(?#comment) 注释 f(?=oo)(?#这个后断言不捕获)

断言会不会捕获呢?也就是断言占不占分组号呢?

断言不占分组号。断言如同条件,只是要求匹配必须满足断言的条件。

分组和捕获是同一个意思。使用正则表达式时,能用简单表达式,就不要用复杂的表达式。

15.3.5、贪婪与非贪婪

默认是贪婪模式,也就是说尽量多匹配更长的字符串。

非贪婪很简单,在重复的符号后面加上一个 ? 问号,就尽量的少匹配了。

代码 说明 举例
*? 匹配任意次,但尽可能少重复
+? 匹配至少1次,但尽可能少重复
?? 匹配0次或1次,但尽可能少重复
{n,}? 匹配至少n次,但尽可能少重复
{n,m}? 匹配至少n次,至多m次,但尽可能少重复

very very happy 使用 v.*yv.*?y

15.3.6、引擎选项

代码 说明 Python
IgnoreCase 匹配时忽略大小写 re.I
re.IGNORECASE
Singleline 单行模式
. 可以匹配所有字符,包括 \n
re.S
re.DOTALL
Multiline 多行模式
^ 行首、$ 行尾
re.M
re.MULTILINE
IgnorePatternWhitespace 忽略表达式中的空白字符,如果要使用空白字符用转义,#可以用来做注释 re.X
re.VERBOSE

单行模式:
. 可以匹配所有字符,包括换行符。

^ 表示整个字符串的开头,$ 表示整个字符串的结尾。

多行模式:
. 可以匹配除了换行符之外的字符。

^ 表示行首,$ 表示行尾。

^ 表示整个字符串的开始,$ 表示整个字符串的结尾。开始指的是 \n 后紧接着下一个字符,结束指的是 \n 前的字符,特别注意 \r 是看不见的,会影响 $ 以什么字符结尾。

可以认为,单行模式就如同看穿了换行符,所有文本就是一个长长的只有一行的字符串,所有 ^ 就是这一行字串的行首,$ 就是这一行的行尾。

多行模式,无法穿透换行符,^$ 还是行首行尾的意思,只不过限于每一行。

注意:注意字符串中看不见的换行符,\r\n 会影响 e$ 的测试,e$ 只能匹配 e\n

举例:
very very happy
harry key

上面2行happy之后,有可能是\r\n结尾。
y$ 单行匹配key的y,多行匹配happy和key的y。

15.3.7、案例

  1. 匹配一个 0~999 之间的任意数字

    1
    12
    995
    9999
    102
    02
    003
    4d
    \d                       1位数
    [1-9]?\d             1-2位数
    ^([1-9]\d\d?|\d)     1-3位数
    ^([1-9]\d\d?|\d)$        1-3位数的行
    ^([1-9]\d\d?|\d)\r?$
    ^([1-9]\d\d?|\d)(?!\d)   数字开头1-3位数且之后不能是数字
  2. IP地址

    匹配合法的IP地址

    192.168.1.150
    0.0.0.0
    255.255.255.255
    17.16.52.100
    172.16.0.100
    400.400.999.888
    001.022.003.000
    257.257.255.256

    ((\d{1,3}).){3}(\d{1,3})
    (?:(\d{1,3}).){3}(\d{1,3}) # 400.400.999.888
    对于 ip 地址验证的问题:

    • 可以把数据提出来后,交给 IP 地址解析库处理,如果解析异常,就说明有问题,正则的验证只是一个初步的筛选,把明显错误过滤掉。
    • 可以使用复杂的正则表达式验证地址正确性。
    • 前导 0 是可以的。
    import socket
    
    nw = socket.inet_aton('192.168.05.001')
    print(nw, socket.inet_ntoa(nw))

    分析:
    每一段上可以写的数字有 1、01、001、000、23、023、230、100,也就说 1 位就是 0-9,2 位每一位也是 0-9,3 位第一位只能 0-2,其余 2 位都可以 0-9

    (?:([0-2]\d{2}|\d{1,2})\.){3}([0-2]\d{2}|\d{1,2}) # 解决超出 200 的问题,但是 256 呢?

    200 是特殊的,要再单独分情况处理
    25[0-5]|2[0-4]\d|[01]?\d\d? 这就是每一段的逻辑

    (?:(25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)

  3. 选出含有 ftp 的链接,且文件类型是 gz 或者 xz 的文件名。

    ftp://ftp.astron.com/pub/file/file-5.14.tar.gz
    ftp://ftp.gmplib.org/pub/gmp-5.1.2/gmp-5.1.2.tar.xz
    ftp://ftp.vim.org/pub/vim/unix/vim-7.3.tar.bz2
    http://anduin,linuxfromscratch,org/sources/LFS/lfs-packages/conglomeration//iana-etc/iana-etc-2.30.tar.bz2
    http://anduin.linuxfromscratch.org/sources/other/udev-lfs-205-1.tar.bz2
    http://download.savannah.gnu.org/releases/libpipeline/libpipeline-1.2.4.tar.gz
    http://download.savannah.gnu.org/releases/man-db/man-db-2.6.5.tar.xz
    http://download.savannah.gnu.org/releases/sysvinit/sysvinit-2.88dsf.tar.bz2
    http://ftp.altlinux.org/pub/people/legion/kbd/kbd-1.15.5.tar.gz
    http://mirror.hust.edu.cn/gnu/autoconf/autoconf-2.69.tar.xz
    http://mirror.hust.edu.cn/gnu/automake/automake-1.14.tar.xz
    .*ftp.*\.(?:gz|xz)
    ftp.*/(.*(?:gz|xz))
    .*ftp.*/([^/]*\.(?:gz|xz))   # 捕获文件名分组
    (?<=.*ftp.*/)[^/]*\.(?:gz|xz) # 断言文件名前一定还有 ftp

15.4、Python 正则表达式

Python 使用 re 模块提供了正则表达式处理的能力。

15.4.1、常量

常量 说明
re.M
re.MULTILINE
多行模式
re.S
re.DOTALL
单行模式
re.I
re.IGNORECASE
忽略大小写
re.X
re.VERBOSE
忽略表达式中的空白字符

使用 |位或 运算开启多种选项。

15.4.2、方法

15.4.2.1、编译

re.compile(pattern, flags=0)

设定 flags,编译模式,返回正则表达式对象 regex。

pattern 就是正则表达式字符串,flags 是选项。正则表达式需要被编译,为了提高效率,这些编译后的结果被保存,下次使用同样的 pattern 的时候,就不需要再次编译。

re 的其它方法为了提高效率都调用了编译方法,就是为了提速。

15.4.2.2、单次匹配

re.match(pattern, string, flags=0)

regex.match(string[, pos[, endpos]])

match 匹配从字符串的开头匹配,regex 对象 match 方法可以重设定开始位置和结束位置。返回 match 对象。

re.search(pattern, string, flags=0)

regex.search(string[, pos[, endpos]])

从头搜索直到第一个匹配,regex 对象 search 方法可以重设定开始位置和结束位置,返回 match 对象。

re.fullmatch(pattern, string, flags=0)

regex.fullmatch(string[, pos[, endpos]])

整个字符串和正则表达式匹配。

import re

s = """bottle\nbag\nbig\napple"""
for i, c in enumerate(s, 1):
    print((i - 1, c), end='\n' if i % 8 == 0 else ' ')

print()

# match 方法
print('--> match <--')
result = re.match('b', s)  # 找到一个就不找了
print(1, '-->', result)
result = re.match('a', s)  # 没找到,返回None
print(2, '-->', result)
result = re.match('^a', s, re.M)  # 依然从头开始找,多行模式没有用
print(3, '-->', result)
result = re.match('^a', s, re.S)  # 依然从头开始找
print(4, '-->', result)

# 先编译,然后使用正则表达式对象
regex = re.compile('a')
result = regex.match('s')  # 依然从头开始找
print(5, '-->', result)
result = regex.match(s, 15)  # 把索引15作为开始找
print(6, '-->', result)
print()

# search 方法
print('--> search <--')
result = re.search('a', s)  # 扫描找到匹配的第一个位置
print(7, '-->', result)
regex = re.compile('b')
result = regex.search(s, 1)
print(8, '-->', result)
regex = re.compile('^b', re.M)
result = regex.search(s)  # 不管是不是多行,找到就返回
print(8.5, result)
result = regex.search(s, 8)
print(9, '-->', result)

# fullmatch 方法
result = re.fullmatch('bag', s)
print(10, '-->', result)
regex = re.compile('bag')
result = regex.fullmatch(s)
print(11, '-->', result)
result = regex.fullmatch(s, 7)
print(12, '-->', result)
result = regex.fullmatch(s, 7, 10)
print(13, '-->', result)  # 要完全匹配,多了少了都不行,[7,10)

15.4.2.3、全文搜索

re.findall(pattern, string, flags=0)

regex.findall(string[, pos[, endpos]])

对整个字符串,从左至右匹配,返回所有匹配项的列表。

re.finditer(pattern, string, flags=0)

regex.finditer(string[, pos[, endpos]])

对整个字符串,从左至右匹配,返回所有匹配项,返回迭代器。

注意每次迭代返回的是 match 对象。

import re

s = """bottle\nbag\nbig\napple"""
for i, c in enumerate(s, 1):
    print((i - 1, c), end='\n' if i % 8 == 0 else ' ')

print()

# findall 方法
result = re.findall('b', s)
print(1, '-->', result)
regex = re.compile('^b')
result = regex.findall(s)
print(2, '-->', result)
regex = re.compile('^b', re.M)
result = regex.findall(s, 7)
print(3, '-->', result)
regex = re.compile('^b', re.S)
result = regex.findall(s)
print(4, '-->', result)
regex = re.compile('^b', re.M)
result = regex.findall(s, 7, 10)
print(5, '-->', result)

# finditer 方法
result = regex.finditer(s)
print(type(result))
print(next(result))
print(next(result))
print(next(result))

15.4.2.4、匹配替换

re.sub(pattern, repl, string, count=0, flags=0)

regex.sub(repl, string, count=0)

使用 pattern 对字符串 string 进行匹配,对匹配项使用 repl 替换。

repl 可以是 string、bytes、function。

re.subn(pattern, repl, string, count=0, flags=0)

regex.subn(repl, string, count=0)

跟 sub 类似,返回一个元组( new_string, number_of_subs_made )

import re

s = """bottle\nbag\nbig\napple"""
for i, c in enumerate(s, 1):
    print((i - 1, c), end='\n' if i % 8 == 0 else ' ')

print()

# 替换方法
regex = re.compile('b\wg')
result = regex.sub('brinnatt', s)
print(1, '-->', result)     # 被替换后的字符串
result = regex.sub('brinnatt', s, 1)    # 替换1次
print(2, '-->', result)     # 被替换后的字符串

regex = re.compile('\s+')
result = regex.subn('\t', s)
print(3, '-->', result)     # 被替换后的字符串及替换次数的元组

15.4.2.5、分割字符串

字符串的分割函数,太难用,不能指定多个字符进行分割。

re.split(pattern, string, maxsplit=0, flags=0)

re.split 分割字符串。

import re

s = '''01 bottle
02 bag
03      big1
100         able'''

for i, c in enumerate(s, 1):
    print((i - 1, c), end='\n' if i % 8 == 0 else ' ')
else:
    print()
    print('-' * 80)

# 把每行单词提取出来
print(s.split())  # 做不到

result = re.split(r'[\s\d]+', s)
print(1, '-->', result)
regex = re.compile(r'^[\s\d]+')  # 字符串首
result = regex.split(s)
print(2, '-->', result)
regex = re.compile(r'^[\s\d]+', re.M)  # 行首
result = regex.split(s)
print(3, '-->', result)
regex = re.compile(r'\s+\d+\s+')
result = regex.split(' ' + s)
print(4, '-->', result)

15.4.2.6、分组

使用小括号的 pattern 捕获的数据被放到了组 group 中。

match、search 函数可以返回 match 对象;findall 返回字符串列表;finditer 返回一个个 match 对象。

如果 pattern 中使用了分组,如果有匹配的结果,会在 match 对象中:

  1. 使用 group(N) 方式返回对应分组,1-N 是对应的分组,0 返回整个匹配的字符串。

  2. 如果使用了命名分组,可以使用 group('name') 的方式取分组。

  3. 也可以使用 groups() 返回所有组。

  4. 使用 groupdict() 返回所有命名的分组。

import re

s = '''bottle\nbag\nbig\napple'''
for i, c in enumerate(s, 1):
    print((i - 1, c), end='\n' if i % 8 == 0 else ' ')
else:
    print()
    print('-' * 80)

# 分组
regex = re.compile(r'(b\w+)')
result = regex.match(s)
print(type(result))
print(1, '-->', 'match', result.groups())

result = regex.search(s, 1)
print(2, '-->', 'search', result.groups())

# 命名分组
regex = re.compile(r'(b\w+)\n(?P<name2>b\w+)\n(?P<name3>b\w+)')
result = regex.match(s)
print(3, '-->', 'match', result)
print(4, '-->', result.group(3), result.group(2), result.group(1))
print(5, '-->', result.group(0).encode())  # 0 返回整个匹配字符串
print(6, '-->', result.group('name2'), result.group('name3'))
print(7, '-->', result.groups())
print(8, '-->', result.groupdict())

result = regex.findall(s)
for x in result:  # 字符串列表
    print(type(x), x)

regex = re.compile(r'(?P<head>b\w+)')
result = regex.finditer(s)
for x in result:
    print(type(x), x, x.groups(), x.groupdict(), x.group('head'))

15.4.3、练习

  1. 匹配邮箱地址

    test@hot-mail.com
    v-ip@brinnatt.com
    web.manager@brinnatt.com.cn
    super.user@google.com
    a@w-a-com
    # 邮箱
    \w+[-.\w]*@[\w-]+(\.[\w-]+)+
  2. 匹配html标记内的内容

    稳重一点
    # html提取
    <[^<>]+>(.*)<[^<>]+>
    
    # 如果要匹配标记a
    <(\w+)\s+[^<>]+>(.*)()
  3. 匹配URL

    http://www.brinnatt.com/index.html
    https://login.brinnatt.com
    file:///etc/sysconfig/network
    # URL提取
    (\w+)://([^\s]+)
  4. 匹配二代中国身份证ID

    321105700101003
    321105197001010030
    11210020170101054X
    17位数字+1位校验码组成
    前6位地址码,8位出生年月,3位数字,1位校验位(0-9或X)
    # 身份证验证需要使用公式计算,最严格的应该实名验证。
    \d{17}[0-9xX]|\d{15}
  5. 判断密码强弱

    要求密码必须由 10-15 位,指定字符组成:
    十进制数字
    大写字母
    小写字母
    下划线
    要求四种类型的字符都要出现才算合法的强密码
    
    例如:Asdf77_72qdi
    1. ^\w{10,15}$
    2. 如果测试有不可见字符干扰使用 ^\w{10,15}\r?$
    3. 看上去思路不错,但是\w包括中文。
    4. ^[a-zA-Z0-9_]{10,15}$
    5. 但是还是没有解决简单的密码,类似于 11111111112,如何解决?
    需要用到一些非正则表达式的手段,利用判断来解决,思路如下:
    1、可以判断当前密码字符串中是否有\W,如果出现就说明一定不是合法的,如果不出现说明合法。
    2、对合法继续判断,如果出现过_下划线,说明有可能是强密码,但是没有下划线说明一定不是强密码。
    3、对包含下划线的合法密码字符串继续判断,如果出现过\d的,说明有可能是强密码,没有出现\d的一定不是强密码。
    4、对上一次的包含下划线、数字的合法的密码字符串继续判断,如果出现了[A-Z]说明有可能是强密码,没有出现[A-Z]说明一定不是强密码。
    5、对上一次包含下划线、数字、大写字母的合法密码字符串继续判断,如果出现了[a-z]说明就是强密码,找到了,没有出现小写字母就一定不是强密码。
    请注意上面判断的顺序,应该是概率上最不可能出现在密码字符串的先判断。
  6. 对 sample 文件进行单词统计,要求使用正则表达式

    from collections import defaultdict
    import re
    
    regex = re.compile(r'[^\w-]+')
    
    def mkkey(line: str):
       for word in regex.split(line):
           if len(word):
               yield word
    
    def wordcount(filename, encoding='utf8', ignore=set()):
       d = defaultdict(lambda: 0)
       with open(filename, encoding=encoding) as f:
           for line in f:
               for word in map(str.lower, mkkey(line)):
                   if word not in ignore:
                       d[word] += 1
       return d
    
    def top(d: dict, n=10):
       for i, (k, v) in enumerate(sorted(d.items(), key=lambda item: item[1], reverse=True)):
           if i > n:
               break
           print(k, v)
    
    top(wordcount('sample.txt', ignore={'the', 'a'}))
标签云