那些年,我爬過的北科(四)——爬蟲進階之極簡併行爬蟲框架開發

叄公子KCN發表於2019-03-04

寫在前面

在看過目錄之後,讀者可能會問為什麼這個教程沒有講一個框架,比如說scrapy或者pyspider。在這裡,我認為理解爬蟲的原理更加重要,而不是學習一個框架。爬蟲說到底就是HTTP請求,與語言無關,與框架也無關。

在本節,我們將用26行程式碼開發一個簡單的併發的(甚至分散式的)爬蟲框架。

爬蟲的模組

首先,我們先來說一下爬蟲的幾個模組。

任務產生器——Producer

定義任務,如:爬取什麼頁面?怎麼解析

下載器——Downloader

下載器,接受任務產生器的任務,下載完成後給解析器進行解析。主要是I/O操作,受限於網速。

解析器——Parser

解析器,將下載器下載的內容進行解析,傳給輸出管道。主要是CPU操作,受限於下載器的下載速度。

輸出管道——Pipeline

如何展示爬取的資料,如之前我們一直都在用print,其實也就是一個ConsolePipeline。當然你也可以定義FilePipeline、MysqlPipeline、Sqlite3Pipeline等等。

  • ConsolePipeline: 把想要的內容直接輸出到控制檯。
  • FilePipeline: 把想要的內容輸出到檔案裡儲存,比如儲存一個json檔案。
  • MongoDBPipeline: 把想要的內容存入MongoDB資料庫中。
  • 等等......

爬蟲框架的結構

上面的四個模組也就構成了四個部分。

  • 1 . 首先,會有個初始的任務產生器產生下載任務。
  • 2 . 下載器不斷從任務佇列中取出任務,下載完任務後,放到網頁池中。
  • 3 . 不同的解析器取出網頁進行解析,傳給對應的輸出管道。期間,解析器也會產生新的下載任務,放入到任務佇列中。
  • 4 . 輸出管道對解析的結果進行儲存、顯示。

那些年,我爬過的北科(四)——爬蟲進階之極簡併行爬蟲框架開發

簡易的爬蟲框架的架構

其實,我們也可以把爬蟲不要分的那麼細,下載+解析+輸出其實都可以歸類為一個Worker。

就像下面一樣,首先初始的任務產生器會產生一個下載任務,然後系統為下載任務建立幾個Worker,Worker對任務進行下載解析輸出,同時根據解析的一些連結產生新下載的任務放入任務佇列。如此迴圈,直到沒有任務。

那些年,我爬過的北科(四)——爬蟲進階之極簡併行爬蟲框架開發

程式間通訊

下面,我們說一下程式間通訊。

這裡我們舉一個生產者消費者的例子。假設有兩個程式,一個叫生產者,一個叫做消費者。生產者只負責生產一些任務,並把任務放到一個池子裡面(任務佇列),消費者從任務佇列中拿到任務,並對完成任務(把任務消費掉)。

我們這裡的任務佇列使用multiprocessing的Queue,它可以保證多程式間操作的安全。

from multiprocessing import Process, Queue
import time


def produce(q):  # 生產
    for i in range(10):
        print('Put %d to queue...' % value)
        q.put(i)
        time.sleep(1)


def consume(q):  # 消費
    while True:
        if not q.empty():
            value = q.get(True)
            print('Consumer 1, Get %s from queue.' % value)


if __name__ == '__main__':
    q = Queue()
    producer = Process(target=produce, args=(q,))
    consumer = Process(target=consume, args=(q,))
    producer.start()
    consumer.start()

    producer.join()  # 等待結束, 死迴圈使用Ctrl+C退出
    consumer.join()
複製程式碼

當然,也可以嘗試有多個生產者,多個消費者。下面建立了兩個生產者和消費者。

from multiprocessing import Process, Queue
import time


def produce(q):  # 生產
    for i in range(10000):
        if i % 2 == 0:
            print("Produce ", i)
            q.put(i)
            time.sleep(1)


def produce2(q):  # 生產
    for i in range(10000):
        if i % 2 == 1:
            print "Produce ", i
            q.put(i)
            time.sleep(1)


def consume(q):  # 消費
    while True:
        if not q.empty():
            value = q.get(True)
            print 'Consumer 1, Get %s from queue.' % value


def consume2(q):  # 消費
    while True:
        if not q.empty():
            value = q.get(True)
            print 'Consumer 2, Get %s from queue.' % value


if __name__ == '__main__':
    q = Queue(5)   # 佇列最多放5個任務, 超過5個則會阻塞住
    producer = Process(target=produce, args=(q,))
    producer2 = Process(target=produce2, args=(q,))
    consumer = Process(target=consume, args=(q,))
    consumer2 = Process(target=consume2, args=(q,))

    producer.start()
    producer2.start()
    consumer.start()
    consumer2.start()

    producer.join()  # 等待結束, 死迴圈使用Ctrl+C退出
    producer2.join()
    consumer.join()
    consumer2.join()
複製程式碼

這裡生產者生產的時間是每秒鐘兩個,消費者消費時間幾乎可以忽略不計,屬於“狼多肉少”系列。執行後,可以看到控制檯每秒都輸出兩行。Consumer1和Consumer2的爭搶十分激烈。

那些年,我爬過的北科(四)——爬蟲進階之極簡併行爬蟲框架開發

考慮一下“肉多狼少”的情形,程式碼如下:

from multiprocessing import Process, Queue
import time


def produce(q):  # 生產
    for i in range(10000):
        print("Produce ", i)
        q.put(i)


def consume(q):  # 消費
    while True:
        if not q.empty():
            value = q.get(True)
            print('Consumer 1, Get %s from queue.' % value)
            time.sleep(1)


def consume2(q):  # 消費
    while True:
        if not q.empty():
            value = q.get(True)
            print('Consumer 2, Get %s from queue.' % value)
            time.sleep(1)


if __name__ == '__main__':
    q = Queue(5)    # 佇列最多放5個資料, 超過5個則會阻塞住
    producer = Process(target=produce, args=(q,))
    consumer = Process(target=consume, args=(q,))
    consumer2 = Process(target=consume2, args=(q,))

    producer.start()
    consumer.start()
    consumer2.start()

    producer.join()  # 等待結束, 死迴圈使用Ctrl+C退出
    consumer.join()
    consumer2.join()
複製程式碼

這裡生產者不停的生產,直到把任務佇列塞滿。而兩個消費者每秒鐘消費一個,每當有任務被消費掉,生產者又會立馬生產出新的任務,把任務佇列塞滿。

那些年,我爬過的北科(四)——爬蟲進階之極簡併行爬蟲框架開發

上面的說明,系統整體的執行速度其實受限於速度最慢的那個。像我們爬蟲,最耗時的操作就是下載,整體的爬取速度也就受限於網速。

以上的生產和消費者類似爬蟲中的Producer和Worker。Producer扮演生產者,生成下載任務,放入任務佇列中;Worker扮演消費者,拿到下載任務後,對某個網頁進行下載、解析、資料;在此同時,Worker也會扮演生產者,根據解析到的連結生成新的下載任務,並放到任務佇列中交給其他的Worker執行。

DIY併發框架

下面我們來看看我們自己的併發爬蟲框架,這個爬蟲框架的程式碼很短,只有26行,除去空行的話只有21行程式碼。

from multiprocessing import Manager, Pool


class SimpleCrawler:
    def __init__(self, c_num):
        self.task_queue = Manager().Queue()  # 任務佇列
        self.workers = {}                    # Worker, 字典型別, 存放不同的Worker
        self.c_num = c_num                   # 併發數,開幾個程式

    def add_task(self, task):
        self.task_queue.put(task)

    def add_worker(self, identifier, worker):
        self.workers[identifier] = worker

    def start(self):
        pool = Pool(self.c_num)
        while True:
            task = self.task_queue.get(True)
            if task['id'] == "NO":  # 結束爬蟲
                pool.close()
                pool.join()
                exit(0)
            else:  # 給worker完成任務
                worker = self.workers[task['id']]
                pool.apply_async(worker, args=(self.task_queue, task))
複製程式碼

這個類中一共就有四個方法:構造方法、新增初始任務方法、設定worker方法、開始爬取方法。

__init__方法:

在構造方法中,我們建立了一個任務佇列,(這裡注意使用了Manager.Queue(),因為後面我們要用到程式池,所以要用Manager類),workers字典,以及併發數配置。

crawler = SimpleCrawler(5)  # 併發數為5
複製程式碼

add_task方法:

負責新增初始任務方法,task的形式為一個字典。有id、url等欄位。id負責分配給不同的worker。如下:

crawler.add_task({
    "id": "worker",
    "url": "http://nladuo.cn/scce_site/",
    "page": 1
})
複製程式碼

add_worker方法:

負責配置worker,以id作為鍵存放在workers變數中,其中worker可以定義為一個抽象類或者一個函式。這裡為了簡單起見,我們直接弄一個函式。

def worker(queue, task):
    url = task["url"]
    resp = requests.get(url)
    # ......,爬取解析網頁
    queue.put(new_task) # 可能還會新增新的task
    # ......

crawler.add_worker("worker", worker)
複製程式碼

start方法:

start方法就是啟動爬蟲,這裡看上面的程式碼,建立了一個程式池用來實現併發。然後不斷的從queue中取出任務,根據任務的id分配給對應id的worker。我們這裡規定當id為“NO”時,我們則停止爬蟲。

crawler.start()
複製程式碼

爬取兩級頁面

下面,我們來使用這個簡單的爬蟲框架,來實現一個兩級頁面的爬蟲。

首先看第一級頁面:nladuo.cn/scce_site/。其實就是之前的新聞列表頁。我們可以爬到新聞的標題,以及該標題對應的網頁連結。

那些年,我爬過的北科(四)——爬蟲進階之極簡併行爬蟲框架開發

第二級頁面是:nladuo.cn/scce_site/a…,也就是新聞的詳情頁,這裡可以獲取到新聞的內容以及點選數目等。

那些年,我爬過的北科(四)——爬蟲進階之極簡併行爬蟲框架開發

下面我們建立兩個worker,一個負責爬取列表頁面,一個負責爬取新聞詳情頁。

def worker(queue, task):
    """ 爬取新聞列表頁 """
    pass


def detail_worker(queue, task):
    """ 爬取新聞詳情頁 """
    pass
複製程式碼

主程式碼

對於main程式碼,這裡首先需要建立一個crawler。然後新增兩個worker,id分別為“worker”和“detail_worker”。然後新增一個初始的任務,也就是爬取新聞列表頁的首頁。

if __name__ == '__main__':
    crawler = SimpleCrawler(5)
    crawler.add_worker("worker", worker)
    crawler.add_worker("detail_worker", detail_worker)
    crawler.add_task({
        "id": "worker",
        "url": "http://nladuo.cn/scce_site/",
        "page": 1
    })
    crawler.start()
複製程式碼

worker程式碼編寫

接下來,完成我們的worker程式碼,worker接受兩個引數:queue和task。

  • queue: 用於解析網頁後,新增新的任務
  • task: 要完成的任務

然後worker①首先下載網頁,②其次解析網頁,③再根據解析的列表進一步需要爬取詳情頁,所以要新增爬取詳情頁的任務;④最後判斷當前是不是最後一頁,如果是就傳送退出訊號,否則新增下一頁的新聞列表爬取任務。

def worker(queue, task):
    """ 爬取新聞列表頁 """
    # 下載任務
    url = task["url"] + "%d.html" % task["page"]
    print("downloading:", url)
    resp = requests.get(url)

    # 解析網頁
    soup = BeautifulSoup(resp.content, "html.parser")
    items = soup.find_all("div", {"class", "list_title"})

    for index, item in enumerate(items):
        detail_url = "http://nladuo.cn/scce_site/" + item.a['href']
        print("adding:", detail_url)
        # 新增新任務: 爬取詳情頁
        queue.put({
            "id": "detail_worker",
            "url": detail_url,
            "page": task["page"],
            "index": index,
            "title": item.get_text().replace("\n", "")
        })

    if task["page"] == 10:  # 新增結束訊號
        queue.put({"id": "NO"})
    else:
        # 新增新任務: 爬取下一頁
        queue.put({
            "id": "worker",
            "url": "http://nladuo.cn/scce_site/",
            "page": task["page"]+1
        })
複製程式碼

detail_worker程式碼編寫

detail_worker的任務比較簡單,只要下載任務,然後解析網頁並列印即可。這裡為了讓螢幕輸出沒那麼亂,我們只獲取點選數。

def detail_worker(queue, task):
    """ 爬取新聞詳情頁 """
    # 下載任務
    print("downloading:", task['url'])
    resp = requests.get(task['url'])
    # 解析網頁
    soup = BeautifulSoup(resp.content, "html.parser")
    click_num = soup.find("div", {"class", "artNum"}).get_text()
    print(task["page"], task["index"], task['title'], click_num)
複製程式碼

思考

到這裡,我們就用我們自己開發的框架實現了一個多級頁面的爬蟲。讀者可以考慮一下以下的問題。

  • 如何實現爬蟲的自動結束?考慮監控佇列的情況和worker的狀態。
  • 如何實現一個分散式爬蟲?考慮使用分散式佇列:celery

相關文章