Python爬虫学习笔记(四)—— [正则表达式]

50 阅读11分钟

​ 开源中国提供的在线正则表达式测试工具:在线正则表达式测试 (oschina.net)

​  正则表达式教程:正则表达式30分钟入门教程 (deerchao.cn)

正则表达式

正则表达式是处理字符串的强大工具,它有自己特定的语法结构,可以实现字符串的检索、替换、匹配验证等功能。

下面通过几个实例来看一下正则表达式的用法:

打开开源中国提供的正则表达式测试工具: tool.oschina.net/regex/,输入待匹配的文本,然后选择常用的正则表达式,就可以得出相应的匹配结果了。 这里输入以下文本:

Hello, my phone number is 010-86432100 and email is cqc@cuiqingcai.com, and my website is https://cuiqingcai.com.

这段字符串中包含了一个电话号码和一个电子邮件,接下来就尝试用正则表达式提取出来:

image.png

这里在网页右侧选择了“匹配 Email 地址”,匹配结果中就可以看到页面下方出现了匹配的邮箱地址,选择“匹配网址URL”就可以看到匹配出的网址。

image.png

这里就是用了正则表达式匹配,也就是用一定的规则将特定的文本提取出来。比如,电子邮件开头是一段字符串,然后是一个 @ 符号,最后是某个域名,这是有特定的组成格式的。另外,对于 URL,开头是协议类型,然后是冒号加双斜线,最后是域名加路径。

如果要了解更多的正则表达式匹配规则,可前往上方的正则表达式教程。

match

这里首先介绍第一个常用的匹配方法 — match,向它传入要匹配的字符串以及正则表达式,就可以检测这个正则表达式是否匹配字符串。match 方法会尝试从字符串的起始位置匹配正则表达式,如果匹配,就返回匹配成功的结果;如果不匹配,就返回 None

import re

content = "Hello 123 4567 World_This"
print(len(content))
result = re.match("^Hello\s\d\d\d\s\d{4}\s\w{10}", content)
print(result)
print(result.group())
print(result.span())

运行结果:

25
<re.Match object; span=(0, 25), match='Hello 123 4567 World_This'>
Hello 123 4567 World_This
(0, 25)

在match方法中,第一个参数是传入了正则表达式,第二个参数是传入了要匹配的字符串。

开头的 ^ 是匹配字符串的开头,也就是以 Hello 开头;然后 \s 匹配空白字符,用来匹配目标字符串的空格;\d 匹配数字,3 个 \d 匹配123;然后再写 1 个 \s 匹配空格;后面还有 4567,这里使用 4 个 \d 来匹配,但是这么写比较烦琐,所以后面可以跟 {4} 以代表匹配前面的规则 4 次,也就是匹配 4 个数字;然后后面再紧接 1 个空白字符,最后 \w{10} 匹配 10 个字母及下划线。

将输出结果打印出来,可以看到结果是 SRE_Match 对象,证明匹配成功。该对象包含两个方法;

group 方法可以输出匹配到的内容,结果是Hello 123 4567 WorldThis,这恰好是正则表达式按照规则匹配的内容;

span 方法可以输出匹配的范围,结果是(0,25),这是匹配到的结果字符串在原字符串中的位置范围。

匹配目标

可以使用括号()将想提取的子字符串括起来,实现获取字符串中的部分内容。()实际上标记了一个子表达式的开始和结束位置,被标记的每个子表达式依次对应每个分组,调用 group 方法传入分组的索引即可获取提取结果。

import re

content = "Hello 1234567 World_This"
result = re.match("^Hello\s(\d+)\sWorld", content)
print(result)
print(result.group())
print(result.group(1))
print(result.span())

运行结果:

<re.Match object; span=(0, 19), match='Hello 1234567 World'>
Hello 1234567 World
1234567
(0, 19)

可以看到,我们成功得到了1234567。这里用的是 group(1),它与 group() 有所不同,后者会输出完整的匹配结果,前者会输出第一个被()包围的匹配结果。

假如正则表达式后面还有用()包围的内容,那么可以依次用 group(2)group(3) 等获取。

通用匹配

通用匹配符:   .*

前面写的正则表达式其实比较复杂,用 \s 匹配每个空白字符,用 \d 匹配每个数字,其实完全没必要这么做。可以使用(点星),即(.*)来实现万能匹配。其中.(点)可以匹配任意字符(除换行符),(星)代表匹配前面的字符无限次,所以它们组合在一起就可以匹配任意字符了。

改写上面的例子:

import re

content = "Hello 1234567 World_This is a Regex Demo"
result = re.match("^Hello.*Demo$", content)
print(result)
print(result.group())
print(result.span())

这里我们将中间部分直接省略,全部用 .* 来代替,最后加一个结尾字符串就好了。

运行结果:

<re.Match object; span=(0, 40), match='Hello 1234567 World_This is a Regex Demo'>
Hello 1234567 World_This is a Regex Demo
(0, 40)

可以看到,group 方法输出了匹配的全部字符串,即正则表达式匹配到了目标字符串的全部内容;span 方法输出 (0, 41),这是整个字符串的长度。因此,我们可以使用 .* 简化正则表达式的书写。

贪婪与非贪婪

使用通用匹配 .* 时,有时候匹配到的可能并不是我们想要的结果:

import re

content = "Hello 1234567 World_This is a Regex Demo"
result = re.match("^He.*(\d+).*Demo$", content)
print(result)
print(result.group(1))

这里我们想获取中间的数字,所以中间依然写的是(\d+)。而数字两侧都写成 .* 。

运行结果:

<re.Match object; span=(0, 40), match='Hello 1234567 World_This is a Regex Demo'>
7

正则表达式中 .* 后面是 \d+ ,也就是说会至少一个数字,而且没有指定具体几个数字,因此,.* 会匹配尽可能多的字符,所以这里就把 123456 都匹配了,只给 \d+ 留下一个可满足匹配条件的数字 7 ,因此最后得到的内容就只有数字7。

要解决这个问题只需要使用非贪婪匹配就好了。非贪婪匹配的写法是 .*? , 比通用匹配多了一个 ?

修改代码:

result = re.match("^He.*?(\d+).*Demo$", content)

输出结果:

<re.Match object; span=(0, 40), match='Hello 1234567 World_This is a Regex Demo'>
1234567

贪婪匹配是匹配尽可能多的字符,非贪婪匹配就是匹配尽可能少的字符。

.*? 匹配到 Hello 后面的空白字符时,再往后的字符就是数字了,而 \d+ 恰好可以匹配,于是这里 .*? 就不再进行匹配了,而是交给 \d+ 去匹配。

最后 .*? 匹配了尽可能少的字符,\d+ 的结果就是 1234567。

需要注意的是,如果匹配的结果在字符串结尾,.*? 就有可能匹配不到任何内容,因为它会匹配尽可能少的字符。

import re

content = 'http://weibo.com/comment/kEraCN'
result1 = re.match('http.*?comment/(.*?)', content)
result2 = re.match('http.*?comment/(.*)', content)
print('result1', result1.group(1))
print('result2', result2.group(1))

运行结果:

result1 
result2 kEraCN

可以观察到,.*? 没有匹配到任何结果,而 .* 则尽量匹配多的内容,成功得到了匹配结果。

修饰符

在正则表达式中,可以用一些可选标志修饰符来控制匹配的模式。修饰符被指定为一个可选的标志。

这里在待匹配的文本中加入了换行:

import re

content = """Hello 1234567 World_This 
          is a Regex Demo"""
result = re.match("^He.*?(\d+).*Demo$", content)
print(result)
print(result.group(1))

运行结果:

None
AttributeError Traceback (most recent call last) <ipython-input-18-c7d232b39645> in <module>() 5 ''' 6 result = re.match('^He.*?(\d+).*?Demo$', content) ----> 7 print(result.group(1)) AttributeError: 'NoneType' object has no attribute 'group'

发现运行直接报错,也就是说正则表达式没有匹配到这个字符串,返回结果为None,而我们又调用了 group方法,导致 AttributeError。

因为匹配的内容是除换行符之外的任意字符,当遇到换行符时,.*? 就不能匹配了,所以导致匹配失败。

这里只需加一个修饰符 re.S ,即可修正这个错误:

result = re.match("^He.*?(\d+).*Demo$", content, re.S)

输出结果:

<re.Match object; span=(0, 51), match='Hello 1234567 World_This \n          is a Regex D>
1234567

常用修饰符:

修饰 符描 述
re,I使匹配对大小写不敏感
re.L实现本地化识别(locale-aware)匹配
re.M多行匹配,影响^和$
re.S使匹配内容包括换行符在内的所有字符
re.U根据 Unicode 字符集解析字符。这个标志会影响\w、\W、\b和\B
re.X该标志能够给予你更灵活的格式,以便将正则表达式书写得更易于理解

转义匹配

正则表达式定义了许多匹配模式,如 .*? 匹配除换行符以外的任意字符,但是如果目标字符串里面就包含 .*? 那该怎么办呢?这里就需要用到转义匹配了.

当在目标字符串中遇到用作正则匹配模式的特殊字符时,在此字符前面加反斜线 \ 转义一下即可。

例如 \. 就可以用来匹配 .

import re

content = '(百度)www.baidu.com'
result = re.match('(百度)www.baidu.com', content)
print(result)

输出结果:

<re.Match object; span=(0, 17), match='(百度)www.baidu.com'>

search

match 方法是从字符串的开头开始匹配的,一旦开头不匹配,那么整个匹配就失败了.

import re 

content = 'Extra stings Hello 1234567 World_This is a Regex Demo Extra stings' 
result = re.match('Hello.*?(\d+).*?Demo', content) 
print(result)

这里的字符串以 Extra 开头,但是正则表达式以 Hello 开头,整个正则表达式是字符串的一部分,这样会匹配失败:

None

另外一个方法 search,它在匹配时会扫描整个字符串,然后返回第一个匹配成功的结果。

也就是说,正则表达式可以是字符串的一部分。在匹配时,search 方法会依次以每个字符作为开头扫描字符串,直到找到第一个符合规则的字符串,然后返回匹配内容;如果扫描完还没有找到符合规则的字符串,就返回 None

下面修改方法为 search:

<_sre.SRE_Match object; span=(13, 53), match='Hello 1234567 World_This is a Regex Demo'> 
1234567

成功得到了匹配结果。

接下来尝试在一段 HTML 文本中提取 classactiveli 节点内部的超链接包含的歌手名和歌名:

html = """
<div id="songs-list">
    <h2 class="title">经典老歌</h2> 
    <p class="introduction"> 
    经典老歌列表
    </p> 
    <ul id="list" class="list-group">
        <li data-view="2">一路上有你</li> 
        <li data-view="7">
        <a href=*/2.mp3" singer="任贤齐">沧海一声笑</a>
        </li> 
        <li data-view="4" class="active">
        <a href="/3.mp3" singer="齐秦">往事随风</a> 
        </li>
        <li data-view="6"><a href="/4.mp3" singer="beyond">光晖岁月</a>
        </li>
        <li data-view="5">ca href="/5.mp3" singer="陈慧琳">记事本</a>
        <li data-view="5">
        <a href="/6.mp3" singer="邓丽君">但愿人长久</a>
        </li>
    </ul> 
</div>"""

正则表达式可以以 li 开头,然后寻找一个标志符 active ,中间的部分可以用 .? 来匹配。接下来,要提取 singer 这个属性值,所以还需要写入 singer="(.?)",这里需要提取的部分用小括号括起来,以便用 group 方法提取出来,它的两侧边界是双引号。然后还需要匹配 a 节点的文本,其中它的左边界是 & gt;,右边界是 &lt;/a>。然后目标内容依然用 (.*?) 来匹配。

result = re.search('<li.*?active.*?singer="(.*?)">(.*?)</a>', html, re.S)
if result:
    print(result.group(1), result.group(2))

输出结果:

齐秦 往事随风

把 active标签去掉后:

result = re.search('<li.*?singer="(.*?)">(.*?)</a>', html, re.S)

输出结果:

任贤齐 沧海一声笑

去掉修饰符 re.S 之后:

result = re.search('<li.*?singer="(.*?)">(.*?)</a>', html)

输出结果:

beyond 光晖岁月

可以看到,结果变成了第四个 li 节点的内容。这是因为第二个和第三个 li 节点都包含换行符,去掉 re.S 之后,.*? 已经不能匹配换行符,所以正则表达式不会匹配这两个 li 节点,而第四个 li 节点中不包含换行符,可以成功匹配。

findall

search 方法可以返回匹配正则表达式的第一个内容,但要获取匹配正则表达式的所有内容,那该怎么做呢?这时就要借助 findall 方法了。该方法会搜索整个字符串,然后返回匹配正则表达式的所有内容。

还是上面的 HTML 文本,要获取所有 a 节点的超链接、歌手和歌名,就可以将 search 方法换成 findall 方法。如果有返回结果的话,就是列表类型,所以需要遍历一下来依次获取每组内容。

import re

html = """
<div id="songs-list">
    <h2 class="title">经典老歌</h2> 
    <p class="introduction"> 
    经典老歌列表
    </p> 
    <ul id="list" class="list-group">
        <li data-view="2">一路上有你</li> 
        <li data-view="7">
        <a href=*/2.mp3" singer="任贤齐">沧海一声笑</a>
        </li> 
        <li data-view="4" class="active">
        <a href="/3.mp3" singer="齐秦">往事随风</a> 
        </li>
        <li data-view="6"><a href="/4.mp3" singer="beyond">光晖岁月</a>
        </li>
        <li data-view="5">ca href="/5.mp3" singer="陈慧琳">记事本</a>
        <li data-view="5">
        <a href="/6.mp3" singer="邓丽君">但愿人长久</a>
        </li>
    </ul> 
</div>"""

results = re.findall('<li.*?href="(.*?)".*?singer="(.*?)">(.*?)</a>', html, re.S)
print(results)
print(type(results))
for result in results:
    print(result)
    print(result[0], result[1], result[2])

输出结果:

[('/3.mp3', '齐秦', '往事随风'), ('/4.mp3', 'beyond', '光晖岁月'), ('/5.mp3', '陈慧琳', '记事本'), ('/6.mp3', '邓丽君', '但愿人长久')]
<class 'list'>
('/3.mp3', '齐秦', '往事随风')
/3.mp3 齐秦 往事随风
('/4.mp3', 'beyond', '光晖岁月')
/4.mp3 beyond 光晖岁月
('/5.mp3', '陈慧琳', '记事本')
/5.mp3 陈慧琳 记事本
('/6.mp3', '邓丽君', '但愿人长久')
/6.mp3 邓丽君 但愿人长久

可以看到,返回的列表中的每个元素都是元组类型,我们用索引依次取出每个条目即可。

sub

除了使用正则表达式提取信息,有时候还需要借助它来修改文本。

使用 sub 可以实现对文本的修改。

这里把一串文本中的所有数字都去掉:

import re

content = "54aK54yr5oiR?4ix5Lzg"
content = re.sub('\d+', '', content)
print(content)

输出结果:

aKyroiR?ixLzg

这里往 sub 方法的第一个参数中传入 \d+ 以匹配所有的数字,往第二个参数中传入把数字替换成的字符串(如果去掉该参数,可以赋值为空),第三个参数是原字符串。

下面获取所有li节点的歌名:

import re

html = """
<div id="songs-list">
    <h2 class="title">经典老歌</h2> 
    <p class="introduction"> 
    经典老歌列表
    </p> 
    <ul id="list" class="list-group">
        <li data-view="2">一路上有你</li> 
        <li data-view="7">
        <a href=*/2.mp3" singer="任贤齐">沧海一声笑</a>
        </li> 
        <li data-view="4" class="active">
        <a href="/3.mp3" singer="齐秦">往事随风</a> 
        </li>
        <li data-view="6"><a href="/4.mp3" singer="beyond">光晖岁月</a>
        </li>
        <li data-view="5"><a href="/5.mp3" singer="陈慧琳">记事本</a>
        </li>
        <li data-view="5"><a href="/6.mp3" singer="邓丽君">但愿人长久</a>
        </li>
    </ul> 
</div>
"""

html = re.sub('<a.*?>|</a>', '', html)
results = re.findall('<li.*?>(.*?)</li>', html, re.S)
for result in results:
    print(result.strip())

输出结果:

一路上有你
沧海一声笑
往事随风
光晖岁月
记事本
但愿人长久

这里先用 sub 方法将 a 节点去掉,只留下文本,然后再利用 findall 提取。

compile

compile 方法,这个方法可以将正则字符串编译成正则表达式对象,以便在后面的匹配中复用。

这里分别将这3个日期中的时间去掉:

可以借助 compile 方法将正则表达式编译成一个正则表达式对象,以便复用。

import re

content1 = '2019-12-15 12:00'
content2 = '2019-12-17 12:55'
content3 = '2019-12-22 13:21'
pattern = re.compile('\d{2}:\d{2}')
result1 = re.sub(pattern, '', content1)
result2 = re.sub(pattern, '', content2)
result3 = re.sub(pattern, '', content3)
print(result1, result2, result3)

运行结果:

2019-12-15  2019-12-17  2019-12-22 

另外,compile 还可以传入修饰符,例如 re.S 等修饰符,这样在 searchfindall 等方法中就不需要额外传了。所以,可以说 compile 方法是给正则表达式做了一层封装,以便我们更好地复用。