建立爬虫大军

  • 1.简谈多协程
  • 2.探索多协程用法
  • 3.创立多个爬虫
    • 3.1 queue模块
    • 3.2队列的应用与多协程实现
    • 3.3多协程运行的输出结果与解密
    • 3.4多协程与debug
  • 4.多协程实战应用
    • 4.1分析任务
    • 4.2format方法的应用
    • 4.3 拆解任务
      • i.存放网站
      • ii.爬取内容
      • iii.使用多协程爬取内容,并存入.xlsx文件

目前为止,我们已经接触了许多爬虫爬取网站的方法,但是所有用到爬虫的地方,数据量都还非常小。如果我们想爬取的内容非常多,就会遇到一个问题,成千上万的数据等待爬取,而程序又是一条一条执行的,效率肯定会非常低。面对这样的情况,我们可不可以让代码不是一条一条的顺序执行呢?
听上去有点难以理解,我们换一个形象的方式进行解释,我们知道,想要爬取网页内容,程序首先需要根据我们提供的网址来找到内容,而这个过程是需要一个等待时间的,并且需要的时间不尽相同。如果我们一下子需要爬取许多网页的内容时,难免会遇到有的网站需要等待时间很长的情况。假如在等待的过程中同时爬取后面的内容,是不是就比程序一条一条执行下来节约时间呢?这就好比我们平时做饭,在等待米饭煮熟的过程中炒菜,一定比等饭煮熟以后再炒菜更高效一样。
这件事情在计算机里有独特的概念——同步和异步。所谓同步,即为一个任务结束之后才能进行下一个任务,之前我们接触到的程序都是以同步的方式运行的。异步即为一个任务进行的同时,还有其他的任务也在进行,这些同时进行的任务彼此间没有影响。
爬虫的异步运行方式听起来会节约很多时间,可是要如何代码实现呢?这就需要用到多协程方式了。

1.简谈多协程

想要了解多协程,就需要了解一点计算机的历史知识。每台计算机都靠着 CPU干活。开始时,计算机都是单核的。单核 CPU 的计算机在处理多任务时,会出现一个问题:每个任务都要抢占 CPU,执行完了一个任务才开启下一个任务。但CPU 毕竟只有一个,这会让计算机处理的效率很低。为了解决这样的问题,一种非抢占式的异步技术被创造了出来,这种方式叫多协程。多协程的原理是:如果一个任务执行时需要等待响应(如等待一个网页响应)的时间比较长,就先去执行其他的任务,当等待结束,再回来继续之前的那个任务。在计算机的世界,这种任务来回切换得非常快速,看上去就像多个任务在被同时执行一样。

2.探索多协程用法

想要使用多协程,就需要安装gevent库。
安装完成之后,我们一起看看怎么多协程,以及多协程与我们普通程序的区别。我们以获取8个网站(包括百度、新浪、搜狐、腾讯、网易、爱奇艺、天猫、凤凰)数据为例,首先用之前的代码风格实现:

import requests
import time
url_list=['https://www.baidu/',
            'https://www.sina/',
            'http://www.sohu/',
            'https://www.qq/',
            'https://www.163/',
            'http://www.iqiyi/',
            'https://www.tmall/',
            'http://www.ifeng/']
start=time.time()
# 做个开始时间点,用来查看函数运行时间
def seek(url):# 设置函数获取网站,打印状态码和当前处理的网址
    res=requests.get(url, verify=False)
    print(res.status_code,url)
address_seek=[]
for url in url_list:
    address_seek.append(seek(url))
    # 调用函数并存储每次运行seek函数时所占地址
end=time.time()
print('程序消耗时间为:'+str(end-start)+'s')

下面我们观察一下运行结果(直接忽略警告):

这是我们的基础写法,是以同步的方式运行的。除了运行时间有点长以外也没看出什么。那么我们下面来用一下异步方式编写代码,大家要仔细看注释和运行结果:

# 从 gevent 库里导入 monkey 模块
from gevent import monkey

monkey.patch_all()
# monkey.patch_all()能给程序打上补丁,让程序变成是异步模式,而不是同步模式。
# monkey.patch_all()必须要放在其他任何导入的模块和库之前,否则无法给程序打好补丁。

import gevent, time, requests

# 记录程序开始时间
start = time.time()
url_list = ['https://www.baidu/',
            'https://www.sina/',
            'http://www.sohu/',
            'https://www.qq/',
            'https://www.163/',
            'http://www.iqiyi/',
            'https://www.tmall/',
            'http://www.ifeng/']


# 定义一个seek()函数
def seek(url):  # 设置函数获取网站,打印状态码和当前处理的网址
    r = requests.get(url, verify=False)
    print(r.status_code, url)


tasks_list = []
# 创建空的任务列表
for url in url_list:  # 遍历 url_list
    task = gevent.spawn(seek, url)
    # 用 gevent.spawn() 函数创建任务,其参数为(要调用的函数名,要调用的函数的参数)
    # 和普通的调用函数不同,这种方式可以理解成先把要执行的函数以函数地址的形式予以保存,暂时不会执行。

    tasks_list.append(task)
    # 将函数的地址保存到列表中,等待执行指令

gevent.joinall(tasks_list)
# 以异步方式执行tasks_list列表内保存的待执行函数

end = time.time()
# 打印程序最终所需时间
print('程序消耗时间为:'+str(end-start)+'s')

我们再来看看这个代码的运行结果:

可以看到,执行时间有了比较明显的减少。但除此之外还有一点引起了我的注意,打印出来的网页顺序和之前我们存入列表的并不一样。仔细研究发现,这里的快速响应是因为计算机同时发起了八个请求,哪个响应快,哪个就先被打印出来。
这种方式好像和我们之前介绍的多协程有一点出入,毕竟一下子打开八个网页,谁先响应先打印谁先打开一个网页,等待期间打开第二个网页,以此类推,哪个完成响应就立刻打印的方式毕竟不一样。用第一种方法,假如我们需要爬取1000个网站,就需要一口气发送1000个请求,这样的恶意请求,会拖垮网站的服务器。
可能有的小伙伴会说,那我们把1000个请求拆成5组200个请求呢?

# 将上述代码以下内容作改动
def crawler(url_list-):
    for url in url_list-:
        r = requests.get(url, verify=False)
        print(url, time.time() - start, r.status_code)
    
tasks_list = []
for i in range(5):
    # 用 gevent.spawn() 函数创建 5 个任务
    task = gevent.spawn(seek, url_list[i * 200:(i + 1) * 200])
    # 往任务列表添加任务
    tasks_list.append(task)

emmm…很遗憾,这种方法也行不通,因为这种方法将1000个网址分成五组,者这五组之间是异步的,但每组的内部却是同步的,也就是说,这段代码只是把1000个同步运行所花费的时间缩减到了五组200个同步运行中运行时间最长的那200个所花费的时间,并没有最大程度的节约时间。
这种方式也不行,又该怎么办呢?

3.创立多个爬虫

上面我们实践了多个网站同时获取,但是遇到了新的麻烦。为了解决这个问题,给大家引入一个生活中的场景:我们排队去银行办理业务,假设此时只有一个窗口开放,等候的人必须一个一个办理业务,这就是同步的程序的处理方式。如果开放了无数个窗口,让这些顾客同时办理,办理完成的时间取决于最慢的那个顾客,这就是我们之前使用的方法。这样很高效,但一般不会设置这么多窗口,太浪费资源。
现在,还是这个队列没变,我们假设有两个工作人员办理业务,1号工作人员办理1号顾客业务的同时,2号工作人员为2号顾客办理业务,假如2号顾客的业务已经办完但1号顾客还在办理,那么2号业务员会帮3号顾客办理业务。等1号顾客业务办理完成时,1号业务员会紧接着帮下一位等待中的顾客办理业务。这样是不是效率就会高很多?并且这也就是我们今天讨论的重点——多协程。

通过上文的例子不难看出,如果我们不是把获取网站的函数(所有顾客)添加到列表tasks_list里,而是把多只爬虫(工作人员)放到该列表里然后同时初始化多只爬虫去有序获取网站,是不是就是多协程了呢?
很好的思路,下面上代码:

3.1 queue模块

想要实现上述功能,就不能把待获取网站放到列表里了,我们需要一个先进先出的数据类型——队列。
当我们用多协程来爬虫,需要创建大量任务时,我们可以借助queue模块,把所有的网站排成一个队列。这样,协程就可以从队列里把任务提取出来执行,直到队列空了,任务也就处理完了。就像银行窗口的工作人员会根据排号系统里的排号,处理客人的业务,如果已经没有新的排号,就意味着客户的业务都已办理完毕。
下面我们介绍一下queue的基本用法:

方法作用
put_nowait()向队列里添加数据
get_nowait()从队列里提取数据
empty()判断队列是否为空
full()判断队列是否为满
qsize()获取队列剩余存储量

3.2队列的应用与多协程实现

先给大家奉上完整版的代码,后面进行分析:

from gevent import monkey
monkey.patch_all()
import time,requests,gevent
start=time.time()
url_list= ['https://www.baidu/',
            'https://www.sina/',
            'http://www.sohu/',
            'https://www.qq/',
            'https://www.163/',
            'http://www.iqiyi/',
            'https://www.tmall/',
            'http://www.ifeng/']
import gevent.queue
work=gevent.queue.Queue()
for url in url_list:
    work.put_nowait(url)
def seek():
    while not work.empty():
        url=work.get_nowait()
        res=requests.get(url,verify=False)
        print(url,res.status_code,work.qsize())
tasks_list=[]
insect=2
for x in range(insect):
    task=gevent.spawn(seek)
    tasks_list.append(task)
gevent.joinall(tasks_list)
end=time.time()
print('程序消耗时间为:'+str(end-start)+'s')  

这就是我们实现多协程的代码了,接下来给大家分析之前没有出现过的代码:

work=gevent.queue.Queue()
for url in url_list:
    work.put_nowait(url)

这段代码第一行为使用Queue方法创建一个空队列。队列的长度可以指定,比如:

work=gevent.queue.Queue(10)

就是创建一个长度为10的队列。当我们没有指定长度时,创建的队列长度可以无限。
创建好队列之后,我们需要用这个队列储存网址或网址的后半部分(需要存储内容前半部分相同时)。所有的网页全都入队后,我们就可以办理业务(获取网站)了。

def seek():
    while not work.empty:
        url=work.get_nowait()
        res=requests.get(url,verify=False)
        print(url,res.status_code,work.qsize())

因为引入了队列这个概念,这段代码和之前有了一点不同。它的功能是检测队列是否还有元素,如果有元素,就拿出第一个元素,然后根据这个元素(网址)进行网页获取。如果不判断队列是否为空就直接提取元素的话,当队列为空时,计算机会报错。这个函数目前只用来获取网站,当然有需要的话也可以加入解析网站和提取内容等功能。

tasks_list=[]
insect=2
for x in range(insect):
    task=gevent.spawn(seek)
    tasks_list.append(task)
gevent.joinall(tasks_list)

这段代码是多协程的关键。首先建立一个空列表。insect代表需要准备的爬虫数量。后面的循环就是初始化爬虫,也就是为爬虫装备seek函数。还用刚刚银行的里再来说,这个过程就相当于给银行的工作人员下发工作流程,有了工作流程,工作人员才能为顾客办理业务。空列表的作用可以理解成存放准备好的爬虫,只带指令一出,爬虫就去爬取网站信息。
这个例子里,我们是初始化了两个爬虫,当遇到指令

gevent.joinall(tasks_list)

时,两个爬虫会同时开始工作。下面给大家看看这两只爬虫的工作结果:

打印出的网站顺序和列表中的网站顺序不一样这个问题出现的原理和我们之前讲的一样,这里不做赘述,我们重点看到另一个问题:这段代码打印出第一个网站之后显示后面等待的数量是6而不是7,并且最后有两个0,是不是很奇怪?

3.3多协程运行的输出结果与解密

其实这是因为有两个爬虫在工作的原因。看我们的seek函数,因为work.qsize()语句在res=requests.get(url,verify=False)语句之后,也就是说,查看还在等待的元素数量要在获取网站之后才可以执行。
这就有了一个问题,假如一共有三个任务两只爬虫,爬虫1在执行任务1,爬虫2在执行任务2,任务1的等待时间很长,以至于爬虫2已经将任务2执行完时,任务一还在执行中。此时,爬虫2检测还在等待的任务数就只有1个,因为任务一已经出队了,不管有没有执行完成都不会影响到队列长度了。
然后爬虫2去执行任务3,如果这个期间爬虫1执行完任务一检查等待的任务还有多少的时候大家猜猜会有什么结果?会输出0,理由同上。那么在任务3执行完成之后,work.qsize()又会输出什么结果呢?还是0。那么如果任务3先执行完,任务1最后执行完会改变结果吗?也不会。
理解了这个结果,我们对代码做出调整,在执行任务之前先判断剩余元素看看是否有所改变:

def seek():
    while not work.empty():
        url=work.get_nowait()
        size = work.qsize()
        res=requests.get(url,verify=False)
        print(url,res.status_code,size)


数字就没有消失和重复了,虽然出现了乱序,但这个乱序可以间接告诉我们那个页面等待的时间比较长哦~大家自己分析一下咯。

3.4多协程与debug

还需要提醒大家一点就是,这个程序如果使用debug进行单步运行结果会和我们想象的不一样。我先让大家看看这段程序里tasks_list列表里的内容:

# 改动以下代码
tasks_list=[]
insect=2
for x in range(insect):
    task=gevent.spawn(seek)
    tasks_list.append(task)
print(tasks_list)# 加入打印函数查看tasks_list列表内容
gevent.joinall(tasks_list)

可以查看tasks_list的内容:

下面我们单步运行(可以故意拉长点击下一步的时间,这样看得更清楚)看到列表内容:

自己操作的小伙伴一定有很多问号。下面我们来解释一下:

for x in range(insect):
    task=gevent.spawn(seek)
    tasks_list.append(task)

这几句代码,之前说过是给爬虫(工作人员)执行爬取的方法1(工作流程)。但是有一个问题就是,在我们下发工作流程的同时,已经拿到工作流程的工作人员就可以直接去执行任务了,而没有拿到工作流程的工作人员只能等到拿到工作流程之后之后才可以去工作。在程序自己运行的时候,这段代码执行的非常快,导致两位爬虫(工作人员)几乎是同时拿到执行方法(工作流程),而后

gevent.joinall(tasks_list)

这个语句会命令两个爬虫对同一队列进行操作。但如果用debug单步运行很可能出现一个爬虫已经去工作而另一个还在等执行方法,因此才会出现和直接运行结果有明显不同的情况。
那么多协程的那些事基本就给大家介绍完了。时至今日,我们电脑一般都会是多核 CPU。多协程,其实只占用了 CPU 的一个核运行,没有充分利用到其他核。
利用 CPU 的多个核同时执行任务的技术,我们把它叫做 “多进程”。
所以,真正大型的爬虫程序不会单单只靠多协程来提升爬取速度的。比如,百度搜索引擎,可以说是超大型的爬虫程序,它除了靠多协程,一定还会靠多进程,甚至是分布式爬虫。
这些内容就不在这里讲了,以后有机会给大家单独拿出来分享。感兴趣的小伙伴们可以关注我。

4.多协程实战应用

下面我们用一个实例研究多协程的应用。进入薄荷健康网页爬取食物的名称、热量等信息。

4.1分析任务

首先打开网站,点击检查并进入Network并刷新。我们将name栏滑动到最下面,然后随意点击一个常见的食物分类(这里我点击的是“谷薯芋、杂豆、主食”)。而后查看新增内容中的第一个doc类型:

这里已经包含了我们想要的信息了,那么我们研究一下怎么爬取。首先找一下General和Query String Parameters(以下简称QSP):

很遗憾,这个headers里没有包含QSP,这就意味着我们不能通过这种方式找到网页的附附带参数了。不过也不用灰心,我们自己来观察一下。依次点击各食物分类,然后看看网页的变化:

可以清晰地看到,每一个大类的不同对应请求前半部分的最后那个数字,而每个大类里不同页则对应附加参数page的数字。当然,这里没有举太多的例子,大家自己操作的时候尽量把大类遍历一遍。因为,这里最后一个大类是有些不符合上面的规律的:

4.2format方法的应用

了解到以上内容之后,我们就可以编写代码了,但是这种明显有规律可循的网站,如果我们以手动的方式一条一条赋值,然后放到队列显然太麻烦了。想要简化这个步骤就需要一个新的方法:.format()
format方法可以给一个字符串中的花括号赋予内容,举几个例子:

exp1='{} {}'
# 花括号不给任何提示,按照format内参数顺序赋值
print(exp1.format('hello','word'))
# 输出为:hello world hello
exp2='{0} {1} {0}'
# 花括号内有数字作为索引,数字可以指定该位置填充format内第几个参数(从0开始计算)
print(exp2.format('hello','word'))
# 输出为:hello world 
exp3='can it print {one} {two}? '
# 花括号内有特定名称作为索引,format的参数要标注赋值给谁
print(exp3.format(one='hello',two='word'))
# 输出为:can it print hello word? 

以上是这个方法的几个基本用法,想详细了解这个方法可以参考文章Python format 格式化函数

4.3 拆解任务

i.存放网站

因为我们只作演示,就只爬取前四个大类中每个大类的前两页内容,这样也可以避免给服务器造成太大的压力。
先将网页存储到队列:

import gevent.queue

enqueue=gevent.queue.Queue()

url = 'http://www.boohee/food/{ends}?page={page}'
ends = list(range(1, 5))
ends.append('view_menu')
print(ends)
# 检查列表是否正确,后期代码会删掉
for end in ends:
    for i in range(1, 3):
        if end!='view_menu':
            enqueue.put_nowait(url.format(ends='group/'+str(end), page=i))
        else :
            enqueue.put_nowait(url.format(ends=str(end), page=i))
while not enqueue.empty():# 检查队列是否正确,后期代码会删掉
    print(enqueue.get_nowait())

运行试试看:

结果符合我们的预期,我们已经成功将网页存储至队列了。
需要注意一点,如果我们没有使用monkey.patch_all()时,queue方法会被判断为不在gevent里,因此我们必须用上述方式写代码,而不能用以下方式:

import gevent
work=gevent.queue.Queue()

否则计算机会报错。

ii.爬取内容

尽管爬取内容的方式我们已经很熟练了,还是要再练习一次。因为我们要爬取的内容在HTML里,所以这里不需要借助Network了:

import requests
from bs4 import BeautifulSoup as bs
headers = {
        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
    }
url='http://www.boohee/food/view_menu?page=1'
res=requests.get(url,headers=headers,verify=False)
con=bs(res.text,'html.parser')
datas=con.find_all('li',class_='clearfix')
for data in datas:
    food=data.find_all('div')[1]
    name=food.find('a').text
    heat=food.find('p').text
    print(name,heat)

iii.使用多协程爬取内容,并存入.xlsx文件

from gevent import monkey

monkey.patch_all()
import gevent, time, requests, openpyxl
from bs4 import BeautifulSoup as bs

enqueue = gevent.queue.Queue()
wb = openpyxl.Workbook()
sheet = wb.active
sheet.title = '食物能量表'
# 更改工作表名称sheet1食物能量表
sheet.append(['食物名', '热量'])
# 将刚刚的列表内容写入新的一行

url = 'http://www.boohee/food/{ends}?page={page}'
ends = list(range(1, 5))
ends.append('view_menu')
for end in ends:
    for i in range(1, 3):
        if end!='view_menu':
            enqueue.put_nowait(url.format(ends='group/'+str(end), page=i))
        else :
            enqueue.put_nowait(url.format(ends=str(end), page=i))
headers = {
    'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
}


def job():
    while not enqueue.empty():
        url = enqueue.get_nowait()
        res = requests.get(url, headers=headers, verify=False)
        con = bs(res.text, 'html.parser')
        datas = con.find_all('li', class_='item clearfix')
        for data in datas:
            food = data.find_all('div')[1]
            name = food.find('a').text
            heat = food.find('p').text
            meal = [name, heat]
            sheet.append(meal)
tasks_list = []
insect = 5
for x in range(insect):
    task = gevent.spawn(job)
    tasks_list.append(task)
gevent.joinall(tasks_list)
wb.save('食物信息.xlsx')
# 保存修改的.xlsx文件,并将其命名为“食物信息”
wb.close()

运行之后找到了“食物信息.xlsx”的文件,存储内容为:

说明我们的代码已经运行成功。
这一课我们主要讲了多协程,以及如何使用多协程完成具体任务,爬取网站资源,相信大家已经有所了解。下一节将会继续带着大家探索爬虫宇宙,我们不见不散~


  1. 这个方法就是seek函数 ↩︎

更多推荐

高效爬取网站信息