廢話少說
在上一篇文章python使用代理+多線程爬取速賣通評論(一)中,我已經成功分析出了速賣通評論請求數據的策略,但是為了防止我們的爬蟲觸發速賣通的反爬策略,我們決定采取使用代理IP的方式來進行偽裝,同時為了提高爬取速度,我決定開多個線程進行數據爬取。
這篇文章,更多的是我在實現多線程爬取過程中的思考過程和收獲,以及代碼大概的說明,完整的代碼我已放到github,大概300行,如有bug或者更好更優雅的實現,我會及時更新。
需要代碼的看這里,代碼是默認保存到數據庫的,你可以本地建一下數據庫和表,也可以使用我提供的save_data_to_csv()方法,直接保存到csv文件中。
使用代理IP發送請求
監控同一IP訪問頻率是非常常見的反爬手段之一,你用同一個IP在短時間內大量訪問目標網站,而且沒有sleep的話,你的ip很容易被服務器禁止訪問。所以為了反反爬,我們要學會如何使用代理IP來發送請求,這也是我第一次學習使用代理IP爬數據,超easy。
對于我們個人來說,如果只是自己爬小量數據用于研究,分析的話,可以直接從代理IP網站爬取免費的代理IP。
比如國內高匿代理IP,如圖
我們直接把首頁的IP爬取下來就夠用了,當然免費的肯定沒有付費的好用,有些IP不可以用,但是說實話我還沒有碰到幾個不能用的。這個爬取很簡單,直接附代碼了,爬取到本地之后,按行保存到本地一個txt文件中
from bs4 import BeautifulSoup
import queue
url='http://www.xicidaili.com/nn/'
headers={
'user-agent':'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36',
}
ip_data=requests.get(url,headers=headers)
soup=BeautifulSoup(ip_data.text,'html.parser')
ips=soup.select('tr')
ip_list=[]
for i in range(1,len(ips)):
ip_info=ips[i]
tds=ip_info.select('td')
ip_list.append(tds[1].text+':'+tds[2].text)
with open("iplist.txt","a",encoding='utf-8') as f:
for ip in ip_list:
f.write(ip)
f.write('\n')
接著你就可以用代理IP來爬數據了,聽起來感覺很復雜,但是對應到代碼上,也就多加一個參數(當然只限定python+requests,其它不了解)
def get_ip_list(self):
temp_ip_list=[]
with open('iplist.txt', 'r') as f:
while True:
ip=f.readline().replace('\n','')
# 記得加 'http://'
temp_ip_list.append('http://'+ip)
if not ip:
break
return temp_ip_list
def get_random_ip(self):
proxy_ip=random.choice(self.ip_list)
proxies={
'http':proxy_ip
}
return proxies
因為我是從完整代碼里截取的,所以里邊有self,第一個函數用于將每一個ip從iplist.txt這個文件讀取到一個python list中,然后第二個函數用于從該list中隨機獲取一個Ip,而真正使用代理ip發請求是超級簡單,只是在原來的基礎上,多一個proxies參數
proxies=self.get_random_ip()
requests.get(url,headers=headers,proxies=proxies)
多線程爬取
我回家連的就是家里wifi,本來爬的就慢,動不動就超時了,再加上,為了保險,每爬一個頁面,我都sleep1秒鐘,這樣一來,爬取的速度我感覺有點慢,所以就考慮要不要多開幾個線程,但是因為對之前從來沒寫過多線程程序,對多線程的認識就是一些模糊的概念,因此在編程中間還碰到一些問題,但是后來解決問題之后,對并發編程,線程同步,生產者與消費者模型,線程安全等有一個更進一步的認識。
代碼整體結構如圖
CommentSpyder 是爬蟲類,主要負責爬取數據和解析數據
Saver是存儲類,主要負責儲存數據
get_total_page()函數用于獲取評論總頁數
get_url()函數用于構造token請求地址
update_ip_list()函數用于更新iplist.txt文件中的代理IP,需要手動執行
crawl()為封裝好的爬取函數
main()函數為主函數
在main函數中,首先發出請求獲取總頁數,然后根據總頁數給每個線程平均分配自己所要爬取的評論頁碼范圍,默認開10個線程,同時在開一個線程,用于往數據庫或者csv文件寫數據,然后這10個線程相當于生產者-消費者模型中的生產者,saver線程相當于是消費者,這11個線程共享一個pyhon提供的線程安全的隊列,生產者爬到數據之后寫入該隊列,然后消費者從該隊列取數據,并一條一條插入數據庫或者保存到csv中。
說一下我踩過的坑
踩坑1:我在爬蟲類初始化的時候首先發一個請求,獲取token,這樣在爬取每一頁的時候就不必每次去取token了,但是我在寫多線程的時候,一開始是這么寫的,只貼相關代碼
spyder=CommentSpyder(url,productid,owner_memberid,companyid,result_queue,start_page,end_page)
crawl_thread = threading.Thread(target = spyder.crawlComments,args=(url,productid,owner_memberid,companyid,result_queue,start_page,end_page))
一開始一直沒覺得有什么問題,但是當我發現多線程跑和單線程跑的時間差不多的時候,我突然想起了,學pyhon基礎的時候有一個GIL(python全局解釋器鎖),然后又從別人博客中看到所謂的“python多線程是雞肋的言論”,于是我恍然大悟,“怪不得多線程時間和單線程時間差不多嘞,原來python多線程沒什么鳥用”。
但是當我百度輸入python多線程爬蟲,還是有很多人用python的多線程來寫代碼,如果真的沒用,為什么還有這么多人采取多線程,所以我還是多思考了一會,終于想清楚了原因。
因為我為每個線程實例化了一個爬蟲對象,而在爬蟲對象初始化的過程中,會發出網絡請求取得token,而我給thread添加的target中只有python爬取數據的代碼,所以這十個爬蟲對象請求token的過程是線程阻塞的,這也是為什么我總感覺線程是一個個按順序運行的,我一開始還誤以為是全局解釋器鎖的原因,每次只能有一個線程獲得鎖,很顯然是我錯了,python的多線程雞肋只是雞肋在無法利用多核CPU,但是即使單核CPU,在做IO密集型操作時,多線程效率還是遠遠高于單線程。
我也曾一度鉆進牛角尖,我想不通,單核CPU多線程的時間為什么會比單線程短...
因為,學習多線程的時候,經常講到一個時間片的切換,微觀上是一個個操作來的,只是切換足夠快,快到看上去就好像計算機在同時做兩個操作。那么既然實際上是按順序一個個運行的,只是看上去在并行,那么多線程時間怎么會縮短呢?假設有兩個任務A和B
A中包含a1,a2倆個操作,分別耗時1s,2s
B中包含b1,b2,b3三個操作,分別耗時1s,2s,3s
同步運行的話肯定是9s(當然簡化了模型)
就算開了多線程,單核CPU,不管你切換的有多快,但是本質上你一次只做一個操作,你完成了a1,切換到b1,不管怎么切換,最終運行時間也應該等于9秒才對啊。
而通過寫這個多線程爬蟲,也讓我想通了這個問題,我之所以有上述錯誤的想法就是因為我忽略了IO往往存在大量的阻塞時間。
任務AB耗費的總時間等于AB操作+IO阻塞的時間(如網絡IO,磁盤IO),而相比IO阻塞時間,cpu執行操作的時間幾乎可以忽略不計。
那么再以上面那個例子來講一下
同步執行的情況下
a1 1秒,等待IO10秒
a2 2秒,等待IO20秒
b1 1秒,等待IO10秒
b2 2秒,等待IO20秒
b3 3秒,等待IO30秒
總耗時99秒
而使用多線程的話,
a1 1秒 ,遇到IO阻塞,釋放GIL鎖,而不會傻等在這里,線程B獲得GIL,轉去執行b1,說到這里,后面就不用說了吧,這樣下來總時間肯定少于99秒。所以時間可以縮短全是因為IO阻塞的存在。
踩坑2:一開始我只開了10個線程,在爬到數據并解析后立刻插入數據庫,但是數據庫這邊有時候會報鏈接不可以獲得的錯誤,我猜測肯定是數據庫訪問頻率某個瞬間太高了,后來就想要不用個隊列,爬下來先寫到隊列里,然后再開一個線程,專門用于從隊列中慢慢讀,并保存到數據庫,寫著寫著,哇,這不就是操作系統上講的消費者與生產者模型嘛。
踩坑3:一開始我在保存數據的時候,想要打印一個信息,即這是第幾條數據,但是經常會出現多個線程打印同一個數字,這是因為我沒有進行加鎖,當我加鎖之后,對該變量的讀取和加1操作每一個時刻只有一個線程可以運行,從而打印出了正確的順序,這似乎沒什么,稍微了解一下鎖的概念就可以知道,但是后邊我在用一個共享隊列的時候,我并沒有加鎖,但是我發現從來沒有出現多個線程同時訪問一條數據的情況,我試了很多遍,一次都沒有出現,我突然,(真的是突然),想起了一個詞“線程安全”,前段時間看java,總是說哪些容器是線程安全的,哪些是不安全的,肯定就是這兒的這個意思,我百度一查,果然如此,import queue進來后,我使用的是python自帶的線程安全隊列,該隊列內部實現了鎖原語,所以保證了不會有多個線程對其同時進行讀寫,如果你換成list,肯定就有問題了。
最后
踩坑越多,收獲越大,我知道我的智商只是正常人的智商,無論我怎么思考也解決不了世界難題,但是思考總是可以讓我進步,讓我更優秀,所以希望我永遠熱愛思考,永遠享受想通問題時的暢快!