2024年5月22日 星期三

Python 學習筆記 : 網頁擷取 (六) : 處理表單與會員網站登入

在前面的爬蟲測試中只要用 requests.get() 發出 HTTP GET 請求即可取得目標資料, 但有些目標網頁 (例如高鐵班次時刻表) 使用者必須先在網頁表單中輸入或選擇後提交給伺服器, 後端程式才會提供目標資料. 另外有些網站 (例如會員制網站) 必須填寫帳密登入成功後才能進一步瀏覽或查詢內部資料, 這兩種類型網站都無法依靠 requests.get() 取得, 必須使用 requests.post() 才行. 

本系列之前的筆記參考 : 


以下是我讀 "網頁擷取-使用 Python" 第九章後所做的整理與測試. 


一. 基本的表單 : 

在 HTML 網頁中, 超連結 a 元素基本上是用來讓使用者送出 GET 請求的 (雖然也可以觸發 Javascript 來送出 POST 請求), 而表單 form 元素則主要是用來送出 POST 請求 (當然也可以送出 GET 請求), requests 套件中的 post() 函式能向伺服器送出 POST 請求, 且能攜帶 cookies, headers 等參數, 可模擬使用者填寫表單的動作. 

測試表單的 POST 請求需要後端程式配合, 所幸我在 "網頁擷取-使用 Python" 這本書中找到作者所提供的基本表單測試網頁 : 





檢視原始碼可知此網頁是由兩個 input-text 輸入框元素與一個提交按鈕元素 submit 構成的表單, 當填好輸入欄位按 "Submit" 鈕就會向伺服器的後端程式 processing.php 提交 POST 請求, 表單中的這兩個欄位 firstname 與 lastname 就會被放在請求參數 :

<h2>Tell me your name!</h2>
<form method="post" action="processing.php">
First name: <input type="text" name="firstname"><br>
Last name: <input type="text" name="lastname"><br>
<input type="submit" value="Submit" id="submit">
</form>

在 Chrome 按 F12 開啟開發者頁面後, 在此表單網頁輸入姓名提交, 後端程式 processing.php 會將收到的欄位資料回應給客戶端, 結果如下 : 



這時在開發者頁面的 "Network/All" 頁籤, 點左方的 processing.php 請求, 再點右邊的 "Headers" 即可看到這個 POST 請求的標頭 :




點 "Headers" 右邊的 "Payload" 就可看到表單資料 (form data) 傳出的兩個參數 firstname 與 lastname :




以上是手動操作的結果, 接下來我們使用 requests.post() 模擬表單的 POST 請求動作, 匯入 requests 套件後, 將要傳遞的變數放在字典中, 然後在呼叫 requests.post() 函式向 processing.php 提交 POST 請求時將變數字典傳給 data 參數 :

>>> import requests  
>>> url='https://pythonscraping.com/pages/files/processing.php'   
>>> data={'firstname': 'Donald', 'lastname': 'Trump'}   
>>> res=requests.post(url, data=data)   # post 傳遞變數的參數名稱為 data
>>> print(res.text)   
Hello there, Donald Trump!   

可見得到與手動操作瀏覽器提交表單同樣的結果. 

注意, GET 請求除了直接在 URL 上用 ? 與 & 攜帶變數, 還可以在呼叫 requests.get() 時透過 params 參數來傳遞, params 也是一個傳遞變數組成的字典, 但此處要強調的是 get() 使用的關鍵字參數名稱是 params, 而 post() 則是使用 data, 不要搞混了. 


二. 上傳欄位表單 : 

網頁表單內的欄位型態不只是文字欄位與按鈕, 還可能有單選圓鈕, 可複選的核取方塊, 文字區域, 與下拉式選單等, 但不管表單包含多少元素, 使用 requests.post() 去模擬 POST 請求前只要知道這些被提交的欄位名稱 (name 屬性) 與其所傳遞之變數值是哪種類型即可, 這些資訊可以透過檢視網頁原始碼與瀏覽器的開發者工具得知. 關於表單元素參考 :


"網頁擷取-使用 Python" 這本書的官方網站提供了一個檔案上傳網頁 :


檢視原始碼如下 :

<h2>Upload a file!</h2>
<form action="processing2.php" method="post" enctype="multipart/form-data">
Submit a jpg, png, or gif: <input type="file" name="uploadFile"><br>
<input type="submit" value="Upload File">
</form>

可見表單內就是一個型態為 file, 名稱為 uploadFile 的 input 元素, 它其實是一種固定標示為 '選擇檔案' 的按鈕, 按下時可從本機選取檔案 :



選取檔案後檔名會顯示在按鈕後面 :




按下 Upload File 按鈕會將檔案上傳給後端伺服器上的 processing2.php 處理, 該程式回應如下 :




用 Chrome 開發者工具觀察請求的 payload 與回應訊息如下 :





使用 requests 套件來模擬檔案上傳動作需以二進位方式開啟圖檔, 將其以所傳遞之變數名稱 (此處為 input 元素名稱 uploadFile) 為屬性組成字典傳給 requests.post() 的 files 參數 : 

>>> import requests  
>>> url='https://pythonscraping.com/pages/files/processing.php'   
>>> f=open('種矮仙丹-1.jpg', 'rb')          # 以二進位方式開啟圖檔
>>> files={'uploadFile': f}                        # 製作 files 字典
>>> res=requests.post(url, files=files)     # 提交表單
>>> print(res.text)     
uploads/種矮仙丹-1.jpg
The file 種矮仙丹-1.jpg has been uploaded.
>>> f.close()                                                # 關閉檔案

結果與人工操作是一樣的. 


三. 登入表單 : 

這種網站後端擁有會員管理系統, 使用者以註冊好的帳密在登入表單提出 POST 請求登入成功後才能進一步取得網站內容, 所以通常需要至少兩次 POST 請求才能取得目標網頁. 由於 HTTP 的請求回應是無狀態的, 為了能辨別使用者是否已登入, 伺服器會在前端儲存一個 cookie (伺服器產生的 token) 或在後端用 Session 物件保存登入狀態. 

"網頁擷取-使用 Python" 這本書的官方網站提供了測試登入表單的網頁 :





檢視原始碼如下 :

<h2>Log In Here!</h2>
Warning: Your browser must be able to use cookies in order to view our site!
<form method="post" action="welcome.php">
Username (use anything!): <input type="text" name="username"><br>
Password (try "password"): <input type="password" name="password"><br>
<input type="submit" value="Login">
</form>

可見此提交此表單時會向後端伺服器的 welcome.php 傳遞 username (帳號) 與 password (密碼) 這兩個變數, 帳號可隨便填, 但密碼必須輸入 password 才能成功登入 (可見 welcome.php 只是簡單地比對密碼是否符合而已). 

按 Login 鈕提交表單經 welcome.php 檢查密碼正確回應如下網頁 :




點下方的超連結即可進入網站 : 





登入時開發者頁面的請求 payload 與回應如下 :





如果要用 requests 套件模擬表單登入, 必須進行兩次 POST 請求, 第一次是用 data 參數傳遞 username 與 password 變數向 welcome.php 程式提出請求, 若登入成功伺服器會回應 cookie 給瀏覽器儲存, 我們可以用回應物件的 cookies 子物件的 get_dict() 方法取得放在 HTTP 回應中的 cookies 字典 :

>>> import requests  
>>> url='https://pythonscraping.com/pages/cookies/welcome.php' 
>>> data={'username': 'Tony', 'password': 'password'}     
>>> res=requests.post(url, data=data)   
>>> print(f'cookies: {res.cookies.get_dict()}')   
cookies: {'loggedin': '1', 'username': 'Tony'}   

然後向 profile.php 發出 POST 請求並將此 cookies 字典傳給 cookies 參數即可 :

>>> url='https://pythonscraping.com/pages/cookies/profile.php'      
>>> res=requests.post(url, cookies=res.cookies  
>>> res.text   
"Hey Tony! Looks like you're still logged into the site!"

這樣便成功取得目標網頁了. 

2024年5月21日 星期二

參加 AIGC 圖表大師課程

昨天內訓課程請到 No-RED 社團益師傅來講如何利用 AIGC 來繪製圖表 (河道圖, 甘特圖, 流程圖等), 對於每天記錄學習筆記的而言蠻實用的, 但我在忙爬蟲, 所以也只是聽聽而已, 沒跟著實作, 所幸課程有錄影可複習.

先將課程所介紹的三個 AIGC 圖表網站紀錄如下 : 


精通十幾種 AIGC 應用的益師傅實在太厲害了, 我現在比較少參加社團分享會, 因為我已經落隊太遠, 看不到益師傅車尾燈了, 哈哈. 

2024年5月20日 星期一

露天購買 LED 走道感應燈 x2

因為高雄家通往房間的走道晚上沒開燈很暗, 在露天找到下面這個免拉線充電款的 (也可以拉線 用 USB 充電), 先買兩個試試, 滿意的話鄉下老家也可添購 :





全家免運 457 元. 

市圖還書 1 本 : 懂語感無痛學好任一種外語

這本書三天前到期, 但怎麼找就是不見影, 想說大概又要去買一本賠圖書館了, 沒想到昨晚整理床鋪發現是被衣服蓋住了, 下班順路去還了 : 
真是好險, 這讓我想起上回找不到的那本書一定還在家裡. 

2024年5月19日 星期日

2024 年第 20 周記事

哇, 今年已經過了 20 周了, 2024 已經去掉超過 1/3. 人生就像坐雲霄飛車, 上升段又期待又害怕, 過了 50 歲之後就是往下衝, 讓人對青春的飛逝不斷的哇哇叫. 

最近國會全武行真是轟轟烈烈, 我從去年國會選舉結束後就持續不斷研究為何人民會拋棄民進黨, 期待新政治的來臨, 研究了大半年始終沒答案, 坦白說都是做白工. 不過最近這局面彷彿是砸到牛頓的蘋果一樣, 答案頓時浮現在腦海中 : 一切都是電視台有問題, 他們已經非常久沒拍武俠劇了! 這就是去年的新民意根源, 絕對錯不了! 

我已經很久沒看新聞與政論了, 昨天看了一下他們在吵甚麼, 其它的議題我不清楚就不評論了, 但聽說藍白在推國會改革要通過藐視國會罪, 國會議員這德行從解嚴之後不是一直被人民藐視嗎? 看來他們是終於受不了了, 希望立個法源好治治那些藐視他們的選民. 我真心覺得, 你現在不藐視國會, 以後換他們藐視你. 

週六管委會在大樓中庭進行 C 棟樓下的植栽更新, 把樹齡已超過 27 年的矮仙丹全部挖掉, 換成從市政府申請的矮仙丹幼苗, 被挖起來的矮仙丹大部分都丟垃圾車燒掉, 因上週更新 A/B 棟時我有問主委可否讓我剪枝帶回鄉下看看能否重起來, 這次主委說不要剪枝, 直接把整株載回去種. 下午忙完晚餐材料準備就整理門口水圳邊坡 (剛好婷婷表妹帶孩子來玩), 將這九株老矮仙丹種下去, 由於馬路邊土壤較薄, 我又去菜園水溝清淤時堆在溝邊的泥土提了兩桶把泥肉墊厚些, 終於在傍晚完成 :




兩周前看完淚之女王後好無聊, 在 Netflix 隨便找到池昌旭與申惠善主演的 "歡迎回到三達里", 每天晚餐時看一集, 周六終於來到完結篇, 原本覺得就是一部輕鬆愛情片, 其實這部戲的元素蠻豐富的, 看完突然好想去濟州島玩! 這部片該不會是韓國觀光公社贊助的吧? 

蝦皮購買 z51 Predator (捕食者) 遙控飛機

最近逛網路商店都在看遙控飛機, 今天又看上一架固定翼的 z51 Predator, 呵呵 : 







店到店運費 45, 合計 995, 扣掉 20 元折價券實付 975 元. 

商品特色 : 

1.大倍率EPO發泡材質,表面光滑,結構強度好重量輕,機翼空心設計使重量更輕
2. 自穩模式可以使飛機輕鬆姿態飛行。
3.採用高性能馬達,動力強筋,壽命長,效率高飛行時間更長 
4.螺旋槳聲音小更省電效率好高
若需加購電池多一顆150元,從運費增加並備註要加購。

產品尺寸:40×66×13CM
彩盒尺寸:43 * 21 * 12CM
風葉直徑:5.6CM機身電池:3.7V 750MAH
飛行時間:20分鐘左右
充電時間:60分鐘左右
遙控器電池:4 * 5號電池 (即台灣的三號電池)
遙控距離:250米左右

功能簡介:1。 2通道,帶LED燈,低電保護,內置陀螺儀; 2上升,下降,前進,左轉,右轉。

另外露天也有 Z51 賣家, 但它的電池是 500 mAH, 可單購電池 :



2024-05-20 補充 :

PCHome 賣 $1749 : 


米家 App 設定攝影機僅錄製移動畫面的方法

前幾天在 momo 買的三片十銓 MicroSD 卡昨天帶回鄉下, 早上起床後拿長梯更換車庫那兩台 1080P 室內攝影機的儲存卡為 32GB, 先關閉電源, 順便清除灰塵後取下舊的卡片 (分別為 4GB 與 16GB), 然後插上 32GB 新卡, 接回電源後檢查影像正常. 門口的 CW300 則上週裝機時就沒有插卡, 直接斷電後插入 256GB 新卡. 

操作米家 App 時發現儲存卡不需要一直錄影, 可以設定為畫面有移動時才錄, 這樣不僅可以節省空間, 讓 SD 卡可以存放更多有用的影片 (如果畫面不變還一直錄, SD 卡的讀寫次數會增加很多, 使卡片壽命變短), 而且回放時也比較快找到目標. 設定方式如下 :

進入米家 App 的某支攝影機後, 按右上角的三個點點鈕進入設定頁面 : 




按 "儲存設定" :




點 "記憶卡狀態" 後面的 "良好" :




按 "錄製模式" 後面的 "始終錄製" :



  
按上方 "僅錄製移動畫面" :




返回上一頁可見錄製模式已改變 : 




這樣就設定完成了. 

回監視器首頁按影像右下角的四點鏡頭控制按鈕切換到功能選項點選 "回放" :




按最底下 "播放全部影片" 鈕 :




可見所錄製的影片時間不連續 : 




有間隔的部分就是畫面沒有變化而停止錄影的關係. 

2024年5月18日 星期六

好站 : SQLite

這兩天寫爬蟲有用到 SQLite, 除了複習自己寫的筆記外, 還找到下面這篇很棒的文章 :


此文從安裝 DB Browser for SQLite 建立資料庫, 到新增/修改/刪除資料表,  CRUD 作業之 SQL 語法, 資料庫匯出匯入, 到資料表欄位說明等都整理得非常簡明扼要, 是臨時要 SQLite 用時可以迅速找到解答的好地方. 

露天購買太陽能 LED 燈

今天逛露天賣場看到下面這款太陽能 LED 燈似乎不錯用, 可以裝在陽台晚上自動亮燈 :





萊爾富免運 169. 先買一個試試看, 好用再加購.

露天購買 K10 Max 四軸遙控飛機

今天又買了一個新玩具, 前天在露天看到下面這款 K10 Max 四軸遙控, 覺得是這價位中飛控做得還不錯的一款入門練習機, 賣家原本最多只有配雙電池, 經我要求增加一個單購電池選項才下標購買 (一顆 200 元) : 






這款 K10 Max 在 Aliexpress 也有賣, 但它只配單電池 :


雖然這種 1000 多塊的玩具型 4 軸不用期待太高, 但可以拿來當練習機熟悉四軸控制. 以下是在 YT 找到的一些介紹與測試影片 :














2024年5月17日 星期五

好用的 SQLite 管理軟體 SQLite Expert Personal

SQLite 是一款小而美的單檔資料庫軟體, 今天找到一款好用的 SQLite 資料庫管理軟體 SQLite Expert Personal 來操作資料庫, 網址如下 : 


官網提供商用收費的 SQLite Expert Professional 試用版 (30 日到期) 與免費的 Personal 版 (功能較少但無到期日), 安裝 Personal 版就夠用了 : 




關於付費之專業版與免費版功能差異比較參考 :



1. 安裝軟體 : 

執行下載之檔案進行安裝 : 








安裝完畢執行此軟體 : 




2. 新增資料庫 : 

按左上角的按鈕即可新增資料庫 :





3. 新增資料表 : 

開啟資料庫後, 按上面中間的 "New Table" 鈕 :




輸入資料表名稱 : 





切換到第二個頁籤 "Columns", 按右上角的 "Add" 鈕 :




於彈出視窗中設定欄位資料後按 OK, 先輸入 date, stock_id, 與 stock_name 這三個欄位 :







按右下角的 "Apply" 鈕即將此三欄位加入資料表中 : 



Python 學習筆記 : 網頁爬蟲實戰 (八) 台股每日盤後資訊網頁

今天繼續來玩 Python 爬蟲, 本篇要爬的對象是證交所台股每日盤後資訊網頁, 十幾年前我用 PHP 爬這張網頁時覺得處理起來很麻煩 (那時的網頁是將全部上市股票擠在一張網頁, 用字串處理技巧硬剖), 現在改用 Python 相對簡單多了. 

本系列之前的筆記參考 : 


本篇測試部分作法參考了下面這本書中的範例加以改寫 : 



一. 檢視目標網頁 :  

證交所台股每日盤後資訊網頁之網址如下 :





不過這個網頁只是大盤統計資料而已, 我們要的標的網頁必須勾選上面的 "分類" 選項, 勾選 "全部 (不含權證, 牛熊證, 可展延牛熊證)" 後按查詢才會看到 : 




但得到的網頁包含了 8 張表格, 我們的目標資訊是最底下的那張表格 :




由於查詢後網址不變, 可見網頁中的這 8 張表格是透過 Ajax/XHR 產生的, 這可以用 Chrome 擴充功能 Quick Javascript Switcher 關掉 Javascript 功能印證 (關掉就看不到這些表格), 所以可以利用 Chrome 開發者功能的 Networking/XHR 功能來觀察後端提供表格內容的網址, 可取得證交所不公開的 API 來直接擷取資料 (通常是 JSON 檔). 

首先在瀏覽器上按 F12 打開開發者視窗, 然後重新整理上面台股每日盤後資訊網頁, 開發者工具視窗點選 "Networking" 與 "Fetch/XHR", 點擊左邊的回應網址, 看看右邊 Response 頁籤中是否有我們要的目標資料 (通常是檔案較大的那個) :




找到目標後在左邊回應網址上按滑鼠右鍵選 "Copy/Copy URL" 將網址複製下來 :

https://www.twse.com.tw/rwd/zh/afterTrading/MI_INDEX?date=20240513&type=ALLBUT0999&response=json&_=1715650102878

這就是證交所不公開的 API, 透過此 URL 即可直接取得我們想要的資料了 (完全用不到 BeautifulSoup). 網址最後面的參數 _ 可有可無, 即使去掉也可以順利取得資料 (JSON) :


把此網址貼到 Chrome 就會看到傳回的 JSON 字串 :




勾選左上角的 "美化排版" 即可將 JSON 排列整齊以利閱讀 :



{
    "tables": [
        {
            "title": "113年05月13日 價格指數(臺灣證券交易所)",
            "fields": [
                "指數",
                "收盤指數",
                "漲跌(+/-)",
                "漲跌點數",
                "漲跌百分比(%)",
                "特殊處理註記"
            ],
            "data": [
                [
                    "寶島股價指數",
                    "23,695.45",
                    "<p style ='color:red'>+</p>",
                    "150.48",
                    "0.64",
                    ""
                ],
                [
                    "發行量加權股價指數",
                    "20,857.71",
                    "<p style ='color:red'>+</p>",
                    "148.87",
                    "0.72",
                    ""
                ],
                [
                    "臺灣公司治理100指數",
                    "12,043.95",
                    "<p style ='color:red'>+</p>",
                    "122.85",
                    "1.03",
                    ""
                ],
                ... (略) ...
            ]
        },
        {
            "title": "113年05月13日 每日收盤行情(全部(不含權證、牛熊證))",
            "fields": [
                "證券代號",
                "證券名稱",
                "成交股數",
                "成交筆數",
                "成交金額",
                "開盤價",
                "最高價",
                "最低價",
                "收盤價",
                "漲跌(+/-)",
                "漲跌價差",
                "最後揭示買價",
                "最後揭示買量",
                "最後揭示賣價",
                "最後揭示賣量",
                "本益比"
            ],
            "data": [
                [
                    "0050",
                    "元大台灣50",
                    "9,642,900",
                    "12,460",
                    "1,576,521,176",
                    "163.35",
                    "163.90",
                    "162.95",
                    "163.30",
                    "<p style= color:red>+</p>",
                    "1.65",
                    "163.25",
                    "58",
                    "163.30",
                    "14",
                    "0.00"
                ],
                ... (略) ...


API 網址可以用 f 字串來表示 :

url=f'https://www.twse.com.tw/rwd/zh/afterTrading/MI_INDEX?date={data}&type=ALLBUT0999&response=json'

只要傳入日期 date (格式 YYYYMMDD) 即可取得指定日期之盤後資訊. 


二. 擷取目標網頁內容 :  

找到資料來源 URL 後就可以用 requests 套件來抓資料, 先匯入要用到的模組 : 

>>> import requests   
>>> from fake_useragent import UserAgent    
>>> import csv  
>>> import json   

建立偽裝用的 User Agent 標頭字典 :

>>> ua=UserAgent()  
>>> headers={'User-Agent': ua.random}    

關於 fake_useragent 套件用法參考 :


將網址與標頭傳入 requests.get() :

>>> url='https://www.twse.com.tw/rwd/zh/afterTrading/MI_INDEX?date=20240513&type=ALLBUT0999&response=json&_=1715650102878'    
>>> res=requests.get(url, headers=headers)   

將回應內文 res.text 傳給 json.loads() 把 JSON 字串轉為字典 :

>>> data=json.loads(res.text)    
>>> type(data)   
<class 'dict'>   

呼叫 keys() 查詢字典有哪些鍵 : 

>>> data.keys()  
dict_keys(['tables', 'params', 'stat', 'date'])

對照上面瀏覽器顯示的 JSON 資料, 可知表格內容放在 tables 屬性中, 查詢共有 10 個 table :

>>> len(data['tables'])  
10

不過最後一個為空表格, 只有前 9 個有資料 : 

>>> data['tables'][0]['title']   
'113年05月13日 價格指數(臺灣證券交易所)'
>>> data['tables'][1]['title']   
'價格指數(跨市場)'
>>> data['tables'][2]['title']   
'價格指數(臺灣指數公司)'
>>> data['tables'][3]['title']       
'報酬指數(臺灣證券交易所)'
>>> data['tables'][4]['title']    
'報酬指數(跨市場)'
>>> data['tables'][6]['title']     
'113年05月13日 大盤統計資訊'
>>> data['tables'][7]['title']     
'漲跌證券數合計'
>>> data['tables'][8]['title']     
'113年05月13日 每日收盤行情(全部(不含權證、牛熊證))'
>>> data['tables'][9]     
{}

可見目標資料放在第 9 個表格 ( tables 屬性值為串列, 目標資料放在其索引 8 的一個字典的 data 屬性值裡) :

        {
            "title": "113年05月13日 每日收盤行情(全部(不含權證、牛熊證))",
            "fields": [
                "證券代號",
                "證券名稱",
                "成交股數",
                "成交筆數",
                "成交金額",
                "開盤價",
                "最高價",
                "最低價",
                "收盤價",
                "漲跌(+/-)",
                "漲跌價差",
                "最後揭示買價",
                "最後揭示買量",
                "最後揭示賣價",
                "最後揭示賣量",
                "本益比"
            ],
            "data": [
                [
                    "0050",
                    "元大台灣50",
                    "9,642,900",
                    "12,460",
                    "1,576,521,176",
                    "163.35",
                    "163.90",
                    "162.95",
                    "163.30",
                    "<p style= color:red>+</p>",
                    "1.65",
                    "163.25",
                    "58",
                    "163.30",
                    "14",
                    "0.00"
                ],
                [
                    "0051",
                    "元大中型100",


因此用 data['tables'][8]['data'] 便能取得全部上市股票盤後資訊, 先用 len() 看看有幾筆 : 

>>> len(data['tables'][8]['data'])    
1236

可見共有 1236 支股票, 檢視其中幾筆 : 

>>> data['tables'][8]['data'][0]    
['0050', '元大台灣50', '9,642,900', '12,460', '1,576,521,176', '163.35', '163.90', '162.95', '163.30', '<p style= color:red>+</p>', '1.65', '163.25', '58', '163.30', '14', '0.00']
>>> data['tables'][8]['data'][1]   
['0051', '元大中型100', '53,991', '230', '4,253,963', '79.00', '79.00', '78.55', '78.95', '<p style= color:red>+</p>', '0.20', '78.80', '1', '79.00', '6', '0.00'] 
>>> data['tables'][8]['data'][2]   
['0052', '富邦科技', '476,629', '683', '75,336,734', '158.00', '158.75', '157.50', '157.90', '<p style= color:red>+</p>', '1.20', '157.90', '17', '158.00', '5', '0.00']

前面都是 ETF 基金, 到索引 191 才是個股 : 

>>> data['tables'][8]['data'][191]    
['1101', '台泥', '30,344,896', '14,455', '1,021,662,057', '33.60', '33.85', '33.45', '33.70', '<p style= color:red>+</p>', '0.40', '33.70', '158', '33.75', '183', '32.72']
>>> data['tables'][8]['data'][200]    
['1203', '味王', '1,071', '77', '50,950', '--', '--', '--', '--', '<p> </p>', '0.00', '48.95', '1', '49.55', '1', '23.48']

可以用迴圈走訪 data['tables'][8]['data'] 串列來取得全部上市證券盤後資料並使用 csv 套件將其存入 .csv 檔或存入資料庫. 但在此之前須先進行資料清洗, 處理表格各欄位之資料型態 : 


1. 去除漲跌價差欄位 :

第 9 欄位的漲跌價差是一個 HTML 字串, 此欄位可透過前後日收盤價計算而得故可移除 :

>>> data['tables'][8]['data'][191][9]    
'<p style= color:red>+</p>'

這可以透過串列切片來處理.


2. 添加日期欄位 :

如果要將每日擷取到的盤後資料存入資料庫, 則必須添加一個日期欄位來區別, 我們可以從 data['title'] 屬性值中取得日期 :

"title": "113年05月13日 每日收盤行情(全部(不含權證、牛熊證))"

注意, 日期資訊是民國年月日, 與後面的 "每日收盤行情... " 之間有個空格, 先用 split() 將日期資訊萃取出來 : 

>>> date_str=data['tables'][8]['title'].split(' ')[0]      
>>> date_str     
'113年05月13日'

在利用正規表示法把年月日都替換成 '-' :

>>> import re   
>>> date_str=re.sub(r"\D", "-", date_str)     
>>> date_str      
'113-05-13-'   

用字串切片去除結尾的 '-' 字元 :

>>> date_str=date_str[:-1]   
>>> date_str    
'113-05-13'  

接下來將日期字串用 split() 拆開後把民國年加上 1911 再組回去即可 :

>>> y, m, d=date_str.split('-')   
>>> date_str=str(int(y) + 1911) + '-' + m + '-' + d   
>>> date_str    
'2024-05-13'


3. 將數字字串轉成數值型態 (int/float) :

由上可知, data['tables'][8]['data'] 串列中各欄位之值都是字串, 為了爾後進行計算, 除了第二欄位 "證券名稱" 外都要轉為數值, 浮點數可傳給 float() 轉換, 整數可傳給 int() 轉換. 

例如大立光的價格資訊中有小數部分, 所以需要用 float() 轉成浮點數, 但整數部分有逗點必須先去除 : 

>>> data['tables'][8]['data'][725]    
['3008', '大立光', '426,653', '1,576', '951,013,700', '2,235.00', '2,250.00', '2,215.00', '2,240.00', '<p style= color:red>+</p>', '10.00', '2,235.00', '1', '2,240.00', '6', '14.43']   

不過在進行數字字串轉數值時會遇到一個問題, 若股票當日成交股數 (第三欄位) 為 0, 則欄位 6, 7, 8, 9 的開高低收欄位會是 '--' 而非數值字串 :  

>>> data['tables'][8]['data'][165]  
['01002T', '土銀國泰R1', '0', '0', '0', '--', '--', '--', '--', '<p> </p>', '0.00', '17.15', '1', '17.19', '1', '0.00']   

因此對於成交股數為 0 的股票就跳過去不予處理

這樣就可以走訪 data['tables'][8]['data'] 串列之元素來進行轉換了, 以下為了縮短指涉長度, 用一個新變數 stocks 來指向二維串列 data['tables'][8]['data'] :

>>> stocks=data['tables'][8]['data']     
>>> stocks[0]    
['0050', '元大台灣50', '9,642,900', '12,460', '1,576,521,176', '163.35', '163.90', '162.95', '163.30', '<p style= color:red>+</p>', '1.65', '163.25', '58', '163.30', '14', '0.00']

原始欄位共 16 個, 如果刪除第 9 欄位 (索引 8) "漲跌(+/-)", 然後在最前面插入日期欄位, 則一刪一增後仍然是 16 欄, 結果會變成如下 : 

>>> del stocks[0][9]  
>>> stocks[0].insert(0, date_str)   
>>> stocks[0]    
['2024-05-13', '0050', '元大台灣50', '9,642,900', '12,460', '1,576,521,176', '163.35', '163.90', '162.95', '163.30', '1.65', '163.25', '58', '163.30', '14', '0.00']

為了能在做欄位型態轉換時能知道此欄應該轉成甚麼型態, 定義一個變數 col_type 來做對映一刪一增後的欄位型態分布 : 

>>> col_types=['date', 'text', 'text', 'int', 'int', 'int', 'float', 'float', 'float', 'float', 'float', 'float', 'int', 'float', 'int', 'float']  
>>> col_types   
['date', 'text', 'text', 'int', 'int', 'int', 'float', 'float', 'float', 'float', 'float', 'float', 'int', 'float', 'int', 'float']

然後定義一個函式來處理欄位型態轉換 : 
 
>>> def col_trans(col, col_type):     
    if col_type in ['int', 'float']:         # 欄位型態是 int/float 才轉換
        col=col.strip().replace(',', '')    # 先將左右空格與千位逗號去除
        try :                                         # int() 與 float() 轉型失敗會丟出例外
            if col_type == 'int':             # 整數用 int() 轉換
                return int(col)
            else:
                return float(col)              # 浮點數用 float() 轉換
        except:
            return -1                              # 例外傳回 -1
    else:
        return col                                 # 非 int, float 不轉換傳回本身

接下來以 stocks[0] 台灣 50 為例測試此函式是否能正確進行資料清洗, 只要以串列生成式用一個指令就能完成 : 

[col_trans(col, col_type[idx]) for idx, col in enumerate(stocks[0])]  

此處我們利用 enumerate() 函式來取得 stocks[0] 串列的元素 col 與其索引 idx, 因為要從 col_types 串列中取得各欄位所對映之型態必須用到索引 : 

>>> stocks[0]   
['2024-05-13', '0051', '元大中型100', '53,991', '230', '4,253,963', '79.00', '79.00', '78.55', '78.95', '0.20', '78.80', '1', '79.00', '6', '0.00']
>>> [col_trans(col, col_type[idx]) for idx, col in enumerate(stocks[0])]   
['2024-05-13', '0051', '元大中型100', 53991, 230, 4253963, 79.0, 79.0, 78.55, 78.95, 0.2, 78.8, 1, 79.0, 6, 0.0]

結果顯示所有 int/float 型態的欄位都被正確地轉換了. 

我們可以將每支股票清洗後的串列依序存入一個空串列中, 結果就是一個二維串列, 然後用 csv 套件將此二維串列寫入 .csv 檔案, 完整程式碼如下 : 

import requests   
from fake_useragent import UserAgent    
import csv  
import json
import re

def col_trans(col, col_type):   # 資料清洗與轉換
    if col_type in ['int', 'float']:  # 只轉換 int/float 類型資料
        col=col.replace(',', '')       # 去除整數部分的千位逗號
        try :
            if col_type == 'int':
                return int(col)      # 轉成 int
            else:
                return float(col)   # 轉成 float
        except:
            return -1
    else:
        return col    # 非 int/float 類型不處理直接傳回

ua=UserAgent()  
headers={'User-Agent': ua.random}    
url='https://www.twse.com.tw/rwd/zh/afterTrading/MI_INDEX?date=20240513&type=ALLBUT0999&response=json&_=1715650102878'    
res=requests.get(url, headers=headers)
data=json.loads(res.text)
if 'tables' in data:  # 非交易日 data 字典中無 tables 屬性
    date_str=data['tables'][8]['title'].split(' ')[0]
    date_str=re.sub(r"\D", "-", date_str)
    date_str=date_str[:-1]
    y, m, d=date_str.split('-')
    date_str=str(int(y) + 1911) + '-' + m + '-' + d    # 民國轉西元
    stocks=data['tables'][8]['data']    # 盤後資訊在第 9 張表
    col_types=['date', 'text', 'text', 'int', 'int', 'int',
               'float', 'float', 'float', 'float', 'float', 'float', 
               'int', 'float', 'int', 'float']    # 欄位型態對映
    results=[]   # 儲存個股清洗後的結果
    for stock in stocks:   # 走訪每支股票
        if stock[2] != '0':
            del stock[9]      # 刪除第 10 欄 '漲跌+-'
            stock.insert(0, date_str)    # 交易日期插在最前面 (第 1 欄)
            results.append([col_trans(col, col_types[idx])
             for idx, col in enumerate(stock)])   # 資料清洗與轉換
    with open('twse_after_trading.csv', 'w', newline='') as f:    # 結果寫入 csv 檔
        writer=csv.writer(f)  
        writer.writerows(results)
        print('寫入檔案完成')
else:
    print('非交易日無盤後資料')
      
注意, 由於非交易日無盤後交易資料, 證交所網頁會傳回 JSON 字串 {'stat': '很抱歉,沒有符合條件的資料!'}, 故 data 字典中會找不到 tables 屬性, 故程式中使用 if 'tables' in data 來檢查是否維交易日, 是的話才進行截取與清理作業. 最後呼叫 csv 寫入物件 Writer 的 writerows() 方法將二維串列寫入檔案, 參考 :


結果如下 : 

2024-05-13,0050,元大台灣50,9642900,12460,1576521176,163.35,163.9,162.95,163.3,1.65,163.25,58,163.3,14,0.0
2024-05-13,0051,元大中型100,53991,230,4253963,79.0,79.0,78.55,78.95,0.2,78.8,1,79.0,6,0.0
2024-05-13,0052,富邦科技,476629,683,75336734,158.0,158.75,157.5,157.9,1.2,157.9,17,158.0,5,0.0
2024-05-13,0053,元大電子,18050,104,1586656,87.85,88.2,87.75,88.0,1.2,87.9,1,88.15,14,0.0
2024-05-13,0055,元大MSCI金融,512466,448,13619756,26.65,26.68,26.5,26.53,0.09,26.52,11,26.53,18,0.0
2024-05-13,0056,元大高股息,16994138,12450,663441474,39.07,39.15,38.93,39.04,0.0,39.03,8,39.04,105,0.0
2024-05-13,0057,富邦摩台,12177,14,1472245,120.9,121.0,120.75,120.75,1.65,120.85,25,121.15,15,0.0
... (略) ...
2024-05-13,9940,信義,321271,748,10599015,33.15,33.15,32.85,32.95,0.2,32.95,1,33.0,17,13.79
2024-05-13,9941,裕融,904683,1580,130613753,144.5,145.5,143.0,145.5,0.5,145.0,1,145.5,59,16.29
2024-05-13,9941A,裕融甲特,18867,23,961069,50.9,51.0,50.9,51.0,0.1,50.9,3,51.1,4,0.0
2024-05-13,9942,茂順,81343,171,10499679,130.0,130.0,128.5,129.5,0.5,129.0,4,129.5,2,10.99
2024-05-13,9943,好樂迪,488748,487,44056473,91.0,91.0,89.0,90.7,0.3,90.6,16,90.7,9,15.37
2024-05-13,9944,新麗,1017075,538,21658756,21.15,22.0,20.85,21.55,0.4,21.5,8,21.55,5,215.5
2024-05-13,9945,潤泰新,45390604,23639,1940083275,40.95,43.95,40.9,43.9,3.4,43.9,53,43.95,357,16.08
2024-05-13,9946,三發地產,2558212,1500,95295621,36.7,38.2,35.95,38.0,1.1,37.9,1,38.0,131,100.0
2024-05-13,9955,佳龍,1380053,1160,41882995,31.5,31.65,29.75,29.75,0.65,29.75,28,29.8,14,0.0
2024-05-13,9958,世紀鋼,12639860,9709,2989728306,230.0,241.0,229.5,236.0,11.5,235.5,61,236.0,15,53.15




可見已正確擷取盤後行情資訊.  


三. 寫成爬蟲函式 :

上面的程式中目標網頁 URL 是固定的, 要擷取特定日期時必須去修改 URL 字串, 我們可以將其改寫為函式, 只要傳入日期字串 (格式為 'YYmmdd') 就傳回一個二為串列, 所以需要匯入 datetime 套件來製作日期字串, 呼叫 datetime.datetime.today() 或 datetime.datetime.now() 函式都會傳回一個 datetime.datetime 物件, 然後呼叫其 strftime() 方法並傳入 '%Y%m%d' 格式字串即可 : 

>>> from datetime import datetime 
>>> datetime.today().strftime('%Y%m%d')    
'20240517'
>>> datetime.now().strftime('%Y%m%d')    
'20240517'

完整的程式碼如下 : 

# craw_twse_after_trading.py
import requests   
from fake_useragent import UserAgent    
import csv  
import json
import re
from datetime import datetime

def col_trans(col, col_type):
    if col_type in ['int', 'float']:
        col=col.replace(',', '')
        try :
            if col_type == 'int':
                return int(col)
            else:
                return float(col)
        except:
            return -1
    else:
        return col

def get_twse_after_trade(the_date):
    ua=UserAgent()  
    headers={'User-Agent': ua.random}    
    url='https://www.twse.com.tw/rwd/zh/afterTrading/MI_INDEX?date=' +\
        the_date + '&type=ALLBUT0999&response=json&_=1715650102878'    
    res=requests.get(url, headers=headers)
    data=json.loads(res.text)
    if 'tables' in data:
        date_str=data['tables'][8]['title'].split(' ')[0]
        date_str=re.sub(r"\D", "-", date_str)
        date_str=date_str[:-1]
        y, m, d=date_str.split('-')
        date_str=str(int(y) + 1911) + '-' + m + '-' + d
        stocks=data['tables'][8]['data']
        col_types=['date', 'text', 'text', 'int', 'int', 'int',
                   'float', 'float', 'float', 'float', 'float', 'float', 
                   'int', 'float', 'int', 'float']
        results=[]
        for stock in stocks:
            if stock[2] != '0':
                del stock[9]
                stock.insert(0, date_str)
                results.append([col_trans(col, col_types[idx])
                 for idx, col in enumerate(stock)])            
        return results
    else:
        return None

if __name__ == '__main__':
    the_date=datetime.now().strftime('%Y%m%d')
    results=get_twse_after_trade(the_date)
    with open('twse_after_trading.csv', 'w', newline='') as f:   
        writer=csv.writer(f)  
        writer.writerows(results)
        
只要更改 the_date 字串就可以擷取指定日期之盤後資料了. 


四. 將結果存入 SQLite 資料庫 :

在上面的測試中我們將爬蟲的擷取結果存入 csv 檔, 如果存入資料庫會讓存取更方便, 例如用 Flask/Django 建構一個網站來瀏覽或查詢盤後資料時. 此處使用小而美的單檔資料庫 SQLite, 關於 SQLite 用法參考 :


首先用 SQL Manager for SQLite 軟體建立一個資料庫 tw_stocks, 然後建立一個包含下列 16 個欄位的資料表 twse_after_trade : 
  1. date : 交易日, DATETEXT(10)
  2. stock_id : 證券代號, TEXT(10)
  3. stock_name : 證券名稱, TEXT(20)
  4. volumn : 成交股數 (成交量), INTEGER
  5. transactions : 成交筆數, INTEGER
  6. turnover : 成交金額, INTEGER
  7. open : 開盤價, REAL
  8. high : 最高價, REAL
  9. low : 最低價, REAL
  10. close : 收盤價, REAL
  11. spread : 漲跌價差, REAL
  12. last_best_bid_price : 最後揭示買價, REAL
  13. last_best_bid_volumn : 最後揭示買量, INTEGER
  14. last_best_ask_price : 最後揭示賣價, REAL
  15. last_best_ask_volumn : 最後揭示賣量, INTEGER
  16. PER : 本益比, REAL



關於 SQL Manager for SQLite 軟體用法參考 :


也可以先用 SQL Manager for SQLite 建立空白資料庫 tw_stocks 後, 用 SQL 指令建立 twse_after_trade 資料表 :

CREATE TABLE [twse_after_trade](
  [date] DATETEXT(10) NOT NULL, 
  [stock_id] TEXT(10) NOT NULL, 
  [stock_name] TEXT(20) NOT NULL, 
  [volumn] INTEGER, 
  [transactions] INTEGER, 
  [turnover] INTEGER, 
  [open] REAL, 
  [high] REAL, 
  [low] REAL, 
  [close] REAL, 
  [spread] REAL, 
  [last_best_bid_price] REAL, 
  [last_best_bid_volumn] INTEGER, 
  [last_best_ask_price] REAL, 
  [last_best_ask_volumn] INTEGER, 
  [PER] REAL)

建立 twse_after_trade 資料表後先用第一筆資料來測試看看資料表能否正常寫入, 先匯入 sqlite3 套件並建立資料庫連線 : 

>>> import sqlite3   
>>> conn=sqlite3.connect("tw_stocks.sqlite")   

製作寫入資料表的 SQL 指令後呼叫 execute() 執行, 接著呼叫 commit() 回存資料庫 :

>>> SQL="INSERT INTO twse_after_trade(date, stock_id, stock_name, " +\
    "volumn, transactions, turnover, open, high, low, " +\
    "close, spread, last_best_bid_price, last_best_bid_volumn, " +\
    "last_best_ask_price, last_best_ask_volumn, PER) " +\
    "VALUES('2024-05-17', '0050', '元大台灣50', 4652000, 2229, " +\ 
    "777580350, 167.55, 167.7, 166.7, 167.25, 0.2, 167.2, -1, " +\
    "167.25, -1, 0.0)" 
>>> conn.execute(SQL)   
<sqlite3.Cursor object at 0x0000016933A33B40>
>>> conn.commit()  

這時將 SQL Manager for SQLite 切到第二個頁籤 Browse Data 就可看到寫入之資料 :




用 SELECT 指令確認可將資料讀出來 : 

>>> SQL="SELECT * FROM twse_after_trade"   
>>> cursor=conn.execute(SQL)   
>>> print(cursor.fetchone())   
('2024-05-17', '0050', '元大台灣50', 4652000, 2229, 777580350, 167.55, 167.7, 166.7, 167.25, 0.2, 167.2, -1, 167.25, -1, 0.0)  

這樣就可以將上面的程式修改為儲存到 SQLite 版了, 完整程式碼如下 :

# craw_twse_after_trading_sqlite.py
import requests   
from fake_useragent import UserAgent    
import csv  
import json
import re
from datetime import datetime
import sqlite3

def col_trans(col, col_type):
    if col_type in ['int', 'float']:
        col=col.replace(',', '')
        try :
            if col_type == 'int':
                return int(col)
            else:
                return float(col)
        except:
            return -1
    else:
        return col

def get_twse_after_trade(the_date):
    ua=UserAgent()  
    headers={'User-Agent': ua.random}    
    url='https://www.twse.com.tw/rwd/zh/afterTrading/MI_INDEX?date=' +\
        the_date + '&type=ALLBUT0999&response=json&_=1715650102878'    
    res=requests.get(url, headers=headers)
    data=json.loads(res.text)
    if 'tables' in data:
        date_str=data['tables'][8]['title'].split(' ')[0]
        date_str=re.sub(r"\D", "-", date_str)
        date_str=date_str[:-1]
        y, m, d=date_str.split('-')
        date_str=str(int(y) + 1911) + '-' + m + '-' + d
        stocks=data['tables'][8]['data']
        col_types=['date', 'text', 'text', 'int', 'int', 'int',
                   'float', 'float', 'float', 'float', 'float', 'float', 
                   'int', 'float', 'int', 'float']
        results=[]
        for stock in stocks:
            if stock[2] != '0':
                del stock[9]
                stock.insert(0, date_str)
                results.append([col_trans(col, col_types[idx])
                 for idx, col in enumerate(stock)])            
        return results
    else:
        return None

if __name__ == '__main__':
    the_date=datetime.now().strftime('%Y%m%d')
    stocks=get_twse_after_trade(the_date)
    if stocks:
        conn=sqlite3.connect("tw_stocks.sqlite")
        for stock in stocks:
            stock=[str(i) for i in stock]
            values="VALUES('" + "','".join(stock) + "')"
            SQL="INSERT INTO twse_after_trade(date, stock_id, " +\
                "stock_name, volumn, transactions, turnover, " +\
                "open, high, low, close, spread, " +\
                "last_best_bid_price, last_best_bid_volumn, " +\
                "last_best_ask_price, last_best_ask_volumn, " +\
                "PER) " + values
            conn.execute(SQL)
        conn.commit()
        conn.close()
    else:
        print('非交易日')

由於在前面清理資料時將數值字串資料轉換成 int 與 float, 一般 SQL 指令中, 數值資料不加括號, 字串資料要加括號, 而 SQLite 的 SQL 指令不管數值或字串一律加括號也可以 (它應該是照欄位類型自動轉換, 這樣好處理), 所以此處用串列生成式先將 stock 的元素都轉成字串, 然後串成 values 字串以組成 SQL 的 INSERT 指令, 結果如下 :




不過我以前用 PHP 寫的系統是各股有自己的資料表, 擷取到資料後是分別寫入各股的資料表, 而不是像這樣全部存在一個資料表. 

也可以把資料庫檔上傳到線上 SQLite 網站 SQLite Viewer 進行瀏覽與操作 :




關於 SQLite Viewer 用法參考 :


感覺 SQLite Viewer 字大介面簡潔又可輸出 CSV 檔, 就是機敏資料不好上傳.