想象一下你正在一家餐厅吃饭,你想要点几道菜,但是你不想等一道菜吃完后才能点下一道菜。你希望在等待第一道菜的时候,可以继续点下一道菜,饭菜会按照你点的顺序陆续上来,不会因为你等待而被耽误。
异步爬虫的概念有点像这种情况。在普通的爬虫中,当我们发送请求获取网页内容时,程序会等待直到获取到内容后才继续执行下一步。但在异步爬虫中,我们可以同时发送多个请求,然后不需要等待所有的请求都完成,就可以开始处理那些已经收到的响应。这就好像在餐厅里,你点了几道菜后,不需要等待一道菜吃完才能点下一道,而是可以随时点其他的菜。
在异步爬虫中,我们可以发送多个请求,然后处理那些已经返回的数据,而不必一个接一个地等待每个请求的响应。这可以显著提高爬取数据的速度,因为我们可以最大程度地利用网络传输和处理数据的时间,而不是浪费时间在等待每个请求的响应上。总之,异步爬虫就是一种可以同时处理多个请求和响应的方法,让爬虫更加高效地从网页中抓取数据,就像在餐厅里同时点多道菜并且能够随时享受到它们一样。
要实现异步机制的爬虫,那自然和协程脱不了关系。了解协程之前需要先了解一些基础概念,如阻塞和非阻塞、同步和异步、多进程和协程。
阻塞状态指的是程序未得到所需的计算资源时被挂起的状态。程序在等待某个操作完成期间,自身无法继续干别的事情,则称该程序在该操作中是阻塞的。常见的阻塞有:网络I/O阻塞、磁盘I/O阻塞、用户输入阻塞等。
程序在等待某个操作的过程中,自身不被阻塞,可以继续干别的事情,则称该程序在该操作上非阻塞的。非阻塞并不是在任何程序级别、任何情况下都存在的。仅当程序封装的级别可以囊括独立的子程序单元时,程序才可能存在非阻塞状态。
不同程序单元为了共同完成某个任务,在执行过程中需要靠某种通信方式保持协调一致,此时这些程序单元是同步执行的。简而言之,同步意味着有序。
异步编程是一种编程范式,用于处理那些可以独立运行,互不影响的任务,而无需等待其它任务完成。这种情况下,不同的任务可以并行执行,无需等待每个任务完成后再开始下一个任务。
假设您要从不同的网页上爬取数据。每个网页是一个独立的任务。使用异步编程,您可以同时开始爬取多个网页,而无需等待每个网页的爬取完成,然后再爬取下一个。这样,您可以充分利用计算资源,同时进行多个任务。
在这个过程中,不同网页的下载、保存等操作是独立的,它们不需要相互通信或协调。您只需告诉程序启动每个任务,并且程序会自动在适当的时候切换任务,让每个任务有机会执行。因为这些任务是无序的,它们的完成时刻并不确定,取决于网络延迟、服务器响应速度等因素。
综上所述,异步编程允许独立的任务并行执行,而无需等待彼此完成。每个任务在需要等待某些操作完成时可以暂停,让其他任务有机会执行。这样可以提高效率,特别是在处理需要等待的 I/O 操作(例如网络请求)时。
多进程就是利用CPU的多核优势,在同一时间并行执行多个任务。
协程,英文叫做coroutine,又称微线程、纤程,是一种运行在用户态的轻量级线程。协程拥有自己的寄存器和栈。协程在调度切换时,将寄存器上下文和栈保存到其他地方,等切回来的时,再恢复之前保存的寄存器上下文和栈。因此,协程能保留上一次调度时的状态,即所有布局状态的一个特定组合,每一次过程重入,就相当于进入了上一次调用的状态。
Python中使用协程最常用的库莫过于asyncio,接下来以它为基础讲解协程的用法,首先先来了解一下下面几个概念:
我们来定义一个协程,体验一下它和普通进程的不同之处:
import asyncio
async def execute(x):
print('Number:', x)
coroutine = execute(1) #1
print('Coroutine:', coroutine)
print('After calling execute')
loop = asyncio.get_event_loop() #1.5
loop.run_until_complete(coroutine) #2
print('After calling loop')
Coroutine:
After calling execute
Number: 1
After calling loop
可见,async定义的方法会变成一个无法直接执行的协程对象,必须将此对象注册到事件循环中才可以执行。
前面我们还提到task,它是对协程对象的进一步封装,比协程对象多了运行状态,例如running、finished等,我们可以通过这些状态来获取对象的执行情况。 在上面例子中,将协程对象coroutine传递给run_until_complete方法时候已经把coroutine封装成task,对此,我们可以显式地声明:
import asyncio
async def execute(x):
print('Number:', x)
return x
coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')
loop = asyncio.get_event_loop()
task = loop.create_task(coroutine)
print('Task:', task)
loop.run_until_complete(task)
print('Task:', task)
print('After calling loop')
Coroutine:
After calling execute
Task: >
Number: 1
Task: result=1>
After calling loop
定义task对象还有另外一种方式,就是直接调用asyncio包的ensure_future方法,返回结果也是task对象。
import asyncio
async def execute(x):
print('Number:', x)
return x
coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')
task = asyncio.ensure_future(coroutine)
print('Task:', task)
loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task:', task)
print('After calling loop')
Coroutine:
After calling execute
Task: >
Number: 1
Task: result=1>
After calling loop
我们亦可以为某个task对象绑定一个回调方法,来看下面这个例子:
import asyncio
import requests
async def request():
url = 'https://www.baidu.com'
status = requests.get(url)
return status
def callback(task):
print('Status:', task.result())
coroutine = request()
task = asyncio.ensure_future(coroutine)
task.add_done_callback(callback)
# add_done_callback()内部会将完成的任务作为参数调用这个回调函数
print('Task:', task)
loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task:', task)
Task: cb=[callback() at C:\Users\85710\Desktop\insect\study1.py:11]>
Status:
Task: result=>
实际上,即使不使用回调方法,在task运行完毕之后,也可以直接调用result方法获取结果。
在上面的例子中,我们都只进行了一次循环,我们可以定义一个task列表,然后使用asyncio包中的wait方法执行:
import asyncio
import requests
async def request():
url = 'https://www.baidu.com'
status = requests.get(url)
return status
tasks = [asyncio.ensure_future(request()) for _ in range(5)]
print('Tasks:', tasks)
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
for task in tasks:
print('Task Result:', task.result())
根据我现在看的这本书——python3网络爬虫开发与实战(第二版)我觉得有必要将错误的样例总结一遍。
我们先来看一个案例网站,url= https://www.httpbin.org/delay/5,访问这个链接需要先等待五秒才能够获得结果,因为这是服务器强制的。下面我们来试一下,用requests写一个遍历程序,直接遍历50次样例网站:
import requests
import logging
import time
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(levelname)s: %(message)s')
TOTAL_NUMBER = 10
url = 'https://www.httpbin.org/delay/5'
start_time = time.time()
for _ in range(1, TOTAL_NUMBER + 1):
logging.info('scraping %s', url)
response = requests.get(url)
end_time = time.time()
logging.info('total time %s seconds', end_time - start_time)
结果就不放出来了,等太久了。
import asyncio
import requests
import time
start = time.time()
async def request():
url = 'https://httpbin.org/delay/5'
print('Waiting for', url)
response = requests.get(url)
print('Get response from', url, 'response', response)
tasks = [asyncio.ensure_future(request()) for _ in range(10)]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
end = time.time()
print('Cost time:', end - start)
结果也不放了。这和正常请求没有什么区别,各个任务依然是顺次执行的,这并不是异步处理。
其实要实现异步处理,先要有挂起操作,当一个任务需要等待IO结果时候,可以挂起当前任务转而执行其他任务,这样才能充分应用好资源。
要实现异步我们再来了解一下await关键字的用法,它可以将耗时等待的操作挂起,让出控制权。如果协程在执行时候遇到await,事件循环就会将本协程挂起,转而去执行别的协程,直到其他协程挂起或执行完毕。根据官方文档说明,await后面的对象必须是如下格式之一:
于是代码可以改写成这个样子:
import asyncio
import requests
import time
start = time.time()
async def get(url):
return requests.get(url)
async def request():
url = 'https://www.httpbin.org/delay/5'
print('Waiting for', url)
response = await get(url)
print('Get response from', url, 'response', response)
tasks = [asyncio.ensure_future(request()) for _ in range(10)]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
end = time.time()
print('Cost time:', end - start)
这还是错的,也就是说我们仅仅将涉及到IO操作的代码封装到async修饰的方法里是不可行的。只有使用支持异步操作的请求方式才可以实现真正的异步操作,所以接下来就介绍一下aiohttp。
aiohttp是一个支持异步请求的库,它和asyncio配合使用,可以是我们非常方便地实现异步请求操作。aiohttp的官方文档链接为: https://aiohttp.readthedocs.io/
下面将aiohttp投入使用,将代码改写成这个样子:
import asyncio
import aiohttp
import time
start = time.time()
async def get(url):
session = aiohttp.ClientSession()
response = await session.get(url)
await response.text()
await session.close()
return response
async def request():
url = 'https://www.httpbin.org/delay/5'
print('Waiting for', url)
response = await get(url)
print('Get response from', url, 'response')
tasks = [asyncio.ensure_future(request()) for _ in range(100)]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
end = time.time()
print('Cost time:', end - start)
Waiting for https://www.httpbin.org/delay/5
Waiting for https://www.httpbin.org/delay/5
......
Waiting for https://www.httpbin.org/delay/5
Waiting for https://www.httpbin.org/delay/5
Get response from https://www.httpbin.org/delay/5 response
Get response from https://www.httpbin.org/delay/5 response
......
Get response from https://www.httpbin.org/delay/5 response
Get response from https://www.httpbin.org/delay/5 response
Cost time: 68.26689767837524
开始运行时,事件循环会运行第一个 task。对于第一个 task 来说,当执行到第一个 await 跟着的 get 方法时,它会被挂起,但这个get 方法第一步的执行是非阻塞的,挂起之后会立马被唤醒立即又进人执行,并创建了ClientSession 对象。接着遇到第二个await,调用session.get 请求方法,然后就被挂起了。由于请求需要耗时很久,所以一直没有被唤醒,好在第一个 task 被挂起了,那么接下来该怎么办呢?事件循环会寻找当前未被挂起的协程继续执行,于是转而去执行第二个task,流程操作和第一个 task 也是一样的,以此类推,直到执行第十个 task 的 session.get 方法之后,全部的 task 都被挂起了。所有 task 都已经处于挂起状态,那怎么办?只好等待了。5 秒之后,几个请求几乎同时有了响应,然后几个 task 也被唤醒接着执行,并输出请求结果,最后总耗时是 6秒! 在前面提到:它们的完成时刻并不确定,取决于网络延迟、服务器响应速度等因素。 你可以加上标号试试。
前面介绍的asyncio模块,其内部实现了对TCP、UDP、SSL协议的异步操作,但是对于HTTP请求来说就要用aiohttp实现了。
aiohttp是一个基于asyncio的异步HTTP网络模块,它即提供了服务,又提供了客户端。客户端可以用来发起请求,类似于使用requests发起一个HTTP请求然后获得响应,但是requests发起的是同步的网络请求,aiohttp则是异步的。我们先来看一个简单的请求案例:
import aiohttp
import asyncio
async def fetch(session, url):
async with session.get(url) as response:
return await response.text(), response.status
async def main():
async with aiohttp.ClientSession() as session:
html, status = await fetch(session, 'https://baidu.com')
print(f'html: {html[:100]}...')
print(f'status: {status}')
if __name__ == '__main__':
asyncio.run(main())
html: ...parmas = {字典}
...session.get(url, params= params)
和requests类似,session.get(post)(delete)等
对于响应,我们可以用如下方法来获取其中的状态码、响应头、响应体、响应体二进制内容、响应体json结果:
我们可以借助ClientTimeout对象设置超时,单位为秒
async def main():
timeout = aiohttp.ClientTimeout(total=1)
async with aiohttp.ClientSession(timeout=timeout) as session: # !!!
async with session.get('https://httpbin.org/get') as response:
print('status:', response.status)
由于aiohttp可以支持非常高的并发量,如几万、十万、百万都是可以做到的,理论上使用aiohttp并发是可以实现DDoS攻击。面对如此高的并发量,目标网站可能无法在短时间内回应,而且有瞬间将目标网站爬挂掉的风险,这提示我们需要控制一下爬取的并发量。
一般情况下,我们可以借助asyncio的Semaphore来控制并发量,代码如下:
import asyncio
import aiohttp
CONCURRENCY = 50
URL = "https://spa3.scrape.center/"
semaphore = asyncio.Semaphore(CONCURRENCY)
async def scrape_api():
async with semaphore:
print("scraping", URL)
async with aiohttp.ClientSession() as session:
async with session.get(URL) as response:
return await response.text()
async def main():
scrape_index_tasks = [asyncio.ensure_future(scrape_api()) for _ in range(1000)]
await asyncio.gather(*scrape_index_tasks)
if __name__ == '__main__':
asyncio.get_event_loop().run_until_complete(main())
更多aiohttp的基本用法,请查阅官方文档: https://docs.aiohttp.org/
import asyncio
import aiohttp
URL = "https://spa3.scrape.center/"
semaphore = asyncio.Semaphore(CONCURRENCY)
async def scrape_api():
print("scraping", URL)
async with aiohttp.ClientSession() as session:
async with session.get(URL) as response:
return await response.text()
async def main():
scrape_index_tasks = [asyncio.ensure_future(scrape_api()) for _ in range(1000)]
await asyncio.gather(*scrape_index_tasks)
if __name__ == '__main__':
asyncio.get_event_loop().run_until_complete(main())
import asyncio
import aiohttp
import logging
import time
import json
from os import makedirs
from os.path import exists
import re
# 第一次异步请求时间计算
requests_time_start = 0
requests_time_end = 0
scrape_index_url = "https://spa5.scrape.center/api/book/?limit={limit}&offset={offset}"
scrape_book_url = "https://spa5.scrape.center/api/book/{ID}"
# 并发限制,报错调小
concurrency = 30
semaphore = asyncio.Semaphore(concurrency)
# 目录设置
RESULTS_DIR = 'results'
exists(RESULTS_DIR) or makedirs(RESULTS_DIR)
logging.basicConfig(level=logging.INFO)
error_id = list()
# 自定义异常
class ScraperError(Exception):
pass
# 得到书本的id编号
async def scrape_books_id(url):
books_page_id = []
async with semaphore:
logging.info("scraping books\'id, %s", url)
async with aiohttp.ClientSession() as session:
async with session.get(url) as aio_response:
try:
json_content = await aio_response.text()
json_data = json.loads(json_content)
for item in json_data.get("results"):
books_page_id.append(item.get("id"))
return books_page_id
except json.JSONDecodeError as e:
logging.error("JSON decoding error: %s", e)
logging.error("connecting error")
except Exception as e:
logging.info("Error:", e)
logging.info("connecting error")
logging.info(aio_response.text())
raise ScraperError("Unexpected error") # 自定义异常
# 爬取json文件
async def scrape_detail(url):
async with semaphore:
async with aiohttp.ClientSession() as session:
async with session.get(url) as aio_response:
try:
json_content = await aio_response.text()
json_data = json.loads(json_content)
await save_data(json_data, url)
except json.JSONDecodeError as e:
logging.info("JSON decoding error:", e)
logging.info("detail connecting error")
error_id.append(str(url.split('/')[-1]) + "_scrape")
print(aio_response.text())
except Exception as e:
error_id.append(str(url.split('/')[-1]) + "_scrape")
raise ScraperError("Unexpected error") # 自定义异常
# 书本信息异步
async def scrape_detail_tasks(books_id):
scrape_tasks = [asyncio.ensure_future(scrape_detail(scrape_book_url.format(ID= ID))) for ID in books_id]
await asyncio.gather(*scrape_tasks)
# 保存数据
async def save_data(json_data, url):
try:
name = json_data.get('name')
# 使用正则表达式清理书本名称中的特殊字符
cleaned_name = re.sub(r'[\/:*?"<>|]', '_', name) # 替换特殊字符为下划线
data_path = f'{RESULTS_DIR}/{cleaned_name}.json'
logging.info("Saving Book %s...", cleaned_name)
json.dump(json_data, open(data_path, 'w', encoding='utf-8'),
ensure_ascii=False, indent=2)
logging.info("Saving Book %s over", cleaned_name)
except Exception as e:
logging.error("Error occurred: %s", e)
error_id.append(str(url.split('/')[-1]) + "_scrape")
except Exception as e:
error_id.append(str(url.split('/')[-1]) + "_json")
raise ScraperError("Unexpected error") # 自定义异常
# 主函数
async def main():
scrape_tasks = [asyncio.ensure_future(scrape_books_id(scrape_index_url.format(limit= 18, offset= 18 * (index - 1)))) for index in range(1, 504)]
global requests_time_start
requests_time_start = time.time()
result = await asyncio.gather(*scrape_tasks)
global requests_time_end
requests_time_end = time.time()
logging.info("Spend time for %s", requests_time_end - requests_time_start)
# 异步爬取json,Ajax接口
books_id = [item for sublist in result for item in sublist]
logging.info(f"Save {len(books_id)} projects")
await scrape_detail_tasks(books_id)
if __name__ == "__main__":
loop = asyncio.get_event_loop()
coroutine = main()
task = loop.create_task(coroutine)
logging.info("Main task created: %s", task)
loop.run_until_complete(task)
logging.info("Main task completed: %s", task)
print(error_id)