2024年5月14日 星期二

Python 學習筆記 : 網頁爬蟲實戰 (七) 台股上市櫃公司清單網頁

本篇要爬的對象是證交所的上市櫃公司列表, 這是台股上千支股票的完整清單, 只要有新股完成掛牌上市或舊股申請下市, 這張表格隨即更新. 爬這張表的目的主要是要追蹤上市櫃公司的動態變化, 如果想要掃描有潛在獲利的投資標的時, 手上必須隨時有最新的市場全貌, 所以這張表也是建立自有量化投資資料庫的根本. 



一. 檢視目標網頁 : 

證交所上市公司清單的網頁名稱為 "本國上市證券國際證券辨識號碼一覽表", 網址如下 :





而上櫃公司清單的網頁名稱則為 "本國上櫃證券國際證券辨識號碼一覽表", 網址如下 :





這兩個網頁都是靜態網頁, 因此非常容易擷取, 從外觀來看兩個網頁結構是一樣的, 但還須檢視原始碼才能確定. 我在 2017 年時曾經使用 PHP 撰寫爬蟲程式去爬這兩個網頁, 多年來網頁格式都沒變 (連編碼格式都還是 MS950, 只有內容變而已), 參考 :


不過在瀏覽器中檢視原始碼時可能會因為占用記憶體過多而無法顯示 (因為網頁內容很長), 解決辦法是先用 requests 套件抓下來後存成 .htm 檔, 然後用文字編輯軟體如記事本開啟來觀察. 雖然這兩個網頁沒有防爬機制, 但我們還是習慣在提出請求時送出攜帶模擬瀏覽器 User-Agent 的 headers 參數, 參考 :


首先匯入 requests 與 fake_useragent 套件 :

>>> import requests    
>>> from fake_useragent import UserAgent    
>>> ua=UserAgent()         # 建立 FakeUserAgent 物件 
>>> headers={'User-Agent': ua.random}    # 使用隨機 User-Agent

先來處理上市公司部分 : 

>>> url='https://isin.twse.com.tw/isin/C_public.jsp?strMode=2'    
>>> res=requests.get(url, headers=headers)      # 檔案很大下載時間約 10 秒

接下來檢視網頁編碼, 可知格式為 MS950 (即 big5) : 

>>> res.encoding   
'MS950'
>>> len(res.text)    
8974741

內容高達 9 百萬個字元, 難怪用瀏覽器檢視原始碼時都會載入失敗, 所以先將它存成 .htm 檔案再用編輯器開啟 : 

>>> with open('twse_exchange_list.htm', "w", encoding='utf-8') as f:    
    f.write(res.text)    
    print("儲存網頁檔 OK")    
    
8974741
儲存網頁檔 OK

注意, 此處我們在存檔時同時也將編碼格式改成 utf-8. 

用文字編輯器開啟網頁檔來檢視原始碼 : 




可見上市公司內容是放在一個 class='h4' 的 table 元素裡. 

接下來處理上櫃公司清單網頁 :

>>> url='https://isin.twse.com.tw/isin/C_public.jsp?strMode=4'   
>>> res=requests.get(url, headers=headers)    
>>> res.encoding   
'MS950'   
>>> len(res.text)   
2778216   
>>> with open('twse_counter_list.htm', "w", encoding='utf-8') as f:      
    f.write(res.text)      
    print("儲存網頁檔 OK")  
    
2778216
儲存網頁檔 OK

雖然上櫃公司較上市少, 但此檔案也有 277 萬字元 (約 2.9MB), 用文字編輯器開啟 : 




可見網頁原始碼結構與上市公司清單完全一樣, 公司內容同樣是放在一個 class='h4' 的表格中, 因此擷取內容的處理方式相同. 我將這兩個檔案放在 GitHub :



二. 擷取目標網頁內容 : 

由於這兩個網頁都是靜態網頁, 所以只要用 BeautifulSoup 就可以將內容抓出來放到資料庫, 此處為了簡單計, 將它們以 csv 檔存檔, 以後要進一步放進資料庫也方便. 先以上市公司為例擷取, 之後將流程寫成同時處理上市與上櫃的函式 : 

匯入套件模組 : 

>>> import requests    
>>> from fake_useragent import UserAgent 
>>> from bs4 import BeautifulSoup   

以上市公司為例擷取網頁內容 : 
   
>>> ua=UserAgent()         # 建立 FakeUserAgent 物件 
>>> headers={'User-Agent': ua.random}     
>>> url='https://isin.twse.com.tw/isin/C_public.jsp?strMode=2'     # 上市公司清單
>>> res=requests.get(url, headers=headers)  
>>> soup=BeautifulSoup(res.text, 'lxml')   

呼叫 find() 方法找尋 class 屬性值為 'h4' 的 table 元素 :

>>> table_tag=soup.find('table', class_='h4')   # 傳回 Tag 物件

呼叫 find_all() 尋找 table 底下所有的 tr 元素 (列), 傳回 Tag 物件串列 :

>>> tr_tags=table_tag.find_all('tr')    # 在 table 元素內尋找所有 tr 元素
>>> len(tr_tags)    # 列數
39450

可見此表格有 39450 列, 檢視前 3 列, 第一列為欄位名稱 : 

>>> tr_tags[0]   
<tr align="center"><td bgcolor="#D5FFD5">有價證券代號及名稱 </td><td bgcolor="#D5FFD5">國際證券辨識號碼(ISIN Code)</td><td bgcolor="#D5FFD5">上市日</td><td bgcolor="#D5FFD5">市場別</td><td bgcolor="#D5FFD5">產業別</td><td bgcolor="#D5FFD5">CFICode</td><td bgcolor="#D5FFD5">備註</td></tr>

第二列只有單欄的 "股票" :

>>> tr_tags[1]  
<tr><td bgcolor="#FAFAD2" colspan="7"><b> 股票 <b> </b></b></td></tr>

第三列開始才是公司資料 :

>>> tr_tags[2]   
<tr><td bgcolor="#FAFAD2">1101 台泥</td><td bgcolor="#FAFAD2">TW0001101004</td><td bgcolor="#FAFAD2">1962/02/09</td><td bgcolor="#FAFAD2">上市</td><td bgcolor="#FAFAD2">水泥工業</td><td bgcolor="#FAFAD2">ESVUFR</td><td bgcolor="#FAFAD2"></td></tr>

在這種公司資料列中搜尋 td 元素, 會傳回內部所有 td 元素的 Tag 物件串列 :

>>> td_tags=tr_tags[2].find_all('td')    
>>> td_tags
[<td bgcolor="#FAFAD2">1101 台泥</td>, <td bgcolor="#FAFAD2">TW0001101004</td>, <td bgcolor="#FAFAD2">1962/02/09</td>, <td bgcolor="#FAFAD2">上市</td>, <td bgcolor="#FAFAD2">水泥工業</td>, <td bgcolor="#FAFAD2">ESVUFR</td>, <td bgcolor="#FAFAD2"></td>]

正常會有 7 個欄位 : 

>>> len(td_tags)   
7

檢視這 7 個欄位 :

>>> td_tags[0].text     # 有價證券代號及名稱
'1101\u3000台泥'
>>> td_tags[1].text     # 國際證券辨識號碼(ISIN Code)
'TW0001101004'
>>> td_tags[2].text     # 上市日
'1962/02/09'
>>> td_tags[3].text     # 市場別
'上市'
>>> td_tags[4].text     # 產業別
'水泥工業'
>>> td_tags[5].text     # CFICode
'ESVUFR'
>>> td_tags[6].text     # 備註

因為表格是二維資料, 先定義一個空的二維串列來儲存上市股票清單 :

>>> data=[]  

然後在 table 元素內搜尋所有的 tr 元素 :

>>> tr_tags=table_tag.find_all('tr')  

接著就可以用兩層迴圈來取得表格儲存格裡的資料, 第一層掃描列, 第二層掃描欄 : 

>>> for tr_tag in tr_tags:            # 掃描列 (row)
    td_tags=tr_tag.find_all('td')   # 取得該列所有 td 元素之 Tag 物件串列
    if len(td_tags) == 7:                 # 剔除欄位數不是 7 的列
        data.append([td_tag.text for td_tag in td_tags])   # 將 td 的內文串列存入 data
    
>>> len(data)    
39441

上面我們在內層迴圈中只抓欄位數為 7 的列放入 data 串列, 因此過濾後只剩下 39441 列, 比上面原始表格的 39450 列少了 9 列. 檢視 data 串列前幾個元素 :

>>> data[0]     # 欄位標題
['有價證券代號及名稱 ', '國際證券辨識號碼(ISIN Code)', '上市日', '市場別', '產業別', 'CFICode', '備註']

>>> data[0]   
['有價證券代號及名稱 ', '國際證券辨識號碼(ISIN Code)', '上市日', '市場別', '產業別', 'CFICode', '備註']
>>> data[1]   
['1101\u3000台泥', 'TW0001101004', '1962/02/09', '上市', '水泥工業', 'ESVUFR', '']
>>> data[2]  
['1102\u3000亞泥', 'TW0001102002', '1962/06/08', '上市', '水泥工業', 'ESVUFR', '']
>>> data[3]   
['1103\u3000嘉泥', 'TW0001103000', '1969/11/14', '上市', '水泥工業', 'ESVUFR', '']

可見表格中的上市公司清單都已存入 data 串列中了, 但是第一欄的公司代號與名稱之間有一個 '\u3000' 字元, 這是空格的 unicode 碼, 我們希望新增一欄來放公司名稱, 第一欄只放公司代號, 以後轉入資料庫後在搜尋上較好處理, 方法是在用一個迴圈來掃描列, 然後將第一欄位以 '\u3000' 為界進行拆分, 前者仍放回第一欄位, 後者插入到新增的第二欄位 :

>>> for row in data[1:]:                             # 從第 2 列開始 (跳過欄位名稱列)
    stock_name=row[0].split('\u3000')[1]   # 先將拆出的證券名稱暫存
    row[0]=row[0].split('\u3000')[0]            # 將拆出的證券名稱覆蓋第一欄
    row.insert(1, stock_name)                      # 在第 2 欄位置插入拆出之證券名稱

檢視改變後的結果 :

>>> data[1]  
['1101', '台泥', 'TW0001101004', '1962/02/09', '上市', '水泥工業', 'ESVUFR', '']
>>> data[2]   
['1102', '亞泥', 'TW0001102002', '1962/06/08', '上市', '水泥工業', 'ESVUFR', '']
>>> data[3]   
['1103', '嘉泥', 'TW0001103000', '1969/11/14', '上市', '水泥工業', 'ESVUFR', '']
>>> len(data[3])      # 從 7 欄變 8 欄了
8

可見證券代號與名稱已經順利拆成兩欄了. 

另外第一列的欄位標題的第一欄也要拆開 (以利於輸出到 csv 檔) :

>>> data[0][0]='有價證券代號'              # 第一欄為證券代號
>>> data[0].insert(1, '有價證券名稱')    # 插入第二欄為證券名稱
>>> data[0]    
['有價證券代號', '有價證券名稱', '國際證券辨識號碼(ISIN Code)', '上市日', '市場別', '產業別', 'CFICode', '備註']   
>>> len(data[0])     # 從 7 欄變 8 欄了
8

可以使用 csv 套件將 data 串列寫到 .csv 檔案中 :

>>> with open('twse_exchange_list.csv', 'w', newline='') as f:  
    writer=csv.writer(f)     # 建立 writer 物件
    for row in data:            # 將資料逐列寫入檔案
        chars=writer.writerow(row)   # 將列寫入檔案傳回寫入字元數

注意, 呼叫 open() 時需傳入 newline='', 否則用 Excel 開啟時每列之間會多出一個空白列. 也可以用 writerows() 一次將整個二維串列 data 寫入 .csv 檔 :

>>> with open('twse_exchange_list.csv', 'w', newline='') as f:   
    writer=csv.writer(f)  
    writer.writerows(data)  

開啟輸出檔 twse_exchange_list.csv 結果如下 :

有價證券代號,有價證券名稱,國際證券辨識號碼(ISIN Code),上市日,市場別,產業別,CFICode,備註
1101,台泥,TW0001101004,1962/02/09,上市,水泥工業,ESVUFR,
1102,亞泥,TW0001102002,1962/06/08,上市,水泥工業,ESVUFR,
1103,嘉泥,TW0001103000,1969/11/14,上市,水泥工業,ESVUFR,
1104,環泥,TW0001104008,1971/02/01,上市,水泥工業,ESVUFR,
1108,幸福,TW0001108009,1990/06/06,上市,水泥工業,ESVUFR,
1109,信大,TW0001109007,1991/12/05,上市,水泥工業,ESVUFR,
1110,東泥,TW0001110005,1994/10/22,上市,水泥工業,ESVUFR,
1201,味全,TW0001201002,1962/02/09,上市,食品工業,ESVUFR,
... (略) ...
01114S,111中租賃B,TW00001114S1,2022/04/15,上市,,DAFUFR,
01001T,土銀富邦R1,TW00001001T8,2005/03/10,上市,,CBCIXU,
01002T,土銀國泰R1,TW00001002T6,2005/10/03,上市,,CBCIXU,
01004T,土銀富邦R2,TW00001004T2,2006/04/13,上市,,CBCIXU,
01007T,兆豐國泰R2,TW00001007T5,2006/10/13,上市,,CBCIXU,
01009T,王道圓滿R1,TW00001009T1,2018/06/21,上市,,CBCIXU,
01010T,京城樂富R1,TW00001010T9,2018/12/05,上市,,CBCIXU,




以上完成了上市公司清單擷取, 由於上櫃公司清單網頁與上市一樣, 只是網址不同, 因此只要更改 url 即可, 演算法都是一樣的 : 

>>> url='https://isin.twse.com.tw/isin/C_public.jsp?strMode=4'     # 上櫃公司清單
>>> res=requests.get(url, headers=headers)  
>>> soup=BeautifulSoup(res.text, 'lxml')   
>>> table_tag=soup.find('table', class_='h4')   # 傳回 Tag 物件
>>> data=[]  
>>> tr_tags=table_tag.find_all('tr') 
>>> for tr_tag in tr_tags:            # 掃描列 (row)
    td_tags=tr_tag.find_all('td')   # 取得該列所有 td 元素之 Tag 物件串列
    if len(td_tags) == 7:                 # 剔除欄位數不是 7 的列
        data.append([td_tag.text for td_tag in td_tags])   # 將 td 的內文串列存入 data
>>> for row in data[1:]:                             # 從第 2 列開始 (跳過欄位名稱列)
    stock_name=row[0].split('\u3000')[1]   # 先將拆出的證券名稱暫存
    row[0]=row[0].split('\u3000')[0]            # 將拆出的證券名稱覆蓋第一欄
    row.insert(1, stock_name)                      # 在第 2 欄位置插入拆出之證券名稱
>>> with open('twse_counter_list.csv', 'w', newline='') as f:      
    writer=csv.writer(f)    
    writer.writerows(data)  

這樣便完成上櫃公司清單的擷取了, 開啟 twse_counter_list.csv 檢視內容 :

有價證券代號及名稱 ,國際證券辨識號碼(ISIN Code),上市日,市場別,產業別,CFICode,備註
70000U,譜瑞群益39售07,TW24Z70000U2,2024/03/12,上櫃,,RWSCPE,
70001U,宣德國票3A售01,TW24Z70001U0,2024/03/12,上櫃,,RWSCPE,
70002U,碩禾國票3A售01,TW24Z70002U8,2024/03/12,上櫃,,RWSCPE,
70003U,福華凱基3B售01,TW24Z70003U6,2024/03/13,上櫃,,RWSCPE,
70004U,鈊象元富39售07,TW24Z70004U4,2024/03/13,上櫃,,RWSCPE,
70005U,新普統一3C售02,TW24Z70005U1,2024/03/13,上櫃,,RWSCPE,
70006U,環球晶群益39售02,TW24Z70006U9,2024/03/13,上櫃,,RWSCPE,
70007U,宏捷科國票3A售03,TW24Z70007U7,2024/03/13,上櫃,,RWSCPE,
70008U,智通國票3A售02,TW24Z70008U5,2024/03/13,上櫃,,RWSCPE,
70009U,鈊象國票3A售01,TW24Z70009U3,2024/03/13,上櫃,,RWSCPE,
... (略) ...
9962,有益,TW0009962001,2006/07/10,上櫃,鋼鐵工業,ESVUFR,
8349A,恒耀甲特,TW0008349A03,2020/02/24,上櫃,,EPNRAR,
01014S,93中信貸a,TW00001014S3,2004/08/10,上櫃,,DAVUBR,
01015S,93中信貸b,TW00001015S0,2004/08/10,上櫃,,DAVUBR,
01016S,93中信貸c,TW00001016S8,2004/08/10,上櫃,,DAVUBR,
01017S,93中信貸d,TW00001017S6,2004/08/10,上櫃,,DAVUBR,
01111S,081中租賃A,TW00001111S7,2019/12/11,上櫃,,DAFUFR,
01112S,081中租賃B,TW00001112S5,2019/12/11,上櫃,,DAFUFR,




四. 寫成爬蟲程式 : 

將上面的測試程序寫成如下之爬蟲程式 get_twse_stocks_list.py :

# get_twse_stocks_list.py
import requests    
from fake_useragent import UserAgent 
from bs4 import BeautifulSoup
import csv

ua=UserAgent()          
headers={'User-Agent': ua.random}
markets={'exchange': '2', 'counter': 4}
for market in markets:
    try:
        m=markets[market]
        url=f'https://isin.twse.com.tw/isin/C_public.jsp?strMode={m}'
        res=requests.get(url, headers=headers)
        soup=BeautifulSoup(res.text, 'lxml')
        table_tag=soup.find('table', class_='h4')
        data=[]
        tr_tags=table_tag.find_all('tr')
        for tr_tag in tr_tags:
            td_tags=tr_tag.find_all('td')
            if len(td_tags) == 7:
                data.append([td_tag.text for td_tag in td_tags])
        for row in data[1:]:
            stock_name=row[0].split('\u3000')[1]
            row[0]=row[0].split('\u3000')[0]
            row.insert(1, stock_name)
        file=f'twse_{market}_list.csv'
        with open(file, 'w', newline='') as f:
            writer=csv.writer(f)  
            writer.writerows(data)
    except Exception as e:
        print(e)

測試結果 OK, 在工作目錄下產生 twse_exchange_list.csv 與 twse_counter_list.csv 兩個檔案. 

沒有留言:

張貼留言