Python爬虫——教你异步爬虫二十秒爬完两百多万字六百多章的小说

大家好!我是霖hero。

相信很多人喜欢在空闲的时间里看小说,甚至有小部分人为了追小说而熬夜看,那么问题来了,喜欢看小说的小伙伴在评论区告诉我们为什么喜欢看小说,今天我们手把手教你使用异步协程20秒爬完两百四十多万字,六百章的小说,让你一次看个够。

在爬取之前我们先来简单了解一下什么是同步,什么是异步协程?

同步与异步

同步

同步是有序,为了完成某个任务,在执行的过程中,按照顺序一步一步执行下去,直到任务完成。

爬虫是IO密集型任务,我们使用requests请求库来爬取某个站点时,网络顺畅无阻塞的时候,正常情况如下图所示:

Python爬虫——教你异步爬虫二十秒爬完两百多万字六百多章的小说

但在网络请求返回数据之前,程序是处于阻塞状态的,程序在等待某个操作完成期间,自身无法继续干别的事情,如下图所示:

Python爬虫——教你异步爬虫二十秒爬完两百多万字六百多章的小说

当然阻塞可以发生在站点响应后的执行程序那里,执行程序可能是下载程序,大家都知道下载是需要时间的。

当站点没响应或者程序卡在下载程序的时候,CPU一直在等待而不去执行其他程序,那么就白白浪费了CPU的资源,导致我们的爬虫效率很低。

异步

异步是一种比多线程高效得多的并发模型,是无序的,为了完成某个任务,在执行的过程中,不同程序单元之间过程中无需通信协调,也能完成任务的方式,也就是说不相关的程序单元之间可以是异步的。如下图所示:

Python爬虫——教你异步爬虫二十秒爬完两百多万字六百多章的小说

当请求程序发送网络请求1并收到某个站点的响应后,开始执行程序中的下载程序,由于下载需要时间或者其他原因使处于阻塞状态,请求程序和下载程序是不相关的程序单元,所以请求程序发送下一个网络请求,也就是异步。

  • 微观上异步协程是一个任务一个任务的进行切换,切换条件一般就是IO操作;
  • 宏观上异步协程是多个任务一起在执行;

注意:上面我们所讲的一切都是在单线程的条件下实现。

请求库

我们发送网络请求一定要用到请求库,在Python从多的HTTP客户端中,最常用的请求库莫过于requests、aiohttp、httpx。

在不借助其他第三方库的情况下,requests只能发送同步请求;aiohttp只能发送异步请求;httpx既能发送同步请求,又能发送异步请求。

接下来我们将简单讲解这三个库。

requests库

相信大家对requests库不陌生吧,requests库简单、易用,是python爬虫使用最多的库。

在命令行中运行如下代码,即可完成requests库的安装:

pip install requests

使用requests发送网络请求非常简单,

在本例中,我们使用get网络请求来获取百度首页的源代码,具体代码如下:

import requests
headers={
    'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36'
}
response=requests.get('https://baidu.com',headers=headers)
response.encoding='utf-8'
print(response.text)

运行部分结果如下图:

Python爬虫——教你异步爬虫二十秒爬完两百多万字六百多章的小说

首先我们导入requests库,创建请求头,请求头中包含了User-Agent字段信息,也就是浏览器标识信息,如果不加这个,网站就可能禁止抓取,然后调用get()方法发送get请求,传入的参数为URL链接和请求头,这样简单的网络请求就完成了。

这里我们返回打印输出的是百度的源代码,大家可以根据需求返回输出其他类型的数据。

需要注意的是:

百度源代码的head部分的编码为:utf-8,如下图所示:

Python爬虫——教你异步爬虫二十秒爬完两百多万字六百多章的小说

我们利用requests库的方法来查看默认的编码类型是什么,具体代码如下所示:

import requests
url = 'https://www.baidu.com'
response = requests.get(url)
print(response.encoding)  

运行结果为:ISO-8859-1

由于默认的编码类型不同,所以需要更改输出的编码类型,更改方式也很简单,只需要在返回数据前根据head部分的编码来添加以下代码即可:

response.encoding='编码类型'

除了使用get()方法实现get请求外,还可以使用post()、put()、delete()等方法来发送其他网络请求,在这里就不一一演示了,关于更多的requests网络请求库用法可以到官方参考文档进行查看,我们今天主要讲解可以发送异步请求的aiohttp库和httpx库。

asyncio模块

在讲解异步请求aiohttp库和httpx库请求前,我们需要先了解一下协程。

协程是一种比线程更加轻量级的存在,让单线程跑出了并发的效果,对计算资源的利用率高,开销小的系统调度机制。

Python中实现协程的模块有很多,我们主要来讲解asyncio模块,从asyncio模块中直接获取一个EventLoop的引用,把需要执行的协程放在EventLoop中执行,这就实现了异步协程。

协程通过async语法进行声明为异步协程方法,await语法进行声明为异步协程可等待对象,是编写asyncio应用的推荐方式,具体示例代码如下:

import asyncio
import time
async def function1():
    print('I am Superman!!!')
    await asyncio.sleep(3)
    print('function1')

async def function2():
    print('I am Batman!!!')
    await asyncio.sleep(2)
    print('function2')

async def function3():
    print('I am iron man!!!')
    await asyncio.sleep(4)
    print('function3')
    
async def Main():
    tasks=[
        asyncio.create_task(function1()),
        asyncio.create_task(function2()),
        asyncio.create_task(function3()),
    ]
    await asyncio.wait(tasks)
    
if __name__ == '__main__':
    t1=time.time()
    asyncio.run(Main())
    t2=time.time()
    print(t2-t1)

运行结果为:

I am Superman!!!
I am Batman!!!
I am iron man!!!
function2
function1
function3
4.0091118812561035

首先我们用了async来声明三个功能差不多的方法,分别为function1,function2,function3,在方法中使用了await声明为可等待对象,并使用asyncio.sleep()方法使函数休眠一段时间。

再使用async来声明Main()方法,通过调用asyncio.create_task()方法将方法封装成一个任务,并把这些任务存放在列表tasks中,这些任务会被自动调度执行;

最后通过asyncio.run()运行协程程序。

注意:当协程程序出现了同步操作的时候,异步协程就中断了。

例如把上面的示例代码中的await asyncio.sleep()换成time.time(),运行结果为:

I am Superman!!!
function1
I am Batman!!!
function2
I am iron man!!!
function3
9.014737844467163

所以在协程程序中,尽量不使用同步操作。

好了,asyncio模块我们讲解到这里,想要了解更多的可以进入asyncio官方文档进行查看。

aiohttp库

aiohttp是基于asyncio实现的HTTP框架,用于HTTP服务器和客户端。安装方法如下:

pip install aiohttp

aiohttp只能发送异步请求,示例代码如下所示:

import aiohttp
import asyncio
headers={
    'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36'
}
async def Main():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://www.baidu.com',headers=headers) as response:
            html = await response.text()
            print(html)
loop=asyncio.get_event_loop()
loop.run_until_complete(Main())

运行结果和前面介绍的requests网络请求一样,如下图所示:

Python爬虫——教你异步爬虫二十秒爬完两百多万字六百多章的小说

大家可以对比requests网络请求发现,其实aiohttp.ClientSession() as session相当于将requests赋给session,也就是说session相当于requests,而发送网络请求、传入的参数、返回响应内容都和requests请求库大同小异,只是aiohttp请求库需要用async和await进行声明,然后调用asyncio.get_event_loop()方法进入事件循环,再调用loop.run_until_complete(Main())方法运行事件循环,直到Main方法运行结束。

注意:在调用Main()方法时,不能使用下面这条语句:

asyncio.run(Main())

虽然会得到想要的响应,但会报:RuntimeError: Event loop is closed错误。

我们还可以在返回的内容中指定解码方式或编码方式,例如:

await response.text(encoding='utf-8')

或者选择不编码,读取图像:

await resp.read()

好了aiohttp请求库我们学到这里,想要了解更多的可以到pypi官网进行学习。

httpx请求库

在前面我们简单地讲解了requests请求库和aiohttp请求库,requests只能发送同步请求,aiohttp只能发送异步请求,而httpx请求库既可以发送同步请求,又可以发送异步请求,而且比上面两个效率更高。

安装方法如下:

pip install httpx

httpx请求库——同步请求

使用httpx发送同步网络请求也很简单,与requests代码重合度99%,只需要把requests改成httpx即可正常运行。

具体示例代码如下:

import httpx
headers={
    'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36'
}
response=httpx.get('https://www.baidu.com',headers=headers)
print(response.text)

运行结果如下图所示:

Python爬虫——教你异步爬虫二十秒爬完两百多万字六百多章的小说

注意:httpx使用的默认utf-8进行编码来解码响应。

httpx请求库——同步请求高级用法

当发送请求时,httpx必须为每个请求建立一个新连接(连接不会被重用),随着对主机的 请求数量增加,网络请求的效率就是变得很低。

这时我们可以用Client实例来使用HTTP连接池,这样当我们主机发送多个请求时,Client将重用底层的TCP连接,而不是为重新创建每个请求。

with块用法如下:

with httpx.Client() as client:
    ...

我们把Client作为上下文管理器,并使用with块,当执行完with语句时,程序会自动清理连接。

当然我们可以使用.close()显式关闭连接池,用法如下:

client = httpx.Client()
try:
    ...
finally:
    client.close()

为了我们的代码更简洁,我们推荐使用with块写法,具体示例代码如下:

import httpx
headers={
    'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36'
}
with httpx.Client(headers=headers)as client:
    response=client.get('https://www.baidu.com')
    print(response.text)

其中httpx.Client()as client相当于把httpx的功能传递给client,也就是说示例中的client相当于httpx,接着我们就可以使用client来调用get请求。

注意:我们传递的参数可以放在httpx.Client()里面,也可以传递到get()方法里面。

httpx请求库——异步请求

要发送异步请求时,我们需要调用AsyncClient,具体示例代码如下:

import httpx
import asyncio
headers={
    'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36'
}
async def function():
    async with httpx.AsyncClient()as client:
        response=await client.get('https://www.baidu.com',headers=headers)
        print(response.text)
if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(function())

运行结果为:

Python爬虫——教你异步爬虫二十秒爬完两百多万字六百多章的小说

首先我们导入了httpx库和asyncio模块,使用async来声明function()方法并用来声明with块的客户端打开和关闭,用await来声明异步协程可等待对象response。接着我们调用asyncio.get_event_loop()方法进入事件循环,再调用loop.run_until_complete(function())方法运行事件循环,直到function运行结束。

好了,httpx请求库讲解到这里,想要了解更多的可以到httpx官方文档进行学习,接下来我们正式开始爬取小说。

实战演练

接下来我们将使用requests请求库同步和httpx请求库的异步,两者结合爬取17k小说网里面的百万字小说,利用XPath来做相应的信息提取。

Xpath小技巧

在使用Xpath之前,我们先来介绍使用Xpath的小技巧。

技巧一:快速获取与内容匹配的Xpath范围。

我们可以将鼠标移动到我们想要获取到内容div的位置并右击选择copy,如下图所示:

Python爬虫——教你异步爬虫二十秒爬完两百多万字六百多章的小说

这样我们就可以成功获取到内容匹配的Xpath范围了。

技巧二:快速获取Xpath范围匹配的内容。

当我们写好Xpath匹配的范围后,可以通过Chrome浏览器的小插件Xpath Helper,该插件的安装方式很简单,在浏览器应用商店中搜索Xpath Helper,点击添加即可,如下图所示:

Python爬虫——教你异步爬虫二十秒爬完两百多万字六百多章的小说

使用方法也很简单,如下图所示:

Python爬虫——教你异步爬虫二十秒爬完两百多万字六百多章的小说

首先我们点击刚刚添加的插件,然后把已经写好的Xpath范围写到上图2的方框里面,接着Xpath匹配的内容将出现在上图3方框里面,接着被匹配内容的背景色全部变成了金色,那么我们匹配内容就一目了然了。

这样我们就不需要每写一个Xpath范围就运行一次程序查看匹配内容,大大提高了我们效率。

获取小说章节名和链接

首先我们选取爬取的目标小说,并打开开发者工具,如下图所示:

Python爬虫——教你异步爬虫二十秒爬完两百多万字六百多章的小说
Python爬虫——教你异步爬虫二十秒爬完两百多万字六百多章的小说

我们通过上图可以发现,<div class=”Main List”存放着我们所有小说章节名,点击该章节就可以跳转到对应的章节页面,所以可以使用Xpath来通过这个div来获取到我们想要的章节名和URL链接。

由于我们获取的章节名和URL链接的网络请求只有一个,直接使用requests请求库发送同步请求,主要代码如下所示:

async def get_link(url):
    response=requests.get(url)
    response.encoding='utf-8'
    Xpath=parsel.Selector(response.text)
    dd=Xpath.xpath('/html/body/div[5]')
    for a in dd:
        #获取每章节的url链接
        links=a.xpath('./dl/dd/a/@href').extract()
        linklist=['https://www.17k.com'+link for link in links]
        #获取每章节的名字
        names=a.xpath('./dl/dd/a/span/text()').extract()
        namelist=[name.replace('\n','').replace('\t','') for name in names]
        #将名字和url链接合并成一个元组
        name_link_list=zip(namelist,linklist)

首先我们用async声明定义的get_text()方法使用requests库发送get请求并把解码方式改成’utf-8’,接着使用parsel.Selector()方法将文本构成Xpath解析对象,最后我们将获取到的URL链接和章节名合并成一个元组。

获取到URL链接和章节名后,需要构造一个task任务列表来作为异步协程的可等待对象,具体代码如下所示:

task=[]
for name,link in name_link_list:
 task.append(get_text(name,link))
await asyncio.wait(task)

我们创建了一个空列表,用来存放get_text()方法,并使用await调用asyncio.wait()方法保存创建的task任务。

获取每章节的小说内容

由于需要发送很多个章节的网络请求,所以我们采用httpx请求库来发送异步请求。

主要代码如下所示:

async def get_text(name,link):
    async with httpx.AsyncClient() as client:
        response=await client.get(link)
        html=etree.HTML(response.text)
        text=html.xpath('//*[@id="readArea"]/div[1]/div[2]/p/text()')
        await save_data(name,text)

首先我们将上一步的获取到的章节名和URL链接传递到用async声明定义的get_text()方法,使用with块调用httpx.AsyncClient()方法,并使用await来声明client.get()是可等待对象,然后使用etree模块来构造一个XPath解析对象并自动修正HTML文本,将获取到的小说内容和章节名传入到自定义方法save_data中。

保存小说内容到text文本中

好了,我们已经把章节名和小说内容获取下来了,接下来就要把内容保存在text文本中,具体代码如下所示:

async def save_data(name,text):
    f=open(f'小说/{name}.txt','w',encoding='utf-8')
    for texts in text:
        f.write(texts)
        f.write('\n')
        print(f'正在爬取{name}')

老规矩,首先用async来声明save_data()协程方法save_data(),然后使用open()方法,将text文本文件打开并调用write()方法把小说内容写入文本中。

最后调用asyncio.get_event_loop()方法进入事件循环,再调用loop.run_until_complete(get_link())方法运行事件循环,直到function运行结束。具体代码如下所示:

url='https://www.17k.com/list/2536069.html'
loop = asyncio.get_event_loop()
loop.run_until_complete(get_link(url))

结果展示

Python爬虫——教你异步爬虫二十秒爬完两百多万字六百多章的小说
Python爬虫——教你异步爬虫二十秒爬完两百多万字六百多章的小说

好了,异步爬虫爬取小说就讲解到这里了,感谢观看!!!

– END –