[Crawler] Ptt 八卦版爬蟲實作


Table of contents

  • [Crawler] Ptt 八卦版爬蟲實作
    • Table of contents
    • Info
      • Author
      • Goal description
    • Code
    • Guide
      • Setting
      • Review index
      • Requests eacharticle
      • Score record
      • Article info
      • Saves that
      • Debug
    • Supplementary content
      • Other Info

Info

Author

  • Hey Guys We Have A Gift For You! Click On The Following Link To Claim Your Gift.
                                          -- Belion

大家好我是作者彼獅,本文是我的 Python 實作系列文,也是我個人的筆記文章,故撰寫分為目標描述程式碼與將程式碼分段描述部分,如果有補充的背景知識或其他資訊會附註在文末的參考資訊,同時我產出自己的技術文章與學習筆記,相關的內容可以參考下方資訊,本魯尚菜,文章僅供參考,若有錯誤煩請多賜教言亦歡迎留言討論,拜謝其臨。


|   HackMD   |   Github   |   Coderbridge   |  Facebook  |


Goal description

PTT-
  本篇文章使用 python 進行爬蟲實作,當我們有各式各樣的目標需要透過分析來輔助決策時,不論是政治輿情分析、購物習慣分析等等,我們都需要大量且有意義的資訊內容,而在台灣 PTT 論壇長紅於深度網路使用者,除了 BBS 電子佈告欄作為最早期的社群並架設在臺灣大學延續至今之由以外,PTT 上面討論的主題無論深度廣度皆具,也是以往學生交流求學資訊之始最重要的論壇。
  現在雖然有其他的社群平台取代了 PTT 的部分功能,但是 PTT 的影響仍在,以八卦版舉例,曾經白衫軍運動也就是聲援洪仲丘案的公民運動,在八卦版透過爆料的方式開始,並在 PTT 平台上激起一連串的討論、檢討與監督國防部、軍政體系對於此案的處理與改進,並在八卦版成功號召第一次由公民舉行的社會運動,接著還有太陽花運動、國艦國造、小燈泡案等等,諸多政治與社會議題的高度關注都仍然指出八卦版(PTT)還是台灣網路生態中極度活躍的社群平台,故因此我們在蒐集分析材料的取得是不能忽略 PTT 的。

Python-
  python 常用於資料分析,有許多第三方套件支援強大的功能來滿足其需求,在採集資料的階段,爬蟲像是本篇文章會利用到的 requests 模組,就是第三方訪問 url 的常用套件,或是 BeautifulSoup 解析網頁的常用套件,並且還在持續開發新功能,完全發揮高階語言的好處,不僅易讀易懂、相容性也相當高而容易使用,因此做為爬蟲目標的使用語言再好不過。

Crawler-
  因此我們依照我們可能會碰到的分析需求來進行爬蟲程式的設計,換言之先知道需要什麼資料再進行撈取有利於下個階段的資料預處理或是資料儲存,畢竟資料量大時的資料來源或可能散布再諸多不同的 Web 或資料型態,所以針對需求來設計爬蟲:

  • 輿情分析
    若我們要針對 PTT 熱門版進行輿情分析,那麼我們需要大量有意義的文章,所以八卦版的各篇文章的內文是一定需要爬取的,接著是 PTT 設計留言區具有推&噓的性質,分別表示正面與反對意見,如果同個標題的回文會以Re:加入文章開頭,而系列文並未每一篇都具有參考價值,所以可以加入推噓的綜合分數來做加權。

  • 使用者資料
    PTT 的使用者雖然透過註冊可以匿名交流,但每個足跡仍有 IP 可追蹤,假如我們要觀察使用者生態諸如網軍判別、使用者分佈等,那我們也需要爬取每個留言的 ID&IP

  • 圖片下載
    論壇文章中的圖片通常透過第三方圖庫,使用 URL link 來預覽,因此我們只要在文章內文中爬取圖片的 url 並下載即可。

Code

以下是本篇的完整程式碼:

# 引入我們所需要的模組套件
import re
import os
import requests
import pandas as pd
from bs4 import BeautifulSoup

# 設定儲存目標資料夾
path = r'C:\\Users\\Username\\Desktop\\Ptt_Gossiping_Article'
if not os.path.exists(path):
    os.mkdir(path)

# 設定爬蟲程式偽裝瀏覽器的參數
userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Safari/537.36'
headers = {'User-Agent' : userAgent}
cookie = {'over18':'1'}

# Ptt網頁版 Gossiping版 主頁index
url = 'https://www.ptt.cc/bbs/Gossiping/index.html'
# 設定頁數
n = n
# 翻頁loop
for i in range(0, n):
    res = requests.get(url, headers=headers, cookies=cookie)
    html = res.text
    soup = BeautifulSoup(html, 'html.parser')
    # 同頁內的文章標題取出各文章 url
    title = soup.select('div[class="title"]')
    # 訪問各文章 loop
    for eachArticle in title:
        try:
            # 利用 try except loop 訪問未被刪除的文章 否則 return 刪除資訊
            try:
                each_articleUrl = 'https://www.ptt.cc' + eachArticle.select('a')[0]['href']
                print(each_articleUrl)
            except:
                print(eachArticle)
            each_articleRes = requests.get(url=each_articleUrl, headers=headers, cookies=cookie)
            each_articleHtml = each_articleRes.text
            each_articleSoup = BeautifulSoup(each_articleHtml, 'html.parser')
            each_articleAuthor = each_articleSoup.select('span[class="article-meta-value"]')[0].text
            each_articleTitle = each_articleSoup.select('span[class="article-meta-value"]')[2].text
            each_articleText = each_articleSoup.select('div[id="main-content"]')[0].text.split('--')[0]
            # [踩坑] 由於 windows 檔案名稱不得有特定的特殊字元 所以直接刪除特殊字元
            if ':' and '/' and '*' and '"' and '>' and '<' and '|' and '?' and ':' and ' ' in each_articleTitle:
                each_articleTitle = re.sub("\<|\'|\?|\ |\>|\*|\:|\"|\?|\:|\||\/|\//","",each_articleTitle)
            else:
                pass
            # 若只想要儲存內文作為文字分析需求減少 預處理刪除網址連結
#             if 'https://' in each_articleText:
#                 each_articleText = each_articleText.split('\n')
#                 for h in each_articleText[0:]:
#                     if 'https://' in h:
#                         each_articleText.remove(h)
#                 each_articleText = "".join(map(str, each_articleText))
#             else :
#                 pass
            # 本篇文章 information
            push_info_list = each_articleSoup.select('div[class="push"] span')
            description_list = each_articleSoup.select('div[class="article-metaline"] span')
            for i, item in enumerate(description_list):
                if (i+1)%6 == 2:
                    Article_author = item.text
                if (i+1)%6 == 4:
                    Article_title = item.text
                if (i+1)%6 == 0:
                    Article_datetime = item.text
            # 統計推噓文
            Push = 0
            Boo = 0
            Score = 0 
            for info in push_info_list:
                if '推' in info.text:
                    Push += 1
                if '噓' in info.text:
                    Boo += 1
            Score = Push - Boo
            # 留言區整理
            Commtes_author = []
            Commtes_comment = []
            Commtes_IP = []
            for n, info in enumerate(push_info_list):
                if (n+1)%4 == 2:
                    Commtes_author.append(info.text)
                if (n+1)%4 == 3:
                    Commtes_comment.append(info.text)
                if (n+1)%4 == 0:
                    Commtes_IP.append(info.text)
            # 將文章內文加上文章 info
            each_articleText += '\n----------\n'
            each_articleText += '作者:%s\n' %(Article_author)
            each_articleText += '標題:%s\n' %(Article_title)
            each_articleText += '時間:%s\n' %(Article_author)
            each_articleText += '推:%s\n' %(Push)
            each_articleText += '噓:%s\n' %(Boo)
            each_articleText += '分數:%s' %(Score)
            each_articleText += '\n----------\n'
            # 將文章內文加上留言區
            for n in range(0,len(Commtes_author)):
                each_articleText += Commtes_author[n]
                each_articleText += Commtes_comment[n]
                each_articleText += '|'+Commtes_IP[n]
            # 儲存文章到指定 Path
            try:
                if os.path.isfile(r'%s\\\%s.txt' % (path, each_articleTitle)):
                    filename = each_articleTitle+'!'+each_articleAuthor
                    with open(r'%s\\\%s.txt' % (path, filename), 'w', encoding='utf-8') as w:
                        w.write(each_articleText)
                else:
                    with open(r'%s\\\%s.txt' % (path, each_articleTitle), 'w', encoding='utf-8') as w:
                        w.write(each_articleText)
                print()
        # 偵錯放在 loop 最後透過 except print 出 方便檢查 log
            except FileNotFoundError as e:
                print(each_articleTitle)
                print(e.args)
                print('==============')
            except OSError as e:
                print(each_articleTitle)
                print(e.args)
                print('==============')
        except AttributeError as e:
            print(each_articleTitle)
            print(e.args)
            print('==============')
    # 翻頁
    url = 'https://www.ptt.cc'+soup.findAll('a', class_="btn wide")[1]['href']

Guide

由於本篇文章定位為操作指南與個人筆記,因此以下呈現程式碼分區塊做功能簡述,如有需求可至文末連結處下載 Jupyter notebook 可執行檔案,副檔名.ipynb程式碼供參考。

Setting

# 引入我們所需要的模組套件
import re
import os
import requests
import pandas as pd
from bs4 import BeautifulSoup

# 設定儲存目標資料夾
path = r'C:\\Users\\Username\\Desktop\\Ptt_Gossiping_Article'
if not os.path.exists(path):
    os.mkdir(path)

# 設定爬蟲程式偽裝瀏覽器的參數
userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Safari/537.36'
headers = {'User-Agent' : userAgent}
cookie = {'over18':'1'}

本節設置我們需要的模組與路徑,最重要的是 headers 以及 cookies 的設置,稍有維護的商用平台通常都設有反爬蟲的機制,像是沒帶入 headers 被判別為程式的 Web request 成功一次後將被阻擋訪問 api 的權限,重複訪問或可能會被封鎖客戶端 IP 的訪問權限,週期與時間由伺服器端決定,因此我們需替程式偽裝瀏覽器的辨識參數,cookies 則是我們訪問特定網頁時,伺服器設置我們需要帶特定的資料來訪問才可以獲得權限,而此處是 PTT 八卦版的十八禁門檻。

Review index

# Ptt網頁版 Gossiping版 主頁index
url = 'https://www.ptt.cc/bbs/Gossiping/index.html'
# 設定頁數
n = 1
# 翻頁loop
for i in range(0, n):
    res = requests.get(url, headers=headers, cookies=cookie)
    html = res.text
    soup = BeautifulSoup(html, 'html.parser')
    # 同頁內的文章標題取出各文章 url
    title = soup.select('div[class="title"]')
    .
    .
    .
    .
    .
    .
    url = 'https://www.ptt.cc'+soup.findAll('a', class_="btn wide")[1]['href']

本節是訪問網頁八卦版的首頁,我們要爬取的文章在每一頁的標題裡,此處先將標題取出後為一個 list,下節恰好可以用 loop 逐一取出 list's elements,重點是記得我們要翻頁,在每一頁 index 有選頁的 icon 透過 css 選擇器做搜尋並翻頁。

Requests eacharticle

    for eachArticle in title:
        try:
            # 利用 try except loop 訪問未被刪除的文章 否則 return 刪除資訊
            try:
                each_articleUrl = 'https://www.ptt.cc' + eachArticle.select('a')[0]['href']
                print(each_articleUrl)
            except:
                print(eachArticle)
            each_articleRes = requests.get(url=each_articleUrl, headers=headers, cookies=cookie)
            each_articleHtml = each_articleRes.text
            each_articleSoup = BeautifulSoup(each_articleHtml, 'html.parser')
            each_articleAuthor = each_articleSoup.select('span[class="article-meta-value"]')[0].text
            each_articleTitle = each_articleSoup.select('span[class="article-meta-value"]')[2].text
            each_articleText = each_articleSoup.select('div[id="main-content"]')[0].text.split('--')[0]
            # [踩坑] 由於 windows 檔案名稱不得有特定的特殊字元 所以直接刪除特殊字元
            if ':' and '/' and '*' and '"' and '>' and '<' and '|' and '?' and ':' and ' ' in each_articleTitle:
                each_articleTitle = re.sub("\<|\'|\?|\ |\>|\*|\:|\"|\?|\:|\||\/|\//","",each_articleTitle)
            else:
                pass

本節透過 loop 訪問 index 被我們取出包含 url 的 article's title,因此我們在參數的設定上可以做出區別,方便辨別參數的意義,本節遇到兩個問題:

  • 第一個是當文章被刪除之後,title 仍然有被刪除文章等字樣,但因為已經被刪除所以沒有 url 可以訪問,想當然而我們爬取不到,所以對 title_list 而言 element 為空集合,放置在迴圈裡面會造成 list index out of range的錯誤,可是實際上並不影響我們下列程式碼的執行,所以我們使用try&except迴圈來替我們做例外處置。Sure, we just go pass.
  • 第二個是文章標題作為我們儲存.txt檔案的檔名,但是碰上了 Windows 對於檔名的特殊字元限制,所以我們在此處使用if&else找到具有保留字元的標題時先行刪除。

Score record

            push_info_list = each_articleSoup.select('div[class="push"] span')
            description_list = each_articleSoup.select('div[class="article-metaline"] span')
            for i, item in enumerate(description_list):
                if (i+1)%6 == 2:
                    Article_author = item.text
                if (i+1)%6 == 4:
                    Article_title = item.text
                if (i+1)%6 == 0:
                    Article_datetime = item.text
            # 統計推噓文
            Push = 0
            Boo = 0
            Score = 0 
            for info in push_info_list:
                if '推' in info.text:
                    Push += 1
                if '噓' in info.text:
                    Boo += 1
            Score = Push - Boo

本節誠如前言所述,留言除了一般留言外具有正負面屬性,我們可以某種程度認定這份指標是鄉民對於文章的認同指標,除了特定的例外,我們若是能以此作為系列文或是特定 Tags 的加權,方可以對於模型訓練多一個參數可用,

Article info

            Commtes_author = []
            Commtes_comment = []
            Commtes_IP = []
            for n, info in enumerate(push_info_list):
                if (n+1)%4 == 2:
                    Commtes_author.append(info.text)
                if (n+1)%4 == 3:
                    Commtes_comment.append(info.text)
                if (n+1)%4 == 0:
                    Commtes_IP.append(info.text)

每篇文章的作者、標題、時間與發文 IP,都是重要的資訊。1

Saves that

            # 將文章內文加上文章 info
            each_articleText += '\n----------\n'
            each_articleText += '作者:%s\n' %(Article_author)
            each_articleText += '標題:%s\n' %(Article_title)
            each_articleText += '時間:%s\n' %(Article_author)
            each_articleText += '推:%s\n' %(Push)
            each_articleText += '噓:%s\n' %(Boo)
            each_articleText += '分數:%s' %(Score)
            each_articleText += '\n----------\n'
            # 將文章內文加上留言區
            for n in range(0,len(Commtes_author)):
                each_articleText += Commtes_author[n]
                each_articleText += Commtes_comment[n]
                each_articleText += '|'+Commtes_IP[n]
            # 儲存文章到指定 Path
            try:
                if os.path.isfile(r'%s\\\%s.txt' % (path, each_articleTitle)):
                    filename = each_articleTitle+'!'+each_articleAuthor
                    with open(r'%s\\\%s.txt' % (path, filename), 'w', encoding='utf-8') as w:
                        w.write(each_articleText)
                else:
                    with open(r'%s\\\%s.txt' % (path, each_articleTitle), 'w', encoding='utf-8') as w:
                        w.write(each_articleText)
                print()

本節將每天文章的 string type's content 按照我們想要的格式排版,在各篇迴圈內+=我們前述所整理好的資訊整理好我們各篇的內容,接著就是儲存在本機,並且碰上一個問題:

  • PTT 文章具有回文功能,也就是指定已發表文章進行撰寫文章回應,回文的文章標題帶有Re:,帶有特殊字元我們在 Requests eacharticle 一節中已經解決。而回覆同一篇文章並無數量限制,所以會有不同作者甚至同一作者的重覆回文,同一作者的話題延續通常會接續回覆可以當作例外忽略,而不同作者的回文會導致標題重覆,因此我們儲存在本機時候的檔名會重覆,若無設定偵錯則會覆蓋重名檔案,導致系列回文被整串忽略,所以我們在回文的文章後面帶入回文作者的 ID 做識別並解決問題重名問題。
  • (補):除了回文以外轉版文章也會帶有Fw:同上,我們已經解決特殊字元之後,不用擔心儲存問題,另外筆者記得轉版文章的回文並不會掛上Fw:標題,所以可以適用上述的固有處理。

Debug

        # 偵錯放在 loop 最後透過 except print 出 方便檢查 log
            except FileNotFoundError as e:
                print(each_articleTitle)
                print(e.args)
                print('==============')
            except OSError as e:
                print(each_articleTitle)
                print(e.args)
                print('==============')
        except AttributeError as e:
            print(each_articleTitle)
            print(e.args)
            print('==============')

本節我們已經 run 完整個 title_list 也就是一整頁八卦版 index 的每一篇文章的 loops,所以我們在例外處理偵錯的方面,透過as宣告參數後在print出,方便我們找到是哪一種error

Supplementary content

Other Info

本文使用到套件的文件 Documents:

如果想補充本文的背景知識,歡迎參考我在 CoderBridge Blog 發表的 PyETL 系列文章:

最後感謝閱讀本篇文章的客官,我是彼獅,希望文章能對您有幫助或有所獲的話,
歡迎關注資訊欄中的帳號,您的鼓勵是我發文的動力,謝謝大大分享;好人一生平安


  1. 舉個令人惋惜的例子:蘇外交官以死明志事蹟,當時中國透過輿論操作,釋出加工假資訊、散佈農場文來鞏固中國自身的國族情面,被台灣自己的網軍作為素材在八卦版散佈假消息,導致蘇外交官不願受辱而以死明志。當回頭來檢討並且追溯其源時,八卦版爆料文章的 IP 與時間就找到了攻擊來源與時間上的造假。

#Python #PyETL #Crawler







你可能感興趣的文章

[day-5] 10分鐘了解陣列的簡易應用

[day-5] 10分鐘了解陣列的簡易應用

七天學會 swift - 基礎篇 Day2

七天學會 swift - 基礎篇 Day2

git 狀況劇

git 狀況劇






Comments