第五章 爬蟲進階
經過了前面四章的學習,相信小夥伴對爬取基本的網站的時候都可以信手拈來了。那麼接下來介紹比較高級一點的東西來幫助我們更順利更快速的進行爬蟲。
首先來看看我們這一章要學哪些進階技術:多線程爬蟲、ajax數據爬取、圖形驗證碼識別。
5.1 多線程
連接線程之前先來看看進程的概念。
進程通俗的講就是指正在運行的程序,每個進程之間擁有獨立的功能。而每一個進程都有至少一個執行單元來完成任務,這個(些)執行單元就是 線程。線程在創建的時候會把進程中的數據進行一份拷貝,作爲自己的獨有數據。最簡單的比喻是進程是一輛火車,每個線程就是每個車廂,車廂離開火車是無法跑動的。
那什麼是多線程呢?就是一個程序中有多個線程在同時執行。
而當我們爬蟲需要下載較多圖片的時候,就可以使用多線程來提高效率。
接下來介紹如何使用代碼來創建多線程
threading
模塊
import threading
import time
def naicha():
for i in range(3):
time.sleep(1)
print(i, '正在喝奶茶。。。')
def xigua():
for i in range(3):
time.sleep(1)
print(i, '正在喫西瓜。。。')
# 創建子線程 name=''表示給線程取別名
t1 = threading.Thread(target=naicha)
t2 = threading.Thread(target=xigua)
# 開啓線程
t1.start()
t2.start()
可以看出這兩個線程是同時進行的。
我們還有通過 threading.enumerate()
來查看線程數量。
[<_MainThread(MainThread, started 22596)>, <Thread(Thread-1, started 19476)>, <Thread(Thread-2, started 18868)>]
此時線程數爲 3 個,爲什麼呢?這是因爲每個進程中都默認有一個主線程。
threading.current_thread()
這個函數可以獲取到當前的線程對象。
除此之外還可以給線程取別名,threading.Thread(target=xigua, name='xg')
,此時該線程對象就變成這樣了 <Thread(xg, started 25368)>
。
線程封裝:
所謂的線程封裝就是將上面一些的步驟封裝成類,方便使用。
封裝步驟:
- 編寫類(繼承
threading.Thread
) - 重寫
run
方法,在此寫線程的操作
import threading
import time
class Naicha(threading.Thread):
def run(self):
# 在run方法寫線程的操作
for i in range(3):
time.sleep(1)
print(i, '正在喝奶茶。。。')
class Xigua(threading.Thread):
def run(self):
for i in range(3):
time.sleep(1)
print(i, '正在喫西瓜。。。')
if __name__ == '__main__':
# 創建自己編寫的線程對象
t1 = Naicha()
t2 = Xigua()
# 開啓線程
t1.start()
t2.start()
鎖機制:
因爲多線程是在同一個進程中運行的,所以在進程中的全局變量對所有線程都是可共享的。然後又因爲線程的執行是無序的,這就出現了髒讀現象。
比如有全局變量n=100
,線程1跟線程2同時運行且都需要對n
進行加1操作,此時對於線程1來說,n=100
加完1後n=101
,因爲線程1跟線程2是同時運行的,所以在線程2操作的時候n=100
加完1後n=101
。本來n
應該等於102的,現在n卻等於101。這種現象就叫做髒讀現象。
那如何結果這個問題呢?給當前運行的線程上鎖。
在線程1進行加n+1
的時候進行上鎖,當其他線程也要使用全局變量n
的時候,它們就會等待前一個線程釋放鎖。
在很多語言中都有這樣的一把鎖,方便我們使用。
import threading
# 全局變量
n = 0
# 創建鎖對象
lock = threading.Lock() # 創建鎖對象
def naicha():
# 聲明n爲全局變量
global n
lock.acquire() # 加鎖
for i in range(1000000):
n = n + 1
print('線程1運行完之後,n=', n)
lock.release() # 解鎖
def xigua():
# 聲明n爲全局變量
global n
lock.acquire() # 加鎖
for i in range(1000000):
n = n + 1
print('線程2運行完之後,n=', n)
lock.release() # 解鎖
# 創建子線程
t1 = threading.Thread(target=naicha)
t2 = threading.Thread(target=xigua)
# 開啓線程
t1.start()
t2.start()
小結:
-
在多線程需要使用到全局變量時需要給線程加鎖。
-
acquire() 可以加鎖,release() 可以釋放鎖。
-
加了鎖之後一定要釋放鎖。不然鎖就得不到釋放而形成死鎖。
生產者與消費者模式:
生產者與消費者模式是多線程開發中很常見的一種模式。
這種模式有兩種模塊,生產者模塊與消費者模塊,生產者模塊負責生產數據,然後將數據存儲到中間變量中(一般是全局變量),消費者模塊負責從全局變量中消費數據。
比如當爬取大量圖片時,我們可以創建多個線程來爬取要下載圖片的url
,然後將這些url
存儲到全局的列表中,最後再創建多個線程負責下載這些url
。負責爬取圖片url
的線程就被稱爲生產者,負責下載的線程就稱爲消費者。
很明顯,這種模式中多個線程都在同時使用全局變量,需要怎麼保證能正常運行呢,這時你可能會想到加鎖,沒錯,這種模式可以使用加鎖,但如果加鎖解鎖很頻繁的情況下最好不要使用加鎖的方式,因爲加鎖解鎖需要消耗CPU資源,有一定的開銷。
實際上,實現這種模式的方式有多種:
- 使用
threading.Condition
(繼承自threading.Lock
) - 使用
theading.Lock
threading.Condition
可以在沒有數據的時候使用wait
來使線程處於堵塞等待狀態。一旦有合適的數據了,使用notify
等一些函數來通知其他處於等待狀態的線程。這樣就可以不用做頻繁的上鎖和解鎖的操作,可以提高程序的性能。
下面來介紹一下Condition常用函數的用法:
- acquire():加鎖
- release():解鎖
- wait():掛起該線程並且釋放鎖,可被
notify
或者notify_all
喚醒,喚醒後繼續執行下面的代碼 - notify():通知某個等待的線程,默認是第一個等待的線程。
- notify_all():通知所有等待的線程
tips:
notify()
跟notify_all()
都不會釋放鎖,只有release()
纔可以釋放,所以兩者必須放在release()
前面通知。- 使用了wait()之後一定要記得用notify()喚醒
下面來說看一下Lock版本與Condition版本的區別:
Queue線程安全隊列:
Queue叫做隊列,用來在生產者和消費者線程之間的信息傳遞。自帶了鎖,可以自己掛起線程自己喚醒線程,全自動化。可以使用隊列來實現線程間的同步。
相關的函數如下:
- Queue(maxsize):創建一個先進先出的最大容量爲
maxsize
的隊列。 - qsize():返回隊列的大小。
- empty():判斷隊列是否爲空。
- full():判斷隊列是否滿了。
- get(block=True):取出隊尾的數據(消費者)。當隊空時會將線程掛起,直接隊中有數據。
- put(block=True):將一個數據放到隊尾(生產者)。當隊滿時會將線程掛起,直接隊中有位置。
from queue import Queue
q = Queue(4) # 創建一個大小爲4的隊列
for i in range(4):
q.put(i)
while not q.empty():
print(q.get())
案例:
本節的案例是以 生產者消費者模式 + Queue 的方式來爬取下載表情包。
import requests
from urllib import request
from lxml import etree
import threading
from queue import Queue
import time
import re
img_queue = Queue(1000) # 存放圖片url跟名字的隊列
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/82.0.4077.0 Safari/537.36'
}
# 創建生產者 即 爬取要下載圖片的url的線程
class parse_html(threading.Thread):
# *args, **kwargs是獲取其他傳給Thread類的參數
def __init__(self, img_queue, *args, **kwargs):
threading.Thread.__init__(self, *args, **kwargs)
self.img_queue = img_queue
def run(self):
# 解析10頁的內容
for i in range(1, 10):
url = 'https://www.doutula.com/photo/list/?page=%d' % i
# 解析網頁
self.parse(url)
def parse(self, url):
res = requests.get(url, headers=headers)
html = etree.HTML(res.text)
# 獲取圖片所在的所有a標籤
a_s = html.xpath('//div[@class="page-content text-center"]//a')
# 遍歷獲取a標籤下的img裏的相關屬性
for i in a_s:
# 獲取圖片路徑
href = i.xpath('./img/@data-original')[0]
# 獲取圖片的alt 作爲 圖片的名字
alt = i.xpath('./img/@alt')[0]
# 去掉特殊符號
alt = re.sub(r'[=!@?≈,]', '', alt)
# 以元祖的形式存入隊列
self.img_queue.put((href, alt))
# 創建消費者 即 下載圖片的線程
class dowmload_img(threading.Thread):
def __init__(self, img_queue, *args, **kwargs):
threading.Thread.__init__(self, *args, **kwargs)
self.img_queue = img_queue
def run(self):
while True:
# 圖片全部下載完時跳出循環
if self.img_queue.empty():
print('當前圖片隊列空啦!')
break
# 對元祖進行解構
href, alt = self.img_queue.get()
# 將圖片下載在本項目的images文件夾下
request.urlretrieve(href, 'images/' + alt + '.jpg')
print('images/' + alt + '.jpg')
if __name__ == '__main__':
# 開啓5個生產者
for i in range(8):
parse_html(img_queue).start()
# 先讓生產者先爬取網頁,再來下載
time.sleep(5)
# 開啓5個消費者
for i in range(5):
dowmload_img(img_queue).start()
有一些要注意的點:
- 你的項目下要有
images
文件夾,不然會報錯 - 給表情包命名是必須去掉一些奇怪的字符,例如
!@?≈,
- 給表情包命名要加上後綴
總結語:
多線程是比較難理解的一個點,有很多相關的概念本節都沒有提及,例如異步與多線程的關係與區別、併發並行等。我在寫此篇博客的時候有想過要加上這些,但是我查了一天的資料,感覺都沒有可以找到很合適的語言來解釋他們,小夥伴們可以自行百度,自行查資料。如果小夥伴們有發現比較好的資料記得分享~~