這是程式語言?你確定這不是英文嗎? — 實作一個簡單的爬蟲程式
說到Python,跟它相關的關聯詞,相信大家應該也都有耳聞;像是AI人工智慧、大數據分析、機器學習等等,應該都蠻常聽見的,也可以說是Python的強項。
這些強項裡面,爬蟲也是其中之一。
所以什麼是爬蟲呢?簡單講就是利用程式幫我們獲取想要的資訊。比起我們自己手動作業,電腦程式的速度一定快許多,也比較不容易出錯,更能完整的獲取相關細節資料,不會有人工遺漏之類的問題產生。
實作勝於理論,今天就利用這個簡單的小程式,讓大家更理解爬蟲到底是什麼吧!
今天的範例將會利用Google Colab實作。多少寫過Python程式的人應該都知道Jupyter notebooks,Colab其實就是一個線上版的Jupyter notebook,完成的檔案還可以同步存在自己的Google Drive雲端硬碟裡面,使用起來確實方便不少。
那麼話不多說,直接進入正題吧!
首先點上面的鏈結,應該會出現以上這個畫面。
接著點檔案 > 新增筆記本
看到以下畫面就可以開始寫程式囉!
今天將會以爬取PTT網頁版裡的八卦版做為示範。
相信不少人應該使用過PTT,八卦版更是時下最新時事的討論區。透過我們的爬蟲程式,能夠將前20條推文數超過10以上的議題自動抓取出來。這樣就算沒有時間看新聞,也能跟上週遭的時事討論。
首先我們先將接下來會用到的套件引進來。這次的爬蟲比較簡單,主要會用到兩個第三方套件:分別是 requests
和 BeautifulSoup
。不用擔心,這兩個套件Colab都已經幫我們預先裝好了,所以不用額外下載。
除了 requests
和 BeautifulSoup
,其他三個則是Python內建的套件: StringIO
可以方便我們處理字串訊息、 re
可以讓我們使用正規表達式,從文字串當中抓到我們想要的訊息、 time
則是時間模組,主要會使用到它的 sleep()
函式,讓程式適時的停頓,避免短時間內發送大量請求遭到網站封鎖IP。
寫完之後,就點一下左邊的執行鍵,如果正常運行,應該不會出現任何訊息(如果有錯誤底下會立刻跳出錯誤訊息)。
很好,接下就是重點囉~
首先我們先新增一格程式碼區塊。
Jupyter notebook的好處就是:可以把大量得程式碼打散成小區塊執行。這樣不只更有效率(有些程式碼只需要執行一次就好,像是套件引入),出錯時也比較好除錯(debug)。在開發的過程中,我們一定會不斷的執行程式碼,看結果有沒有符合預期,但很多時候我們關心的只有一小段有疑問的程式碼,這時候Jupyter notebook區塊執行就顯得非常方便,能夠馬上看到結果,也不用整段程式整個執行一遍(耗時)。
我們可以在新的程式碼區塊裡面,測試一下剛剛引入的套件。
get()
是 requests
套件裡的函式之一,利用 requests
這個套件,我們成功的向 http://www.google.com
這個網站,發送了一個GET請求。HTTP請求當中,常見的除了GET,還有POST,主要用於發送請求時,同時攜帶一些表單資訊到遠端的伺服器。還有PUT以及DELETE,這個留待下次再開一篇新的講解吧。
執行完我們可以看到,底下輸出了 <Response [200]>
,這個是伺服器回應我們的結果。透過 requests
套件發出一個請求(request)給在遠端的伺服器,並且得到一個伺服器的回應(response),這就是一個HTTP循環。
這裡的 <Response [200]>
是 requests
套件重新封裝之後的一個Python物件,物件裡有很多方法可以使用,包括獲取回應的內容字串,以及狀態代碼(status code)等等。這裡提一下:200是指 正常 的意思,常見的狀態代碼包括404(not found)、403(Forbidden)、以及500(Internal Server Error)等等。
透過 .text
屬性,可以看到回應裡面的內容。當這些文字被瀏覽器解讀之後,瀏覽器就會渲染成我們平常看到的google頁面,而這些文字就是html網頁原始碼。
一般我們會把 requests
的請求存放在一個變數裡,方便後續的取用。從上圖可以看到response物件有很多屬性與方法。
我們知道response物件裡,有 .text
屬性可以獲取網頁的原始碼,不過大家也看見了,一大串字串相連排在一起,根本分不清哪邊是哪邊,非常的不好辨讀。於是 BeautifulSoup
就派上用場了。
上圖是以Yahoo!奇摩股市網頁為例,底下的輸出是不是看起來清楚多了呢?各個標籤不只排版好了,透過 BeautifulSoup
的重新封裝,我們可以直接用 soup
這個變數來調用 BeautifulSoup
提供的許多方法,幫我們找到我們想要的標籤以及資訊。
既然已經知道怎麼使用了,稍微把網址換一下…
這樣就成功獲取到PTT的網頁原始碼拉!
做到這裡,我們已經成功的獲取PTT的網頁原始碼。不過現在有個小問題…
從上面的輸出內容可以得知,我們貌似是獲取到真正八卦版的前一個頁面,也就是詢問是否滿18歲的頁面。不過實際操作一次,我們可以發現我們真正想要的頁面其實是以下這個頁面:
原來八卦版有18歲限制,光是敲網址可不行。因為當伺服器接收到網址時,會順便檢查送過來的請求裡,有沒有包含已認證18歲的cookie。如果沒有這個cookie的話,伺服器就會丟認證18歲的頁面給你,而不是真正的八卦版。
這可不是我們想要的阿,那該怎麼解決呢?
很簡單,只要我們在發送請求的時候,假裝自己已經驗證過了,讓伺服器誤以為我們已經做完驗證。
怎麼假裝呢?
還記得剛剛講的cookie嗎?只要在發送請求的時候,把假的cookie帶過去,就行啦!
順帶一提,八卦版除了檢查cookie之外,也會檢查這個請求是用什麼平台發送的,所以我們不只要做假的cookie,也要假裝自己其實是用一個正常的瀏覽器瀏覽網頁,而不是用 requests
發送請求的。
怎麼達成這兩個條件呢?
注意看第2、3行。
第2行是假裝自己是用瀏覽器瀏覽的。
第3行則是假的cookie,可以讓我們成功skip掉驗證頁面。
至於cookie該帶什麼屬性過去,我是怎麼知道呢?
其實只要自己實際瀏覽網站一遍,打開chrome的開發者工具,就能看到拉!
Application
頁籤裡的 Storage
,有個 Cookies
屬性。點進去看,裡面有個很明顯的屬性 over18
,其值是1,代表只要伺服器看到你有這個cookie,擬就可以直接bypass驗證頁面,直接進到八卦版的首頁啦!
看到這個輸出就表示,我們已經成功進來了。
有逛過八卦版的鄉民應該知道,八卦版的議題非常多,多的不的了。但是我們現在比較關心的是時下最熱門的議題。通常這類型的議題推文數都會比較多,推文數超過10,標題前就會顯示黃字,表示很多人參與討論。
我們的目標就是把這些推文數超過10以上的議題抓出來。
首先我們先用開發者工具研究一下,八卦版首頁的整體架構。
覺得看不清楚的話,可以把它整個複製下來貼到記事本裡,仔細觀察。
可以看出這個小區塊,是由許多 <div>
標籤所組成。我們想要的數字,就在第2行的 <div>
標籤裡。
soup裡包含了整個網頁的架構,這些 <div>
標籤當然也在裡面。
要怎麼找到這個標籤呢?
仔細觀察的話,可以發現他被包在一個 <div class="r-ent">
裡面。
裡面又包了一層 <div class="nrec">
。
然後整個頁面充斥著一堆 <div class="r-ent">
。
- 首先我們先新增一個空陣列(titles_over_10_comments)。這個陣列專門拿來放符合資格的議題(也就是推文數大於10)
- 利用
soup.find_all('div', { 'class': 'r-ent'})
找出所有<div class="r-ent">
標籤,存放在一個變數all_title_tags
- 接著再跑迴圈循環找出各個
<div class="r-ent">
裡面的子標籤<div class="nrec">
,由於這個標籤裡面還有一層<span class="h1 f3">
標籤,於是再用.find()
方法找出這個標籤的內容,放進變數indicator
- 只要這個
indicator
變數不為空,且字串轉換為數字是大於10的,就加進titles_over_10_comments
陣列裡 - 最後輸出一下,驗證我們的想法對不對
看到上面的結果,很顯然這個邏輯是對的,只有推文數大於10的才被加進陣列裡。
檢查一下網頁看看…
基本上一路看下來沒什麼問題…等等,爆那篇文怎沒沒有被算進陣列?原則上推文數變成爆表示已經破百。既然這樣,那我們的的邏輯就要調整,把堆爆的文也算進來,才符合推文數大於10的規定。
檢查一下爆的網頁結構…
基本上爆這篇推文,跟其他的結構一樣,只不過它的內容是文字,還有它的class不一樣:仔細看會發現它是 <span class="h1 f1">
。
稍微調整一下if邏輯,只要推文數那個區塊是爆或是大於10,我們都納入陣列。在執行一次發現這次就對了,爆確實被納進來了。
最後核對一下頁面…
此時的頁面確實有3則貼文推文數是大於10的,包含爆。這樣我們就成功完成一半啦!
我們成功的把推文數大於10的文章都抓到了,不過我們才不想只看一個推文數字,我們真正要的是那篇文的標題,以及鏈結,這樣才能知道是什麼再被討論,還有想看細節也才能點進去看。
結構大概是這種感覺。
於是完整的樣子長這樣。
這裡發生了蠻多事情的,我們一個一個看吧。
- 首先最上面的
url
被我拆成了3個變數:base_url
、sub_base_url
、full_url
。這麼做是為了配合下面打印鏈結時,能夠正確的導向想看的網頁,而我們點擊標題進入文章的網址結構跟在首頁時不一樣。因此這樣的拆分是有必要的,下面會在詳講。 requests
的get請求,當然就不能只放url
,要放full_url
。- 我們都知道
titles_over_10_comments
這個陣列存放著我們想看的東西,因此只要跑個迴圈,循環每個元素,再重新組裝成想要的樣子就可以了。
推文數以及標題可能比較沒有問題,重點是鏈結的部分。
https://www.ptt.cc/bbs/Gossiping/M.1602771705.A.628.html
隨便點一頁進去,會看到網址如上所示。
跟我們首頁的網址比較一下…
https://www.ptt.cc/bbs/Gossiping/index.html
有沒有發現?從 Gossiping/
後面開始,網址就不一樣了。
可以發現兩邊共同的部分為: https://www.ptt.cc/bbs/Gossiping
這就是為什麼 url
的部分要拆成 base_url
以及 sub_base_url
。透過直接改 sub_base_url
,拼接成新的網址串,就可以直接到指定位置瀏覽網頁。
a_link = tag.find_all(href=re.compile("/bbs/Gossiping/.*"))
來講講以上這行在幹嘛吧!
最下面的 for
迴圈,在循環輸出我們自訂格式的資料。由於 tag
裡面都是一個一個的標籤,其內容結構包含了非常多的子標籤,因此,要找到各個子標籤的文章鏈結,利用 soup
的 find_all()
方法,找出標籤當中含有 href
屬性的,並且它的內容值為 "/bbs/Gossiping/.*"
。這裡就要借用到Python內建模組 re
,它能夠讓我們使用正規表達式,從文字內容裡搜尋出符合樣式的資料。
我們要搜尋的樣式就是 /bbs/Gossiping/
開頭,斜線後面接 .*
代表不指定任意格式的所有內容。有關正規表達式,未來有時間再詳述吧。
於是我們就找到了所有 <a>
標籤。通常html裡的鏈結都會以 <a>
標籤表示。
print(f'{push_amount} {a_link[0].get_text()} \n link: {base_url}{a_link[0]["href"]} \n')
至於最後這行,則是在打印我們自訂的內容, \n
表示換行的意思。於是就出現了這樣的格式:
推文數 a標籤標題內容
a標籤鏈結(href)內容
做到這裡,我們的程式又向前邁進了一大步。不過很顯然我們還沒有完成。
執行完可以看到輸出只有3行而已。
那是因為我們在最後一頁的位置。
用BBS上過PTT的人,應該都知道,PTT不管哪個版,進入之後的預設位置都是在最底部。要看舊文章就要往上瀏覽。網頁的部分也是遵循這樣的編排。
如果要看舊文章,就要往上,也就是上一頁。
我們已經知道目前的程式,在首頁運作已經完全沒問題了。那其他頁的運作基本一樣,只要想辦法改變首頁的網址就好。
點個上一頁看一下網址的變化吧!
上一頁的網址:https://www.ptt.cc/bbs/Gossiping/index39087.html
利用開發者工具檢查上一頁的網址,可以發現以下結構:
<a class="btn wide" href="/bbs/Gossiping/index39086.html">‹ 上頁</a>
其實上一頁本身就是一個 <a>
標籤,裡面的 href
就是我們要的網址。不過它只給一半而已,如果要用 requests
獲取到完整的上一頁,我們必須把前半段和這段拼接起來,得到完整的網址,這就是為什麼一開始的 url
要拆分成 base_url
以及 sub_base_url
,如此一來只要想獲取上一頁,只要改一下 sub_base_url
就OK啦!
首先,我們先手動改一下網址測試看看程式是否還能正常運作…
即使換頁也能正常地獲取到文章及推文數!
基本上做到這裡已經完成90%了。給自己一個讚👍
我們成功的做了一個爬蟲程式,不過目前這個程式還很笨,只會爬一頁的資訊…
很顯然這不是我們想要的,那麼接下來就來講講怎麼讓它自動換頁,爬到我們指定的目標吧!
首先,在寫code之前,先想像一個情境…
平常我們在滑PTT的時候,整頁看完才會換上一頁,對吧?
既然這樣,我們也要告訴程式:當你整頁看完的時候,請換上一頁。
不過我們總不能無上限的上一頁,這樣跟我們當初設計程式的初衷就偏離了。原本的設計就是:抓前20則推文數大於10的文章。
所以我們要一直重複換頁的動作,直到已經累積了20則貼文。
既然是重複,那答案已經很明顯了,就是迴圈。
基本的功能我們已經完成了,所以只要加入迴圈,讓某些行為一直重複做,直到滿足目標為止,就大功告成啦!
先讓大家看code吧!
它的輸出長這樣:
可以看到輸出依序打印了每頁的擷取進度,直到我們的目標20則為止。
把迴圈加進原本的程式,想像一下,有哪些行為是要一直重複做的呢?
發請求進網頁 > 瀏覽整頁 > 尋找所有目標 > 加進陣列裡 > 換頁
仔細想想,以上這些行為是不是一直在重複?在還沒有累積到20則貼文之前,勢必要一直重複以上動作,直到貼文數累積到了20。以上行為,除了換頁,其他剛好是我們程式的正常流程。
那就把整段包在一個 while
迴圈裡就好啦!最後面再加上換頁的動作,這樣就完成我們的爬蟲程式啦!
要怎麼判斷目前貼文累積20則了沒有?很簡單,只要利用Python內建函式 len()
就可以判斷目前 titles_over_10_comments
陣列已經累積了多少貼文,只要等於20,迴圈就會中斷。
# 如果已經累積20則貼文, 中斷迴圈if len(titles_over_10_comments) >= 20: break
這裡要注意:再新增標籤至 titles_over_10_comments
的時候,必須一邊判斷陣列是否已經達標,達標的話就中斷迴圈不在增加了。因為在換頁時,最後那頁的文章數不見得可以剛剛好累積到20則貼文,可能會超過,例如:5、3、4、6、7,從第四頁到第五頁,第四頁已經累積了18則,第五頁有7則的話,加進去會超過20,所以要加上這個條件。
# 找完整頁的標籤後, 請換下一頁page_btns = soup.find_all('a', { 'class': 'btn wide' })gossiping_index = page_btns[1]['href']full_url = base_url + gossiping_indexprint(f'Done fetching... {len(titles_over_10_comments)}')time.sleep(1)
換頁的部分,首先要找到上一頁的標籤。這裡使用 soup
找出所有的 <a class="btn wide">
,裡面剛好會包含上一頁的標籤。獲取到標籤之後,再將上頁(剛好是 page_btns
裡第一個元素) href
屬性擷取出來,和 base_url
拼成新的鏈結,這樣在跑下一次迴圈時, requests
獲取到的結果就會是上一頁的html。 time.sleep(1)
是讓程式每跑完一次迴圈時,休息1秒,避免短時間內重複發送過多請求,導致鎖IP( time
是Python內建模組)
以下為完整的程式碼:
以上就是整個爬蟲程式的製作過程。
以上程式適用於PTT WEB所有版面,想看股票版就把 Gossiping
換成 Stock
,想看西洽版就換成 C_Chat
,想看男女版就換成 Boy-Girl
,想看西斯版就… 你懂的,我就不說啦😎
有什麼問題也歡迎留言,喜歡就點個拍手吧!