寫在前面
在看過目錄之後,讀者可能會問為什麼這個教程沒有講一個框架,比如說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("
", "")
})
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