Try   HackMD

使用證卷交易所API爬取股票資訊

透過股票API(Application Program Interface)來取得即時股票資訊,並動態產生網頁後更新到雲端,並提供網址,透過個人電腦或手機就可以瀏覽該網頁的最新股票資訊。

API

API(Application Program Interface),用來定義應用程式的介面,讓不同廠商、不同服務之間可以互相溝通,透過HTTP傳輸的API,讓我們可以透過網路來取得網路上服務的資源。

實作

步驟一:找出股票資訊來源

台灣證卷交易所有提供每日的即時股價,在基本市況報導網站(https://mis.twse.com.tw/stock/index.jsp)進入後,右上角可以使用股票代號或名稱來搜尋特定的股票資訊,例如輸入2330(台積電)進入股價頁面後,按下「F12」打開Chrome瀏覽器提供的開發工具,透過network分頁來觀察該網頁的連線狀況,根據常理,當網頁上有資訊會不斷更新,但網頁卻不會整個重新整理時,通常背後都會有JavaScript默默的在呼叫API來做這件事情。

果然,發現該網頁會對http://mis.twse.com.tw/stock/api/getStockInfo.jsp這個網址不斷的發出HTTP GET請求。

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 →

對這個URL分析了一下:

https://mis.twse.com.tw/stock/api/getStockInfo.jsp?ex_ch=tse_2330.tw&json=1&delay=0&_=1635167108897

該URL呼叫時代了四個參數:ex_ch、json、delay和_,發現ex_ch參數帶的是股票代號,每個股票代號前面要加上「tse_」字串,切最後面要加上「.tw」字串。

還可以用「|」符號一次帶多個股票代號進去,json=1代表回應的資料為JSON格式,但經測試不管填入什麼值,回傳的資料格式都一樣是JSON,沒有出現過其他格式;剩下兩個參數意義不明,但測試後,其實不帶也沒有關係,都可以正常地拿到股價資訊。

再測試時發現6xxx代號的過票無法取得(例如高端疫苗的股票代號為6547),經直接使用網頁輸入,發現其參數內的每個股票代號前面不是「tse_」開頭,而是「otc_」開頭。

所以,如果要取得多筆股票資訊的API網址,大概會像下面的樣子:

http://mis.twse.com.tw/stock/api/getStockInfo.jsp?ex_ch=tse_0050.tw|tse_0056.tw|tse_2330.tw|tse_2317.tw|tse_1216.tw|otc_6547.tw|otc_6180.tw
  • tse開頭為上市股票。
  • otc開頭為上櫃股票。
  • 如果是興櫃股票則無法取得。

補充

因為GET方法的API,所以可以直接使用瀏覽器,然後把該API放入網址就可以直接呼叫了。

回應如下:

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格式,但因為沒有整理過,比較不容易閱讀,可以使用online的工具來排版一下,例如使用https://jsonformatter.curiousconcept.com/網站格式化後會成像下面的樣子:

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的資料型態以及結構相似度達99%,因此很容易可以辨認出來其資料結構和形態。

步驟二:透過API取得股票資訊,並清洗它

取回來的股票資訊,有很多都是不需要的,所以會使用Pandas模組來進行清洗,只留下需要的資訊,轉換資料成可利用的型態,並且額外增加欄位來存放清洗過程中計算出來的資訊,例如在這個範例中我們會:

  1. 去掉不需要的資料欄位,只留下:['股票代號','公司簡稱','成交價','成交量','累積成交量','開盤價','最高價','最低價','昨收價', '資料更新時間']。
  2. 將資料更新欄位的timestamp(時間戳記)轉成可閱讀的時間格式。
  3. 根據昨日收盤價跟現價來計算出漲跌幅百分比。
import pandas as pd
import requests
import time
import json

# 打算要取得的股票代碼
stock_list_tse = ['0050', '0056', '2330', '2317', '1216']
stock_list_otc = ['6547', '6180']
    
# 組合API需要的股票清單字串
stock_list1 = '|'.join('tse_{}.tw'.format(stock) for stock in stock_list_tse) 

# 6字頭的股票參數不一樣
stock_list2 = '|'.join('otc_{}.tw'.format(stock) for stock in stock_list_otc) 
stock_list = stock_list1 + '|' + stock_list2
print(stock_list)

# 組合完整的URL
query_url = f'http://mis.twse.com.tw/stock/api/getStockInfo.jsp?ex_ch={stock_list}'

# 呼叫股票資訊API
response = requests.get(query_url)

# 判斷該API呼叫是否成功
if response.status_code != 200:
  raise Exception('取得股票資訊失敗.')
else:
  print(response.text)

# 將回傳的JSON格式資料轉成Python的dictionary
data = json.loads(response.text)

# 過濾出有用到的欄位
columns = ['c','n','z','tv','v','o','h','l','y', 'tlong']
df = pd.DataFrame(data['msgArray'], columns=columns)
df.columns = ['股票代號','公司簡稱','成交價','成交量','累積成交量','開盤價','最高價','最低價','昨收價', '資料更新時間']

# 自行新增漲跌百分比欄位
df.insert(9, "漲跌百分比", 0.0) 

# 用來計算漲跌百分比的函式
def count_per(x):
  if isinstance(x[0], int) == False:
    x[0] = 0.0
  
  result = (x[0] - float(x[1])) / float(x[1]) * 100

  return pd.Series(['-' if x[0] == 0.0 else x[0], x[1], '-' if result == -100 else result])

# 填入每支股票的漲跌百分比
df[['成交價', '昨收價', '漲跌百分比']] = df[['成交價', '昨收價', '漲跌百分比']].apply(count_per, axis=1)

# 紀錄更新時間
def time2str(t):
  print(t)
  t = int(t) / 1000 + 8 * 60 * 60. # UTC時間加8小時為台灣時間
  
  return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(t))

# 把API回傳的秒數時間轉成容易閱讀的格式
df['資料更新時間'] = df['資料更新時間'].apply(time2str)

# 顯示股票資訊
display(df)

回傳的JSON欄位說明:

欄位名稱 描述
z 當前盤中成交價
tv 當前盤中盤成交量
v 累積成交量
b 揭示買價(從高到低,以_分隔資料)
g 揭示買量(配合b,以_分隔資料)
a 揭示賣價(從低到高,以_分隔資料)
f 揭示賣量(配合a,以_分隔資料)
o 開盤價格
h 最高價格
l 最低價格
y 昨日收盤價格
u 漲停價
w 跌停價
tlong 資料更新時間(單位:毫秒)
d 最近交易日期(YYYYMMDD)
t 最近成交時刻(HH:MI:SS)
c 股票代號
n 公司簡稱
nf 公司全名

注意

該API無法查詢興櫃的鬼票資訊。

步驟三:使用樣板來產生網頁。

index.html

<!DOCTYPE html>
<html>
  <head>
    <link rel="stylesheet" href="main.css" />
  </head>
  
  <body>
    <h2>Responsive Table</h2>
    <div class="table-wrapper">
    <table class="fl-table">
      <thead>
      <tr>
        <th>Header 1</th>
        <th>Header 2</th>
        <th>Header 3</th>
        <th>Header 4</th>
        <th>Header 5</th>
      </tr>
      </thead>
      <tbody>
      <tr>
        <td>Content 1</td>
        <td>Content 1</td>
        <td>Content 1</td>
        <td>Content 1</td>
        <td>Content 1</td>
      </tr>
      <tbody>
    </table>
    </div>
  </body>
</html>

main.css

* {
  box-sizing: border-box;
  -webkit-box-sizing: border-box;
  -moz-box-sizing: border-box;
}
body {
  font-family: Helvetica;
  -webkit-font-smoothing: antialiased;
  background: rgba( 71, 147, 227, 1);
}
h2 {
  text-align: center;
  font-size: 18px;
  text-transform: uppercase;
  letter-spacing: 1px;
  color: white;
  padding: 30px 0;
}

/* Table Styles */
.table-wrapper {
  margin: 10px 70px 70px;
  box-shadow: 0px 35px 50px rgba( 0, 0, 0, 0.2 );
}

.fl-table {
  border-radius: 5px;
  font-size: 12px;
  font-weight: normal;
  border: none;
  border-collapse: collapse;
  width: 100%;
  max-width: 100%;
  white-space: nowrap;
  background-color: white;
}

.fl-table td, .fl-table th {
  text-align: center;
  padding: 8px;
}

.fl-table td {
  border-right: 1px solid #f8f8f8;
  font-size: 12px;
}

.fl-table thead th {
  color: #ffffff;
  background: #4FC3A1;
}

.fl-table thead th:nth-child(odd) {
  color: #ffffff;
  background: #324960;
}

.fl-table tr:nth-child(even) {
  background: #F8F8F8;
}

/* Responsive */
@media (max-width: 767px) {
    .fl-table {
      display: block;
      width: 100%;
    }
    .table-wrapper:before{
      content: "Scroll horizontally >";
      display: block;
      text-align: right;
      font-size: 11px;
      color: white;
      padding: 0 0 10px;
    }
    .fl-table thead, .fl-table tbody, .fl-table thead th {
      display: block;
    }
    .fl-table thead th:last-child{
      border-bottom: none;
    }
    .fl-table thead {
      float: left;
    }
    .fl-table tbody {
      width: auto;
      position: relative;
      overflow-x: auto;
    }
    .fl-table td, .fl-table th {
      padding: 20px .625em .625em .625em;
      height: 60px;
      vertical-align: middle;
      box-sizing: border-box;
      overflow-x: hidden;
      overflow-y: auto;
      width: 120px;
      font-size: 13px;
      text-overflow: ellipsis;
    }
    .fl-table thead th {
      text-align: left;
      border-bottom: 1px solid #f7f7f9;
    }
    .fl-table tbody tr {
      display: table-cell;
    }
    .fl-table tbody tr:nth-child(odd) {
      background: none;
    }
    .fl-table tr:nth-child(even) {
      background: transparent;
    }
    .fl-table tr td:nth-child(odd) {
      background: #F8F8F8;
      border-right: 1px solid #E6E4E4;
    }
    .fl-table tr td:nth-child(even) {
      border-right: 1px solid #E6E4E4;
    }
    .fl-table tbody td {
      display: block;
      text-align: center;
    }
}

補充

表格式網頁樣板來源:https://freefrontend.com/css-tables/#tables

將網頁樣板直接放到Python中並包裝成函式:

# 用來產生網頁的函式
def html_template(html_table):
  return f'''
    <!DOCTYPE html>
    <html>
      <head>
        <link rel="stylesheet" href="main.css" />
        <style>
        * {{
            box-sizing: border-box;
            -webkit-box-sizing: border-box;
            -moz-box-sizing: border-box;
          }}
          body {{
            font-family: Helvetica;
            -webkit-font-smoothing: antialiased;
            background: rgba( 71, 147, 227, 1);
          }}
          h2 {{
            text-align: center;
            font-size: 30px;
            text-transform: uppercase;
            letter-spacing: 1px;
            color: white;
            padding: 30px 0;
          }}

          /* Table Styles */
          .table-wrapper {{
            margin: 10px 70px 70px;
            box-shadow: 0px 35px 50px rgba( 0, 0, 0, 0.2 );
          }}

          .fl-table {{
            border-radius: 5px;
            font-size: 12px;
            font-weight: normal;
            border: none;
            border-collapse: collapse;
            width: 100%;
            max-width: 100%;
            white-space: nowrap;
            background-color: white;
          }}

          .fl-table td, .fl-table th {{
            text-align: center;
            padding: 8px;
          }}

          .fl-table td {{
            border-right: 1px solid #f8f8f8;
            font-size: 12px;
          }}

          .fl-table thead th {{
            color: #ffffff;
            background: #4FC3A1;
          }}

          .fl-table thead th:nth-child(odd) {{
            color: #ffffff;
            background: #324960;
          }}

          .fl-table tr:nth-child(even) {{
            background: #F8F8F8;
          }}

          /* Responsive */
          @media (max-width: 767px) {{
              .fl-table {{
                display: block;
                width: 100%;
              }}
              .table-wrapper:before {{
                content: "Scroll horizontally >";
                display: block;
                text-align: right;
                font-size: 11px;
                color: white;
                padding: 0 0 10px;
              }}
              .fl-table thead, .fl-table tbody, .fl-table thead th {{
                display: block;
              }}
              .fl-table thead th:last-child {{
                border-bottom: none;
              }}
              .fl-table thead {{
                float: left;
              }}
              .fl-table tbody {{
                width: auto;
                position: relative;
                overflow-x: auto;
              }}
              .fl-table td, .fl-table th {{
                padding: 20px .625em .625em .625em;
                height: 60px;
                vertical-align: middle;
                box-sizing: border-box;
                overflow-x: hidden;
                overflow-y: auto;
                width: 120px;
                font-size: 13px;
                text-overflow: ellipsis;
              }}
              .fl-table thead th {{
                text-align: left;
                border-bottom: 1px solid #f7f7f9;
              }}
              .fl-table tbody tr {{
                display: table-cell;
              }}
              .fl-table tbody tr:nth-child(odd) {{
                background: none;
              }}
              .fl-table tr:nth-child(even) {{
                background: transparent;
              }}
              .fl-table tr td:nth-child(odd) {{
                background: #F8F8F8;
                border-right: 1px solid #E6E4E4;
              }}
              .fl-table tr td:nth-child(even) {{
                border-right: 1px solid #E6E4E4;
              }}
              .fl-table tbody td {{
                display: block;
                text-align: center;
              }}
          }}
        </style>
      </head>
      
      <body>
        <h2>我的最新股票資訊</h2>
        <div class="table-wrapper">
        <table class="fl-table">
          <thead>
          <tr>
            <th>股票代號</th>
            <th>公司簡稱</th>
            <th>成交價</th>
            <th>成交量</th>
            <th>累積成交量</th>
            <th>開盤價</th>
            <th>最高價</th>
            <th>最低價</th>
            <th>昨收價</th>
            <th>漲跌百分比</th>
            <th>資料更新時間</th>
          </tr>
          </thead>
          <tbody>
            {html_table}
          <tbody>
        </table>
        </div>
      </body>
    </html>
    '''

然後就可以透過這樣來產生網頁:

# 將每筆股票資訊組合成HTML的Table格式資料
html_table = ''
for index, row in df.iterrows():
  html_table += '<tr>\n'
  html_table += '<td>' + '</td>\n<td>'.join(row) + '</td>'
  html_table += '\n</tr>'

補充

for-in迴圈用來取出每一筆股票資訊。

並寫入檔案:

# 將產生的網頁寫入檔案
with open('index.html', 'w') as src:
  src.write(html_template(html_table))

寫入的檔案會直接放在Colab雲端(其實也就是你的Google Drive)。

步驟四:申請免費雲端空間

網址:https://www.000webhost.com/

補充

  1. 因為使用email註冊常常有認證信很慢才收到的問題,使用Google帳號註冊會比使用email來的方便快速。
  2. 免費的額度目前僅能建立一個網站。

步驟五:上傳網頁到雲端空間

​​​import ftplib
​​​
​​​session = ftplib.FTP('HOST_NAME','USERNAME','PASSWORD')
​​​file = open('index.html','rb')
​​​session.storbinary('STOR /public_html/index.html', file) 
​​​file.close() 
​​​session.quit()

登入000webhost後,請到Manage Website-> Websit Settings -> General

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 →

  • HOST_NAME的地方請填入000webhost FTP Detailed Information的Host Name欄位值。
  • USERNAME填入Username欄位值。
  • PASSWORD則填入在建立000webhost時設定的密碼。

步驟六:透過瀏覽器來打開網頁

成功上傳到000webhost雲端空間後,透過:

https://{project_name}.000webhostapp.com

{project_name}替換成建立網站時的專案名稱就可以透過該網址連線到你剛剛建立網頁。

注意

因為該網頁已經公開在網路上,所以請注意如果有個資或私人隱密資料,請小心的評估是否要放到網頁上。

完整程式碼

import pandas as pd
import datetime
import requests
import sched
import time
import json

# 用來產生網頁的函式
def html_template(html_table):
  return f'''
    <!DOCTYPE html>
    <html>
      <head>
        <link rel="stylesheet" href="main.css" />
        <style>
        * {{
            box-sizing: border-box;
            -webkit-box-sizing: border-box;
            -moz-box-sizing: border-box;
          }}
          body {{
            font-family: Helvetica;
            -webkit-font-smoothing: antialiased;
            background: rgba( 71, 147, 227, 1);
          }}
          h2 {{
            text-align: center;
            font-size: 30px;
            text-transform: uppercase;
            letter-spacing: 1px;
            color: white;
            padding: 30px 0;
          }}

          /* Table Styles */
          .table-wrapper {{
            margin: 10px 70px 70px;
            box-shadow: 0px 35px 50px rgba( 0, 0, 0, 0.2 );
          }}

          .fl-table {{
            border-radius: 5px;
            font-size: 12px;
            font-weight: normal;
            border: none;
            border-collapse: collapse;
            width: 100%;
            max-width: 100%;
            white-space: nowrap;
            background-color: white;
          }}

          .fl-table td, .fl-table th {{
            text-align: center;
            padding: 8px;
          }}

          .fl-table td {{
            border-right: 1px solid #f8f8f8;
            font-size: 12px;
          }}

          .fl-table thead th {{
            color: #ffffff;
            background: #4FC3A1;
          }}

          .fl-table thead th:nth-child(odd) {{
            color: #ffffff;
            background: #324960;
          }}

          .fl-table tr:nth-child(even) {{
            background: #F8F8F8;
          }}

          /* Responsive */
          @media (max-width: 767px) {{
              .fl-table {{
                display: block;
                width: 100%;
              }}
              .table-wrapper:before {{
                content: "Scroll horizontally >";
                display: block;
                text-align: right;
                font-size: 11px;
                color: white;
                padding: 0 0 10px;
              }}
              .fl-table thead, .fl-table tbody, .fl-table thead th {{
                display: block;
              }}
              .fl-table thead th:last-child {{
                border-bottom: none;
              }}
              .fl-table thead {{
                float: left;
              }}
              .fl-table tbody {{
                width: auto;
                position: relative;
                overflow-x: auto;
              }}
              .fl-table td, .fl-table th {{
                padding: 20px .625em .625em .625em;
                height: 60px;
                vertical-align: middle;
                box-sizing: border-box;
                overflow-x: hidden;
                overflow-y: auto;
                width: 120px;
                font-size: 13px;
                text-overflow: ellipsis;
              }}
              .fl-table thead th {{
                text-align: left;
                border-bottom: 1px solid #f7f7f9;
              }}
              .fl-table tbody tr {{
                display: table-cell;
              }}
              .fl-table tbody tr:nth-child(odd) {{
                background: none;
              }}
              .fl-table tr:nth-child(even) {{
                background: transparent;
              }}
              .fl-table tr td:nth-child(odd) {{
                background: #F8F8F8;
                border-right: 1px solid #E6E4E4;
              }}
              .fl-table tr td:nth-child(even) {{
                border-right: 1px solid #E6E4E4;
              }}
              .fl-table tbody td {{
                display: block;
                text-align: center;
              }}
          }}
        </style>
      </head>
      
      <body>
        <h2>我的最新股票資訊</h2>
        <div class="table-wrapper">
        <table class="fl-table">
          <thead>
          <tr>
            <th>股票代號</th>
            <th>公司簡稱</th>
            <th>成交價</th>
            <th>成交量</th>
            <th>累積成交量</th>
            <th>開盤價</th>
            <th>最高價</th>
            <th>最低價</th>
            <th>昨收價</th>
            <th>漲跌百分比</th>
            <th>資料更新時間</th>
          </tr>
          </thead>
          <tbody>
            {html_table}
          <tbody>
        </table>
        </div>
      </body>
    </html>
    '''

# 打算要取得的股票代碼
stock_list_tse = ['0050', '0056', '2330', '2317', '1216']
stock_list_otc = ['6547', '6180']
    
# 組合API需要的股票清單字串
stock_list1 = '|'.join('tse_{}.tw'.format(stock) for stock in stock_list_tse) 

# 6字頭的股票參數不一樣
stock_list2 = '|'.join('otc_{}.tw'.format(stock) for stock in stock_list_otc) 
stock_list = stock_list1 + '|' + stock_list2
print(stock_list)

# 組合完整的URL
query_url = f'http://mis.twse.com.tw/stock/api/getStockInfo.jsp?ex_ch={stock_list}'

# 呼叫股票資訊API
response = requests.get(query_url)

# 判斷該API呼叫是否成功
if response.status_code != 200:
  raise Exception('取得股票資訊失敗.')
else:
  print(response.text)

# 將回傳的JSON格式資料轉成Python的dictionary
data = json.loads(response.text)

# 過濾出有用到的欄位
columns = ['c','n','z','tv','v','o','h','l','y', 'tlong']
df = pd.DataFrame(data['msgArray'], columns=columns)
df.columns = ['股票代號','公司簡稱','成交價','成交量','累積成交量','開盤價','最高價','最低價','昨收價', '資料更新時間']

# 自行新增漲跌百分比欄位
df.insert(9, "漲跌百分比", 0.0) 

# 用來計算漲跌百分比的函式
def count_per(x):
  if isinstance(x[0], int) == False:
    x[0] = 0.0
  
  result = (x[0] - float(x[1])) / float(x[1]) * 100

  return pd.Series(['-' if x[0] == 0.0 else x[0], x[1], '-' if result == -100 else result])

# 填入每支股票的漲跌百分比
df[['成交價', '昨收價', '漲跌百分比']] = df[['成交價', '昨收價', '漲跌百分比']].apply(count_per, axis=1)

# 紀錄更新時間
def time2str(t):
  print(t)
  t = int(t) / 1000 + 8 * 60 * 60. # UTC時間加8小時為台灣時間
  
  return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(t))

# 把API回傳的秒數時間轉成容易閱讀的格式
df['資料更新時間'] = df['資料更新時間'].apply(time2str)

# 將每筆股票資訊組合成HTML的Table格式資料
html_table = ''
for index, row in df.iterrows():
  html_table += '<tr>\n'
  html_table += '<td>' + '</td>\n<td>'.join(row) + '</td>'
  html_table += '\n</tr>'

# 將產生的網頁寫入檔案
with open('index.html', 'w') as src:
  src.write(html_template(html_table))

# 上傳index.html到雲端
import ftplib
session = ftplib.FTP('files.000webhost.com','web16800000','3939889.')
file = open('index.html','rb')
session.storbinary('STOR /public_html/index.html', file)
file.close()
session.quit()

補充

  1. 因為f-string的大括號是語法,所以如果要顯示大括號,必須再多加一個大括號,例如:「{{」、「}}」。

  2. 樣板的h5文字大小從18px調整到30px。

透過瀏覽器瀏覽的畫面如下:

其它

取得歷史紀錄API

台灣證卷交易所有另外一隻API,可以用來取得一整個月的改票資訊:

http://www.twse.com.tw/exchangeReport/STOCK_DAY?response=json&date=202110011&stockNo=2330

參數有三個:

  • response: 回傳的資料格式,可以是json或csv。
  • date: 股票資訊的月份,格式為YYYYMMDD,但DD其實沒有用到,只是傳入值要大於1,否則會拿到上一個月份的資料。