之前沒學過Python,最近因一些個人需求,需要寫個小爬蟲,於是就搜羅了一批資料,看了一些別人寫的代碼,現在記錄一下學習時爬過的坑。
如果您是從沒有接觸過Python的新手,又想迅速用Python寫出一個爬蟲,那么這篇文章比較適合你。
首先,我通過:
https://mp.weixin.qq.com/s/ET9HP2n3905PxBy4ZLmZNw
找到了一份參考資料,它實現的功能是:爬取當當網Top 500本五星好評書籍
源代碼可以在Github上找到:
https://github.com/wistbean/learn_python3_spider/blob/master/dangdang_top_500.py
然而,當我運行這段代碼時,發現CPU幾乎滿負荷運行了,卻根本沒有輸出。
現在我們來分析一下其源代碼,並將之修復。
先給出有問題的源碼:
1 import requests 2 import re 3 import json 4 5 6 def request_dandan(url): 7 try: 8 response = requests.get(url) 9 if response.status_code == 200: 10 return response.text 11 except requests.RequestException: 12 return None 13 14 15 def parse_result(html): 16 pattern = re.compile( 17 '<li>.*?list_num.*?(\d+).</div>.*?<img src="(.*?)".*?class="name".*?title="(.*?)">.*?class="star">.*?class="tuijian">(.*?)</span>.*?class="publisher_info">.*?target="_blank">(.*?)</a>.*?class="biaosheng">.*?<span>(.*?)</span></div>.*?<p><span\sclass="price_n">¥(.*?)</span>.*?</li>', 18 re.S) 19 items = re.findall(pattern, html) 20 21 for item in items: 22 yield { 23 'range': item[0], 24 'iamge': item[1], 25 'title': item[2], 26 'recommend': item[3], 27 'author': item[4], 28 'times': item[5], 29 'price': item[6] 30 } 31 32 33 def write_item_to_file(item): 34 print('開始寫入數據 ====> ' + str(item)) 35 with open('book.txt', 'a', encoding='UTF-8') as f: 36 f.write(json.dumps(item, ensure_ascii=False) + '\n') 37 38 39 def main(page): 40 url = 'http://bang.dangdang.com/books/fivestars/01.00.00.00.00.00-recent30-0-0-1-' + str(page) 41 html = request_dandan(url) 42 items = parse_result(html) # 解析過濾我們想要的信息 43 for item in items: 44 write_item_to_file(item) 45 46 47 if __name__ == "__main__": 48 for i in range(1, 26): 49 main(i)
是不是有點亂?別急,我們來一步步分析。(如果您不想看大段的分析,可以直接跳到最后,在那里我會給出修改后的,帶有完整注釋的代碼)
首先,Python程序中的代碼是一行行順序執行的,前面都是函數定義,因此直接先運行第47-49行的代碼:
if __name__ == "__main__": for i in range(1, 26): main(i)
看樣子這里是在調用main函數(定義在第39行),那么__name__是什么呢?
__name__是系統內置變量,當直接運行包含main函數的程序時,__name__的值為"__main__",因此main函數會被執行,而當包含main函數程序作為module被import時,__name__的值為對應的module名字,此時main函數不會被執行。
為了加深理解,可以閱讀這篇文章,講得非常清楚:
https://www.cnblogs.com/keguo/p/9760361.html
我們的程序里是直接運行包含main函數的程序的,因此__name__的值就是__main__。
還有個小細節需要注意一下:
像Lua這種語言,函數在結束之前會有end作為函數結束標記,包括if,for這種語句,都會有相應的end標記。但Python中是沒有的,Python中是用對應的縮進來表示各個作用域的,我們把第47-49行的代碼稍微改一下來進一步說明:
新建個Python文件,直接輸入:
if __name__ == "__main__": for i in range(1,5): print("內層") print("外層")
此時for語句比if語句縮進更多,因此位於if的作用域內,同理,print("內層")語句位於for語句的作用域內,因此會打印5次,print("外層")已經不在for語句的作用域內,而在if語句的作用域內,因此只打印1次,運行結果如下:
那么47-49行做的就是循環調用25次main函數(range左閉右開),為什么是25次呢?因為要爬取的當當網好評榜一頁有20本圖書數據,要爬500本我們需要發送25次數據請求。
我們看一下main函數(39-44行)做了什么:
首先進行了url的拼接,每次調用時傳入不同的page,分別對應第1-25頁數據。隨后調用request_dandan發送數據請求,看一下request_dandan(第6-12行)做了什么:
這里調用了requests模塊向服務器發送get請求,因此要在程序開頭導入requests模塊(第1行),get請求去指定的url獲取網頁數據,隨后對響應碼作了判斷,200代表獲取成功,成功就返回獲取的響應數據。要注意的一點是,這里get請求是同步請求,意思是發送請求后程序會阻塞在原地,直到收到服務器的響應后繼續執行下一行代碼。
接下來main函數要調用parse_result(第15到30行)對獲取到的html文本進行解析,提取其中與圖書有關的信息,在分析這段代碼之前,我們需要先了解下返回的html文件的格式:
我們可以在chrome瀏覽器中的開發者工具里,查看對應請求網頁響應的html格式,以我的為例:
以第一本書“有話說出來”為例,用Command+F(Mac下)快速翻找一下與要爬取的圖書有關的信息:
每一本書的信息格式是這樣的:
<li> <div class="list_num red">1.</div> <div class="pic"><a href="http://product.dangdang.com/25345988.html" target="_blank"><img src="http://img3m8.ddimg.cn/8/26/25345988-1_l_1.jpg" alt="有話說出來!(徹底顛覆社會人脈的固有方式,社交電池幫你搞定社交。社交恐懼症患者必須擁有的一本實用社交指南,初入大學和職場的必備“攻略”,拿起這本書,你也是“魏瓔珞”)纖閱出品" title="有話說出來!(徹底顛覆社會人脈的固有方式,社交電池幫你搞定社交。社交恐懼症患者必須擁有的一本實用社交指南,初入大學和職場的必備“攻略”,拿起這本書,你也是“魏瓔珞”)纖閱出品"/></a></div> <div class="name"><a href="http://product.dangdang.com/25345988.html" target="_blank" title="有話說出來!(徹底顛覆社會人脈的固有方式,社交電池幫你搞定社交。社交恐懼症患者必須擁有的一本實用社交指南,初入大學和職場的必備“攻略”,拿起這本書,你也是“魏瓔珞”)纖閱出品">有話說出來!(徹底顛覆社會人脈的固有方式,社交電池幫你搞定社<span class='dot'>...</span></a></div> <div class="star"><span class="level"><span style="width: 100%;"></span></span><a href="http://product.dangdang.com/25345988.html?point=comment_point" target="_blank">17757條評論</a><span class="tuijian">100%推薦</span></div> <div class="publisher_info">【美】<a href="http://search.dangdang.com/?key=帕特里克·金" title="【美】帕特里克·金 著,張捷/李旭陽 譯" target="_blank">帕特里克·金</a> 著,<a href="http://search.dangdang.com/?key=張捷" title="【美】帕特里克·金 著,張捷/李旭陽 譯" target="_blank">張捷</a>/<a href="http://search.dangdang.com/?key=李旭陽" title="【美】帕特里克·金 著,張捷/李旭陽 譯" target="_blank">李旭陽</a> 譯</div> <div class="publisher_info"><span>2018-08-01</span> <a href="http://search.dangdang.com/?key=天津人民出版社" target="_blank">天津人民出版社</a></div> <div class="biaosheng">五星評分:<span>16273次</span></div> <div class="price"> <p><span class="price_n">¥30.40</span> <span class="price_r">¥42.00</span>(<span class="price_s">7.2折</span>) </p> <p class="price_e"></p> <div class="buy_button"> <a ddname="加入購物車" name="" href="javascript:AddToShoppingCart('25345988');" class="listbtn_buy">加入購物車</a> <a ddname="加入收藏" id="addto_favorlist_25345988" name="" href="javascript:showMsgBox('addto_favorlist_25345988',encodeURIComponent('25345988&platform=3'), 'http://myhome.dangdang.com/addFavoritepop');" class="listbtn_collect">收藏</a> </div> </div>
是不是很亂?不要急,我們慢慢來分析,首先我們要明確自己要提取圖書的哪部分信息,我們這里決定爬取它的:
排名,書名,圖片地址,作者,推薦指數,五星評分次數和價格。
那么對這么大段的html文本,怎么提取每本書的相關信息呢?答案自然是通過正則表達式,在parse_result函數中,先構建了用來匹配的正則表達式(第16行),隨后對傳入的html文件執行匹配,獲取匹配結果(第19行),注意,這一步需要re模塊的支持(在第1行導入re模塊),re.compile是對匹配符的封裝,直接用re.match(匹配符,要匹配的原文本)可以達到相同的效果, 當然,這里沒有用re.match來執行匹配,而是用了re.findall,這是因為后者可以適用於多行文本的匹配。另外,re.compile后面的第2個參數,re.S是用來應對換行的,.匹配的單個字符不包括\n和\r,當遇到換行時,我們需要用到re.S。
上面的這段表述可能不大清楚,具體re模塊的正則匹配用法請自行百度,配合自己動手實驗才能真正明白,這里只能描述個大概,另外,我們這里不會從頭開始講解正則表達式的種種細節,而是僅對代碼中用到的正則表達式進行分析,要了解更多正則表達式相關的消息,就需要您自行百度了,畢竟對一個程序員來說,自學能力還是很重要的。
好,我們來看下代碼用到的正則表達式:
一段段來分析,首先是:
<li>.*?list_num.*?(\d+).</div>
.代表匹配除了\n和\r之外的任意字符,*代表匹配0次或多次,?跟在限制符(這里是*)后面是代表使用非貪婪模式匹配,因為默認的正則匹配是貪婪匹配,比如下面這段代碼:
import re content = 'abcabc' res = re.match('a.*c',content) print(res.group())
此時匹配時會匹配盡可能長的字符串,因此會輸出abcabc,而若把a.*c改為a.*c?,此時是非貪婪匹配,會匹配盡可能少的字符串,因此會輸出abc。
然后是\d,代表匹配一個數字,+代表匹配1個或多個。因此上面的表達式匹配的就是html文本中下圖所示的部分:
注意,\d+被括號括起來了,代表將匹配的這部分內容(即圖中的1這個數字)捕獲並作為1個元素存放到了一個數組中,所以現在匹配結果對應的數組中(即item)第一個元素是1,也就是排名。
隨后是
.*?<img src="(.*?)"
顯然它匹配的是下面這段:
此時會把括號中匹配到的圖片地址作為第2個元素存到數組中
剩下的匹配都是同樣的原理,並沒有什么值得注意的點,就不一一描述了,整個正則表達式一共有7對括號,因此有數組中存了7個元素,分別對應我們要提取的排名,書名,圖片地址,作者,推薦指數,五星評分次數和價格。
re.findall進行了進行了匹配后返回一個數組items,里面存放了所有匹配成功的條目(item),每個item對應一次對正則表達式的成功匹配,也就是上面說的7個元素的數組。
隨后程序中遍歷了items數組,將數組中每個item的數據封裝成一個表,並分別進行返回。
這里出了問題,每次返回的都是items數組中的一個item,而main函數中卻對返回值又進一步遍歷了其中的元素,並調用write_item_to_file將每個元素寫到自定義的文件中(43-44行)。
我們來看下write_item_to_file:
第31行的:
with open('book.txt', 'a', encoding='UTF-8') as f:
是一種簡化寫法,等價於:
try: f = open('book.txt', 'a') print(f.read()) finally: if f: f.close()
具體可以參考:https://www.cnblogs.com/yizhenfeng/p/7554620.html
這里打開book.txt后(沒有會自動創建),用write函數將item轉換成的json格式的字符串寫入(json.dumps函數是將一個Python數據類型列表進行json格式的編碼(可以這么理解,json.dumps()函數是將字典轉化為字符串))
而我們傳入write_item_to_file函數的是item中的一個元素,顯然不是一個表,這當然不對。顯然這里源代碼寫得有問題,在parse_result函數中,不應該遍歷匹配到的items並返回其中的每個item,而是應該直接返回items(對應正則表達式所有匹配結果),這樣,在main函數中,就可以正常地對items遍歷,抽出每個item(對應正則表達式的一組匹配結果),傳入write_item_to_file,后者在寫入時,對item進行json轉換,由於item是一個表,可以正常轉換,自然也能正常寫入。
下面給出修改后的代碼:
1 import requests 2 import re 3 import json 4 5 def request_dandan(url): 6 try: 7 #同步請求 8 response = requests.get(url) 9 if response.status_code == 200: 10 return response.text 11 except requests.RequestException: 12 return None 13 14 def parse_result(html): 15 pattern = re.compile('<li>.*?list_num.*?(\d+).</div>.*?<img src="(.*?)".*?class="name".*?title="(.*?)">.*?class="star">.*?class="tuijian">(.*?)</span>.*?class="publisher_info">.*?target="_blank">(.*?)</a>.*?class="biaosheng">.*?<span>(.*?)</span></div>.*?<p><span\sclass="price_n">¥(.*?)</span>.*?</li>',re.S) 16 items = re.findall(pattern, html) 17 return items 18 # for item in items: 19 # yield { 20 # 'range': item[0], 21 # 'iamge': item[1], 22 # 'title': item[2], 23 # 'recommend': item[3], 24 # 'author': item[4], 25 # 'times': item[5], 26 # 'price': item[6] 27 # } 28 29 def write_item_to_file(item): 30 print('開始寫入數據 ====> ' + str(item)) 31 with open('book.txt', 'a', encoding='UTF-8') as f: 32 f.write(json.dumps(item, ensure_ascii=False) + '\n') 33 34 def main(page): 35 url = 'http://bang.dangdang.com/books/fivestars/01.00.00.00.00.00-recent30-0-0-1-' + str(page) 36 html = request_dandan(url) 37 items = parse_result(html) 38 for item in items: 39 write_item_to_file(item) 40 41 if __name__ == "__main__": 42 for i in range(1,5): 43 main(i)
標紅的就是修改的部分,去試試吧,此時查看新生成的book.txt文件,結果如下:
至此,代碼修改成功。下面記錄一些額外的知識點,感興趣的可以看看:
原先的錯誤代碼用到了yield,之前用C#寫代碼時會用到協程,里面就用到了yield關鍵子,那么yield在Python中是怎么用的呢?
事實上,函數中一旦有語句被yield標記,那這個函數就不一樣了,此時直接調用它是不會調用的,而得理解成一種賦值效果,相當於暫存了這個函數,當要真正調用此函數時,需要用next驅動它,沒驅動一次,就相當於調用一次這個函數,而yield標記的語句實現的功能可以理解為是return,因此調用函數時,一旦運行到yield語句,就會返回,但返回后會記住當前運行到的yield語句位置,當下次再調用yield時,會從之前中斷的yield語句處繼續執行剩下的代碼。
是不是感覺有點抽象?不要着急,我為您精心准備了一份資料,參考下面這篇文章,相信你很快就能明白它的使用方法:
https://www.cnblogs.com/gausstu/p/9545519.html
至此,要講的基本講完了。最后講一個小知識點吧,寫代碼時,在return或yield后面的大括號,是不能移到下一行中的,比如Python中:
return { 'range': 1, 'image':here }
return { 'range': 1, 'image':here }
這兩種寫法,區別僅僅是第二種把大括號寫在了下面那行,但在Python中,是會報錯的,這點需要注意一下。
以前碰到問題搜別人的博客時,總是各種嫌棄,嫌棄這個寫得不清楚,那個寫得太簡單,現在終於明白為什么會這樣了,主要是寫博客確實比較麻煩,舉個例子吧,同樣的知識,花一天能吸收完,但真要把它寫出來,並且寫得比較清晰,別人能看懂,基本還要再花一天。以前還好,博主一直在讀研,有大把的時間可以記錄,現在工作了,也沒啥時間了,雖然工作中學了不少東西,但實在沒時間記錄,畢竟空余時間若是都用來寫博客,就沒時間學更多的東西。
很多人的做法是用降低博客的質量來彌補,比如有些問題,明明很多細節,就用一兩句話一筆帶過,導致的結果就是除了自己沒人能看懂,別人照着做的時候各種踩坑。說真的,這其實是一種很自私的行為,對記錄人來說,可能真的只需要記幾筆,提醒下自己關鍵點即可。但對那些碰到問題去搜解決方案的人來說,真的是一種煎熬,我相信大家都有這樣的體會:項目中碰到了一個問題,去網上搜索解決方案,結果花了半天時間,網上的方案各種不靠譜,各種不詳細,耗時又耗神。
在我看來,即使有着上面提到的原因,這種做法也是不可原諒的,隨着垃圾信息的不斷增加,每個人獲取有用信息的成本必然不斷上升,到最后,受害的是所有人。
可惜我無法改變這一切,我唯一能做到的就是,在我的博客中,盡可能將我的探索過程描述清楚,將每個細節展現給來看我博客的人,畢竟你們花了時間看我的博客,我也不能對不起你們。
好了,發點牢騷而已,不要在意,有空我會陸續將工作中碰到的問題及解決方案逐漸記錄下來的,可能會有點慢,但好在足夠詳細。