使用aiohttp+MongoDB完成異步爬蟲

? ? 我們知道爬蟲是 IO 密集型任務,比如如果我們使用 requests 庫來爬取某個站點的話,發出一個請求之后,程序必須要等待網站返回響應之后才能接著運行,而在等待響應的過程中,整個爬蟲程序是一直在等待的,實際上沒有做任何的事情。如果每個頁面都至少要等待 5 秒才能加載出來,因此 100 個頁面至少要花費 500 秒的時間,將近 9 分鐘。這個在實際情況下是很常見的,有些網站本身加載速度就比較慢,稍慢的可能 1~3 秒,更慢的說不定 10 秒以上才可能加載出來。如果我們用 requests 單線程這么爬取的話,總的耗時是非常多的。此時如果我們開了多線程或多進程來爬取的話,其爬取速度確實會成倍提升,但有沒有更好的解決方案呢?

? ? 下面我們來了解一下使用異步執行方式來加速的方法,此種方法對于 IO 密集型任務非常有效。如將其應用到網絡爬蟲中,爬取效率甚至可以成百倍地提升。

基本了解

在了解異步協程之前,我們首先得了解一些基礎概念,如阻塞和非阻塞、同步和異步、多進程和協程。

阻塞

阻塞狀態指程序未得到所需計算資源時被掛起的狀態。程序在等待某個操作完成期間,自身無法繼續處理其他的事情,則稱該程序在該操作上是阻塞的。

常見的阻塞形式有:網絡 I/O 阻塞、磁盤 I/O 阻塞、用戶輸入阻塞等。阻塞是無處不在的,包括 CPU 切換上下文時,所有的進程都無法真正處理事情,它們也會被阻塞。如果是多核 CPU 則正在執行上下文切換操作的核不可被利用。

非阻塞

程序在等待某操作過程中,自身不被阻塞,可以繼續處理其他的事情,則稱該程序在該操作上是非阻塞的。

非阻塞并不是在任何程序級別、任何情況下都可以存在的。僅當程序封裝的級別可以囊括獨立的子程序單元時,它才可能存在非阻塞狀態。

非阻塞的存在是因為阻塞存在,正因為某個操作阻塞導致的耗時與效率低下,我們才要把它變成非阻塞的。

同步

不同程序單元為了完成某個任務,在執行過程中需靠某種通信方式以協調一致,我們稱這些程序單元是同步執行的。

例如購物系統中更新商品庫存,需要用“行鎖”作為通信信號,讓不同的更新請求強制排隊順序執行,那更新庫存的操作是同步的。

簡言之,同步意味著有序。

異步

為完成某個任務,不同程序單元之間過程中無需通信協調,也能完成任務的方式,不相關的程序單元之間可以是異步的。

例如,爬蟲下載網頁。調度程序調用下載程序后,即可調度其他任務,而無需與該下載任務保持通信以協調行為。不同網頁的下載、保存等操作都是無關的,也無需相互通知協調。這些異步操作的完成時刻并不確定。

簡言之,異步意味著無序。

多進程

多進程就是利用 CPU 的多核優勢,在同一時間并行地執行多個任務,可以大大提高執行效率。

協程

協程,英文叫作 Coroutine,又稱微線程、纖程,協程是一種用戶態的輕量級線程。

協程擁有自己的寄存器上下文和棧。協程調度切換時,將寄存器上下文和棧保存到其他地方,在切回來的時候,恢復先前保存的寄存器上下文和棧。因此協程能保留上一次調用時的狀態,即所有局部狀態的一個特定組合,每次過程重入時,就相當于進入上一次調用的狀態。

協程本質上是個單進程,協程相對于多進程來說,無需線程上下文切換的開銷,無需原子操作鎖定及同步的開銷,編程模型也非常簡單。

我們可以使用協程來實現異步操作,比如在網絡爬蟲場景下,我們發出一個請求之后,需要等待一定的時間才能得到響應,但其實在這個等待過程中,程序可以干許多其他的事情,等到響應得到之后才切換回來繼續處理,這樣可以充分利用 CPU 和其他資源,這就是協程的優勢。

協程用法

接下來,我們來了解下協程的實現,從 Python 3.4 開始,Python 中加入了協程的概念,但這個版本的協程還是以生成器對象為基礎的,在 Python 3.5 則增加了 async/await,使得協程的實現更加方便。

Python 中使用協程最常用的庫莫過于 asyncio,所以本文會以 asyncio 為基礎來介紹協程的使用。

首先我們需要了解下面幾個概念。

·event_loop:事件循環,相當于一個無限循環,我們可以把一些函數注冊到這個事件循環上,當滿足條件發生的時候,就會調用對應的處理方法。

·coroutine:中文翻譯叫協程,在 Python 中常指代為協程對象類型,我們可以將協程對象注冊到時間循環中,它會被事件循環調用。我們可以使用 async 關鍵字來定義一個方法,這個方法在調用時不會立即被執行,而是返回一個協程對象。

·task:任務,它是對協程對象的進一步封裝,包含了任務的各個狀態。

·future:代表將來執行或沒有執行的任務的結果,實際上和 task 沒有本質區別。

另外我們還需要了解 async/await 關鍵字,它是從 Python 3.5 才出現的,專門用于定義協程。其中,async 定義一個協程,await 用來掛起阻塞方法的執行。

定義協程

首先我們來定義一個協程,體驗一下它和普通進程在實現上的不同之處,代碼如下:

import asyncio

async def execute(x):

? print('Number:', x)

coroutine = execute(1)

print('Coroutine:', coroutine)

print('After calling execute')

loop = asyncio.get_event_loop()

loop.run_until_complete(coroutine)

print('After calling loop')

運行結果:

Coroutine: <coroutine object execute at 0x1034cf830>

After calling execute

Number: 1

After calling loop

首先我們引入了 asyncio 這個包,這樣我們才可以使用 async 和 await,然后我們使用 async 定義了一個 execute 方法,方法接收一個數字參數,方法執行之后會打印這個數字。

隨后我們直接調用了這個方法,然而這個方法并沒有執行,而是返回了一個 coroutine 協程對象。隨后我們使用 get_event_loop 方法創建了一個事件循環 loop,并調用了 loop 對象的 run_until_complete 方法將協程注冊到事件循環 loop 中,然后啟動。最后我們才看到了 execute 方法打印了輸出結果。

可見,async 定義的方法就會變成一個無法直接執行的 coroutine 對象,必須將其注冊到事件循環中才可以執行。

上面我們還提到了 task,它是對 coroutine 對象的進一步封裝,它里面相比 coroutine 對象多了運行狀態,比如 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: <coroutine object execute at 0x10e0f7830>

After calling execute

Task: <Task pending coro=<execute() running at demo.py:4>>

Number: 1

Task: <Task finished coro=<execute() done, defined at demo.py:4> result=1>

After calling loop

這里我們定義了 loop 對象之后,接著調用了它的 create_task 方法將 coroutine 對象轉化為了 task 對象,隨后我們打印輸出一下,發現它是 pending 狀態。接著我們將 task 對象添加到事件循環中得到執行,隨后我們再打印輸出一下 task 對象,發現它的狀態就變成了 finished,同時還可以看到其 result 變成了 1,也就是我們定義的 execute 方法的返回結果。

另外定義 task 對象還有一種方式,就是直接通過 asyncio 的 ensure_future 方法,返回結果也是 task 對象,這樣的話我們就可以不借助于 loop 來定義,即使我們還沒有聲明 loop 也可以提前定義好 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: <coroutine object execute at 0x10aa33830>

After calling execute

Task: <Task pending coro=<execute() running at demo.py:4>>

Number: 1

Task: <Task finished coro=<execute() done, defined at demo.py:4> 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)

print('Task:', task)


loop = asyncio.get_event_loop()

loop.run_until_complete(task)

print('Task:', task)

在這里我們定義了一個 request 方法,請求了百度,獲取其狀態碼,但是這個方法里面我們沒有任何 print 語句。隨后我們定義了一個 callback 方法,這個方法接收一個參數,是 task 對象,然后調用 print 方法打印了 task 對象的結果。這樣我們就定義好了一個 coroutine 對象和一個回調方法,我們現在希望的效果是,當 coroutine 對象執行完畢之后,就去執行聲明的 callback 方法。

那么它們二者怎樣關聯起來呢?很簡單,只需要調用 add_done_callback 方法即可,我們將 callback 方法傳遞給了封裝好的 task 對象,這樣當 task 執行完畢之后就可以調用 callback 方法了,同時 task 對象還會作為參數傳遞給 callback 方法,調用 task 對象的 result 方法就可以獲取返回結果了。

運行結果:

Task: <Task pending coro=<request() running at demo.py:5> cb=[callback() at demo.py:11]>

Status: <Response [200]>

Task: <Task finished coro=<request() done, defined at demo.py:5> result=<Response [200]>>

實際上不用回調方法,直接在 task 運行完畢之后也可以直接調用 result 方法獲取結果,如下所示:

import asyncio

import requests


async def request():

? url = 'https://www.baidu.com'

? status = requests.get(url)

? return status


coroutine = request()

task = asyncio.ensure_future(coroutine)

print('Task:', task)


loop = asyncio.get_event_loop()

loop.run_until_complete(task)

print('Task:', task)

print('Task Result:', task.result())

運行結果是一樣的:

Task: <Task pending coro=<request() running at demo.py:4>>

Task: <Task finished coro=<request() done, defined at demo.py:4> result=<Response [200]>>

Task Result: <Response [200]>

多任務協程

上面的例子我們只執行了一次請求,如果我們想執行多次請求應該怎么辦呢?我們可以定義一個 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())

這里我們使用一個 for 循環創建了五個 task,組成了一個列表,然后把這個列表首先傳遞給了 asyncio 的 wait() 方法,然后再將其注冊到時間循環中,就可以發起五個任務了。最后我們再將任務的運行結果輸出出來,運行結果如下:

Tasks: [<Task pending coro=<request() running at demo.py:5>>,

<Task pending coro=<request() running at demo.py:5>>,

<Task pending coro=<request() running at demo.py:5>>,

<Task pending coro=<request() running at demo.py:5>>,

<Task pending coro=<request() running at demo.py:5>>]

Task Result: <Response [200]>

Task Result: <Response [200]>

Task Result: <Response [200]>

Task Result: <Response [200]>

Task Result: <Response [200]>

可以看到五個任務被順次執行了,并得到了運行結果。

協程實現

前面講了這么多,又是 async,又是 coroutine,又是 task,又是 callback,但似乎并沒有看出協程的優勢啊?反而寫法上更加奇怪和麻煩了,別急,上面的案例只是為后面的使用作鋪墊,接下來我們正式來看下協程在解決 IO 密集型任務上有怎樣的優勢吧!

上面的代碼中,我們用一個網絡請求作為示例,這就是一個耗時等待的操作,因為我們請求網頁之后需要等待頁面響應并返回結果。耗時等待的操作一般都是 IO 操作,比如文件讀取、網絡請求等等。協程對于處理這種操作是有很大優勢的,當遇到需要等待的情況的時候,程序可以暫時掛起,轉而去執行其他的操作,從而避免一直等待一個程序而耗費過多的時間,充分利用資源。

為了表現出協程的優勢,我們還是拿本課時開始介紹的網站 https://static4.scrape.center/ 為例來進行演示,因為該網站響應比較慢,所以我們可以通過爬取時間來直觀地感受到爬取速度的提升。

為了讓你更好地理解協程的正確使用方法,這里我們先來看看使用協程時常犯的錯誤,后面再給出正確的例子來對比一下。

首先,我們還是拿之前的 requests 來進行網頁請求,接下來我們再重新使用上面的方法請求一遍:

import asyncio

import requests

import time


start = time.time()


async def request():

? url = 'https://static4.scrape.center/'

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

在這里我們還是創建了 10 個 task,然后將 task 列表傳給 wait 方法并注冊到時間循環中執行。

運行結果如下:

Waiting for https://static4.scrape.center/

Get response from https://static4.scrape.center/ response <Response [200]>

Waiting for https://static4.scrape.center/

Get response from https://static4.scrape.center/ response <Response [200]>

Waiting for https://static4.scrape.center/

...

Get response from https://static4.scrape.center/ response <Response [200]>

Waiting for https://static4.scrape.center/

Get response from https://static4.scrape.center/ response <Response [200]>

Waiting for https://static4.scrape.center/

Get response from https://static4.scrape.center/ response <Response [200]>

Cost time: 51.422438859939575

可以發現和正常的請求并沒有什么兩樣,依然還是順次執行的,耗時 51 秒,平均一個請求耗時 5 秒,說好的異步處理呢?

其實,要實現異步處理,我們得先要有掛起的操作,當一個任務需要等待 IO 結果的時候,可以掛起當前任務,轉而去執行其他任務,這樣我們才能充分利用好資源,上面方法都是一本正經的串行走下來,連個掛起都沒有,怎么可能實現異步?想太多了。

要實現異步,接下來我們需要了解一下 await 的用法,使用 await 可以將耗時等待的操作掛起,讓出控制權。當協程執行的時候遇到 await,時間循環就會將本協程掛起,轉而去執行別的協程,直到其他的協程掛起或執行完畢。

所以,我們可能會將代碼中的 request 方法改成如下的樣子:

async def request():

? url = 'https://static4.scrape.center/'

? print('Waiting for', url)

? response = await requests.get(url)

? print('Get response from', url, 'response', response)

僅僅是在 requests 前面加了一個 await,然而執行以下代碼,會得到如下報錯:

Waiting for https://static4.scrape.center/

Waiting for https://static4.scrape.center/

Waiting for https://static4.scrape.center/

Waiting for https://static4.scrape.center/

...

Task exception was never retrieved

future: <Task finished coro=<request() done, defined at demo.py:8> exception=TypeError("object Response can't be used in 'await' expression")>

Traceback (most recent call last):

File "demo.py", line 11, in request

? response = await requests.get(url)

TypeError: object Response can't be used in 'await' expression

這次它遇到 await 方法確實掛起了,也等待了,但是最后卻報了這么個錯,這個錯誤的意思是 requests 返回的 Response 對象不能和 await 一起使用,為什么呢?因為根據官方文檔說明,await 后面的對象必須是如下格式之一:

A native coroutine object returned from a native coroutine function,一個原生 coroutine 對象。

A generator-based coroutine object returned from a function decorated with types.coroutine,一個由 types.coroutine 修飾的生成器,這個生成器可以返回 coroutine 對象。

An object with an __await__ method returning an iterator,一個包含 __await__ 方法的對象返回的一個迭代器。

可以參見:https://www.python.org/dev/peps/pep-0492/#await-expression。

requests 返回的 Response 不符合上面任一條件,因此就會報上面的錯誤了。

那么你可能會發現,既然 await 后面可以跟一個 coroutine 對象,那么我用 async 把請求的方法改成 coroutine 對象不就可以了嗎?所以就改寫成如下的樣子:

import asyncio

import requests

import time


start = time.time()


async def get(url):

? return requests.get(url)


async def request():

? url = 'https://static4.scrape.center/'

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

這里我們將請求頁面的方法獨立出來,并用 async 修飾,這樣就得到了一個 coroutine 對象,我們運行一下看看:

Waiting for https://static4.scrape.center/

Get response from https://static4.scrape.center/ response <Response [200]>

Waiting for https://static4.scrape.center/

Get response from https://static4.scrape.center/ response <Response [200]>

Waiting for https://static4.scrape.center/

...

Get response from https://static4.scrape.center/ response <Response [200]>

Waiting for https://static4.scrape.center/

Get response from https://static4.scrape.center/ response <Response [200]>

Waiting for https://static4.scrape.center/

Get response from https://static4.scrape.center/ response <Response [200]>

Cost time: 51.394437756259273

還是不行,它還不是異步執行,也就是說我們僅僅將涉及 IO 操作的代碼封裝到 async 修飾的方法里面是不可行的!我們必須要使用支持異步操作的請求方式才可以實現真正的異步,所以這里就需要 aiohttp 派上用場了。

使用 aiohttp

aiohttp 是一個支持異步請求的庫,利用它和 asyncio 配合我們可以非常方便地實現異步請求操作。

安裝方式如下:

pip3 install aiohttp

官方文檔鏈接為:https://aiohttp.readthedocs.io/,它分為兩部分,一部分是 Client,一部分是 Server,詳細的內容可以參考官方文檔。

下面我們將 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://static4.scrape.center/'

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

在這里我們將請求庫由 requests 改成了 aiohttp,通過 aiohttp 的 ClientSession 類的 get 方法進行請求,結果如下:

Waiting for https://static4.scrape.center/

Waiting for https://static4.scrape.center/

Waiting for https://static4.scrape.center/

Waiting for https://static4.scrape.center/

Waiting for https://static4.scrape.center/

Waiting for https://static4.scrape.center/

Waiting for https://static4.scrape.center/

Waiting for https://static4.scrape.center/

Waiting for https://static4.scrape.center/

Waiting for https://static4.scrape.center/

Get response from https://static4.scrape.center/ response <ClientResponse(https://static4.scrape.center/) [200 OK]>

<CIMultiDictProxy('Server': 'nginx/1.17.8', 'Date': 'Tue, 31 Mar 2020 09:35:43 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', 'Vary': 'Accept-Encoding', 'X-Frame-Options': 'SAMEORIGIN', 'Strict-Transport-Security': 'max-age=15724800; includeSubDomains', 'Content-Encoding': 'gzip')>

...

Get response from https://static4.scrape.center/ response <ClientResponse(https://static4.scrape.center/) [200 OK]>

<CIMultiDictProxy('Server': 'nginx/1.17.8', 'Date': 'Tue, 31 Mar 2020 09:35:44 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', 'Vary': 'Accept-Encoding', 'X-Frame-Options': 'SAMEORIGIN', 'Strict-Transport-Security': 'max-age=15724800; includeSubDomains', 'Content-Encoding': 'gzip')>

Cost time: 6.1102519035339355

成功了!我們發現這次請求的耗時由 51 秒變直接成了 6 秒,耗費時間減少了非常非常多。

代碼里面我們使用了 await,后面跟了 get 方法,在執行這 10 個協程的時候,如果遇到了 await,那么就會將當前協程掛起,轉而去執行其他的協程,直到其他的協程也掛起或執行完畢,再進行下一個協程的執行。

開始運行時,時間循環會運行第一個 task,針對第一個 task 來說,當執行到第一個 await 跟著的 get 方法時,它被掛起,但這個 get 方法第一步的執行是非阻塞的,掛起之后立馬被喚醒,所以立即又進入執行,創建了 ClientSession 對象,接著遇到了第二個 await,調用了 session.get 請求方法,然后就被掛起了,由于請求需要耗時很久,所以一直沒有被喚醒。

當第一個 task 被掛起了,那接下來該怎么辦呢?事件循環會尋找當前未被掛起的協程繼續執行,于是就轉而執行第二個 task 了,也是一樣的流程操作,直到執行了第十個 task 的 session.get 方法之后,全部的 task 都被掛起了。所有 task 都已經處于掛起狀態,怎么辦?只好等待了。5 秒之后,幾個請求幾乎同時都有了響應,然后幾個 task 也被喚醒接著執行,輸出請求結果,最后總耗時,6 秒!

怎么樣?這就是異步操作的便捷之處,當遇到阻塞式操作時,任務被掛起,程序接著去執行其他的任務,而不是傻傻地等待,這樣可以充分利用 CPU 時間,而不必把時間浪費在等待 IO 上。

你可能會說,既然這樣的話,在上面的例子中,在發出網絡請求后,既然接下來的 5 秒都是在等待的,在 5 秒之內,CPU 可以處理的 task 數量遠不止這些,那么豈不是我們放 10 個、20 個、50 個、100 個、1000 個 task 一起執行,最后得到所有結果的耗時不都是差不多的嗎?因為這幾個任務被掛起后都是一起等待的。

理論來說確實是這樣的,不過有個前提,那就是服務器在同一時刻接受無限次請求都能保證正常返回結果,也就是服務器無限抗壓,另外還要忽略 IO 傳輸時延,確實可以做到無限 task 一起執行且在預想時間內得到結果。但由于不同服務器處理的實現機制不同,可能某些服務器并不能承受這么高的并發,因此響應速度也會減慢。

在這里我們以百度為例,來測試下并發數量為 1、3、5、10、...、500 的情況下的耗時情況,代碼如下:

import asyncio

import aiohttp

import time



def test(number):

? 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.baidu.com/'

? ? ? await get(url)

? tasks = [asyncio.ensure_future(request()) for _ in range(number)]

? loop = asyncio.get_event_loop()

? loop.run_until_complete(asyncio.wait(tasks))

? end = time.time()

? print('Number:', number, 'Cost time:', end - start)


for number in [1, 3, 5, 10, 15, 30, 50, 75, 100, 200, 500]:

? test(number)

運行結果如下:

Number: 1 Cost time: 0.05885505676269531

Number: 3 Cost time: 0.05773782730102539

Number: 5 Cost time: 0.05768704414367676

Number: 10 Cost time: 0.15174412727355957

Number: 15 Cost time: 0.09603095054626465

Number: 30 Cost time: 0.17843103408813477

Number: 50 Cost time: 0.3741800785064697

Number: 75 Cost time: 0.2894289493560791

Number: 100 Cost time: 0.6185381412506104

Number: 200 Cost time: 1.0894129276275635

Number: 500 Cost time: 1.8213098049163818

可以看到,即使我們增加了并發數量,但在服務器能承受高并發的前提下,其爬取速度幾乎不太受影響。

綜上所述,使用了異步請求之后,我們幾乎可以在相同的時間內實現成百上千倍次的網絡請求,把這個運用在爬蟲中,速度提升是非常可觀的。

現在我們通過一個實戰案例來介紹下使用 aiohttp 完成網頁異步爬取的過程。

aiohttp

前面介紹的 asyncio 模塊內部實現了對 TCP、UDP、SSL 協議的異步操作,但是對于 HTTP 請求的異步操作來說,我們就需要用到 aiohttp 來實現了。

aiohttp 是一個基于 asyncio 的異步 HTTP 網絡模塊,它既提供了服務端,又提供了客戶端。其中我們用服務端可以搭建一個支持異步處理的服務器,用于處理請求并返回響應,類似于 Django、Flask、Tornado 等一些 Web 服務器。而客戶端我們就可以用來發起請求,就類似于 requests 來發起一個 HTTP 請求然后獲得響應,但 requests 發起的是同步的網絡請求,而 aiohttp 則發起的是異步的。

首先我們來看一個基本的 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://cuiqingcai.com')

? ? ? print(f'html: {html[:100]}...')

? ? ? print(f'status: {status}')

if __name__ == '__main__':

? loop = asyncio.get_event_loop()

? loop.run_until_complete(main())

在這里我們使用 aiohttp 來爬取了我的個人博客,獲得了源碼和響應狀態碼并輸出,運行結果如下:

html: <!DOCTYPE HTML>

<html>

<head>

<meta charset="UTF-8">

<meta name="baidu-tc-verification" content=...

status: 200

這里網頁源碼過長,只截取輸出了一部分,可以看到我們成功獲取了網頁的源代碼及響應狀態碼 200,也就完成了一次基本的 HTTP 請求,即我們成功使用 aiohttp 通過異步的方式進行了網頁的爬取,當然這個操作用之前我們所講的 requests 同樣也可以做到。

我們可以看到其請求方法的定義和之前有了明顯的區別,主要有如下幾點:

·首先在導入庫的時候,我們除了必須要引入 aiohttp 這個庫之外,還必須要引入 asyncio 這個庫,因為要實現異步爬取需要啟動協程,而協程則需要借助于 asyncio 里面的事件循環來執行。除了事件循環,asyncio 里面也提供了很多基礎的異步操作。

·異步爬取的方法的定義和之前有所不同,在每個異步方法前面統一要加 async 來修飾。

·with as 語句前面同樣需要加 async 來修飾,在 Python 中,with as 語句用于聲明一個上下文管理器,能夠幫我們自動分配和釋放資源,而在異步方法中,with as 前面加上 async 代表聲明一個支持異步的上下文管理器。

·對于一些返回 coroutine 的操作,前面需要加 await 來修飾,如 response 調用 text 方法,查詢 API 可以發現其返回的是 coroutine 對象,那么前面就要加 await;而對于狀態碼來說,其返回值就是一個數值類型,那么前面就不需要加 await。所以,這里可以按照實際情況處理,參考官方文檔說明,看看其對應的返回值是怎樣的類型,然后決定加不加 await 就可以了。

·最后,定義完爬取方法之后,實際上是 main 方法調用了 fetch 方法。要運行的話,必須要啟用事件循環,事件循環就需要使用 asyncio 庫,然后使用 run_until_complete 方法來運行。

注意在 Python 3.7 及以后的版本中,我們可以使用 asyncio.run(main()) 來代替最后的啟動操作,不需要顯式聲明事件循環,run 方法內部會自動啟動一個事件循環。但這里為了兼容更多的 Python 版本,依然還是顯式聲明了事件循環。

URL 參數設置

對于 URL 參數的設置,我們可以借助于 params 參數,傳入一個字典即可,示例如下:

import aiohttp

import asyncio

async def main():

? params = {'name': 'germey', 'age': 25}

? async with aiohttp.ClientSession() as session:

? ? ? async with session.get('https://httpbin.org/get', params=params) as response:

? ? ? ? ? print(await response.text())

if __name__ == '__main__':

? asyncio.get_event_loop().run_until_complete(main())

運行結果如下:

{

"args": {

? "age": "25",

? "name": "germey"

},

"headers": {

? "Accept": "*/*",

? "Accept-Encoding": "gzip, deflate",

? "Host": "httpbin.org",

? "User-Agent": "Python/3.7 aiohttp/3.6.2",

? "X-Amzn-Trace-Id": "Root=1-5e85eed2-d240ac90f4dddf40b4723ef0"

},

"origin": "17.20.255.122",

"url": "https://httpbin.org/get?name=germey&age=25"

}

這里可以看到,其實際請求的 URL 為 https://httpbin.org/get?name=germey&age=25,其 URL 請求參數就對應了 params 的內容。

其他請求類型

另外 aiohttp 還支持其他的請求類型,如 POST、PUT、DELETE 等等,這個和 requests 的使用方式有點類似,示例如下:

session.post('http://httpbin.org/post', data=b'data')

session.put('http://httpbin.org/put', data=b'data')

session.delete('http://httpbin.org/delete')

session.head('http://httpbin.org/get')

session.options('http://httpbin.org/get')

session.patch('http://httpbin.org/patch', data=b'data')

POST 數據

對于 POST 表單提交,其對應的請求頭的 Content-type 為 application/x-www-form-urlencoded,我們可以用如下方式來實現,代碼示例如下:

import aiohttp

import asyncio

async def main():

? data = {'name': 'germey', 'age': 25}

? async with aiohttp.ClientSession() as session:

? ? ? async with session.post('https://httpbin.org/post', data=data) as response:

? ? ? ? ? print(await response.text())

if __name__ == '__main__':

? asyncio.get_event_loop().run_until_complete(main())

運行結果如下:

{

"args": {},

"data": "",

"files": {},

"form": {

? "age": "25",

? "name": "germey"

},

"headers": {

? "Accept": "*/*",

? "Accept-Encoding": "gzip, deflate",

? "Content-Length": "18",

? "Content-Type": "application/x-www-form-urlencoded",

? "Host": "httpbin.org",

? "User-Agent": "Python/3.7 aiohttp/3.6.2",

? "X-Amzn-Trace-Id": "Root=1-5e85f0b2-9017ea603a68dc285e0552d0"

},

"json": null,

"origin": "17.20.255.58",

"url": "https://httpbin.org/post"

}

對于 POST JSON 數據提交,其對應的請求頭的 Content-type 為 application/json,我們只需要將 post 方法的 data 參數改成 json 即可,代碼示例如下:

async def main():

? data = {'name': 'germey', 'age': 25}

? async with aiohttp.ClientSession() as session:

? ? ? async with session.post('https://httpbin.org/post', json=data) as response:

? ? ? ? ? print(await response.text())

運行結果如下:

{

"args": {},

"data": "{\"name\": \"germey\", \"age\": 25}",

"files": {},

"form": {},

"headers": {

? "Accept": "*/*",

? "Accept-Encoding": "gzip, deflate",

? "Content-Length": "29",

? "Content-Type": "application/json",

? "Host": "httpbin.org",

? "User-Agent": "Python/3.7 aiohttp/3.6.2",

? "X-Amzn-Trace-Id": "Root=1-5e85f03e-c91c9a20c79b9780dbed7540"

},

"json": {

? "age": 25,

? "name": "germey"

},

"origin": "17.20.255.58",

"url": "https://httpbin.org/post"

}

響應字段

對于響應來說,我們可以用如下的方法分別獲取響應的狀態碼、響應頭、響應體、響應體二進制內容、響應體 JSON 結果,代碼示例如下:

import aiohttp

import asyncio

async def main():

? data = {'name': 'germey', 'age': 25}

? async with aiohttp.ClientSession() as session:

? ? ? async with session.post('https://httpbin.org/post', data=data) as response:

? ? ? ? ? print('status:', response.status)

? ? ? ? ? print('headers:', response.headers)

? ? ? ? ? print('body:', await response.text())

? ? ? ? ? print('bytes:', await response.read())

? ? ? ? ? print('json:', await response.json())

if __name__ == '__main__':

? asyncio.get_event_loop().run_until_complete(main())

運行結果如下:

status: 200

headers: <CIMultiDictProxy('Date': 'Thu, 02 Apr 2020 14:13:05 GMT', 'Content-Type': 'application/json', 'Content-Length': '503', 'Connection': 'keep-alive', 'Server': 'gunicorn/19.9.0', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Credentials': 'true')>

body: {

"args": {},

"data": "",

"files": {},

"form": {

? "age": "25",

? "name": "germey"

},

"headers": {

? "Accept": "*/*",

? "Accept-Encoding": "gzip, deflate",

? "Content-Length": "18",

? "Content-Type": "application/x-www-form-urlencoded",

? "Host": "httpbin.org",

? "User-Agent": "Python/3.7 aiohttp/3.6.2",

? "X-Amzn-Trace-Id": "Root=1-5e85f2f1-f55326ff5800b15886c8e029"

},

"json": null,

"origin": "17.20.255.58",

"url": "https://httpbin.org/post"

}

bytes: b'{\n? "args": {}, \n? "data": "", \n? "files": {}, \n? "form": {\n? ? "age": "25", \n? ? "name": "germey"\n? }, \n? "headers": {\n? ? "Accept": "*/*", \n? ? "Accept-Encoding": "gzip, deflate", \n? ? "Content-Length": "18", \n? ? "Content-Type": "application/x-www-form-urlencoded", \n? ? "Host": "httpbin.org", \n? ? "User-Agent": "Python/3.7 aiohttp/3.6.2", \n? ? "X-Amzn-Trace-Id": "Root=1-5e85f2f1-f55326ff5800b15886c8e029"\n? }, \n? "json": null, \n? "origin": "17.20.255.58", \n? "url": "https://httpbin.org/post"\n}\n'

json: {'args': {}, 'data': '', 'files': {}, 'form': {'age': '25', 'name': 'germey'}, 'headers': {'Accept': '*/*', 'Accept-Encoding': 'gzip, deflate', 'Content-Length': '18', 'Content-Type': 'application/x-www-form-urlencoded', 'Host': 'httpbin.org', 'User-Agent': 'Python/3.7 aiohttp/3.6.2', 'X-Amzn-Trace-Id': 'Root=1-5e85f2f1-f55326ff5800b15886c8e029'}, 'json': None, 'origin': '17.20.255.58', 'url': 'https://httpbin.org/post'}

這里我們可以看到有些字段前面需要加 await,有的則不需要。其原則是,如果其返回的是一個 coroutine 對象(如 async 修飾的方法),那么前面就要加 await,具體可以看 aiohttp 的 API,其鏈接為:https://docs.aiohttp.org/en/stable/client_reference.html

超時設置

對于超時的設置,我們可以借助于 ClientTimeout 對象,比如這里我要設置 1 秒的超時,可以這么來實現:

import aiohttp

import asyncio

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)

if __name__ == '__main__':

? asyncio.get_event_loop().run_until_complete(main())

如果在 1 秒之內成功獲取響應的話,運行結果如下:

200

如果超時的話,會拋出 TimeoutError 異常,其類型為 asyncio.TimeoutError,我們再進行異常捕獲即可。

另外 ClientTimeout 對象聲明時還有其他參數,如 connect、socket_connect 等,詳細說明可以參考官方文檔:https://docs.aiohttp.org/en/stable/client_quickstart.html#timeouts。

并發限制

由于 aiohttp 可以支持非常大的并發,比如上萬、十萬、百萬都是能做到的,但這么大的并發量,目標網站是很可能在短時間內無法響應的,而且很可能瞬時間將目標網站爬掛掉。所以我們需要控制一下爬取的并發量。

在一般情況下,我們可以借助于 asyncio 的 Semaphore 來控制并發量,代碼示例如下:

import asyncio

import aiohttp

CONCURRENCY = 5

URL = 'https://www.baidu.com'

semaphore = asyncio.Semaphore(CONCURRENCY)

session = None

async def scrape_api():

? async with semaphore:

? ? ? print('scraping', URL)

? ? ? async with session.get(URL) as response:

? ? ? ? ? await asyncio.sleep(1)

? ? ? ? ? return await response.text()

async def main():

? global session

? session = aiohttp.ClientSession()

? scrape_index_tasks = [asyncio.ensure_future(scrape_api()) for _ in range(10000)]

? await asyncio.gather(*scrape_index_tasks)

if __name__ == '__main__':

? asyncio.get_event_loop().run_until_complete(main())

在這里我們聲明了 CONCURRENCY 代表爬取的最大并發量為 5,同時聲明爬取的目標 URL 為百度。接著我們借助于 Semaphore 創建了一個信號量對象,賦值為 semaphore,這樣我們就可以用它來控制最大并發量了。怎么使用呢?我們這里把它直接放置在對應的爬取方法里面,使用 async with 語句將 semaphore 作為上下文對象即可。這樣的話,信號量可以控制進入爬取的最大協程數量,最大數量就是我們聲明的 CONCURRENCY 的值。

在 main 方法里面,我們聲明了 10000 個 task,傳遞給 gather 方法運行。倘若不加以限制,這 10000 個 task 會被同時執行,并發數量太大。但有了信號量的控制之后,同時運行的 task 的數量最大會被控制在 5 個,這樣就能給 aiohttp 限制速度了。

在這里,aiohttp 的基本使用就介紹這么多,更詳細的內容還是推薦你到官方文檔查閱,鏈接:https://docs.aiohttp.org/。

爬取實戰

上面我們介紹了 aiohttp 的基本用法之后,下面我們來根據一個實例實現異步爬蟲的實戰演練吧。

本次我們要爬取的網站是:https://dynamic5.scrape.center/,頁面如圖所示。

這是一個書籍網站,整個網站包含了數千本書籍信息,網站是 JavaScript 渲染的,數據可以通過 Ajax 接口獲取到,并且接口沒有設置任何反爬措施和加密參數,另外由于這個網站比之前的電影案例網站數據量大一些,所以更加適合做異步爬取。

本課時我們要完成的目標有:

·使用 aiohttp 完成全站的書籍數據爬取。

·將數據通過異步的方式保存到 MongoDB 中。

環境準備:

安裝好了 Python(最低為 Python 3.6 版本,最好為 3.7 版本或以上),并能成功運行 Python 程序。

安裝并成功運行了 MongoDB 數據庫,并安裝了異步存儲庫 motor。

注:這里要實現 MongoDB 異步存儲,需要異步 MongoDB 存儲庫,叫作 motor,安裝命令為:pip3 install motor

頁面分析

在之前我們講解了 Ajax 的基本分析方法,本課時的站點結構和之前 Ajax 分析的站點結構類似,都是列表頁加詳情頁的結構,加載方式都是 Ajax,所以我們能輕松分析到如下信息:

·列表頁的 Ajax 請求接口格式為:https://dynamic5.scrape.center/api/book/?limit=18&offset={offset},limit 的值即為每一頁的書的個數,offset 的值為每一頁的偏移量,其計算公式為 offset = limit * (page - 1) ,如第 1 頁 offset 的值為 0,第 2 頁 offset 的值為 18,以此類推。

·列表頁 Ajax 接口返回的數據里 results 字段包含當前頁 18 本書的信息,其中每本書的數據里面包含一個字段 id,這個 id 就是書本身的 ID,可以用來進一步請求詳情頁。

·詳情頁的 Ajax 請求接口格式為:https://dynamic5.scrape.center/api/book/{id},id 即為書的 ID,可以從列表頁的返回結果中獲取。

實現思路

其實一個完善的異步爬蟲應該能夠充分利用資源進行全速爬取,其思路是維護一個動態變化的爬取隊列,每產生一個新的 task 就會將其放入隊列中,有專門的爬蟲消費者從隊列中獲取 task 并執行,能做到在最大并發量的前提下充分利用等待時間進行額外的爬取處理。

但上面的實現思路整體較為煩瑣,需要設計爬取隊列、回調函數、消費者等機制,需要實現的功能較多。由于我們剛剛接觸 aiohttp 的基本用法,本課時也主要是了解 aiohttp 的實戰應用,所以這里我們將爬取案例的實現稍微簡化一下。

在這里我們將爬取的邏輯拆分成兩部分,第一部分為爬取列表頁,第二部分為爬取詳情頁。由于異步爬蟲的關鍵點在于并發執行,所以我們可以將爬取拆分為兩個階段:

·第一階段為所有列表頁的異步爬取,我們可以將所有的列表頁的爬取任務集合起來,聲明為 task 組成的列表,進行異步爬取。

·第二階段則是拿到上一步列表頁的所有內容并解析,拿到所有書的 id 信息,組合為所有詳情頁的爬取任務集合,聲明為 task 組成的列表,進行異步爬取,同時爬取的結果也以異步的方式存儲到 MongoDB 里面。

因為兩個階段的拆分之后需要串行執行,所以可能不能達到協程的最佳調度方式和資源利用情況,但也差不了很多。但這個實現思路比較簡單清晰,代碼實現也比較簡單,能夠幫我們快速了解 aiohttp 的基本使用。

基本配置

首先我們先配置一些基本的變量并引入一些必需的庫,代碼如下:

import asyncio

import aiohttp

import logging

logging.basicConfig(level=logging.INFO,

? ? ? ? ? ? ? ? ? format='%(asctime)s - %(levelname)s: %(message)s')

INDEX_URL = 'https://dynamic5.scrape.center/api/book/?limit=18&offset={offset}'

DETAIL_URL = 'https://dynamic5.scrape.center/api/book/{id}'

PAGE_SIZE = 18

PAGE_NUMBER = 100

CONCURRENCY = 5

在這里我們導入了 asyncio、aiohttp、logging 這三個庫,然后定義了 logging 的基本配置。接著定義了 URL、爬取頁碼數量 PAGE_NUMBER、并發量 CONCURRENCY 等信息。

爬取列表頁

首先,第一階段我們就來爬取列表頁,還是和之前一樣,我們先定義一個通用的爬取方法,代碼如下:

semaphore = asyncio.Semaphore(CONCURRENCY)

session = None

async def scrape_api(url):

? async with semaphore:

? ? ? try:

? ? ? ? ? logging.info('scraping %s', url)

? ? ? ? ? async with session.get(url) as response:

? ? ? ? ? ? ? return await response.json()

? ? ? except aiohttp.ClientError:

? ? ? ? ? logging.error('error occurred while scraping %s', url, exc_info=True)

在這里我們聲明了一個信號量,用來控制最大并發數量。

接著我們定義了 scrape_api 方法,該方法接收一個參數 url。首先使用 async with 引入信號量作為上下文,接著調用了 session 的 get 方法請求這個 url,然后返回響應的 JSON 格式的結果。另外這里還進行了異常處理,捕獲了 ClientError,如果出現錯誤,會輸出異常信息。

接著,對于列表頁的爬取,實現如下:

async def scrape_index(page):

? url = INDEX_URL.format(offset=PAGE_SIZE * (page - 1))

? return await scrape_api(url)

這里定義了一個 scrape_index 方法用于爬取列表頁,它接收一個參數為 page,然后構造了列表頁的 URL,將其傳給 scrape_api 方法即可。這里注意方法同樣需要用 async 修飾,調用的 scrape_api 方法前面需要加 await,因為 scrape_api 調用之后本身會返回一個 coroutine。另外由于 scrape_api 返回結果就是 JSON 格式,因此 scrape_index 的返回結果就是我們想要爬取的信息,不需要再額外解析了。

好,接著我們定義一個 main 方法,將上面的方法串聯起來調用一下,實現如下:

import json

async def main():

? global session

? session = aiohttp.ClientSession()

? scrape_index_tasks = [asyncio.ensure_future(scrape_index(page)) for page in range(1, PAGE_NUMBER + 1)]

? results = await asyncio.gather(*scrape_index_tasks)

? logging.info('results %s', json.dumps(results, ensure_ascii=False, indent=2))

if __name__ == '__main__':

? asyncio.get_event_loop().run_until_complete(main())

這里我們首先聲明了 session 對象,即最初聲明的全局變量,將 session 作為全局變量的話我們就不需要每次在各個方法里面傳遞了,實現比較簡單。

接著我們定義了 scrape_index_tasks,它就是爬取列表頁的所有 task,接著我們調用 asyncio 的 gather 方法并傳入 task 列表,將結果賦值為 results,它是所有 task 返回結果組成的列表。

最后我們調用 main 方法,使用事件循環啟動該 main 方法對應的協程即可。

運行結果如下:

2020-04-03 03:45:54,692 - INFO: scraping https://dynamic5.scrape.center/api/book/?limit=18&offset=0

2020-04-03 03:45:54,707 - INFO: scraping https://dynamic5.scrape.center/api/book/?limit=18&offset=18

2020-04-03 03:45:54,707 - INFO: scraping https://dynamic5.scrape.center/api/book/?limit=18&offset=36

2020-04-03 03:45:54,708 - INFO: scraping https://dynamic5.scrape.center/api/book/?limit=18&offset=54

2020-04-03 03:45:54,708 - INFO: scraping https://dynamic5.scrape.center/api/book/?limit=18&offset=72

2020-04-03 03:45:56,431 - INFO: scraping https://dynamic5.scrape.center/api/book/?limit=18&offset=90

2020-04-03 03:45:56,435 - INFO: scraping https://dynamic5.scrape.center/api/book/?limit=18&offset=108

可以看到這里就開始異步爬取了,并發量是由我們控制的,目前為 5,當然也可以進一步調高并發量,在網站能承受的情況下,爬取速度會進一步加快。

最后 results 就是所有列表頁得到的結果,我們將其賦值為 results 對象,接著我們就可以用它來進行第二階段的爬取了。

爬取詳情頁

第二階段就是爬取詳情頁并保存數據了,由于每個詳情頁對應一本書,每本書需要一個 ID,而這個 ID 又正好存在 results 里面,所以下面我們就需要將所有詳情頁的 ID 獲取出來。

在 main 方法里增加 results 的解析代碼,實現如下:

ids = []

for index_data in results:

? if not index_data: continue

? for item in index_data.get('results'):

? ? ? ids.append(item.get('id'))

這樣 ids 就是所有書的 id 了,然后我們用所有的 id 來構造所有詳情頁對應的 task,來進行異步爬取即可。

那么這里再定義一個爬取詳情頁和保存數據的方法,實現如下:

from motor.motor_asyncio import AsyncIOMotorClient

MONGO_CONNECTION_STRING = 'mongodb://localhost:27017'

MONGO_DB_NAME = 'books'

MONGO_COLLECTION_NAME = 'books'

client = AsyncIOMotorClient(MONGO_CONNECTION_STRING)

db = client[MONGO_DB_NAME]

collection = db[MONGO_COLLECTION_NAME]

async def save_data(data):

? logging.info('saving data %s', data)

? if data:

? ? ? return await collection.update_one({

? ? ? ? ? 'id': data.get('id')

? ? ? }, {

? ? ? ? ? '$set': data

? ? ? }, upsert=True)

async def scrape_detail(id):

? url = DETAIL_URL.format(id=id)

? data = await scrape_api(url)

? await save_data(data)

這里我們定義了 scrape_detail 方法用于爬取詳情頁數據并調用 save_data 方法保存數據,save_data 方法用于將數據庫保存到 MongoDB 里面。

在這里我們用到了支持異步的 MongoDB 存儲庫 motor,MongoDB 的連接聲明和 pymongo 是類似的,保存數據的調用方法也是基本一致,不過整個都換成了異步方法。

好,接著我們就在 main 方法里面增加 scrape_detail 方法的調用即可,實現如下:

scrape_detail_tasks = [asyncio.ensure_future(scrape_detail(id)) for id in ids]

await asyncio.wait(scrape_detail_tasks)

await session.close()

在這里我們先聲明了 scrape_detail_tasks,即所有詳情頁的爬取 task 組成的列表,接著調用了 asyncio 的 wait 方法調用執行即可,當然這里也可以用 gather 方法,效果是一樣的,只不過返回結果略有差異。最后全部執行完畢關閉 session 即可。

一些詳情頁的爬取過程運行如下:

2020-04-03 04:00:32,576 - INFO: scraping https://dynamic5.scrape.center/api/book/2301475

2020-04-03 04:00:32,576 - INFO: scraping https://dynamic5.scrape.center/api/book/2351866

2020-04-03 04:00:32,577 - INFO: scraping https://dynamic5.scrape.center/api/book/2828384

2020-04-03 04:00:32,577 - INFO: scraping https://dynamic5.scrape.center/api/book/3040352

2020-04-03 04:00:32,578 - INFO: scraping https://dynamic5.scrape.center/api/book/3074810

2020-04-03 04:00:44,858 - INFO: saving data {'id': '3040352', 'comments': [{'id': '387952888', 'content': '溫馨文,青梅竹馬神馬的很有愛~'}, ..., {'id': '2005314253', 'content': '沈晉&秦央,文比較短,平平淡淡,貼近生活,短文的缺點不細膩'}], 'name': '那些風花雪月', 'authors': ['\n? ? ? ? ? ? 公子歡喜'], 'translators': [], 'publisher': '龍馬出版社', 'tags': ['公子歡喜', '耽美', 'BL', '小說', '現代', '校園', '耽美小說', '那些風花雪月'], 'url': 'https://book.douban.com/subject/3040352/', 'isbn': '9789866685156', 'cover': 'https://img9.doubanio.com/view/subject/l/public/s3029724.jpg', 'page_number': None, 'price': None, 'score': '8.1', 'introduction': '', 'catalog': None, 'published_at': '2008-03-26T16:00:00Z', 'updated_at': '2020-03-21T16:59:39.584722Z'}

2020-04-03 04:00:44,859 - INFO: scraping https://dynamic5.scrape.center/api/book/2994915

...

最后我們觀察下,爬取到的數據也都保存到 MongoDB 數據庫里面了,如圖所示:

至此,我們就使用 aiohttp 完成了書籍網站的異步爬取。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,835評論 6 534
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,676評論 3 419
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,730評論 0 380
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,118評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,873評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,266評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,330評論 3 443
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,482評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,036評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,846評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,025評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,575評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,279評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,684評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,953評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,751評論 3 394
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,016評論 2 375