作者 | 元宵大师 责编 | 胡巍巍 随着电子信息技术的蓬勃发展,网络数据呈现着爆炸式的增长,全球知名咨询公司麦肯锡称:“数据,已经渗透到当今每一个行业和业务职能领域,成为重要的生产因素。人们对于海量数据的挖掘和运用,预示着新一波生产率增长和消费者盈余浪潮的到来。” 因此,在当前这个“大数据”的时代,用Python 进行数据分析和价值挖掘成为了炙手可热的技术领域。 如何获取目标数据是数据分析和挖掘的基础步骤,快速、便捷地得到可靠、真实的数据是数据分析和挖掘所重点关注的要求。 随着网络信息技术的不断发展,数据的获取渠道也越来越多,近几年比较火的是使用基于Python网络爬虫技术去爬取网上的数据资源。 相信读者们已经从网上铺天盖地的信息中了解到基于Python爬取各类网站的方法,其实爬取到数据并不难,如何选择Python多任务方案,以最优化的方式爬取到数据才是关键。 那么如何选择Python多任务方案呢?我们从根源上掌握了爬虫技术的原理之后自然就能得到答案了。
初识网络爬虫的过程
在了解网络爬虫的概念之前,我们先设想一个场景:假如没有网络爬虫,我们要阅览糗事百科的笑话帖子来调节下心情,那么我们需要怎么做? 很显然是使用浏览器访问糗事百科的网站!那么,浏览器访问和网络爬虫爬取,两者有什么区别吗? 在正式介绍网络爬虫的工作模式之前,我们先介绍下平时使用浏览器访问网站的整个过程,这有助于我们更好地理解网络爬虫的工作模式。 当我们在浏览器地址栏中输入https://www.qiushibaike.com这个网址后,打开网页会看到糗事百科提供的页面信息。下一步我们点击左侧导航栏的“24小时”文字条目,跳转到了新的页面,此时浏览器地址栏中的网址为https://www.qiushibaike.com/hot/,然后我们就可以一个段子接着一个段子的去阅读。 整个过程简单来说就是浏览器从服务器中获取到网站信息,经过渲染后将效果呈现给我们。总体可以将这个过程概括为以下几步:
- 第一步:浏览器向DNS服务器发起DNS请求,DNS服务器解析域名后返回域名对应的网站服务器IP地址
- 第二步:浏览器获取IP地址后向网络服务器发送一个HTTP请求
- 第三步:网络服务器解析浏览器的请求后从数据库获取资源,将生成的HTML文件封装至HTTP 响应包中,返回至浏览器解析
- 第四步:浏览器解析HTTP 响应后,下载HTML文件,继而根据文件内包含的外部引用文件、图片或者多媒体文件等逐步下载,最终将获取到的全部文件渲染成完整的网站页面。
因此,我们看到的网页实质上是由渲染后的HTML页面构成的,当我们使用Firefox浏览器打开网页时,鼠标右击浏览器,点击“查看元素”就可以看到当前网页的HTML代码,如下图所示:
但是,在网络上有着无数的网页,海量的信息,在数据分析中显然不能仅依靠人为点击网页这种方式去查找数据,于是,纵然进化出了网络爬虫。
网络爬虫的工作模式在原理上与浏览器访问网站相似。 我们把互联网比作一张大网,爬虫在这张大网上爬行,它在爬取一个网页时,如果遇到所需的资源就可以抓取下来,如果在这个网中它发现了通往另外一个网的一条通道,也就是指向另一个网页的超链接,那么它就可以爬到另一张网上来获取数据。 这样,整个连在一起的大网对爬虫来说是触手可及,它将所爬取到的HTML文件内容经过分析和过滤,最终提取所需的图片、文字等资源。 从初级例程掌握爬虫
Python提供了实现爬虫的核心工具包——网络请求包,比如urllib、urllib2和urllib3,我们能够借此来获取HTML文件。实际上Python中最早内置的网络请求包是urllib,然后在Python2.x中开始自带urllib2,在Python3.x中将urllib和urllib2整合为了urllib3,而urllib2成为了urllib.request。 接下来介绍下推荐使用的urllib3库,只需要通过短短几行代码就能实现HTTP客户端的角色。 使用urllib3库时,首先需要导入urllib3库,例程如下所示:
import urllib3
由于urllib3是通过连接池进行网络请求访问的,所以在访问之前需要创建一个连接池对象PoolManager,使用PoolManager的request()方法发起网络请求。
关于request()方法的参数,必须提供method和url,其他参数为选填参数,此处method参数指定为GET请求,url地址为糗事百科主页地址https://www.qiushibaike.com。例程如下所示:
http = urllib3.PoolManager()resp_dat = http.request('GET', "https://www.qiushibaike.com")
由于request()方法返回的是一个urllib3.response.HTTPResponse对象,最终由data属性返回获取到的HTML文件内容。例程如下所示:
print(resp_dat.data.decode())
"""html><html><head><meta charset="UTF-8"><title>
糗事百科 - 超搞笑的原创糗事笑话分享社区title><meta name="applicable-device"content="mobile"><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/><meta http-equiv="Cache-Control" content="no-transform" />
……
"""
掌握了Python中获取HTML文件的方法后,我们就可以通过爬虫方式爬取上文所提到的糗事百科的笑话帖子了,我们以此作为一个入门级的网络爬虫例程来了解网络爬虫的基本实现过程。由于糗事百科是无需登录的,所以在网络爬虫中相对来说更简单些。
我们可以把基本实现过程可以概括为以下三步:
- 第一步:分析URL地址规律
我们发现第一页帖子的URL为http://www.qiushibaike.com/hot/page/1,第二页为http://www.qiushibaike.com/hot/page/2,于是我们分析得到的规律是URL最后一个数字代表着第几页,我们可以传入不同的值来获得某一页的贴子内容,例程如下所示:
page = 1url = 'http://www.qiushibaike.com/hot/page/' + str(page)
- 第二步:获取HTML代码
使用上文所介绍的Python网络请求包urllib3获取URL指定页面的HTML文件代码
- 第三步:解析HTML代码
分析获取到的HTML文件代码结构,可以看出每一个段子都是以
…
这类形式包裹着帖子的内容,如下图所示:
展开包裹着的内容可以找到其中对应者的发布人、段子内容,以及点赞的个数等。如下图所示:
使用正则表达式可以匹配查找的关键信息,re.compile()方法可以定义查找的规则,re.findall()方法可以根据定义的规则寻找出所有匹配的内容。例程代码如下所示:
pattern = re.compile('.*?.*?.*?.*?
(.*?).*?.*?
.*?(.*?).*?
(.*?)
.*?>(.*?).*?
',
re.S)
items = re.findall(pattern, content)
进一步用规则去过滤无用的内容。比如有些帖子是带有图片的,如果不想输出图片相关的帖子,则需要对带图片的段子进行过滤。
我们发现带有图片的段子会包含有类似下面的代码,而不带图片的则没有。如下所示:
class="thumb"><a href="/article/112061287?list=hot&s=4794990" target="_blank"><img src="http://pic.qiushibaike.com/system/pictures/11206/112061287/medium/app112061287.jpg" alt="但他们依然乐观">a>div>
因此,对于带有图片的帖子,经过正则表达式筛选得到的item[2]中会含有img字符串,我们只需要判断item[2]中是否含有img字符串即可完成过滤,如下所示:
for item in items:
haveImg = re.search("img", item[2])if not haveImg:
print(u"发布人:%s\n发布内容%s\n点赞数%s\n"%(item[0], item[1], item[3]))
最终,我们可以通过控制台打印出糗事百科笑话帖子的发布人、发布内容、点赞数等内容。如下图所示:
从根上剖析网络爬虫
从以上的例程可知,爬虫的根本是与Web服务器之间的网络交互,无论是请求和响应都是使用HTTP协议进行的,但是HTTP协议只是应用层使用的协议,数据通信的传输又是如何实现的呢?那么再让我们往根上去了解下保证数据可靠通信的传输层协议——TCP协议。TCP协议是面向连接的协议,总体包括建立连接、数据传输和关闭连接这三个过程,在网络编程中为了更方便地使用TCP/IP协议栈, 更多地是使用Socket编程方式,即将TCP/IP协议封装成API接口,以"打开—读/写—关闭"模式实现交互。实际上,建立连接和断开连接包含了更具体的交互步骤。其中建立连接采用“三次握手”方式来完成:
第一次握手:客户端发送连接请求报文至服务器
第二次握手:服务器收到报文后确认连接请求,向客户端返回应答报文
第三次握手:客户端收到响应后检查应答信息,并向服务器给出确认
完成三次握手,客户端与服务器连接成功,即可开始传送数据。同样在关闭连接之前,为确保数据正确传递完毕,需要采用“四次挥手”方式关闭连接:
第一次挥手:客户端发送释放请求报文至服务器,并不在发送数据
第二次挥手:服务器收到释放连接请求后向客户端返回应答报文。此时连接处于半关闭状态,即客户端不再向服务器发送数据,但如果服务器仍有数据要发送给客户端,仍可以发送,客户端只要正确收到数据,仍应向服务器发送确认
第三次挥手:若服务器不再向客户端发送数据,则服务器发送连接释放应答至客户端,关闭反方向连接
第四次挥手:客户端收到响应后向服务器给出确认,释放从服务器至客户端方向的连接
完成四次挥手,客户端与服务器全部连接完全释放。也许有人会有疑惑,为什么建立连接协议是“三次握手”,而关闭连接却是“四次挥手”呢?由于TCP连接是全双工的,当关闭连接时,服务器收到客户端的释放连接请求报文,仅仅表示客户端没有数据发送给服务器,但未必服务器所有的数据都发送给了客户端,可能还需要发送一些数据后再关闭连接,所以多了该步骤形成了“四次挥手”。
当涉及到复杂的计算、繁多的I/O操作时,我们会考虑使用多任务并行方式充分利用CPU多核性能来提高程序的执行效率。在Python中由于GIL机制的原因,多进行和多线程在计算密集型和I/O密集型的任务场景中执行效率会有所不同。经过上文的分析后,我们知道例程中从糗事百科网站爬取笑话段子是属于I/O密集型的任务,那么接下来我们使用for循环一个接一个地爬取糗事百科每一页的笑话段子,以此为例对比单线程和多线程方法在I/O密集型任务的处理性能上有何差别。首先在不使用多任务的情况下,我们使用timeit方法对for循环遍历爬取糗事百科每一页的笑话段子进行执行时间的测试,返回的时间为3.758566872秒。例程如下所示:
def reptile_forin():
http = urllib3.PoolManager()for page in range(50):
url = 'http://www.qiushibaike.com/hot/page/' + str(page+1)
user_agent = 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)'# 初始化headers
headers = {'User-Agent': user_agent}# 构建请求的request
request = http.request('GET', url, headers=headers)
t1 = timeit(' reptile_forin()', 'from __main__ import reptile_forin', number=1) # 11.333958885
我们可以将爬取糗事百科笑话段子任务分配给多个线程来完成,而不只让一个线程去逐一读取。
在Python3中内置了线程池模块ThreadPoolExecutor,我们通过该模块来实现多线程的处理。例程如下所示:
with ThreadPoolExecutor(max_workers=8) as executor:# map_fun 传入的要执行的map函数# itr_argn 可迭代的参数# resultn 返回的结果是一个生成器
result = executor.map(map_fun, itr_arg)
当max_workers为4时,即4个线程,任务耗时为2.865144722秒,当max_workers为8时,任务耗时仅为1.555277392。
接下来使用多进程并发方式爬取糗事百科每一页的笑话段子,Python中内置的多进程模块为multiprocessing,其中Pool类可以提供指定数量的进程供用户调用,我们通过该模块来实现多进程的处理。例程如下所示:
pool = Pool(4) # 创建拥有4个进程数量的进程池# map_fun 传入的要执行的map函数# itr_argn 可迭代的参数
pool.map(map_fun, itr_arg)
pool.close() # 关闭进程池,不再接受新的进程
pool.join() # 主进程阻塞等待子进程的退出
当Pool为4时,即4个进程,任务耗时为3.31792294秒,当Pool为8时,任务耗时仅为1.779258。
由于测试环境千差万别,此处测试结果仅供读者们对于几种方式横向对比参考,测试结果是多线程的效率更高。最后让我们公布下出现该结果所对应的原因吧。对于网络爬虫这类I/O密集型的任务,不同于计算密集型任务那样会在整个时间片内始终消耗CPU的资源,I/O密集型的任务大部分时间都在等待I/O操作的完成,在这期间无需要做任何事情。因此当一个线程向一个URL发出网络请求后,在它等待服务器响应时会释放GIL,此时允许其他并发线程执行,即可以切换为另一个线程向另一个URL发出另一个网络请求,以此提升了运行程序的效率。由于多进程创建和销毁的开销比多线程大,因此多线程执行效率相对多进程更高。