GitOPEN's Home.

《手把手带你学爬虫──初级篇》第4课 正则表达式以及re库的用法

Word count: 4,960 / Reading time: 21 min
2018/09/14 Share

本教程所有源码下载链接:https://share.weiyun.com/5xmFeUO 密码:fzwh6g

正则表达式以及re库的用法

简介

正则表达式,又称规则表达式,英文Regular Expression,常简写为regex、regexp或者RE;它通常被用来快速检索、替换那些符合某个正则表达式的文本。

正则表达式的优势,决定了我们需要学习它:

  • 具有很强的灵活性和逻辑性,同时功能性也非常强;
  • 可以用及其简单的正则表达式找寻复杂多变的字符串;

但是对于新手而言,掌握它的使用方法又是比较困难。

re库是一个Python内置的用于进行一系列正则表达式操作的库。使用它,我们可以方便的使用正则表达式对字符串进行操作。它可以将一个正则表达式字符串编译为一个正则表达式特征,从而表达具有相同特征的字符串。

例如:我们有这样一组字符串:HIHIIHIIIHIIII、……、HIIIIIII...,那么,就可以用正则表达式HI+来表达这一组无穷字符串。

正则表达式的用法

正则表达式的常用操作符

这些操作符是组成正则表达式的基本单元,因此,我们需要熟悉它们:

操作符 含义 例子
. 表示任意的单个字符
[] 对单个字符设定取值范围,字符集 [a-z]表示a到z单个字符
[^] 对单个字符设定排除范围,非字符集 [^xyz]表示非x非y非z的单个字符
* 前一个字符,出现0次或者无限次扩展 xyz*表示xy,xyz,xyzz,xyzzz,…
+ 前一个字符,出现1次或无限次扩展 xyz+表示xyz,xyzz,xyzzz,…
? 前一个字符,出现0次或者1次扩展 xyz?表示xy,xyz
` ` 左右表达式任意一个字符串 `abc xyz`表示abc,xyz
{m} 重复前一个字符m次 xy{2}z表示xyyz
{m,n} 重复前一个字符m到n次,前后包含 xy{2,3}z表示xyyz,xyyyz
^ 匹配开头,匹配字符串的开头 ^xyz表示xyz在一个字符串的开头
$ 匹配结尾,匹配字符串的结尾 xyz$表示xyz在一个字符串的结尾
() 分组标记,里面只能使用\ 操作符 `(abc xyz)`表示abc,xyz
\d 匹配任意一个0-9的数字 相当于[0-9]
\w 非特殊字符并且非标点符号 相当于[a-zA-Z0-9]

^这个符号叫做异或符。

看几个小例子:

正则表达式 表示的字符串
f(r\ ri\ rie\ rien)?d fd,frd,frid,fried,friend
boy+ boy,boyy,boyyy,boyyy,… …
frien{:4}d fried,friend,friennd,friennnd,friennnnd

常用正则表达式的实例

这里总结了一些常用的正则表达式,既能够达到练手的目的,也能够方便日后直接使用。需要注意的是,这些常用正则表达式不一定精确,有时只在特定的业务背景下,能够得到自己想要的结果。

正则表达式 含义
^[A-Za-z]+$ 26个字母组成的字符串
^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$ 匹配Email地址
[1-9]\d{5} 中国邮政编码
` ^([1-9]\d{5}[12]\d{3}(0[1-9] 1[012])(0[1-9] [12][0-9] 3[01])\d{3}[0-9xX])$` 匹配身份证
\d+.\d+.\d+.\d+ 匹配ip地址,提取时有用
^[1-9]\d*$ 匹配正整数
` ^[1-9]\d* 0$` 匹配非负整数(正整数 + 0)
^[A-Z]+$ 匹配由26个英文字母的大写组成的字符串
^[a-z]+$ 匹配由26个英文字母的小写组成的字符串
^[A-Za-z0-9]+$ 匹配由数字和26个英文字母组成的字符串
^w+$ 匹配由数字、26个英文字母或者下划线组成的字符串
[\u4e00-\u9fa5] 匹配中文字符

re库的使用方法

re库是Python的标准库,主要用于字符串的匹配。

使用时,导入re即可:

1
import re

正则表达式的表示类型

  1. raw string类型,也叫原生字符串类型,指不包含转义字符的字符串。即,原生字符串中的转义字符\当做普通字符,不具有转义功能。

    re库采用raw string类型表示正则表达式,格式为:

    r'[1-9]\d{5}'或者r"[1-9]\d{5}"

  2. string类型,字符串类型,将\理解为转义字符,因此写起来比较繁琐。

    例如,上面的邮政编码正则表达式,就必须写为:'[1-9]\\d{5}',第1个斜杠为转义字符标识,将第2个字符转义为普通的斜杠,从而表示\d

因此,我们推荐,当正则表达式中包含转义字符\的时候,使用raw string类型表示。

re库的常用函数

函数 含义
re.findall() 返回列表类型,返回匹配正则表达式的全部子字符串
re.match() 返回match对象,从字符串的开始位置起,匹配正则表达式
re.search() 返回match对象,在字符串中搜索和正则表达式相匹配的第一个位置
re.sub() 在字符串中替换掉所有匹配正则表达式的子字符串,返回替换后的字符串
re.finditer() 在字符串中搜索匹配正则表达式的子字符串,返回迭代类型,其中元素是match对象
re.split() 将字符串按照正则表达式进行匹配,将字符串匹配正则表达式的部分割开并返回一个列表

下面,我们对这些函数进行详细解释以及在ipython中测试使用:

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

    • pattern:正则表达式的字符串或原生字符串表示;

    • string:待匹配字符串;

    • flags:正则表达式使用时的控制标记。

      | 常用标记 | 说明 |
      | ——————- | ———————————————————— |
      | re.I(re.IGNORECASE) | 忽略正则表达式的大小写,例如,[A-Z]可以匹配小写字符 |
      | re.M(re.MULTILINE) | ^操作符能够将给定字符串的每行当做匹配开始,例如,字符串是一篇文章由多个段落组成,那么可以匹配每一行,并且从每一行的开始匹配 |
      | re.S(re.DOTALL) | .操作符能够匹配所有字符,默认匹配除换行外的所有字符,如果设置了,将可以匹配所有字符包括换行 |

    例子,匹配字符串中的邮政编码,示例字符串为北京海淀 100036

    1
    2
    3
    4
    5
    6
    In [10]: match = re.search(r'[1-9]\d{5}', '北京海淀 100036')

    In [11]: if match:
    ...: print(match.group(0))
    ...:
    100036
  2. re.match(pattern,string,flags=0)

    • 参数和re.search()的一致

    例子,匹配字符串中的邮政编码,示例字符串为北京海淀 100036

    1
    2
    3
    4
    5
    6
    7
    8
    9
    In [12]: match = re.match(r'[1-9]\d{5}', '北京海淀 100036')

    In [13]: match.group(0)
    ---------------------------------------------------------------------------
    AttributeError Traceback (most recent call last)
    <ipython-input-13-4d972d6c40f1> in <module>()
    ----> 1 match.group(0)

    AttributeError: 'NoneType' object has no attribute 'group'

    报错了。也就是说这个match是空的。因为,re.match()是从起始位置开始匹配,所以没有匹配到数据。验证了re.match()是从开始位置进行匹配。

    1
    2
    3
    In [14]: match = re.match(r'[1-9]\d{5}', '100036 北京海淀')
    In [15]: match.group(0)
    Out[15]: '100036'
  3. re.findall(pattern,string,flags=0)

    • 参数和re.search()的一致

    例子,字符串为北京海淀100036 郑州高新450001

    1
    2
    3
    In [16]: ls = re.findall(r'[1-9]\d{5}', '北京海淀100036 郑州高新450001')
    In [17]: ls
    Out[17]: ['100036', '450001']
  4. re.split(pattern,string,maxsplit=0,flags=0):

    • maxsplit:最大分割数,剩余部分作为最后一个元素输出;
    • 其他三个参数和上面的一致。

    例子1,测试字符串为北京海淀100036 郑州高新450001

    1
    2
    3
    4
    In [18]: ls = re.split(r'[1-9]\d{5}', '北京海淀100036 郑州高新450001')

    In [19]: ls
    Out[19]: ['北京海淀', ' 郑州高新', '']

    例子2,增加参数maxsplit=1

    1
    2
    3
    4
    In [20]: ls = re.split(r'[1-9]\d{5}', '北京海淀100036 郑州高新450001', maxsplit=1)

    In [21]: ls
    Out[21]: ['北京海淀', ' 郑州高新450001']

例子3,是否保留匹配项的用法,给正则表达式加上小括号(),则保留匹配项,反之不保留:

1
2
3
4
In [22]: ls = re.split(r'([1-9]\d{5})', '北京海淀100036 郑州高新450001')

In [23]: ls
Out[23]: ['北京海淀', '100036', ' 郑州高新', '450001', '']
  1. re.findall(pattern,string,flags=0)

    • 参数和re.search()的一致

    例子,测试字符串为北京海淀100036 郑州高新450001

    1
    2
    3
    4
    5
    In [24]: for m in re.finditer(r'[1-9]\d{5}', '北京海淀100036 郑州高新450001'):
    ...: print(m.group(0))
    ...:
    100036
    450001
  2. re.sub(pattern,repl,string,count=0,flag=0)

    • repl:替换匹配字符串的字符串,匹配到谁,就用repl将它替换;
    • count:匹配的最大替换次数;
    • 剩余参数和其他re函数一致。

    例子,字符串为北京海淀100036 郑州高新450001

    1
    2
    3
    4
    5
    6
    In [29]: re.sub(r'[1-9]\d{5}',repl=":邮编",string="北京海淀100036 郑州高新450001")
    Out[29]: '北京海淀:邮编 郑州高新:邮编'

    # 将count=1传入
    In [30]: re.sub(r'[1-9]\d{5}',repl=":邮编",string="北京海淀100036 郑州高新450001",count=1)
    Out[30]: '北京海淀:邮编 郑州高新450001'

面向对象的用法

上面的讲解中,类似re.findall()的用法,叫做函数式用法;我们也可以使用面向对象的思想,来调用这些方法:

1
2
3
4
In [31]: regex = re.compile(r'[1-9]\d{5}')

In [32]: regex.sub(repl=":邮编",string="北京海淀100036 郑州高新450001")
Out[32]: '北京海淀:邮编 郑州高新:邮编'

优点:当我们需要多次使用同一个正则表达式规则时,特别方便,可以重复使用pattern这个对象来调用不同的方法达到目的。可以提高匹配速度。

regex = re.compile(pattern, flags=0)

该函数根据包含的正则表达式的字符串创建模式对象,将正则表达式的字符串形式编译成正则表达式对象。

注意1:没有经过编译的正则表达式字符串仅仅是一种表达形式,只有经过编译的正则表达式字符串才能形成一个正则表达式对象,它表示了一组符合规则的字符串。

注意2:经过编译后得到的正则表达式对象,可以调用的方法和re调用的函数一致,请注意,由于正则表达式已经被编译为模式对象,因此,通过模式对象regex调用相应方法的时候,方法的参数pattern不再需要提供。

re库的match对象

我们在前面的例子中,曾经用到一个对象match,这个对象的类型是SRE_Match

1
2
3
4
5
6
In [14]: match = re.match(r'[1-9]\d{5}', '100036 北京海淀')
In [15]: match.group(0)
Out[15]: '100036'
... ... ... ...
In [33]: type(match)
Out[33]: _sre.SRE_Match

Match对象的常用属性有4个:

属性 含义
.re 匹配时使用的pattern对象(正则表达式)
.string 待匹配的文本
.pos 搜索文本的开始位置
.endpos 搜索文本的结束位置

我们在ipython中使用一下:

1
2
3
4
5
6
7
8
9
10
11
In [34]: match.re
Out[34]: re.compile(r'[1-9]\d{5}', re.UNICODE)

In [35]: match.string
Out[35]: '100036 北京海淀'

In [36]: match.pos
Out[36]: 0

In [37]: match.endpos
Out[37]: 11

Match对象常用的方法:

方法 含义
.group(0) 获得匹配后的字符串
.start() 匹配字符串在原始字符串的开始位置
.end() 匹配字符串在原始字符串的结束位置
.span() 返回(.start(),end())

我们在ipython中使用一下:

1
2
3
4
5
6
7
8
9
10
11
12
# 匹配的结构存储在这里
In [38]: match.group(0)
Out[38]: '100036'

In [39]: match.start()
Out[39]: 0

In [40]: match.end()
Out[40]: 6

In [41]: match.span()
Out[41]: (0, 6)

贪婪匹配和最小匹配

首先看一个例子:

1
2
3
4
In [49]: match = re.search(r'one.*n','oneanbncndnen')

In [50]: match.group(0)
Out[50]: 'oneanbncndnen'

g.*n这个正则表达式可以匹配多个字符串,例如,gitopen、gin、等,但是,结果却返回最长的那个匹配字符串gitaabbccddopen

re库默认采用贪婪匹配,即输出匹配最长的子字符串。

但是有的时候,我们需要输出最短的那个子字符串,这时候,需要使用正则表达式的最小匹配:

1
2
3
4
In [51]: match = re.search(r'one.*?n','oneanbncndnen')

In [52]: match.group(0)
Out[52]: 'onean'

re库的最小匹配,需要对以下几个操作符进行扩展:

操作符 含义
*? 前一个字符0次或者无限次扩展,最小匹配
+? 前一个字符1次或者无限次扩展,最小匹配
?? 前一个字符0次或者1次扩展,最小匹配
{m,n}? 扩展前一个字符m到n次,含n次,最小匹配

如果re库的操作符匹配不同长度的字符串的话,都可以在操作符后面加上?进行最小匹配。

实战——定向爬取京东100页商品信息

问题0:按照销量进行爬取,这样可以排除页面中广告

得到的连接如下:

1
https://search.jd.com/Search?keyword=书包&enc=utf-8&qrst=1&rt=1&stop=1&vt=2&psort=3&stock=1&page=1&s=1&click=0

从浏览器开发者工具中可以得到这些GET请求的参数,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"keyword": "书包",
"enc": utf-8",
"qrst": 1",
"rt": 1",
"stop": 1",
"vt": 2",
"psort": 3",
"stock": 1",
"page": 1",
"s": 1",
"click: 0",
}

这样,我们得到了用requests发送GET说必须的连接和参数,连接为https:// search.jd.com/Search,参数为上面的这个字典。其中,需要我们控制的几个参数为:keywordspage

问题1:请求链接的构造,page竟然是奇数?!

在京东搜索商品以后,我们会来搜索页面,这时观察页面的url不难发现一个规律,拼接页面url的时候的page参数,需要传入的数字为奇数。经过计算发现,总共100页搜索结果,如果我们想获取全部信息,就必须从把page的值赋值为200以内的奇数,即1,3,5,7,9,...,199

如:

1
2
3
4
5
第1页 page=1
https://search.jd.com/Search?keyword=书包&enc=utf-8&qrst=1&rt=1&stop=1&vt=2&psort=3&stock=1&page=1&s=1&click=0

第2页 page=3
https://search.jd.com/Search?keyword=书包&enc=utf-8&qrst=1&rt=1&stop=1&vt=2&psort=3&stock=1&page=3&s=61&click=0

问题2:编写页面请求,得到的页面中只有30个商品信息?!

当我们在编写程序的时候,用requests库请求回来的html文本内容中,属性为class='gl-item'li标签只有30个。什么?刚才不是数过了吗?一共60个呀。为什么是30个呢?

打开浏览器的开发者工具栏,将页面从上往下慢慢拖动,并且观察Network中的网络请求,突然,有一个神秘的请求出现了,它的连接为https://search.jd.com/s_new.php?keyword=%E4%B9%A6%E5%8C%85&enc=utf-8&qrst=1&rt=1&stop=1&vt=2&psort=3&stock=1&page=2&s=31&scrolling=y&log_id=1529934788.49163&tpl=3_M&show_items=4208541,12596174640,2804865,877258,4593360,3598223,5308846,11031192090,4028698,5471930,6511832,765798,3796277,6530586,311349,1015774,10552271564,6044018,5025051,10552555536,28941008721,1106925,10285134151,4120808,10492711739,4044524,25595327933,6401561,5181576,2437187963,这是什么东西?

然后查看这个链接的Response,搜索gl-item,我们发现,竟然也有30个!那么。。。刚才的30加上现在的30,不就是60个商品?仔细观察这个链接,我们发现了一个某东做的小手脚!快看,连接中的page=2!So,~~~~,似乎明白了什么。

原来,我们在问题1中得到的搜索页面一共有100页,实际上有200页,奇数页就是我们直接看到的搜索结果页面,一共请求到30个商品信息,而偶数页,则是当用户拖动滚动条的时候,看完了30个,就会自动后台请求另外30个商品,这后来请求的30个,就是偶数页的信息,并且动态的添加到页面上去。

请求的基本链接为https://search.jd.com/s_new.php,请求的基本参数我们提取为字典,其中需要控制的参数为:keywordpageslog_idshow_items

log_idshow_items必须从奇数页请求结果中提取,show_items是用一个列表转化成的字符串,其中数字是,奇数页中每个带有class='gl-item'属性的div,它的data-pid属性的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
'keyword': '书包',
'enc': 'utf-8',
'qrst': '1',
'rt': '1',
'stop': '1',
'vt': '2',
'psort': '3',
'stock': '1',
'page': '2',
's': '31',
'scrolling': 'y',
'log_id': '1529759067.93124',
'tpl': '3_M',
'show_items': '2241345,4153317,10285134151,10503046182,13180766655,6117013,2330770,5168648,10285341597,26194798282,5471930,2804865,28561551177,1585517611,1025981997,6174172,11693982705,10540540570,5386778,11096315457,20682615258,23533395828,5248730,27306328004,3929123,5218868,12422531859,4208541,5634094,2330752'
}

问题3:讲了这么多我们该如何获取到60个商品信息呢?

循环遍历,然后判断页码的奇偶性,根据奇偶性发送不同连接不同请求参数的请求,得到不同的结果进行内容解析。

整个程序的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
import requests
import re
from bs4 import BeautifulSoup
import time
import json


def get_html(url, params=None):
headers = {
'Referer': 'https://search.jd.com',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:58.0) Gecko/20100101 Firefox/58.0'
}
try:
r = requests.get(url=url, timeout=30, headers=headers, params=params)
r.raise_for_status()
r.encoding = r.apparent_encoding
print("### 正在请求的链接为:{}".format(r.url))
return r.text
except Exception as e:
print(str(e))


def set_even_params(html, even_params):
soup = BeautifulSoup(html, "lxml")
li_list = soup.find_all(class_="gl-item")
show_items = []
for li in li_list:
show_items.append(li.attrs['data-pid'])

log_id = re.compile(r"log_id:'\d+.\d+'").search(html).group(0).replace('log_id:', '')

even_params['show_items'] = ",".join(show_items)
even_params['log_id'] = log_id


def parse_page(html):
soup = BeautifulSoup(html, "lxml")
li_list = soup.find_all(class_='gl-item')

products = []

for gl_item in li_list:
product = {}

product['slogan'] = gl_item.find(class_='p-img').a.attrs['title']
product['url'] = "https:" + gl_item.find(class_='p-img').a.attrs['href']
product['price'] = gl_item.find(class_='p-price').strong.i.string
pin_gou = gl_item.find(class_='price-pingou')
if pin_gou is None:
product['pin_price'] = ""
else:
product['pin_price'] = re.compile(r'\d+.\d+').search(str(pin_gou)).group(0)

product['name'] = gl_item.find(class_='p-name').a.em.get_text()
product['comment'] = gl_item.find(class_='p-commit').strong.a.string

a_shop = gl_item.find(class_='p-shop').find('a')
if a_shop is None:
product['shop_name'] = ""
product['shop_url'] = ""
else:
product['shop_name'] = a_shop.attrs['title']
product['shop_url'] = "https:" + a_shop.attrs['href']
products.append(product)

return products


def main():
# 搜索关键词
goods = '书包'
# 爬取深度,最大100页
depth = 10
# 结果列表
products = []
# 每页商品的起始数字
s = 1

odd_params = {
'keyword': goods,
'enc': 'utf-8',
'wq': goods,
'page': '1',
'psort': '3',
's': '1'
}
odd_url = 'https://search.jd.com/Search'

even_params = {
'keyword': goods,
'enc': 'utf-8',
'qrst': '1',
'rt': '1',
'stop': '1',
'vt': '2',
'psort': '3',
'stock': '1',
'page': '2',
's': '31',
'scrolling': 'y',
'log_id': '1529759067.93124',
'tpl': '3_M',
'show_items': '2241345,4153317,10285134151,10503046182,13180766655,6117013,2330770,5168648,10285341597,26194798282,5471930,2804865,28561551177,1585517611,1025981997,6174172,11693982705,10540540570,5386778,11096315457,20682615258,23533395828,5248730,27306328004,3929123,5218868,12422531859,4208541,5634094,2330752'
}
even_url = 'https://search.jd.com/s_new.php'

for num in range(1, 2 * depth + 1):
# 奇数
if num % 2 != 0:
page_id = num
odd_params['page'] = page_id
odd_params['s'] = str(s)
html = get_html(url=odd_url, params=odd_params)
set_even_params(html, even_params)
products.append(parse_page(html))
# 偶数
else:
page_id = num
even_params['page'] = page_id
even_params['s'] = str(s)
html = get_html(url=even_url, params=even_params)
products.append(parse_page(html))
# 每次奇偶数页面迭代,都是30个商品
s += 30
# 将结果保存到文件中,以json格式
localtime = "-".join(time.asctime(time.localtime(time.time())).split(' '))
with open("jd_" + localtime + '.json', 'w') as filename:
filename.write(json.dumps(products, ensure_ascii=False))
print("### 爬取完毕")


if __name__ == '__main__':
main()

欣慰帮到你 一杯热咖啡
【奋斗的Coder!】企鹅群
【奋斗的Coder】公众号
CATALOG
  1. 1. 正则表达式以及re库的用法
  2. 2. 简介
  3. 3. 正则表达式的用法
    1. 3.1. 正则表达式的常用操作符
    2. 3.2. 常用正则表达式的实例
  4. 4. re库的使用方法
    1. 4.1. 正则表达式的表示类型
    2. 4.2. re库的常用函数
    3. 4.3. 面向对象的用法
    4. 4.4. re库的match对象
    5. 4.5. 贪婪匹配和最小匹配
  5. 5. 实战——定向爬取京东100页商品信息