上一部分整理了如何利用 aiohttp
和 asyncio
執行非同步網路請求,接下來我們將在此基礎上實現一個簡潔、普適的爬蟲框架。
一般網站抓取的流程是這樣的:
從入口頁面開始提取一組下一級頁面的連結,然後遞迴地執行下去,直到最後一層頁面為止。唯一不同的是對每一級頁面所要抓取的資訊,也就是需要的正規表示式不同,除此之外,請求頁面、分析內容、正則匹配的步驟是重複的,因此可以將上面的過程簡化為:
其中虛線框中的步驟可以抽象出來,即模擬瀏覽器行為的 Client
,Cleaning
方法用於對正則匹配的結果進行清理,並將下一級所需的入口地址返回給 Client
,在這一過程中也可能涉及到資料輸出到資料庫的過程。
這裡我參考了 Flask
(或 Sanic
)框架的設計,即利用 Python 裝飾器的語法特性,將不同頁面的 Cleaning
方法註冊到 Client
中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
import asyncio import aiohttp import async_timeout import re class AvGot(object): def __init__(self, loop=None): self.loop = loop self.headers = { "User-Agent": ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1)" " AppleWebKit/537.36 (KHTML, like Gecko)" " Chrome/54.0.2840.87 Safari/537.36"), } self.conn = aiohttp.TCPConnector(verify_ssl=False) self.session = aiohttp.ClientSession(loop=loop, connector=self.conn, headers=self.headers) self.pipe = [] async def fetch(self, url=""): with async_timeout(10): async with self.session.get(url) as resp: return await resp.text() async def extract(self, url="", regexp=r''): html = await self.fetch(url) matches = re.findall(regexp, html) return matches def entry(self, url='', regexp=r''): def wrapper(callback): self.pipe.append(callback) return wrapper def close(self): self.session() self.loop.close() def run(self): pass loop = asyncio.get_event_loop() av = AvGot(loop) reTag = re.compile('<a href="(\/tag\/.*?)">(.*?)<\/a>')) ROOT = "https://movie.douban.com/tag/" @av.entry(ROOT, reTag) async def entry_callback(result): # await db.save(result) 儲存到資料庫 def clean(row): return ("https://movie.douban.com{}".format(row[0]), row[1]) return list(map(clean, result))[:2] av.run() av.close() |
這時我們遇到一個比較棘手的問題:由於從當前頁面提取資料(extract()
)的過程是非同步的, 而在上一個頁面執行完成之前是無法進入下一個頁面的,也就是說同一級頁面之間是非同步的,不同層級頁面之間是同步的,那麼如何在 asyncio
中安排這種任務?
這其實是一個帶有遞迴屬性的生產者/消費者模型,上級頁面作為生產者只有在經過網路請求之後才能生產出下級所有入口連結,而下一級的消費者將成為下下一級的生產者……我們可以將事件迴圈看作是一個“傳送帶”,一些可能造成阻塞的任務(如extract()
)會被掛起,等阻塞任務完成後重新進入佇列等待被執行:
在上面的問題中,不同層級頁面的提取過程可以被封裝成 Task
並丟進任務佇列,只是不同任務攜帶不同的頁面地址、正規表示式、Cleaning
回撥函式等屬性,至於這些任務在具體執行時如何排程,就丟給事件迴圈去操心好了(這也是使用 asyncio
的一條基本原則):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
# 下面只列舉更改後的程式碼 from collections import namedtuple # Task 所需要攜帶的屬性 Node = namedtuple("Node", ["url", "re", "callback"]) class AvGot(object): _ENTRY = "ENTRY_NODE" def __init__(self, loop=None): self._prev_node = None self.pipe = {} # 非同步任務佇列 self.queue = asyncio.Queue() def entry(self, url="", regexp=""): def wrapper(callback): node = Node(url, regexp, callback) if self.pipe.get(self._ENTRY) is None: self.pipe[self._ENTRY] = node else: # 以 Cleaning 函式而不是 node 作為 Key # 因為任務佇列中需要構造新的 node self.pipe[self.prev_node.callback] = node self._prev_node = node return wrapper def register(self, regexp=r''): """ 除入口頁面 其他頁面地址 url 依賴上級頁面提取結果 """ return self.entry("", regexp) def run(self): # 將入口頁面放入佇列 self.queue.put_nowait(self.pipe.get(self._ENTRY)) async def _runner(): producer = asyncio.ensure_future(self._worker()) await self.queue.join() producer.cancel() self.loop.run_until_complete(_runner()) async def _worker(): while True: node = self.queue.get() # Cleaning 函式在這裡回撥,併產生下一級頁面入口 results = await node.callback(await self.extract(node.url, node.re)) if results is not None: for page in results: # 從 pipe 連結串列中取出下一級的 node p = self.pipe.get(node.callback) if p is not None: # 根據結果中的 url 構造新的任務並放回到佇列裡 next_node = Node(page[0], p.re, p.callback) self.queue.put_nowait(next_node) self.queue.task_done() |
以上就是非同步爬蟲的基本結構,有一點需要約定好的是所有的 Cleaning
方法必須以列表形式返回清洗之後的結果,且下一級頁面入口必須在第一位(最後一頁除外)。接下來做一個簡單的測試,以豆瓣電影分類頁面為入口,進入該類別列表,最後進入電影詳情頁面,並提取電影時長和評分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
ROOT = "https://movie.douban.com/tag/" # 正則:類別地址與類別名稱 reTag = re.compile('<a href="(\/tag\/.*?)">(.*?)<\/a>') # 正則:詳情頁面連結及電影標題 reLinkTitle = re.compile('<a href="(https:\/\/movie\.douban\.com\/subject\/\d+/)".*?>([\s\S]*?)<\/a>') # 正則:電影時長及評分 reRuntimeRate = re.compile('<span property="v:runtime" content="(\d+)">[\s\S]*?<strong class="ll rating_num" property="v:average">(.*?)<\/strong>') @av.entry(ROOT, reTag) async def entry_callback(results): # 構造列表頁地址 return list(map(lambda row: ("https://movie.douban.com{}".format(row[0]), row[1]), result)) @av.register(reLinkTitle) async def list_page(result): # 顯示未清理前結果 print(result) def clean(row): return (row[0], re.sub(r'<.*?>|\s', "", row[1])) return list(map(clean, result)) @av.register(reRuntimeRate) async def detail_page(result): print(result) av.run() av.close() |
從上面的執行的結果可以看出,正規表示式有時候不能(或不便)直接精確過濾我們所需內容,因此可以在 Cleaning
函式中進行清理(如去掉多餘 Tag 或空位符等),另外:
- 不像
Flask
,這裡通過register
註冊方法的順序必須與頁面處理順序保持一致; - 與
Sanic
一樣,註冊方法必須也是 Coroutine (async def
),同時可以在其中非同步執行資料庫儲存操作; - 上級頁面資訊實際上可以通過擴充套件
Node
直接傳遞給下級頁面,這在某些相關頁面中甚至是必須的;
總結
抽象這一框架的目的主要有以下幾點:
- 學習使用
asyncio
庫及基於協程的非同步; - 將網路爬蟲的編寫過程聚焦到頁面關係分析、精確正規表示式及少量資料清理上;
- 簡化使用,降低學習成本。
仍有以下內容需要完成:
- 錯誤捕捉與 logging,讓除錯過程更簡單;
- 尋找不適應該框架的情況,進行 upgrade;
- 效能測試;
- 完善瀏覽器模擬:Headers、proxy、Referer等……
未完待續。
參考
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!