技術棧:python + scrapy + tor 為什麼要單獨開這麼一篇隨筆,主要還是在上一篇隨筆"一個小爬蟲的整體解決方案"(https://www.cnblogs.com/qinyulin/p/13219838.html)中沒有著重介紹Scrapy,包括後面幾天也對代碼做了Review,優化了 ...
技術棧:python + scrapy + tor
為什麼要單獨開這麼一篇隨筆,主要還是在上一篇隨筆"一個小爬蟲的整體解決方案"(https://www.cnblogs.com/qinyulin/p/13219838.html)中沒有著重介紹Scrapy,包括後面幾天也對代碼做了Review,優化了一些性能,覺得還是應該把自己的勞動成果打個標,也怕後面需要的時候記不住,所以還是規規矩矩的寫一篇隨筆用來記錄,話不多說,上乾貨。
Scrapy框架,我的理解就是在Spider中請求url,在這個請求過程中,我們會用到中間件,用來劫持網路請求,對請求Request進行一些頭部信息、代理的封裝,然後在返回對象Response中也可以做一些處理,把獲取到的網頁,通過bs4解析標簽元素,轉換成自己需要的信息,當拿到信息的時候,可以把信息包裝成對象,通過pipe管道進行數據清理,然後再進行數據存儲(可以存本地文件,也可以調用API存資料庫),具體的原理可以參考下麵的鏈接https://blog.csdn.net/qq_34120459/article/details/86711728,然而在實際做的過程當中,我一次性要對於產品的評論爬成千上萬條,而且還要針對失敗後的斷點續爬,所以我就放棄了數據管道清洗方式,把所有的業務邏輯都放在Spider裡面進行,總的來說,這樣做有悖於Scrapy的數據扭轉原理,但是沒辦法,和很多人交流過,貌似目前的方式至少是可行的。
首先來一個爬蟲Spider的代碼縮略圖:
這裡的Init函數主要是用來做一些初始化工作。詳細代碼如下:
1 #初始化函數 2 def __init__(self,saveType=1): 3 self.keywords = []#關鍵詞數組 4 self.totalObj = {}#所有關鍵詞結果的對象 5 # self.over = True #這個參數暫時沒用,觀察了之後可以刪除 6 self.startTime = time.strftime("%b_%d_%Y_%H_%M_%S", time.localtime()) #開始時間,用來寫入json文件名 7 self.saveType = saveType#來源類型,用來區分是手動還是自動 8 self.Count = 10000#定義每個Asin爬取的最大評論數量 9 self.resCount = 0#最終發送給伺服器的請求條數 10 self.getCount = 0#用來計算keywords的索引是否完成了所有查詢,每次成功之後索引+1 11 self.successArr = []#成功發送的Asin數組 12 self.loseArr = []#失敗發送的Asin數組 13 14 #########下麵的代碼是用來讀取之前的評論數據,每次完成之後會吧數據存在這個JSON文件裡面,如果中途中斷下次會讀取上一次的數據。 15 review_list_file = open("data/review_detail_crawler_all.json","w+") 16 self.review_list = review_list_file.readlines()
接著進入入口函數start_requests,因為我在爬蟲的時候需要定位到US,所以先模擬了一個表單請求。然後setSession和getAsinKeywords都是從伺服器拿數據併進行處理,這裡是自己的業務代碼就不貼了。
1 #爬蟲入口函數 2 def start_requests(self): 3 data = { 4 "locationType":"LOCATION_INPUT", 5 "zipCode": "10001", 6 "storeContext": "hpc", 7 "deviceType": "web", 8 "pageType": "Detail", 9 "actionSource": "glow", 10 } 11 yield scrapy.FormRequest("https://www.amazon.com/gp/delivery/ajax/address-change.html", method="POST",formdata=data, headers={'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'},dont_filter=True, callback=self.setSession,errback=self.ceshi)
主要說一下parse_post_data這個函數,主要是根據獲取到的網頁信息,格式化並生成標簽樹,通過bs4插件拿到數據,註釋寫得比較清楚了,代碼如下:
1 #成功回調函數,先是判斷是否被封,如果被封就調用Tor代理更換IP,如果沒被封就跟著解析。 2 def parse_post_data(self, response): 3 asin = response.meta["asin"] 4 title = BeautifulSoup(response.text, 'lxml').title 5 title = title.string if not title is None else "" 6 if "Robot Check" in title: 7 print("IP被封了Spider") 8 yield from self.renew_connection(asin) 9 else: 10 #抓取該單品從開賣到現在的所有 Review 11 dom = BeautifulSoup(response.text, 'lxml') 12 try: 13 #獲取到評論列表 14 ids = dom.find(id="cm_cr-review_list").select(".review") 15 #如果評論列表長度為0則說明爬完了。 16 if len(ids) == 0: 17 self.totalObj[asin]["over"] = True 18 logger.warning("{}沒有下一頁終止爬取數據".format(asin)) 19 #迴圈獲取到的評論列表,取出數據。因為是第一次做,裡面獲取的方法有點雜, 20 #因為有太多未知的錯誤,所以用了try方法來賦值,裡面的參數沒註釋,可以結合API看。 21 for id in ids: 22 if self.totalObj[asin]["over"] == True or len(self.totalObj[asin]["list"]) >= self.Count: 23 break 24 obj = {} 25 obj["page"] = self.totalObj[asin]["pageNumber"] 26 obj["reviewId"] = id.attrs["id"] 27 28 obj["title"] = id.select( 29 ".review-title span:first-child")[0].string 30 obj["username"] = id.select(".a-profile-name")[0].string 31 obj["content"] = id.select(".review-text-content")[0].get_text() 32 try: 33 obj["reviewDate"] = id.select(".review-date")[0].get_text() 34 except Exception as e: 35 obj["reviewDate"] = "" 36 37 try: 38 obj["voteNum"] = 0 if not len(id.select(".cr-vote-text")) else id.select(".cr-vote-text")[0].string 39 except Exception as e: 40 obj["voteNum"] = 0 41 try: 42 obj["score"] = id.select(".a-icon-alt")[0].string 43 except Exception as e: 44 obj["score"] = 0 45 46 #如果遇到reviewId評論,說明之前已經爬取過,就把當前的over狀態置為True 47 if obj["reviewId"] == self.totalObj[asin]["reviewId"]: 48 self.totalObj[asin]["over"] = True 49 logger.warning("{}返回reviewId終止爬取{}".format(asin,obj["reviewId"])) 50 else: 51 self.totalObj[asin]["list"].append(obj) 52 print(obj) 53 pass 54 except Exception as e: 55 pass 56 #上面是解析了當前頁面的數據,下麵的代碼是用來判斷是否爬完。 57 try: 58 #這裡是用來判斷當前Asin評論的總頁碼 59 if self.totalObj[asin]["totalStr"] == "": 60 try: 61 totalStr = dom.select("#filter-info-section .a-size-base")[0].string 62 self.totalObj[asin]["totalStr"] = totalStr 63 except Exception as e: 64 pass 65 else: 66 totalStr = self.totalObj[asin]["totalStr"] 67 #totalStr示例:Showing 1-20 of 2,442 reviews 68 countArr = totalStr.split(' ') 69 fNum = countArr[1].split('-')[1]#當前數量20 70 tNum = countArr[3]#總數量2442 71 #如果當前數量大於等於總數量,表示已經爬完。 72 if int(fNum.replace(",","")) >= self.Count: 73 self.totalObj[asin]["over"] = True 74 logger.warning("{}大於{}條數據終止爬取".format(asin,self.Count)) 75 if fNum == tNum: 76 #最後一頁,把當前asin的結束標誌置為True 77 self.totalObj[asin]["over"] = True 78 logger.warning("{}最後一頁{}終止爬取數據".format(asin,fNum)) 79 print("這是{}第{}頁".format(asin,self.totalObj[asin]["pageNumber"])) 80 #防止log信息太多,10條錄入一次。 81 if self.totalObj[asin]["pageNumber"] % 10 == 0: 82 logger.warning("這是{}第{}頁".format(asin,self.totalObj[asin]["pageNumber"])) 83 except Exception as e: 84 pass 85 86 #如果當前Asin的over為false,說明沒有遇到reviewId和還有下一頁,則把當前Asin的頁碼加1繼續爬。 87 if self.totalObj[asin]["over"] == False: 88 self.totalObj[asin]["pageNumber"] += 1 89 yield from self.getViewForAsin(asin) 90 else: 91 #如果當前Asin的over為True,說明當前Asin已經爬完,把當前Asin的數據發送到伺服器, 92 #而且根據keywords數組進行下一個Asin的爬取,如果爬取Asin的長度大於等於keywords長度, 93 #說明整個爬取過程已經完成,不進行任何操作,進入close流程。 94 logger.warning("{}完成了一次請求,準備發送數據.".format(asin)) 95 yield from self.sendSingelData(asin) 96 97 self.getCount += 1 98 if self.getCount <= len(self.keywords) - 1: 99 yield from self.getViewForAsin(self.keywords[self.getCount])
最後就是close函數的代碼:
1 #爬蟲關閉的鉤子函數 2 def close(self, reason, spider): 3 # 打開公共設置文件,讀取search_asin_index值,並讀取對應的search_asin_index文件的文本,轉換成數組並組合成發送的數據 4 if self.resCount == len(self.keywords): 5 logger.warning("全部產品的評論發送完成,共發送{}次".format(self.resCount)) 6 else: 7 logger.warning("此次爬蟲未全部爬完數據,共發送{}次".format(self.resCount)) 8 logger.warning("成功的產品有{}".format(self.successArr)) 9 logger.warning("失敗的產品有{}".format(self.loseArr)) 10 self.file = open('data/review_detail_crawler_all.json'.format(self.startTime,time.strftime("%H_%M_%S", time.localtime())), 'wb') 11 self.file.write(json.dumps(self.totalObj).encode()) 12 self.file.close() 13 pass
在確定用Scrapy框架之前,我也是去體驗了一把requests庫和selenium,requests的話做一些簡單的爬蟲需求還是可以,不能規模化;selenium的話主要還是通過模擬用戶行為來進行數據的爬取,主要用於自動化測試的場景,在一些需要複雜操作的爬蟲還是可以的,但是如果用在大規模爬取項目是非常耗時的,所以我最終還是選取了Scrapy框架。而且在後期因為要頻繁爬取,容易觸發亞馬遜的反爬策略,所以又研究了Tor網路,這個是用來隱藏伺服器IP,通過代理去進行爬蟲請求,這個從最終的代碼量來看其實不大,但是在研究的過程也是備受煎熬,因為在百分百成功之前的每一步都是在反思為什麼不行,從拿到需求到熟悉框架,到最後完成上線,然後再review優化代碼差不多1個月時間,特別是最開始在瞭解了框架的原理,但是卻不能解決自己的需求反覆去尋求解決方案是最難熬的,不過當在一次次試錯後找到最後的解決方案,還是很有成就感。
其實Scrapy的東西還很多,我用到的只是很小的一部分,希望在後面有迭代需求的時候可以再繼續研究,下麵貼出項目中用到的一些名詞和對應的網址,也希望看到這篇文章的小伙伴如果在研究Scrapy遇到問題,可以進行留言或者私信交流,學無止境,我們一直在路上。
Python教程(我推薦廖雪峰的):https://www.liaoxuefeng.com/wiki/1016959663602400
Scrapy官網:https://scrapy.org/
requests:https://requests.readthedocs.io/en/master/
基礎爬蟲+selenium教程(我就是看著這位小伙伴的連載入的門):https://www.cnblogs.com/Albert-Lee/p/6238866.html
bs4:https://beautifulsoup.readthedocs.io/zh_CN/v4.4.0/
tor:
win版本:https://www.cnblogs.com/kylinlin/archive/2016/03/04/5242266.html