汪群超 Chun-Chao Wang

Dept. of Statistics, National Taipei University, Taiwan

QT Designer + PyQt + Webscrapping 的技術與應用(二)

Objective:

擷取 JSON 格式的網頁資料與 API


範例 1: 前一單元自〈自由電子報〉擷取各項新聞分類的新聞圖片標題,採用分析(parsing)網頁內容的方式,篩選出所需要的文字與圖片網址。本範例採另一種較簡單的 JSON 格式的網路資料,製作另一種型態的 GUI,展示如下圖一。

目標網址:https://news.ltn.com.tw/ajax/breakingnews/popular/1
程式功能:

  1. 列出新聞標題,雙擊任一筆新聞時,將該新聞的代表圖片與新聞簡介(summary)將出列於右側。

  2. 每次下載 20 筆新聞,可依頁數查看更多新聞(根據網站提供的資料模式)。


注意事項:

  1. JSON 是一種結構化的資料格式,像 Python 的 dictionary,以 key:value 的型態成對出現,而且是多層的。因為有明確的結構,讓資料的存取變得簡單,不像網頁內容那樣的自由型態,必須仔細辨認,找出關鍵的語句。

  2. 每個網站釋出的 JSON 資料都有自己的結構,因此必須先下載來看,找出所需的資料的層次與 key。

  3. 下列參考程式,仍使用 requests 擷取 JSON 資料,指令一樣是 response = requests.get(url),但必須再透過一個程序才能挖掘到資料,如 response.json().get(‘data’)。讀者可以透過 Debug 模式查看 response 與 response.json() 的內容,最後查看 response.json().get(‘data’) 所得到資料長甚麼樣。

  4. 當從 response.json().get(‘data’) 下去拿資料時,必須先通過一層有編號的 key,譬如圖四的 00, 01, 02,…, 19,也就是第一筆資料在 response.json().get(‘data’)[00],如圖五。如要取得這一筆資料的標題(title)則是 response.json().get(‘data’)[00][‘title’]。

  5. 但比較擾人的是,第二頁的資料,也就是網址為:https://news.ltn.com.tw/ajax/breakingnews/popular/2,其層次編號的 key 為 ’20’,’21’,…’29’,變成字串了。於是下列參考程式多了一個函數 def toIdx(p) 專門處理這個不一致的狀況。總之,不管遇到甚麼狀況,programmer 都要能解決問題,此時更凸顯 Debug 模式的重要,讓設計者可以中途介入程式運作,監看所有造成程式錯誤的變數與邏輯。

from PyQt6 import QtCore, QtWidgets, QtGui, uic
from PyQt6.QtGui import QPixmap
from PyQt6.QtCore import Qt
import pandas as pd
import requests
import sys
import os

class TableModel(QtCore.QAbstractTableModel):

    def __init__(self, data):
        super(TableModel, self).__init__()
        self._data = data

    def data(self, index, role):
        if role == Qt.ItemDataRole.DisplayRole:
            value = self._data.iloc[index.row(), index.column()] #pandas's iloc method
            return str(value)

        if role == Qt.ItemDataRole.TextAlignmentRole:          
            # return Qt.AlignmentFlag.AlignVCenter + Qt.AlignmentFlag.AlignHCenter
            return Qt.AlignmentFlag.AlignVCenter + Qt.AlignmentFlag.AlignLeft
        
        if role == Qt.ItemDataRole.BackgroundRole and (index.row()%2 == 0):
            return QtGui.QColor('#d8ffdb')

    def rowCount(self, index):
        return self._data.shape[0]

    def columnCount(self, index):
        return self._data.shape[1]

    # Add Row and Column header
    def headerData(self, section, orientation, role):
        # section is the index of the column/row.
        if role == Qt.ItemDataRole.DisplayRole: # more roles
            if orientation == Qt.Orientation.Horizontal:
                return str(self._data.columns[section])

            if orientation == Qt.Orientation.Vertical:
                return str(self._data.index[section])

class MainWindow(QtWidgets.QMainWindow):

    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)
        
        #Load the UI Page by PyQt6
        uic.loadUi('PyQt_Webscrapping_newsJson.ui', self)
        self.url = "https://news.ltn.com.tw/ajax/breakingnews/popular/"
        self.newsSearch()
        
        # Signals
        self.tableView.doubleClicked.connect(self.rowSelected)
        self.comboBox_page.currentIndexChanged.connect(self.newsSearch)
        self.pBut_exit.clicked.connect(self.close)
    
    # Slots
    def newsSearch(self):
        goto_page = self.comboBox_page.currentIndex()
        p = goto_page * 20
        url = self.url + str(goto_page + 1) # from 1 to 10
        response = requests.get(url)
        self.data = response.json().get('data')

        self.df = pd.DataFrame([[self.data[toIdx(p)]['time'], self.data[toIdx(p)]['title'], self.data[toIdx(p)]['type_cn']]], columns=['日期/時間','標題','類別'])
        for i in range(len(self.data)-1):
            self.df.loc[len(self.df.index)] = [self.data[toIdx(p+i+1)]['time'], self.data[toIdx(p+i+1)]['title'], self.data[toIdx(p+i+1)]['type_cn']]

        self.model = TableModel(self.df)
        self.tableView.setModel(self.model)
        self.df.index = range(p+1,len(self.data)+p+1)
        self.tableView.setColumnWidth(0, 120)
        self.tableView.setColumnWidth(1, 250)
        self.tableView.setColumnWidth(2, 50)
        
    def rowSelected(self, mi):
        current_page = self.comboBox_page.currentIndex()
        idx = current_page *20 + mi.row()
        self.textBrowser_summary.setText(self.data[toIdx(idx)]['summary'])
        img_link = self.data[toIdx(idx)]['photo_S']
        img = requests.get(img_link)
        img_dir = "images/"
        with open( img_dir +  "tmp.jpg", "wb") as file:
            file.write(img.content)
        self.label_img.setPixmap(QPixmap(img_dir + 'tmp.jpg'))
        os.remove(img_dir + 'tmp.jpg')
        
def toIdx(p):
    return p if p < 20 else str(p)
        
def main():
    app = QtWidgets.QApplication(sys.argv)
    main = MainWindow()
    main.show()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()
圖一、從 JSON 資料的結構,安排的 GUI App
圖二、抽絲剝繭 response 資料
圖三、JSON 資料第一層
圖四、JSON 資料第二層
圖五、JSON 資料第三層
圖六、第二頁的 JSON 第二層內容

練習: 前一單元自〈台灣證券交易所〉擷取個股的年成交資料。同樣地,該網站也提供 JSON 格式的網路資料,請試著模仿上一個範例的做法,分析 JSON 格式,做出依樣或類似的 App。

目標網址:https://www.twse.com.tw/exchangeReport/FMNPTK?response=json&stockNo=2330 (以台積電為例)

注意事項:

  1. 留意目標網址的型態,裡面的參數 response=json 或 response=html 決定了網頁的格式。

  2. 利用 JSON 格式的程式絕對省事許多,只要從網頁內容觀察到所需的資料在哪個 name 底下,便能直接 get 到。譬如,res = requests.get(url) 之後, headers = res.json().get(‘fields’) 取得標題,data = res.json().get(‘data’) 取得表格內所有資料。不需要動用到 BeautifulSoup 的網頁解析功能。


範例 2: 中央氣象局提供的資料開放平台,可以下載許多台灣的氣象資料。本範例示範如何透過 API (Application Programming Interface) 擷取所需的資料。中央氣象局開放的資料很多,在此舉較單純天氣預測資料為例,下載全台 22 縣市今明 36 小時的天氣預報。如圖一所示的簡單呈現,圖二則是氣象局網站所呈現的豐富模式。

目標網址:https://opendata.cwb.gov.tw/api/v1/rest/datastore/
這個網址是基底網址(based URL),後面必須接上欲下載項目的分類碼與授權碼。譬如,圖一展示今明 36 小時天氣預報,完整的網址為: https://opendata.cwb.gov.tw/api/v1/rest/datastore/F-C0032-001?Authorization=CWB-AA72CF2B-2317-4F84-8A6B-XXXXXXXXXXXX ,其中 F-C0032-001 是資料分類代碼,而 Authorization= 後面很長一串是授權碼,由於授權碼必須向氣象局申請,屬個人專用,因此以 X 蓋住一部分。申請的方式很簡單,請到 氣象資料開放平台 註冊。圖四到圖六協助讀者找到完整的 URL 與實際的資料結構,方便移植到程式碼。

程式功能:列出資料內的三個時段的氣象預報資料,並可以選擇城市(城市列表也是從下載的 JSON 結構中取得,觀察圖六的資料結構)。

注意事項:

  1. 從每個 API 網站所下載的資料,通常選擇 JSON 格式,至於實際的結構與資料之所在,必須很有耐心的抽絲剝繭。圖六是網站呈現的樣貌,寫程式時還是要一層層剝開來看,尤其對還不熟悉 JSON 的初學者,更是應該仔細觀察,此時利用 Debug 模式,將程式停在資料下載之後,很方便慢慢觀察。

  2. 圖二是氣象局網站對當時同樣資料的呈現方式。讀者可以試著去找一些氣象圖 ICON 來模仿圖二的作法。

圖一、簡單的氣象預報呈現
圖二、中央氣象局呈現的豐富模式
圖三、開放平台資料分類
圖四、從網站直接取得資料
圖五、呈現完整的 URL
圖六、觀察資料結構
from PyQt6 import QtCore, QtWidgets, QtGui, uic
from PyQt6.QtCore import Qt
import pandas as pd
import numpy as np
import requests
import sys


class TableModel(QtCore.QAbstractTableModel):

    def __init__(self, data):
        super(TableModel, self).__init__()
        self._data = data

    def data(self, index, role):
        if role == Qt.ItemDataRole.DisplayRole:
            value = self._data.iloc[index.row(), index.column()] #pandas's iloc method
            return str(value)

        if role == Qt.ItemDataRole.TextAlignmentRole:          
            return Qt.AlignmentFlag.AlignVCenter + Qt.AlignmentFlag.AlignHCenter
            # return Qt.AlignmentFlag.AlignVCenter + Qt.AlignmentFlag.AlignLeft
        
        if role == Qt.ItemDataRole.BackgroundRole and (index.row()%2 == 0):
            return QtGui.QColor('#d8ffdb')

    def rowCount(self, index):
        return self._data.shape[0]

    def columnCount(self, index):
        return self._data.shape[1]

    # Add Row and Column header
    def headerData(self, section, orientation, role):
        # section is the index of the column/row.
        if role == Qt.ItemDataRole.DisplayRole: # more roles
            if orientation == Qt.Orientation.Horizontal:
                return str(self._data.columns[section])

            # if orientation == Qt.Orientation.Vertical:
            #     return str(self._data.index[section])

class MainWindow(QtWidgets.QMainWindow):

    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)
        
        uic.loadUi('PyQt_Webscrapping_CWB_API.ui', self)
        self.data = self.getData()
        self.showData()
        
        # Signals
        self.comboBox_city.currentIndexChanged.connect(self.showData)
        self.pBut_exit.clicked.connect(self.close)
    
    # Slots
    def getData(self):
        api = 'https://opendata.cwb.gov.tw/api/v1/rest/datastore/'
        dataCode = 'F-C0032-001' # 臺灣各縣市天氣預報資料及國際都市天氣預報

        auth = "Authorization=CWB-AA72CF2B-2317-4F84-8A6B-BF9AB41E4CAB"
        url = api + dataCode + "?"+ auth + "&format=JSON"
        res = requests.get(url)
        data = res.json()
        # 首先取得縣市名稱並寫入 comboBox
        city = []
        for i in range(len(data['records']['location'])):
            city.append(data['records']['location'][i]['locationName'])
        
        self.comboBox_city.addItems(city)
        return data
   
    def showData(self):
        n, m = 3, 5
        cityName = self.comboBox_city.currentText()
        cityIdx = self.comboBox_city.currentIndex()
        # 先定位資料所在的結構層次,再依次取用
        tmp =self.data['records']['location'][cityIdx]['weatherElement']
        d = []
        for i in range(n):
            d.append(tmp[0]['time'][i]['startTime'])
            for j in range(m):
                d.append(tmp[j]['time'][i]['parameter']['parameterName'])
        
        self.df = pd.DataFrame(np.reshape(d, (n,m+1)))
        self.df.columns = ['時間', '天氣現象','降雨機率(%)','最低溫度','舒適度','最高溫度']
        self.model = TableModel(self.df)
        self.tableView.setModel(self.model)
        # self.tableView.resizeColumnsToContents
        self.tableView.resizeColumnToContents(0)
        self.tableView.resizeColumnToContents(1)
        self.tableView.resizeColumnToContents(2)
        self.tableView.resizeColumnToContents(3)
        self.tableView.resizeColumnToContents(5)
        
def main():
    app = QtWidgets.QApplication(sys.argv)
    main = MainWindow()
    main.show()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

練習: 從中央氣象局提供的開放資料平台選擇另一組資料,呈現在自己設計的 app。譬如下圖,取自〈觀測〉項目之「局屬氣象站資料(現在天氣觀測報告)」,編號 O-A0003-001。記錄台灣 140 處觀測站的氣象資料。其中的氣象資料多達 21 項,僅取其中幾項。雙擊任一列,還可以在下方秀出所在的縣市區域及地圖。另,該組資料每隔 10 分鐘更新一次,因此可以加入 timer 技術,每隔十分鐘刷新一次表格內容。不過,即便是每隔 10 分鐘更新資料,仍與目前有 20 分鐘的落差。

注意事項:

  1. 在開放平台的網站上,如上圖四的地方,可以選擇下載資料的項目,避免下載太多不用的資料,讓資料比較乾淨些,程式也比較好處理。這些選擇將反映在完整的 URL 上面。

  2. 由於這組資料亦包括觀測站的經緯度(lat, lon),適合配合地圖繞使用者對觀測地點更有感覺。至於置入地圖的技術,請詳見下一個單元 QT Designer + PyQt + 地理地圖

圖一、全台氣候觀測站即時資料。點擊觀測站可呈現觀測站地點與地圖。
圖二、地圖區可以放大縮小與移動
圖三、Zoom out and Shift

範例 3: 環保署環境資料開放平臺也是政府公開資料的一大來源。 本範例示範全台灣 PM2.5 懸浮微粒的監測數據

註冊網站(取得 API key): https://data.epa.gov.tw/
基底網址(based URL):https://data.epa.gov.tw/api/v2/
基底網址(based URL)後面必須接上欲下載項目的分類碼與授權碼。譬如,圖一展示「細懸浮微粒資料」,完整的網址為: https://data.epa.gov.tw/api/v2/aqx_p_02?api_key=01be21dd-ee0e-4286-bb15-XXXXXXXXXXXXX ,其中 aqx_p_02 是資料分類代碼,而 api_key= 後面很長一串是授權碼,由於授權碼必須向網站註冊後取得。環保署環境資料開放平臺的網站與氣象局類似,都可以先從網站選取資料類別,點選「try it out」並輸入 api-key 後取得完整的 URL 及資料結構,較方便進入的網頁可以參考:https://data.epa.gov.tw/swagger/

程式功能:

  1. 列出如下圖左邊表格列出所有測站名稱及所屬縣市名稱及 pm2.5 數字。欄位標題同樣從資料中取得。

  2. 下圖中列出 PM2.5 最高的前十名與最低的十名。

  3. 下圖右則以長條圖表達PM2.5 最高的前十名與最低的十名。

  4. 這個 API 的資料每隔一小時更新一次(非即時,有大約小於 1 小時的時間差),因此也可以加入 Timer,自動更新,只不過頻率太慢,不是很有用。



注意事項:

  1. 當公開資料(Open Data)以 API 或 JSON 形式供下載時,只要掌握正確的 URL 與資料結構,都能如願地取得所需的資料。因此,剩下來的資料處理與表達才是關鍵。資料處理涉及對 Python 程式技巧嫻熟度,而表達則是靠不斷地製作所累積的經驗。

  2. 本範例加入兩個資料處理功能,一是對資料排序,二是製作長條圖。這兩項功能算是這類型應用的基本技術,讀者宜仔細研究並收藏,做為日後作品的重要參考依據。

圖一、「細懸浮微粒資料」
from PyQt6 import QtCore, QtWidgets, QtGui, uic
from PyQt6.QtCore import Qt, QTimer
import pyqtgraph as pg
import pandas as pd
import numpy as np
import requests
import sys


class TableModel(QtCore.QAbstractTableModel):

    def __init__(self, data):
        super(TableModel, self).__init__()
        self._data = data

    def data(self, index, role):
        if role == Qt.ItemDataRole.DisplayRole:
            value = self._data.iloc[index.row(), index.column()] #pandas's iloc method
            return str(value)

        if role == Qt.ItemDataRole.TextAlignmentRole:          
            return Qt.AlignmentFlag.AlignVCenter + Qt.AlignmentFlag.AlignHCenter
            # return Qt.AlignmentFlag.AlignVCenter + Qt.AlignmentFlag.AlignLeft
        
        if role == Qt.ItemDataRole.BackgroundRole and (index.row()%2 == 0):
            return QtGui.QColor('#ffcde9')

    def rowCount(self, index):
        return self._data.shape[0]

    def columnCount(self, index):
        return self._data.shape[1]

    # Add Row and Column header
    def headerData(self, section, orientation, role):
        # section is the index of the column/row.
        if role == Qt.ItemDataRole.DisplayRole: # more roles
            if orientation == Qt.Orientation.Horizontal:
                return str(self._data.columns[section])

            # if orientation == Qt.Orientation.Vertical:
            #     return str(self._data.index[section])

class MainWindow(QtWidgets.QMainWindow):

    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)
        
        uic.loadUi('PyQt_Webscrapping_EPA_API_PM2_5.ui', self)
        self.data = self.getData()
        self.showData()
        # 加入計時器 if necessary
        # self.timer = QTimer()
        # timeInterval = 10 * 60000 # 10 分鐘
        # self.timer.setInterval(timeInterval)
        # self.timer.start()
        
        # Signals
        self.pBut_exit.clicked.connect(self.close)
        # self.timer.timeout.connect(self.timer_action) # emit every timeInterval millisecond
    
    # Slots
    def getData(self):
        api = 'https://data.epa.gov.tw/api/v2/'
        dataCode = 'aqx_p_02' # 無人自動站氣象資料
        auth = "api_key=01be21dd-ee0e-4286-bb15-xxxxxxxxxxxx" # 自行申請 api_key
        url = api + dataCode + "?"+ auth
        res = requests.get(url)
        data = res.json()
        return data
   
    def showData(self):
        # 先定位資料所在的結構層次,再依次取用
        tmp =self.data['records']
        self.label_time.setText(tmp[0]['datacreationdate'])
        d = []
        for i in range(len(tmp)):
            if tmp[i]['pm25'] != '': # 去除沒有資料的位置
                p = int(tmp[i]['pm25']) 
                d.append([tmp[i]['county'], tmp[i]['site'], p])
            
        self.df = pd.DataFrame(d)
        tmp = self.data['fields']
        self.df.columns = [tmp[1]['info']['label'], tmp[0]['info']['label'], tmp[2]['info']['label']]
        self.model = TableModel(self.df)
        self.tableView.setModel(self.model)
        # 取得前 10 名與後 10 名
        n = 10
        pm25_sort_top = self.df.sort_values(by=tmp[2]['info']['label'],ascending=False)
        self.textBrowser_top10.setText(pm25_sort_top.head(n).to_string(index=False, header=False))
        pm25_sort_bot = self.df.sort_values(by=tmp[2]['info']['label'],ascending=True)
        self.textBrowser_bot10.setText(pm25_sort_bot.head(n).to_string(index=False, header=False))
        
        # 繪製 bar chart
        x = np.arange(1, n+1, 1)
        y = pm25_sort_top.head(n)['細懸浮微粒濃度']
        barItem = pg.BarGraphItem(x = x, height = y, width = 0.5, brush=(107,200,224))
        self.graphicsView.addItem(barItem)
        Ticks = pm25_sort_top.head(n)['測站名稱'].values
        self.graphicsView.getAxis('bottom').setTicks([[(i, Ticks[i-1]) for i in x]])
        self.graphicsView.setTitle('Top 10')

        y = pm25_sort_bot.head(n)['細懸浮微粒濃度']
        barItem = pg.BarGraphItem(x = x, height = y, width = 0.5, brush=(255,255,0))
        self.graphicsView_bot.addItem(barItem)
        Ticks = pm25_sort_bot.head(n)['測站名稱'].values
        self.graphicsView_bot.getAxis('bottom').setTicks([[(i, Ticks[i-1]) for i in x]])
        self.graphicsView_bot.setTitle('Bottom 10')

        
def main():
    app = QtWidgets.QApplication(sys.argv)
    main = MainWindow()
    main.show()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

練習:環保署的環境資料非常豐富,譬如,分類編號 aqx_p_13 的「空氣品質監測小時值資料(一般污染物,每日更新)」、編號 aqx_p_136 的「縣市(臺北市)小時值-每小時」,包括 PM2.5 以外多項空氣品質數據。製作一個 app 來呈現部分空氣品質相關的數據,最好有表、圖與統計量,慢慢朝真正有用途的 App,而不只是練習的小程式。製作前,可以先到網站去看看整個 JSON 結構的內容,規劃如何表現哪些數據及如何表現。譬如,關於全台空氣品質的每小時數據,可以刻意挑出幾個項目來用圖形表示,如下圖一~四。除了以圖形作為表達的主軸外,並加入計時器(每小時啟動一次)以配合該數據為每小時數據,而進度顯示器(progress bar)用在轉換城市時,需要幾秒鐘的等待,因此以 progress bar 提醒使用者目前的狀態,以免誤為當機。

注意事項:

  1. 製作 App 沒有別的技巧,就是不斷練習而已,將一些常用的技巧或程式片段用到滾瓜爛熟,並妥善收藏,以備未來使用時,可以立刻派上場,這樣的程式片段愈多,未來寫作程式便會愈快。

  2. 下圖的程式有幾個特點。第一、利用每個縣市獨立的 API code(譬如台北市是 aqx_p_136),先找到觀測站(地區)的資料填入地區的 comboBox,之後方能根據觀測站呈現 12 個空氣品質的數據圖。

  3. 空氣品質 API 資料的預設下載量為 1000 筆,也是最大值,涵蓋不同時段、不同觀測站所觀測到的所有資料,大概不到 12 小時。若想下載較少的資料量,必須加一個參數,譬如 limit=500。

  4. 特點二:整個畫面像個電視牆,利用 GraphicsLayoutWidget 切成 12 個畫面,程式寫作要多留意,可以利用 exec() 處理 12 個圖形視窗的繪圖。

圖一、以圖形呈現台北市中山區的 12 種空氣品質數據
圖二、可以更換觀測站
圖三、可以更換城市
圖四、不同觀測站所記錄的時間與數據都不同

商學院  7F16
ccw@gm.ntpu.edu.tw
(02)8674-1111 
ext 66777

部落格統計

  • 99,324 點擊次數
%d bloggers like this: