2024年4月17日 星期三

Python 學習筆記 : 網頁爬蟲實戰 (三) 證交所休市日期

這篇其實是 2023/3/7 開始寫的, 寫到一半卻擱筆了, 最近整理爬蟲筆記時被我撈出來, 今天就拿這網頁當練習範例唄. 我去年 (2023) 初曾撰寫一個盯盤程式佈署到樹莓派, 利用 Crontab 設定周一到周五執行, 但是遇到連假 (例如中秋節) 股市休市但程式還是照樣丟出 Line Notify 通知盤勢訊息 (其實是上一個交易日舊聞),  如果有證交所休市訊息就可以解決此問題.     


證交所每年年底都會在網站公布次年休市日期, 網址如下 : 





只要在年底或元旦那天去爬這網頁並更新資料庫 (一年一次), 則盯盤程式在執行時就可先查詢此資訊, 判斷要不要發出盤勢訊息. 注意, 此張表格內的日期並非全部都是休市日期, 有三天是交易日期, 因此擷取下來後需剔除交易日才能用. 


1. 分析網頁內容為靜態網頁還是 JS 動態生成 :    

檢視網頁原始碼可知, 休市日期的表格內容並未出現在原始碼中, 初步研判是用 Javascript 產生 內容插入樣式類別為 main-content 的 div 容器 :

<div class="main-content">
    <div class="rwd-table  dragscroll grid all F1 L2_ G2 G3"></div>
</div>

使用 Chrome 擴充功能 Quick Javascript Switcher 檢查, 當關閉 Javascript 時表格就消失, 開啟時就出現, 確認表格內容係透過 Javascript 程式生成 (例如 Ajax), 這種網頁用 requests 套件無法直接擷取到目標資訊. 關於 Quick Javascript Switcher 參考 :


這種網站最常見的是利用 Ajax 方式透過 HttpXML 以非同步方式向伺服器提出資料請求, 通常回應一個 JSON 檔, 此檔案之 URL 必須利用瀏覽器的開發者工具觀察客戶端與伺服端之間的網路傳遞過程. 以 Chrome 來說, 在目標網頁上按 F12 即可開啟開法者工具頁面, 然後按 Ctrl+R 重新整理頁面, 先按開發者工具頁面的 "Networking" 鈕, 再按 "Fetch/XHR" 鈕 : 




觀察伺服器回應中時間最長的那個資源, 通常是透過 Ajax 傳送回應資料, 點一下若又方框顯示該資源為 JSON 格式的目標資料就對了 : 




在該資源上按滑鼠右鍵, 點選 "Copy/Copr URL" 複製此資源之 URL 如下 : 


直接貼到瀏覽器網址列即可看到此 JSON 資料 :




網頁中的 main.js 就是用此 JSON 資料填到樣式類別為 main-content 的 div 容器來生成表格內容. 所以爬蟲的擷取對象便從 holiday.htm 網頁轉為上面的 JSON 網址了. 


2. 直接擷取 JSON 資料 :      

經過上面的觀察分析, 將擷取目標轉為 JSON 資料網址, 可用 requests 來抓 : 

>>> import requests   
>>> url='https://www.twse.com.tw/rwd/zh/holidaySchedule/holidaySchedule?response=json&_=1713199193355'   
>>> response=requests.get(url)    
>>> response   
<Response [200]>  

可見不用設 User-Agent 假裝是瀏覽器, 就可以直接抓 JSON 資料 : 

>>> type(response.text)   
<class 'str'>  
>>> response.text   
'{"stat":"ok","date":"20240101","title":"113 年市場開休市日期","fields":["日期","名稱","說明"],"data":[["2024-01-01","中華民國開國紀念日","依規定放假1日。"],["2024-01-02","國曆新年開始交易日","國曆新年開始交易。"],["2024-02-05","農曆春節前最後交易日","農曆春節前最後交易。"],["2024-02-06","市場無交易,僅辦理結算交割作業",""],["2024-02-07","市場無交易,僅辦理結算交割作業",""],["2024-02-08","農曆除夕前一日","2月8日(星期四)調整放假,於2月17日(星期六)補行上班,但不交易亦不交割。"],["2024-02-09","農曆除夕","依規定放假1日。"],["2024-02-10","農曆春節","依規定於2月10日至2月12日放假3日。<br><br>2月10日及2月11日適逢星期六及星期日,於2月13日(星期二)及2月14日(星期三)補假2日。<br>"],["2024-02-11","農曆春節","依規定於2月10日至2月12日放假3日。\\r\\n\\r\\n2月10日及2月11日適逢星期六及星期日,於2月13日(星期二)及2月14日(星期三)補假2日。\\r\\n"],["2024-02-12","農曆春節","依規定於2月10日至2月12日放假3日。<br><br>2月10日及2月11日適逢星期六及星期日,於2月13日(星期二)及2月14日(星期三)補假2日。<br>"],["2024-02-13","農曆春節","依規定於2月10日至2月12日放假3日。<br><br>2月10日及2月11日適逢星期六及星期日,於2月13日(星期二)及2月14日(星期三)補假2日。<br>"],["2024-02-14","農曆春節","依規定於2月10日至2月12日放假3日。<br><br>2月10日及2月11日適逢星期六及星期日,於2月13日(星期二)及2月14日(星期三)補假2日。<br>"],["2024-02-15","農曆春節後開始交易日","農曆春節後開始交易。"],["2024-02-28","和平紀念日","依規定放假1日。"],["2024-04-04","兒童節","依規定放假1日。"],["2024-04-05","民族掃墓節","依規定放假1日。"],["2024-05-01","勞動節","依規定放假1日。"],["2024-06-10","端午節","依規定放假1日。"],["2024-09-17","中秋節","依規定放假1日。"],["2024-10-10","國慶日","依規定放假1日。"]],"queryYear":2024,"total":20}'

可見欄位標題是放在 fields 欄位, 而表格資料放在 data 欄位, 我們可以用內建模組 json 的 loads()函式將此 JSON 字串轉成 Python 字典以利後續處理 : 

>>> import json  
>>> twse_market_dates=json.loads(response.text)    
>>> twse_market_dates['fields']   
['日期', '名稱', '說明']
>>> twse_market_dates['data']   
[['2024-01-01', '中華民國開國紀念日', '依規定放假1日。'], ['2024-01-02', '國曆新年開始交易日', '國曆新年開始交易。'], ['2024-02-05', '農曆春節前最後交易日', '農曆春節前最後交易。'], ['2024-02-06', '市場無交易,僅辦理結算交割作業', ''], ['2024-02-07', '市場無交易,僅辦理結算交割作業', ''], ['2024-02-08', '農曆除夕前一日', '2月8日(星期四)調整放假,於2月17日(星期六)補行上班,但不交易亦不交割。'], ['2024-02-09', '農曆除夕', '依規定放假1日。'], ['2024-02-10', '農曆春節', '依規定於2月10日至2月12日放假3日。<br><br>2月10日及2月11日適逢星期六及星期日,於2月13日(星期二)及2月14日(星期三)補假2日。<br>'], ['2024-02-11', '農曆春節', '依規定於2月10日至2月12日放假3日。\r\n\r\n2月10日及2月11日適逢星期六及星期日,於2月13日(星期二)及2月14日(星期三)補假2日。\r\n'], ['2024-02-12', '農曆春節', '依規定於2月10日至2月12日放假3日。<br><br>2月10日及2月11日適逢星期六及星期日,於2月13日(星期二)及2月14日(星期三)補假2日。<br>'], ['2024-02-13', '農曆春節', '依規定於2月10日至2月12日放假3日。<br><br>2月10日及2月11日適逢星期六及星期日,於2月13日(星期二)及2月14日(星期三)補假2日。<br>'], ['2024-02-14', '農曆春節', '依規定於2月10日至2月12日放假3日。<br><br>2月10日及2月11日適逢星期六及星期日,於2月13日(星期二)及2月14日(星期三)補假2日。<br>'], ['2024-02-15', '農曆春節後開始交易日', '農曆春節後開始交易。'], ['2024-02-28', '和平紀念日', '依規定放假1日。'], ['2024-04-04', '兒童節', '依規定放假1日。'], ['2024-04-05', '民族掃墓節', '依規定放假1日。'], ['2024-05-01', '勞動節', '依規定放假1日。'], ['2024-06-10', '端午節', '依規定放假1日。'], ['2024-09-17', '中秋節', '依規定放假1日。'], ['2024-10-10', '國慶日', '依規定放假1日。']]

可見 twse_market_dates['data'] 是一個二維串列, 可以用 for 迴圈走訪每列資料 : 

>>> for item in twse_market_dates['data']:    
 print(item)  
                 
['2024-01-01', '中華民國開國紀念日', '依規定放假1日。']
['2024-01-02', '國曆新年開始交易日', '國曆新年開始交易。']
['2024-02-05', '農曆春節前最後交易日', '農曆春節前最後交易。']
['2024-02-06', '市場無交易,僅辦理結算交割作業', '']
['2024-02-07', '市場無交易,僅辦理結算交割作業', '']
['2024-02-08', '農曆除夕前一日', '2月8日(星期四)調整放假,於2月17日(星期六)補行上班,但不交易亦不交割。']
['2024-02-09', '農曆除夕', '依規定放假1日。']
['2024-02-10', '農曆春節', '依規定於2月10日至2月12日放假3日。<br><br>2月10日及2月11日適逢星期六及星期日,於2月13日(星期二)及2月14日(星期三)補假2日。<br>']
['2024-02-11', '農曆春節', '依規定於2月10日至2月12日放假3日。\r\n\r\n2月10日及2月11日適逢星期六及星期日,於2月13日(星期二)及2月14日(星期三)補假2日。\r\n']
['2024-02-12', '農曆春節', '依規定於2月10日至2月12日放假3日。<br><br>2月10日及2月11日適逢星期六及星期日,於2月13日(星期二)及2月14日(星期三)補假2日。<br>']
['2024-02-13', '農曆春節', '依規定於2月10日至2月12日放假3日。<br><br>2月10日及2月11日適逢星期六及星期日,於2月13日(星期二)及2月14日(星期三)補假2日。<br>']
['2024-02-14', '農曆春節', '依規定於2月10日至2月12日放假3日。<br><br>2月10日及2月11日適逢星期六及星期日,於2月13日(星期二)及2月14日(星期三)補假2日。<br>']
['2024-02-15', '農曆春節後開始交易日', '農曆春節後開始交易。']
['2024-02-28', '和平紀念日', '依規定放假1日。']
['2024-04-04', '兒童節', '依規定放假1日。']
['2024-04-05', '民族掃墓節', '依規定放假1日。']
['2024-05-01', '勞動節', '依規定放假1日。']
['2024-06-10', '端午節', '依規定放假1日。']
['2024-09-17', '中秋節', '依規定放假1日。']
['2024-10-10', '國慶日', '依規定放假1日。']

這樣就比較像表格資料, 好閱讀也好處理了. 注意, 此將表格裡的日期並非全部都是休市日, 例如黃底色之 2024-01-02 (星期二), 2024-02-05 (星期一), 2024-02-15 (星期四) 這三天, 名稱欄位均有 '交易日', 所以接下來要掃描此表格之列, 搜尋名稱欄位是否含有 '交易日', 不含才放入休市日串列 closed.  

方法是把日期欄位中的 'YYYY-MM-DD' 格式字串轉成 datetime 物件, 然後呼叫其 weekday() 方法來取得星期幾整數 (0=星期一, 1=星期二, ... 6=星期日). 

首先從內建套件 datetime 匯入 datetime 模組, 然後呼叫其 strptime() 函式並傳入日期字串與格式參數 '%Y-%m-%d', 它會傳回該日期的 datetime 物件, 呼叫此物件之 weekday() 方法即可取得星期整數, 以表格中的最後一個休市日國慶日為例 : 

>>> from datetime import datetime   
>>> the_date=datetime.strptime('2024-10-10', '%Y-%m-%d')   
>>> the_date   
datetime.datetime(2024, 10, 10, 0, 0)   
>>> type(the_date)  
<class 'datetime.datetime'>   
>>> the_date.weekday()   
3

傳回 3 表示 2024 中華民國國慶日為星期四 (正確). 參考 :


接下來要掃描二維串列 holiday 中每一列, 檢查 '日期' 欄的 weekday() 傳回值, 的 '名稱' ''  

>>> closed=[]      # 儲存休市日期
>>> for item in twse_market_dates['data']:   # 走訪每一列
    if '交易日' not in item[1]:         # '名稱' 欄位沒有 '交易日'
        closed.append(item[0])         # 將日期放入串列
                 
>>> closed   
['2024-01-01', '2024-02-06', '2024-02-07', '2024-02-08', '2024-02-09', '2024-02-10', '2024-02-11', '2024-02-12', '2024-02-13', '2024-02-14', '2024-02-28', '2024-04-04', '2024-04-05', '2024-05-01', '2024-06-10', '2024-09-17', '2024-10-10']

可見已將 2024-01-02, 2024-02-05, 2024-02-15 這三個交易日自表中移除, 剩下的都是休市日. 接下來就可以檢查今天是否有開市, 規則只有兩個 : 
  • 週六週日例假日休市 : 即使週六補班股市也不開市
  • 日期在 closed 串列中 : 休市
先從內建 datetime 套件匯入 date 模組, 呼叫其 today() 函式, 它會傳回一個 date 物件, 用 str() 可將此物件轉成日其字串, 例如 :

>>> from datetime import date    
>>> today=date.today()   
>>> today   
datetime.date(2024, 4, 17)     
>>> type(today)   
<class 'datetime.date'>  
>>> str(today)   
'2024-04-17'  
>>> the_week=today.weekday()   
>>> the_week   
2

可見今天 2024-04-17 是週三. 

也可以用 datetime.datetime.now() 來做, 但傳回的是 datetime 物件, 若要取得單獨的日期可以呼叫其 date() 函式來取得, 例如 : 

>>> from datetime import datetime    
>>> today_time=datetime.now()     
>>> today_time      
datetime.datetime(2024, 4, 17, 10, 56, 36, 293862)      
>>> today=today_time.date()     # 去除時間部分只留日期
>>> today    
datetime.date(2024, 4, 17)    
>>> the_week=today.weekday()   
>>> the_week   
2

參考 :


判斷今天是否休市先判斷是否為例假日, 或者該日期字串在 closed 串列裡, 例如 : 

>>> the_week in [5, 6] or str(today) in closed  
False

以上周六 (2024-04-13) 為例, 可用 datetime 模組的 strptime() 函式來將日期字串轉成 datetime 物件後再呼叫 weekday() 取得星期幾, 例如 : 

>>> the_date=datetime.strptime('2024-04-13', '%Y-%m-%d')   
>>> the_date   
datetime.datetime(2024, 4, 13, 0, 0) 
>>> the_week=the_date.weekday()   
>>> the_week   # 5=星期六
5
>>> is_closed=the_week in [5, 6] or str(today) in closed   
>>> is_closed     # 例假日都休市
True   

下面是檢查 2024-02-12 是否休市 (是) : 

>>> the_date=datetime.strptime('2024-02-12', '%Y-%m-%d')   
>>> the_date   
datetime.datetime(2024, 2, 12, 0, 0)
>>> the_week=the_date.weekday()   
>>> the_week    # 週一
0
>>> is_closed=the_week in [5, 6] or str(today) in closed   
>>> is_closed
True  

雖然是周一, 但該日在 closed 串列中 (春節連假之年初三), 因此休市. 


3. 休市日判斷函式 :    

將上面測試過程寫成函式 twse_is_closed() 如下 : 

import requests
import json
from datetime import datetime

def get_closed_dates():
    url='https://www.twse.com.tw/rwd/zh/holidaySchedule/' +\
        'holidaySchedule?response=json&_=1713199193355'
    response=requests.get(url)
    twse_market_dates=json.loads(response.text)
    closed=[]
    for item in twse_market_dates['data']:
        if '交易日' not in item[1]:  
            closed.append(item[0])
    return closed

def is_twse_closed(the_date: str):
    the_date=datetime.strptime(the_date, '%Y-%m-%d').date()
    print(the_date)
    the_week=the_date.weekday()
    return the_week in [5, 6] or str(the_date) in closed

if __name__ == '__main__':
    closed=get_closed_dates()
    print(closed)
    the_date='2024-02-12'
    is_closed=twse_is_closed(the_date)
    print(f'{the_date} 休市 : {is_closed}')
    
此模組有兩個函式, get_closed_dates() 是爬蟲函式, 它會去 TWSE 抓今年的休市表格之 JSON 資料, 然後剔除裡面的交易日後傳回真正休市日期字串之串列. is_twse_closed() 函式則用來判斷所傳入之日期字串為休市日與否 (傳回 True/False). 執行結果如下 :

>>> %Run check_twse_closed.py   
['2024-01-01', '2024-02-06', '2024-02-07', '2024-02-08', '2024-02-09', '2024-02-10', '2024-02-11', '2024-02-12', '2024-02-13', '2024-02-14', '2024-02-28', '2024-04-04', '2024-04-05', '2024-05-01', '2024-06-10', '2024-09-17', '2024-10-10']
2024-02-12
2024-02-12 休市 : True  

沒有留言 :