Try   HackMD

資訊之芽 Py 班 Week 11 - Scraping with Python and BeautifulSoup4

美麗的湯 🥣

接續著前面的講題,現在我們要來進行一些進階的爬蟲操作,包括 BeautifulSoup4 的應用,並且製作一些簡單的爬蟲。

Installation

請遵照你之前在自己電腦安裝 pygame 的方法,執行指令安裝幾個需要的套件:

pip install bs4 html5lib

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

前面如果你沒有裝 requests,也請一起裝上。

基本觀念

為了加強大家對 HTML 的語感(?),我們先來進行一個小小的練習:

我們來利用前面投影片提過的 html tag 寫個簡單的網頁,自我介紹一下。

  • 名字
  • 個人資訊(可以瞎掰)
  • 一句喜歡的話
  • 圖片
  • 其他垃圾內容

我們也來補充一點前面沒提的網頁觀念:

  • 動態網頁 vs 靜態網頁
    • 靜態網頁就是單純的 html 檔案,載下來放在自己的電腦也可以打開
    • 動態網頁必須經由伺服器的程式執行才能回傳 html,所以不能載下來在自己的電腦打開。基本上有會員登入,或是大部分會隨著時間更新的都是動態網頁。
  • 網頁前端 vs 網頁後端
    • 在一個網頁的系統裡,前端是顯示資料的部分、後端是儲存資料的部分。在「前後端分離」的情況,通常如果允許的話,直接從後端抓資料是最容易的,因為可以抓到格式比較整齊的資料。

延伸資訊:CRUD (Create, Read, Update, Delete), Restful API (POST, GET, PUT, DELETE)

Sprout OJ 練習

在接續關於爬蟲的學習之前,我們先來補上一些 requests 練習。

來爬一下 Sprout OJ 吧。

有沒有發現 Sprout OJ 上面的頁面一打開都是空白的,都要載入一下才會顯示?為什麼呢?

  • Ans: 因為網頁是在載入之後,再使用前後端分離的 API 的方式從伺服器抓資料的,所以不會立即顯示出資訊,而是網頁的程式在抓到資料之後,再把抓到的資訊整理後透過 HTML 顯示出來。

再來我們會解釋怎麼利用瀏覽器的功能來找出網頁是怎麼抓取資料的:

抓取資料的基礎(/status)

請跟著講師來檢視一下 Status 的網頁:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

利用檢查元素的網路工具(Network欄)來看一下瀏覽器傳輸的 HTTP requests,選擇只顯示「XHR」。
Note: XHR = XMLHttpRequest (JavaScript)

可以發現瀏覽器在抓資料的時候,會發出一個 POST 的請求,網址是 https://neoj.sprout.tw/api/challenge/list

只要加上對應的參數資料(稱為「request payload」),就可以從 OJ 的網頁伺服器取得「最新 100 筆 submission 的資料」。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

再來從瀏覽器的「Preview」可以看到,伺服器回傳的資料是一個 key-value(索引對應到值)的格式,類似於 dictionary。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

藉由一些方式我們可以知道它其實就是 JSON,我們可以直接透過 python 讀取成 dictionary 跟 list 的組合:

import requests
import json

url = 'https://neoj.sprout.tw/api/challenge/list'
payload = '{"offset":0,"filter":{"user_uid":null,"problem_uid":null,"result":null},"reverse":true,"chal_uid_base":null}'
status_res = requests.post(url, payload)

status = json.loads(status_res.text)

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

整理資料練習

試試把以下的資料從這個 status 的 dictionary 中抓出來,分別用 list 儲存

  • 最新 100 筆 submission 的送出者名字
status_data = reversed(status['data']) # 我們希望從「最晚」的開始顯示

names = []
for x in status_data:
    names.append(x['submitter']['name'])

print(", ".join(names))

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

  • 題目名稱
problems = []
for x in status_data:
    problems.append(x['problem']['name'])

print(", ".join(problems))

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

  • 執行結果(Accepted/Wrong Answer/
result_dict = {1: 'Accepted', 
               2: 'Wrong Answer', 
               3: 'Runtime Error',
               4: 'Time Limit Exceeded',
               5: 'Memory Limit Exceeded',
               6: 'Compile Error',
               7: 'Other',
               0: 'Unknown'}

results = []
for x in status_data:
    results.append(result_dict[int(x['metadata']['result'])])

print(", ".join(results))

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →


/profile

再來試試查看使用者的 profile(個人檔案),這個應該稍微輕鬆一點。

點開 Sprout OJ 右上角自己的名字,再來用前面的方法檢視一下瀏覽器傳輸的 HTTP requests。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

把使用者的資料抓下來

user_id = 129 # 換成你的 user id
profile_res = requests.post('https://neoj.sprout.tw/api/user/'+str(user_id)+'/profile', '{}')
stats_res = requests.post('https://neoj.sprout.tw/api/user/'+str(user_id)+'/statistic', '{}')

# 把收到的資料當成 json 來讀取
profile = profile_res.json() # == json.loads(profile_res.text)
stats = stats_res.json()

print(profile)
print(stats)

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

跟前面的 status 一樣,可以順利把 profile 跟 stats 存入 python 的變數中。

試著用 python 印出跟網頁上一樣的資料吧:

categories = {0: 'Universe', 3: 'Python'}

print('名字:', profile['name'])
print('班別:', categories[profile['category']])
print('Rate:', profile['rate'])

print('嘗試題目:')
tried = []
for x in stats['tried_problems']:
    if stats['tried_problems'][x]['result'] == 1:
        tried.append(x)
print(', '.join(tried))

print('通過題目:')
passed = []
for x, res in stats['tried_problems'].items():
    if res['result'] == 1:
        passed.append(x)
print(', '.join(passed))

Note: 可以參考 Requests Documentation

Why BeautifulSoup?

讓我們先問什麼是 BeautifulSoup?我們可以找到一些網路上的答案:

Beautiful Soup 是一個 Python 的函式庫模組,可以讓開發者僅須撰寫非常少量的程式碼,就可以快速解析網頁 HTML 碼,從中翠取出使用者有興趣的資料、去蕪存菁,降低網路爬蟲程式的開發門檻、加快程式撰寫速度。
from <a href="https://blog.gtwang.org/programming/python-beautiful-soup-module-scrape-web-pages-tutorial/">G.T.Wang's blog</a>

簡單來說就是解析網頁的一個 Python 程式嘛。

那我們為什麼要用 BeautifulSoup?這是一個絕妙的問題,我們先想想到底要怎麼解析一個網頁吧:

首先,透過剛剛教的 requests,我們已經可以用 python 把網站上的資料抓下來了(抓成字串)。

再來,我們上週學過正規表達式(Regular Expression),正規表達式的語法可以用來在很大的字串中篩選(匹配、搜尋、比對)一些我們要找的資料。

Regular Expression 好像很有用啊:

先來試試看:搭配前面的 requests,讓我們試試抓一下維基百科上的內容,從下面的例子開始:
https://zh.wikipedia.org/wiki/Python

這部分不用照著做

import re # 前面已經 import requests 了

wiki = requests.get('https://zh.wikipedia.org/wiki/Python').text

在這裡先來使用 regex 來尋找 <h1> 標籤:

看起來效果不錯,但如果要是比較複雜的情況呢:

Regular Expression 無法區分各層標籤的樹狀結構,所以只要複雜一點的 HTML 它就沒辦法處理了。假設只是要找文章內文,我們得到這樣的資料就不是太有用。

這就是為什麼我們不太適合用 Regular Expression 尋找 HTML 這種結構化語言裡面的資料。因為這樣反而會使「爬到」很多沒有用的資料。

有時候你原本有 99 個問題,用了正規表達式之後會變成 100 個問題

BeautifulSoup4 🥣

我們來開始用 BeautifulSoup4 來「萃取」網頁中的 HTML 元素吧:

import requests
from bs4 import BeautifulSoup

# 用 request 抓所求 URL 的網頁
response = requests.get(url)
# 建立一個 BeautifulSoup 的 instance,熬一碗湯🍜
soup = BeautifulSoup(response.text)

# 如果解析有有問題,可以讓 BeautifulSoup 改用 html5lib:
# = BeautifulSoup(response.text, 'html5lib')

上面的 response.text 可以改成任何 HTML 字串,或是讀進來的 HTML 檔案。

來看看這個連結:http://140.114.67.95/test.html(可以用「檢視原始碼」!)

現在,讓我們用 BeautifulSoup 來抓抓看這個網頁:

# 用 request 抓所求 URL 的網頁
response = requests.get('http://140.114.67.95/test.html')
# 建立一個 BeautifulSoup 的 instance
soup = BeautifulSoup(response.text)

再來執行:

  • soup.title
  • soup.h1
  • soup.p
  • soup.find('p')(跟 soup.p 一樣)
  • soup.h1.text
  • soup.p.text

find and find_all

  • soup.find('p')
  • soup.find_all('p')
  • soup.find(id="good")

再以維基百科為例吧

先用 requests 抓一下維基的頁面:https://zh.wikipedia.org/wiki/Python

# 用 request 抓所求 URL 的網頁
wiki = requests.get('https://zh.wikipedia.org/wiki/Python')

重複上面的各個 soup 操作:

  • soup.title
  • soup.h1
  • soup.p
  • soup.h1.text
  • soup.p.text


看起來不錯,用簡單的程式碼就能讓它順利幫我們找出文章第一個段落的所有內容了,比起 Regular Expression 來說有效率很多。

接下來試試看用 BeautifulSoup 來處理複雜一點的功能。

  • 找出所有內文文字:

這樣就可以詳盡地找到網頁裡各個內文的文字了。


再來我們試著把維基百科頁面的目錄抓下來:

  • soup.find(id="toc")

利用一些組合,就能把完整的目錄用 list 儲存起來:

toc = soup.find(id="toc")

new_toc = []
for x in toc.find_all(class_='toclevel-1'):
    item_text = x.find(class_='toctext').text
    children = x.find_all(class_='toclevel-2')

    if children:
        child_list = []
        for child in children:
            child_text = child.find(class_='toctext').text
            child_list.append(child_text)
        new_toc.append({item_text: child_list})
    else:
        new_toc.append(item_text)

元素的父母及兄弟姐妹

在 HTML 之中,元素層層包著其他元素,除了最外面和最裡面一層之外,每個元素都會有「父元素」和「子元素」。我們稱為 parent 跟 child。

讓我們再回到 test.html

response = requests.get('http://140.114.67.95/test.html')
soup = BeautifulSoup(response.text)
  • soup.find('ul')
  • soup.find('ul').parent
  • list(soup.find('ul').children)(包含文字及 tag 的全部子元素)
  • soup.find('ul').find_all()(只找出子元素裡的 tag)
  • soup.find('ul').find_next_sibling()
  • soup.find('ul').find_previous_sibling()
  • soup.find('ul').find_next_siblings()
  • soup.find('ul').find_previous_siblings()

Note: 使用 find 開頭的函式呼叫就一定是找完整的 html 元素(tag),但如果使用 .children.next_sibling 可能會顯示出元素和元素之間的換行字元(在 html 裡面可忽略),不能確保它一定會找到上一個 tag:

CSS selector

BeautifulSoup 也可以直接用 CSS Selector 篩選,我們就可以直接從網頁上複製 css selector,再讓 soup 根據 selector 去抓取對應的元素。

  • soup.select('#good')
    (跟 soup.find(id='good') 一樣)
  • soup.select('body > ul > li:nth-child(3))')[0]

延伸閱讀:
BeautifulSoup4 Documentation

BeautifulSoup4 + PTT

PTT NTHU_Talk看板

nthu = requests.get('https://www.ptt.cc/bbs/NTHU_Talk/index.html')
soup = BeautifulSoup(nthu.text)

print(soup.title.text)
  • 列出文章清單
for x in soup.find_all(class_='r-ent'):
    link = x.find('a').attrs['href']
    print(x.find('a').text) # 文章標題
    print('https://www.ptt.cc'+link) # 文章連結

  • 文章內容
for x in soup.find_all(class_='r-ent'):
    link = x.find('a').attrs['href']
    print(x.find('a').text)

    article_res = requests.get('https://www.ptt.cc'+link)
    article_soup = BeautifulSoup(article_res.text)
    # metalines:上面的作者、標題、時間
    metalines = article_soup.find(id='main-content').find_all(class_='article-metaline')
    for line in metalines:
        meta = list(line.children) # ['作者', '作者id']
        print(meta[0].text, meta[1].text)

    print('-'*20)
    print(metalines[-1].next_sibling)
    print('-'*20)

  • 作者 IP
import re

for x in soup.find_all(class_='r-ent'):
    link = x.find('a').attrs['href']
    article_res = requests.get('https://www.ptt.cc'+link)
    article_soup = BeautifulSoup(article_res.text)

    ip_line = article_soup.find(class_='f2')
    print(ip_line)

    ip = re.search('\d+\.\d+\.\d+\.\d+', ip_line.text)[0]
    print('IP:', ip)

  • 翻頁
next_page_a = soup.select('#action-bar-container > div > div.btn-group.btn-group-paging > a:nth-child(2)')[0] # 從瀏覽器貼上

link = next_page_a.attrs['href']
print('https://www.ptt.cc'+link)

next_page_res = requests.get('https://www.ptt.cc'+link)
next_page = BeautifulSoup(next_page_res.text)
  • 翻頁,加上讀取文章標題
def read_title(soup):
    for x in reversed(soup.find_all(class_='r-ent')):
        link = x.find('a').attrs['href']
        print(x.find('a').text) # 文章標題
        print('https://www.ptt.cc'+link) # 文章連結
        
read_title(soup) # 列出第一頁的文章

next_page_a = soup.select('#action-bar-container > div > div.btn-group.btn-group-paging > a:nth-child(2)')[0]
link = next_page_a.attrs['href']
next_page_res = requests.get('https://www.ptt.cc'+link)
next_page = BeautifulSoup(next_page_res.text)

read_title(next_page) # 列出第二頁的文章

Note: 請參考北區簡報

End(作業說明連結)

經過了前面的這些練習,你已經知道怎麼使用 BeautifulSoup 的爬蟲進行很多進階的 HTML 操作了。

是時候來看看作業說明囉!


一些列為補充的東西

Sprout OJ Extra(補充)

  • 最新 100 筆 submission 都是哪些題目?每題有多少筆?
    我們先從題目名稱的 code 產出的 problems(list)去修改
prob_set = set(problems)
# print(prob_set)

for p in prob_set:
    print(p, problems.count(p), sep=": ")

再來我們可以使用每題的筆數來排序:

def get_pcount(p):
    return problems.count(p)

sorted_prob_set = sorted(prob_set, key=get_pcount,
                         reverse=True)
for p in sorted_prob_set:
    print(p, problems.count(p), sep=": ")

也可以寫出統計送出者的版本:

def get_ncount(n):
    return names.count(n)

names_set = set(names)
sorted_names_set = sorted(names_set, key=get_ncount, 
                          reverse=True)
for n in sorted_names_set:
    print(n, names.count(n), sep=": ")

試試往下捲會怎樣?修改上面的篩選器會怎樣?

  • Ans: 會送出裡面更改 offset: 1xxxxx 欄位的 POST 請求,修改篩選器則是會更改 filter: {} 欄位

如何確定是 json

BeautifulSoup4 + IMDb(補充)

連結:IMDB top 100

imdb = requests.get('https://www.imdb.com/search/title/?groups=top_100')
isoup = BeautifulSoup(imdb.text)
  • 取得電影名稱,放進 name_list
name_list = []
for x in isoup.find_all(class_='lister-item-header'):
    name_list.append(x.a.text) # 放入電影名稱
print(name_list)


  • 取得電影類別,放進 genre_list
genre_list = []
for x in isoup.find_all(class_='lister-item-header'):
    # 利用跟標題的關係去找到電影類別的 tag
    genre_text = x.parent.find(class_='genre').text
    # 清理 genre_text 裡面的文字:先刪去多餘空白,再用 str.split 轉換成 list
    genre_list.append(genre_text.strip().split(', '))


  • 依照電影類別分類電影
genre_dict = {}
for i in range(len(genre_list)):
    for genre in genre_list[i]:
        if genre not in genre_dict:
            genre_dict[genre] = []
        genre_dict[genre].append(name_list[i])

未竟之志(補充)

  • BeautifulSoup4 + Class 的例子
    • Class 非常適合拿來用在爬蟲上
    • 寫成 class 可以處理複雜的頁面,在功能之間彼此分工、把程式碼變簡潔,避免修改錯誤
    • example