【爬虫】6. 异步爬虫

异步爬虫

文章目录

  • 异步爬虫
    • 1. 基本原理
    • 2. 协程的用法
      • 2.1. 基本用法
      • 2.2. 多任务协程
    • 3. 一个错误的示例:
      • 3.1. 案例网站
      • 3.2. 错误处理-1
    • 4. 简单使用aiohttp
    • 5. aiohttp的使用
      • 5.1. URL参数的设置
      • 5.2. 其他请求类型
      • 5.3. 响应
      • 5.4. 超时设置
      • 5.5. 并发限制
      • 5.6. 一个简单的并发
    • 6. aiohttp异步爬取实战

想象一下你正在一家餐厅吃饭,你想要点几道菜,但是你不想等一道菜吃完后才能点下一道菜。你希望在等待第一道菜的时候,可以继续点下一道菜,饭菜会按照你点的顺序陆续上来,不会因为你等待而被耽误。

异步爬虫的概念有点像这种情况。在普通的爬虫中,当我们发送请求获取网页内容时,程序会等待直到获取到内容后才继续执行下一步。但在异步爬虫中,我们可以同时发送多个请求,然后不需要等待所有的请求都完成,就可以开始处理那些已经收到的响应。这就好像在餐厅里,你点了几道菜后,不需要等待一道菜吃完才能点下一道,而是可以随时点其他的菜。

在异步爬虫中,我们可以发送多个请求,然后处理那些已经返回的数据,而不必一个接一个地等待每个请求的响应。这可以显著提高爬取数据的速度,因为我们可以最大程度地利用网络传输和处理数据的时间,而不是浪费时间在等待每个请求的响应上。总之,异步爬虫就是一种可以同时处理多个请求和响应的方法,让爬虫更加高效地从网页中抓取数据,就像在餐厅里同时点多道菜并且能够随时享受到它们一样。

1. 基本原理

要实现异步机制的爬虫,那自然和协程脱不了关系。了解协程之前需要先了解一些基础概念,如阻塞和非阻塞、同步和异步、多进程和协程。

  • 阻塞:

阻塞状态指的是程序未得到所需的计算资源时被挂起的状态。程序在等待某个操作完成期间,自身无法继续干别的事情,则称该程序在该操作中是阻塞的。常见的阻塞有:网络I/O阻塞、磁盘I/O阻塞、用户输入阻塞等。

  • 非阻塞:

程序在等待某个操作的过程中,自身不被阻塞,可以继续干别的事情,则称该程序在该操作上非阻塞的。非阻塞并不是在任何程序级别、任何情况下都存在的。仅当程序封装的级别可以囊括独立的子程序单元时,程序才可能存在非阻塞状态。

  • 同步:

不同程序单元为了共同完成某个任务,在执行过程中需要靠某种通信方式保持协调一致,此时这些程序单元是同步执行的。简而言之,同步意味着有序。

  • 异步:

异步编程是一种编程范式,用于处理那些可以独立运行,互不影响的任务,而无需等待其它任务完成。这种情况下,不同的任务可以并行执行,无需等待每个任务完成后再开始下一个任务。

假设您要从不同的网页上爬取数据。每个网页是一个独立的任务。使用异步编程,您可以同时开始爬取多个网页,而无需等待每个网页的爬取完成,然后再爬取下一个。这样,您可以充分利用计算资源,同时进行多个任务。

在这个过程中,不同网页的下载、保存等操作是独立的,它们不需要相互通信或协调。您只需告诉程序启动每个任务,并且程序会自动在适当的时候切换任务,让每个任务有机会执行。因为这些任务是无序的,它们的完成时刻并不确定,取决于网络延迟、服务器响应速度等因素。

综上所述,异步编程允许独立的任务并行执行,而无需等待彼此完成。每个任务在需要等待某些操作完成时可以暂停,让其他任务有机会执行。这样可以提高效率,特别是在处理需要等待的 I/O 操作(例如网络请求)时。

  • 多进程:

多进程就是利用CPU的多核优势,在同一时间并行执行多个任务。

  • 协程:

协程,英文叫做coroutine,又称微线程、纤程,是一种运行在用户态的轻量级线程。协程拥有自己的寄存器和栈。协程在调度切换时,将寄存器上下文和栈保存到其他地方,等切回来的时,再恢复之前保存的寄存器上下文和栈。因此,协程能保留上一次调度时的状态,即所有布局状态的一个特定组合,每一次过程重入,就相当于进入了上一次调用的状态。

2. 协程的用法

2.1. 基本用法

Python中使用协程最常用的库莫过于asyncio,接下来以它为基础讲解协程的用法,首先先来了解一下下面几个概念:

  • event_loop:事件循环,相当于一个无限循环,我们可以把一些函数注册到这个事件循环上,当满足发生条件的时候,就调用对应的处理方法。
  • coroutine:协程,在python中常常代指协程对象类型,我们可以将协程对象注册到事件循环中,它会被事件循环调用。我们可以用async关键字来定义一个方法,这个方法在调用时候不会立即被执行,而是返回一个协程对象。
  • task:任务,这是对协程对象的进一步封装,包含协程对象的各个状态。
  • future:代表将来执行或者没有执行的任务的结果,实际上和task没有本质区别。

我们来定义一个协程,体验一下它和普通进程的不同之处:

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方法获取结果。

2.2. 多任务协程

在上面的例子中,我们都只进行了一次循环,我们可以定义一个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())

3. 一个错误的示例:

根据我现在看的这本书——python3网络爬虫开发与实战(第二版)我觉得有必要将错误的样例总结一遍。

3.1. 案例网站

我们先来看一个案例网站,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)

结果就不放出来了,等太久了。

3.2. 错误处理-1

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后面的对象必须是如下格式之一:

  • 一个原生协程对象。
  • 一个由types.coroutine修饰的生成器,这个生成器可以返回协程对象。
  • 由一个包含_ 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。

4. 简单使用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秒! 在前面提到:它们的完成时刻并不确定,取决于网络延迟、服务器响应速度等因素。 你可以加上标号试试。

5. aiohttp的使用

前面介绍的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)

5.2. 其他请求类型

和requests类似,session.get(post)(delete)等

5.3. 响应

对于响应,我们可以用如下方法来获取其中的状态码、响应头、响应体、响应体二进制内容、响应体json结果:

  • 状态码:response.status
  • 响应头:response.headers
  • 响应体:await response.text()
  • 响应体二进制:await response.read()
  • 响应体json:await response.json()

5.4. 超时设置

我们可以借助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)

5.5. 并发限制

由于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/

5.6. 一个简单的并发

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())

6. aiohttp异步爬取实战

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)

你可能感兴趣的:(爬虫,php,前端)