這是程式語言?你確定這不是英文嗎? — 實作一個簡單的爬蟲程式

Kevin Tung
17 min readOct 20, 2020
Photo by Andrey Tikhonovskiy on Unsplash

說到Python,跟它相關的關聯詞,相信大家應該也都有耳聞;像是AI人工智慧、大數據分析、機器學習等等,應該都蠻常聽見的,也可以說是Python的強項。

這些強項裡面,爬蟲也是其中之一。

所以什麼是爬蟲呢?簡單講就是利用程式幫我們獲取想要的資訊。比起我們自己手動作業,電腦程式的速度一定快許多,也比較不容易出錯,更能完整的獲取相關細節資料,不會有人工遺漏之類的問題產生。

實作勝於理論,今天就利用這個簡單的小程式,讓大家更理解爬蟲到底是什麼吧!

今天的範例將會利用Google Colab實作。多少寫過Python程式的人應該都知道Jupyter notebooks,Colab其實就是一個線上版的Jupyter notebook,完成的檔案還可以同步存在自己的Google Drive雲端硬碟裡面,使用起來確實方便不少。

那麼話不多說,直接進入正題吧!

Google Colab的首頁

首先點上面的鏈結,應該會出現以上這個畫面。

接著點檔案 > 新增筆記本

看到以下畫面就可以開始寫程式囉!

今天將會以爬取PTT網頁版裡的八卦版做為示範。

相信不少人應該使用過PTT,八卦版更是時下最新時事的討論區。透過我們的爬蟲程式,能夠將前20條推文數超過10以上的議題自動抓取出來。這樣就算沒有時間看新聞,也能跟上週遭的時事討論。

首先我們先將接下來會用到的套件引進來。這次的爬蟲比較簡單,主要會用到兩個第三方套件:分別是 requestsBeautifulSoup 。不用擔心,這兩個套件Colab都已經幫我們預先裝好了,所以不用額外下載。

除了 requestsBeautifulSoup ,其他三個則是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">

  1. 首先我們先新增一個空陣列(titles_over_10_comments)。這個陣列專門拿來放符合資格的議題(也就是推文數大於10)
  2. 利用 soup.find_all('div', { 'class': 'r-ent'}) 找出所有 <div class="r-ent"> 標籤,存放在一個變數 all_title_tags
  3. 接著再跑迴圈循環找出各個 <div class="r-ent"> 裡面的子標籤 <div class="nrec"> ,由於這個標籤裡面還有一層 <span class="h1 f3"> 標籤,於是再用 .find() 方法找出這個標籤的內容,放進變數 indicator
  4. 只要這個 indicator 變數不為空,且字串轉換為數字是大於10的,就加進 titles_over_10_comments 陣列裡
  5. 最後輸出一下,驗證我們的想法對不對

看到上面的結果,很顯然這個邏輯是對的,只有推文數大於10的才被加進陣列裡。

檢查一下網頁看看…

基本上一路看下來沒什麼問題…等等,那篇文怎沒沒有被算進陣列?原則上推文數變成表示已經破百。既然這樣,那我們的的邏輯就要調整,把堆爆的文也算進來,才符合推文數大於10的規定。

檢查一下爆的網頁結構…

基本上爆這篇推文,跟其他的結構一樣,只不過它的內容是文字,還有它的class不一樣:仔細看會發現它是 <span class="h1 f1">

稍微調整一下if邏輯,只要推文數那個區塊是或是大於10,我們都納入陣列。在執行一次發現這次就對了,爆確實被納進來了。

最後核對一下頁面…

此時的頁面確實有3則貼文推文數是大於10的,包含爆。這樣我們就成功完成一半啦!

我們成功的把推文數大於10的文章都抓到了,不過我們才不想只看一個推文數字,我們真正要的是那篇文的標題,以及鏈結,這樣才能知道是什麼再被討論,還有想看細節也才能點進去看。

結構大概是這種感覺。

於是完整的樣子長這樣。

這裡發生了蠻多事情的,我們一個一個看吧。

  1. 首先最上面的 url 被我拆成了3個變數: base_urlsub_base_urlfull_url 。這麼做是為了配合下面打印鏈結時,能夠正確的導向想看的網頁,而我們點擊標題進入文章的網址結構跟在首頁時不一樣。因此這樣的拆分是有必要的,下面會在詳講。
  2. requests 的get請求,當然就不能只放 url ,要放 full_url
  3. 我們都知道 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 裡面都是一個一個的標籤,其內容結構包含了非常多的子標籤,因此,要找到各個子標籤的文章鏈結,利用 soupfind_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 ,想看西斯版就… 你懂的,我就不說啦😎

有什麼問題也歡迎留言,喜歡就點個拍手吧!

--

--

Kevin Tung

A developer who is passionate about programming , learning to code, and sharing that knowledge