2024年5月9日 星期四

Python 學習筆記 : 網頁爬蟲實戰 (六) 博客來書店每日一書 66 折網頁

本篇要爬的對象是博客來網路書店的每日一書 66 折活動網頁, 可以結合 Line Notify 服務將今日 66 折訊息傳到 Line, 對於愛書的人來說, 能用 66 折買到想看的書是一件很爽的事. 



一. 檢視目標網頁 : 

博客來網路書店的每日一書 66 折優惠活動網址如下 :





右上角即為今日 66 折的書, 檢視網頁原始碼可知這是一個 utf-8 編碼的網頁, 但找不到今日 66 折書籍的內容, 事實上它是在網頁載入後透過 XHR (Ajax) 非同步技術向伺服器提出 Ajax 請求所回應的動態內容, 回應網頁會被嵌入到 id=E180629000000001_94 的 div 容器中 : 

<h2>今日66折</h2>
<div class="countdown" id="cntdwn">05/09(四)</div>
<div class="wrap" id=E180629000000001_94>
</div></div><script type="text/javascript">
    $(document).ready(function () {
        var module_id = "E180629000000001_94";//模組編碼參數
        var E180629000000001_94 = new ajax_info(module_id,'getBooks66OfTheDayAjax','P','{}');
    });
</script>

底下的那段 Javascript 程式碼就是利用 jQuery 提出 Ajax 請求並將回應內容載入 div 元素中. 這種利用 Javascript 動態嵌入內容的網頁可以利用 Chrome 擴充套件 Quick Javascript Switcher 來確認 : 關閉 Javascript 時今日 66 折的內容會消失; 開啟時才會出現, 參考 : 


活動網頁下方為未來 6 天 66 折書籍預告, 這部分為靜態網頁內容, 不是 Ajax 動態嵌入, 故可以用 BeautifulSoup 剖析後將訊息擷取出來. 


二. 尋找目標網頁之 XHR 回應網址 : 

首先來處理右上方今日 66 折的部分, 因為透過 Ajax 取得的動態內容一定會出現在回應資源的網址中, 只要找到該網址就能取得目標網頁內容. 

在 Chrome 瀏覽器按 F12 開啟開發者視窗, 然後重新整理 66 折活動網頁, 切換至 Network 與 Fetch/XHR 頁籤, 點選右下角的 Response 頁籤就可以在左邊看到所有回應資源的網址 :




一個個去點選左邊的回應網址, 然後觀察右邊 Response 頁籤內容, 就能找到下面這個才是傳回今日 66 折書籍資訊的網址 : 


將此網址貼到瀏覽器網址列確認這確實是我們要找的目標網頁 : 




這樣直接去抓這個網址就可以了. 


三. 擷取目標網頁 : 

接下來使用 requests.get() 擷取目標網頁, 然後將回應網頁內容丟給 BeautifulSoup 剖析以便能從其建立之語法樹中取得目標資訊 : 

>>> import requests   
>>> from bs4 import BeautifulSoup     
>>> url='https://activity.books.com.tw/crosscat/ajaxinfo/getBooks66OfTheDayAjax/P?uniqueID=E180629000000001_94'    
>>> res=requests.get(url)   
>>> soup=BeautifulSoup(res.text, 'lxml')   

呼叫 BeautifulSoup 物件的 prettify() 方法印出目標網頁內容 : 

>>> print(soup.prettify())     
<html>
 <body>
  <div class="table">
   <div class="table-tr">
    <div class="table-td">
     <a href="https://www.books.com.tw/products/0010971532?loc=P_books66_title_002">
      <img alt="誘因設計:精準傳遞訊號,讓人照著你的想法行動" src="https://im1.book.com.tw/image/getImage?i=https://www.books.com.tw/img/001/097/15/0010971532.jpg&amp;v=6531053bk&amp;w=330&amp;h=330"/>
     </a>
    </div>
    <div class="table-td">
     <h1>
      <a href="https://www.books.com.tw/products/0010971532?loc=P_books66_title_002">
       誘因設計:精準傳遞訊號,讓人照著你的想法行動
      </a>
     </h1>
     <ul class="price clearfix">
      <li>
       定價480元
      </li>
      <li>
       <b>
        66
       </b>
       折優惠價
       <b>
        316
       </b>
       元
      </li>
     </ul>
     <p>
      <a class="btn btn-cart" href='javascript:ajaxCartItem("0010971532", "P", "316");' onclick='dataLayer.push({ecommerce:null});dataLayer.push({"event":"add_to_cart","ecommerce":{"currency":"TWD","value":316,"items":[{"item_id":"0010971532","item_name":"\u8a98\u56e0\u8a2d\u8a08\uff1a\u7cbe\u6e96\u50b3\u905e\u8a0a\u865f\uff0c\u8b93\u4eba\u7167\u8457\u4f60\u7684\u60f3\u6cd5\u884c\u52d5","item_brand":"\u5929\u4e0b\u6587\u5316","item_category":"001","item_category2":"\u5546\u696d\u7406\u8ca1","price":316,"quantity":1}]}});'>
       放入購物車
      </a>
     </p>
    </div>
   </div>
  </div>
  <blockquote>
   誘因是激勵他人的力量還是巨大陷阱?
  </blockquote>
  <p class="txt-msg">
   <em>
    優惠組合
   </em>
   <a href="//www.books.com.tw/exep/activity/promote/2007_promote/promote_activity.php?id=PKG0218503&amp;loc=P_books66">
    【5/9限定】改變的力量,今日66折加購!
   </a>
  </p>
 </body>
</html>

我們要抓的訊息是 class='table-td' 的 div 裡面的書籍與圖片 URL, 以及定價與優惠價等. 用 BeautifulSoup 搜尋語法樹時可使用 find(), find_all(), select(), select_one() 這四個方法來取得 Tag 物件, 因 Tag 物件也繼承了這些方法, 故可以連續呼叫這些方法來找出 HTML 中位於深層嵌套結構中的元素. 從 Tag 物件中取得屬性值可呼叫其 get() 方法; 而取得元素內容則可呼叫 get_text() 方法, 

例如先找出目標網頁中的第一個 a 元素的 Tag 物件, 然後呼叫其 get() 方法取得 href 屬性值  :

>>> tag_a=soup.find('a')    
>>> tag_a.get('href', None)     # 注意要傳入兩個參數, None 是無此參數時之傳回值
'https://www.books.com.tw/products/0010971532?loc=P_books66_title_002'

由於 img 元素是被 a 元素包起來, 所以接著呼叫 a 元素 Tag 物件的 find() 方法搜尋它, 然後呼叫其 get() 方法分別取得書名 (放在 alt 屬性中) 與圖檔網址 (放在 src 屬性中) :

>>> tag_img=soup.find('img')    # 也可以用 tag_a.find('img')
>>> tag_img.get('alt', None)   
'誘因設計:精準傳遞訊號,讓人照著你的想法行動'
>>> tag_img.get('src', None)    
'https://im1.book.com.tw/image/getImage?i=https://www.books.com.tw/img/001/097/15/0010971532.jpg&v=6531053bk&w=330&h=330'

而價格則是放在 class='price' 的 ul 元素中, 可呼叫 select_one() 取得其 Tag 物件, 然後呼叫其 get_text() 方法取得去除標籤後之文字內容即可取得價格訊息 :

>>> tag_ul=soup.select_one('ul.price')     
>>> tag_ul.get_text()     
'\n定價480元\n66折優惠價316元\n'   

可用字串的 replace() 去除跳行字元並於 66 前面插入一個空格 : 

>>> tag_ul.get_text().replace('\n', '')   
'定價480元66折優惠價316元'
>>> tag_ul.get_text().replace('\n', '').replace('66', ' 66')   
'定價480元 66折優惠價316元'

這樣便完成了我們的主要的爬蟲任務了 (次要任務是未來 6 天 66 折書目). 

將上面測試寫成如下的函式 : 

import requests   
from bs4 import BeautifulSoup

def get_today_66():
    url='https://activity.books.com.tw/crosscat/ajaxinfo/' +\
        'getBooks66OfTheDayAjax/P?uniqueID=E180629000000001_94'
    try:
        res=requests.get(url)
        soup=BeautifulSoup(res.text, 'lxml') 
        book_url=soup.find('a').get('href', None)
        book_name=soup.find('img').get('alt', None) 
        book_img=soup.find('img').get('src', None)
        book_price=soup.select_one('ul.price').get_text()
        book_price=book_price.replace('\n', '').replace('66', ' 66')
        return {'name': book_name,
                'url': book_url,
                'img': book_img,
                'price': book_price}
    except Exception as e: 
        return None 

if __name__ == '__main__':
    get_today_66()

執行結果如下 : 

>>> get_today_66()   
{'name': '誘因設計:精準傳遞訊號,讓人照著你的想法行動', 'url': 'https://www.books.com.tw/products/0010971532?loc=P_books66_title_002', 'img': 'https://im1.book.com.tw/image/getImage?i=https://www.books.com.tw/img/001/097/15/0010971532.jpg&v=6531053bk&w=330&h=330', 'price': '定價480元 66折優惠價316元'}


四. 用 Line Notify 推播每日 66 折書訊 :    

因為上面這個爬蟲程式對我蠻實用的, 所以利用 Line Notify 將其推播到手機, 用法參考 :


不過因為這是功能專一, 所以我修改了 get_today_66() 的傳回值, 直接把要傳給 Line Notify 伺服器的訊息做在函式裡而非主程式 (雖然傳回字典較泛化) : 

# books.com.tw_66.py
import requests   
from bs4 import BeautifulSoup  

def line_notify(msg, token):
    url="https://notify-api.line.me/api/notify"
    headers={"Authorization": "Bearer " + token,
             "Content-Type": "application/x-www-form-urlencoded"
             }
    payload={"message": msg}
    r=requests.post(url, headers=headers, params=payload)
    return r.status_code

def get_today_66():
    url='https://activity.books.com.tw/crosscat/ajaxinfo/' +\
        'getBooks66OfTheDayAjax/P?uniqueID=E180629000000001_94'
    try:
        res=requests.get(url)
        soup=BeautifulSoup(res.text, 'lxml') 
        book_url=soup.find('a').get('href', None)
        book_name=soup.find('img').get('alt', None)
        book_img=soup.find('img').get('src', None)
        book_price=soup.select_one('ul.price').get_text()
        book_price=book_price.replace('\n', '').replace('66', ' 66')
        msg=f'\n❖ {book_name}\n{book_price}\n▶ {book_url}'
        return msg 
    except Exception as e: 
        return None

if __name__ == '__main__':
    token='ud7PaDL45fz849A0e1f5oaMCbRIkxMXapQCt7PfNkzz'    # 範例權杖
    msg=get_today_66()
    if msg:  
        code=line_notify(msg, token)
        if code==200:
            print('Line 訊息發送成功!')
        else:
            print(f'Line 訊息發送失敗! (code={code})')
    else:
        print('無資料')
    
執行結果如下 :




測試 OK!


五. 在樹莓派 Pi 3 佈署爬蟲程式 :    

將上面的 books.com.tw_66.py 程式複製到高雄家那台 24H 運轉的 Pi 3 : 

pi@raspberrypi:~ $ nano books.com.tw_66.py 

執行程式 OK : 

pi@raspberrypi:~ $ python3 books.com.tw_66.py  
Line 訊息發送成功!

但是在放入 Crontab 定時執行前必須用 chmod 指令將此程式改為可執行 : 

pi@raspberrypi:~ $ sudo chmod +x /home/pi/books.com.tw_66.py  
pi@raspberrypi:~ $ ls -ls books.com.tw_66.py     
4 -rwxr-xr-x 1 pi pi 1376  5月  9 21:38 books.com.tw_66.py

這樣就可以編輯 Crontab, 設定此程式每日早上 9 點執行 : 

pi@raspberrypi:~ $ crontab -e    

加入一筆定時執行設定 :  




pi@raspberrypi:~ $ crontab -l  
0 16 * * 1-5 /usr/bin/python3 /home/pi/twstock_dashboard_update.py
*/31 9-13 * * 1-5 /usr/bin/python3 /home/pi/yahoo_twstock_monitor_table.py
0 8,18 * * * /usr/bin/python3 /home/pi/btc_eth_prices_line_notify.py
1 12,17 * * * /usr/bin/python3 /home/pi/technews_2.py
0 9 * * * /usr/bin/python3 /home/pi/books.com.tw_66.py

這樣便完成佈署了. 


2024-06-02 補充 :

由於上面我使用一對一將訊息推播至個人 Line 帳號, 導致與另一個接收 Pi 3 外網 IP 的訊息夾雜在一起, 這個 66 折訊息會被 IP 訊息淹沒 : 




比較好的做法是為每一種爬蟲訊息接收任務建立一個群組, 然後選擇推播至群組 (那個接收 Pi 3 外網 IP 的爬蟲也是要改). 首先在 Line 的 "主頁" 中按 "群組" :




然後按 "建立群組" : 




然後在 "選擇好友" 中點選一個想要讓他也接收此訊息的好友, 此處我要讓備用手機 iPhone 也能收到所以點選了貓先生 (這樣就有兩個人會收到); 如果沒有就跳過直接按右上角的下一步 (這樣只有自己會收到) :




這時會出現 "群組已存在" 的視窗, 表示有同樣成員的群組已存在, 問要不要前往該群組, 意思是既然成員相同, 有必要建立新的嗎? 沒錯, 就是要, 按底下的 "建立群組" 鈕 : 



填寫群組名稱後按 "建立" 即可 :






這樣便建立好群組了. 但最重要的是必須邀請 Line Notify 加入此群組成為好友才會收到推播的訊息, 在聊天室中按右上角的三條槓設定鈕後按 "邀請" : 




點選 Line Notify 後按右上角的 "邀請" 即可 :






這樣就完成手機端的操作了. 

接下來是用 Line 帳號登入 Line Notify 網站 :


按右上角帳號名稱會出現下拉式選單, 點選 "個人頁面" :




將網頁拉到底, 按 "發行權杖" 鈕 : 




填寫權杖名稱並選擇推播對象為 "網路書店每日66折" 群組後按 "發行" 鈕 :




這樣便得到一組權杖了, 務必先按 "複製" 鈕將其儲存在檔案中後才能按 "關閉" 鈕 :




然後修改上面的程式更換權杖後重新執行程式確認推播訊息已經改送至群組了 :

pi@raspberrypi:~ $ python3 books.com.tw_66.py    
Line 訊息發送成功!



大功告成啦 (crontab 不用改)! 

2024年5月7日 星期二

Python 學習筆記 : 網頁爬蟲實戰 (五) 從 OpenWeather 擷取氣象資料

在上一篇測試中, 擷取的對象是台北市公開資料平台提供的 Youbike 即時資料, 此平台直接提供 JSON 資料網址, 因此毋須註冊, 可直接取得或下載 JSON 資料. 另外一種提供公開 API 的網站則需要註冊帳號, 取得 API Key 才能獲准取得資料, 此類網站主要是為了控管使用者的呼叫頻率. 



我在讀陳會安老師的 "Python 從初學到生活應用超實務" 這本書的 11-6 節時看到 OpenWeather 的介紹, OpenWeather 是位於英國倫敦的 OpenWeather 氣象網站, 該組織自 2014 年起向全球提供世界上超過 20 萬個城市的氣象資訊, 使用者可註冊帳號免費取得 API Key 取得天氣資料. 參考下列教學文件 :





以下是實測紀錄.


一. 註冊 OpenWeather 帳號 :   


請按首頁右上角的 "Sign In" : 




於登入頁面中按右下角的 "Create an Account" : 




於註冊頁面輸入帳號, e-mail, 密碼等並勾選年齡與隱私權欄位後按 "Create an Account" 鈕 :





建立帳號後會收到一封驗證信, 開啟後按信中的驗證按鈕即完成註冊 :



登入帳戶後會先調查使用 OpenWeather 的用途 :




然後會跳轉到付費方案的價格表頁面 : 




OpenWeather 也有依用量計費的 "Pay as You Go" 付費方案, 每天前 1000 個 API 請求免費, 超過的部分每次請求以 0.0012 英鎊計費 : 




另外 OpenWeather 也提供免費方案, 免費帳戶每分鐘可以請求 60 次 (平均每分鐘一次), 但一個月總呼叫量上限為 100 萬次 : 




參考 : 


如果呼叫數超過免費額度會收到如下回應 :

{ "cod": 429,
"message": "Your account is temporary blocked due to exceeding of requests limitation of your subscription type. 
Please choose the proper subscription http://openweathermap.org/price"
}

參考 :

# https://openweathermap.org/appid


二. 取得 OpenWeather API Key (金鑰) :   

接下來切到 "API Key" 頁籤, 預設已經為我們產生一個名為 "Default" 的金鑰, 也可以在右方輸入框自訂 Key 名稱, 然後按 Generate 鈕產生新的金鑰, 其數量並無限制 :




將 API Key 複製到記事本中保存備用. 


二. 取得即時氣象資料 :   

取得 API Key (金鑰) 後便可從 OpenWeather 網站取得即時氣象資訊, API 的網址格式如下 :

https://api.openweathermap.org/data/2.5/weather?q={city},{country}&units=metric&lang=zh_tw&appid={api_key}

其中 country 參數為國家名稱, 採用 ISO-3166 兩位字母代碼, 台灣的國家代碼是 'TW'. api_key 參數為上面所取得之金鑰, 而 city 參數為城市名稱, 對台灣而言, 它提供了如下 6 個城市代號 :
  • 'Taipei' : 台北
  • 'Banqiao' : 板橋
  • 'Taoyuan' : 桃園
  • 'Hsinchu' : 新竹
  • 'Taichung' : 台中
  • 'Tainan' : 台南
  • 'Kaohsiung' : 高雄
這樣就可以開始測試了, 首先匯入 requests 與 json 套件 :

>>> import requests  
>>> import json  

然後設定參數 : 

>>> api_key='c1ca9d88d1ctony19664b3c021cedf7e'      # 範例金鑰
>>> city='Kaohsiung'  
>>> country='TW'  

用 f 字串製作請求之 URL : 

>>> url=f'https://api.openweathermap.org/data/2.5/weather?q={city},{country}&units=metric&lang=zh_tw&appid={api_key}'    
>>> url    
'https://api.openweathermap.org/data/2.5/weather?q=Kaohsiung,TW&units=metric&lang=zh_tw&appid=c1ca9d88d1ctony19664b3c021cedf7e'

呼叫 requests.get() 函式提出 HTTP GET 請求來取得氣象資訊並使用 json.loads() 將傳回的 JSON 字串轉成 Python 字典 :

>>> res=requests.get(url)  
>>> data=json.loads(res.text)   
>>> data    
{'coord': {'lon': 120.3133, 'lat': 22.6163}, 'weather': [{'id': 701, 'main': 'Mist', 'description': '薄霧', 'icon': '50n'}], 'base': 'stations', 'main': {'temp': 26.81, 'feels_like': 29.93, 'temp_min': 24.05, 'temp_max': 26.97, 'pressure': 1012, 'humidity': 87}, 'visibility': 5000, 'wind': {'speed': 3.09, 'deg': 20}, 'clouds': {'all': 40}, 'dt': 1715022884, 'sys': {'type': 2, 'id': 2002588, 'country': 'TW', 'sunrise': 1715030550, 'sunset': 1715077684}, 'timezone': 28800, 'id': 1673820, 'name': 'Kaohsiung City', 'cod': 200}

注意, 最後一個鍵 cod 為 HTTP 回應狀態碼, 200 表示 OK. 

關於 json 套件用法參考 :


上面的字典輸出內容擠成一團不好閱讀, 可以用 pprint 模組的 pprint() 函式來輸出 : 

>>> from pprint import pprint  
>>> pprint(data)  
{'base': 'stations',
 'clouds': {'all': 40},
 'cod': 200,
 'coord': {'lat': 22.6163, 'lon': 120.3133},
 'dt': 1715022884,
 'id': 1673820,
 'main': {'feels_like': 29.93,
          'humidity': 87,
          'pressure': 1012,
          'temp': 26.81,
          'temp_max': 26.97,
          'temp_min': 24.05},
 'name': 'Kaohsiung City',
 'sys': {'country': 'TW',
         'id': 2002588,
         'sunrise': 1715030550,
         'sunset': 1715077684,
         'type': 2},
 'timezone': 28800,
 'visibility': 5000,
 'weather': [{'description': '薄霧', 'icon': '50n', 'id': 701, 'main': 'Mist'}],
 'wind': {'deg': 20, 'speed': 3.09}}

關於 pprint 模組用法參考 :


我們主要關心的氣象資訊為 weather 鍵中的 discription (天氣描述) 與 main 鍵的溫度, 氣壓, 與濕度資訊, weather 鍵的值是一個單一元素的串列, 可用 data['weather'][0] 取得此串列, 再用 description 鍵取得天氣描述. main 鍵的值是一個字典, 可用 data['main']['temp'] 取得目前溫度 : 

>>> data['weather'][0]['description']   
'薄霧'
>>> data['main']['temp']  
26.81
>>> data['main']['temp_min']  
24.05
>>> data['main']['temp_max']  
26.97
>>> data['main']['pressure']  
1012
>>> data['main']['humidity']  
87

另外回應資料中的 id 鍵 (此處為 1673820) 代表查詢城市 (此處為 Kaohsiung) 的地理位置代號 ( geocode), 這個在查詢未來五天氣象預報時會用到 : 


三. 取得未來 5 天氣象預報資料 :   

透過上面查詢即時氣象資料得到的 geocode 搭配 API Key 可用來查詢該地的未來一周氣象預報, 其 API 網址格式如下 : 

http://api.openweathermap.org/data/2.5/forecast?id={geocode}&appid={api_key}

參考 : 


例如 :

>>> import requests  
>>> import json
>>> from pprint import pprint  
>>> api_key='c1ca9d88d1ctony19664b3c021cedf7e'      # 範例金鑰
>>> url=f'http://api.openweathermap.org/data/2.5/forecast?id=1673820&appid={api_key}'     
>>> res=requests.get(url)   
>>> data=json.loads(res.text)   
>>> pprint(data)   
{'city': {'coord': {'lat': 22.6163, 'lon': 120.3133},
          'country': 'TW',
          'id': 1673820,
          'name': 'Kaohsiung City',
          'population': 0,
          'sunrise': 1715030550,
          'sunset': 1715077684,
          'timezone': 28800},
 'cnt': 40,
 'cod': '200',
 'list': [{'clouds': {'all': 60},
           'dt': 1715072400,
           'dt_txt': '2024-05-07 09:00:00',
           'main': {'feels_like': 306.74,
                    'grnd_level': 1010,
                    'humidity': 60,
                    'pressure': 1011,
                    'sea_level': 1011,
                    'temp': 303.54,
                    'temp_kf': 1.61,
                    'temp_max': 303.54,
                    'temp_min': 301.93},
           'pop': 0.24,
           'sys': {'pod': 'd'},
           'visibility': 10000,
           'weather': [{'description': 'broken clouds',
                        'icon': '04d',
                        'id': 803,
                        'main': 'Clouds'}],
           'wind': {'deg': 299, 'gust': 3.67, 'speed': 3.94}},
          {'clouds': {'all': 80},
           'dt': 1715083200,
           'dt_txt': '2024-05-07 12:00:00',
           'main': {'feels_like': 305.54,
                    'grnd_level': 1012,
                    'humidity': 65,
                    'pressure': 1012,
                    'sea_level': 1012,
                    'temp': 302.43,
                    'temp_kf': 0.95,
                    'temp_max': 302.43,
                    'temp_min': 301.48},
           'pop': 0.24,
           'sys': {'pod': 'n'},
           'visibility': 10000,
           'weather': [{'description': 'broken clouds',
                        'icon': '04n',
                        'id': 803,
                        'main': 'Clouds'}],
           'wind': {'deg': 270, 'gust': 2.21, 'speed': 1.55}},
          {'clouds': {'all': 100},
           'dt': 1715094000,
           'dt_txt': '2024-05-07 15:00:00',
           'main': {'feels_like': 303.46,
                    'grnd_level': 1012,
                    'humidity': 71,
                    'pressure': 1013,
                    'sea_level': 1013,
                    'temp': 300.9,
                    'temp_kf': 0,
                    'temp_max': 300.9,
                    'temp_min': 300.9},
           'pop': 0,
           'sys': {'pod': 'n'},
           'visibility': 10000,
           'weather': [{'description': 'overcast clouds',
                        'icon': '04n',
                        'id': 804,
                        'main': 'Clouds'}],
           'wind': {'deg': 220, 'gust': 2.4, 'speed': 2.16}},
         
          ... (略) ... 
          
          {'clouds': {'all': 28},
           'dt': 1715482800,
           'dt_txt': '2024-05-12 03:00:00',
           'main': {'feels_like': 304.34,
                    'grnd_level': 1011,
                    'humidity': 60,
                    'pressure': 1012,
                    'sea_level': 1012,
                    'temp': 302.24,
                    'temp_kf': 0,
                    'temp_max': 302.24,
                    'temp_min': 302.24},
           'pop': 0,
           'sys': {'pod': 'd'},
           'visibility': 10000,
           'weather': [{'description': 'scattered clouds',
                        'icon': '03d',
                        'id': 802,
                        'main': 'Clouds'}],
           'wind': {'deg': 279, 'gust': 2.77, 'speed': 3.11}},
          {'clouds': {'all': 53},
           'dt': 1715493600,
           'dt_txt': '2024-05-12 06:00:00',
           'main': {'feels_like': 305.66,
                    'grnd_level': 1008,
                    'humidity': 59,
                    'pressure': 1009,
                    'sea_level': 1009,
                    'temp': 303.08,
                    'temp_kf': 0,
                    'temp_max': 303.08,
                    'temp_min': 303.08},
           'pop': 0.01,
           'sys': {'pod': 'd'},
           'visibility': 10000,
           'weather': [{'description': 'broken clouds',
                        'icon': '04d',
                        'id': 803,
                        'main': 'Clouds'}],
           'wind': {'deg': 295, 'gust': 4.74, 'speed': 5.17}}],
 'message': 0}

可見此 API 會回應 5/7~5/12 共 5 天的氣象預報資料 (每 3 個小時更新一次), 注意, cod 鍵為 HTTP 回應狀態碼, '200' 表示 OK, 注意, 與上面即時氣象回應值為數字不同的是, 此處是字串 '200'. 

此處因輸出資料甚長而略去中間輸出, 完整之輸出參考 :


從上面的輸出可知傳回值是一個 JSON 資料, 透過 json 套件轉成字典後可以很方便地取得內容, 例如 city 鍵儲存所查詢之城市的相關資訊, 例如 geocode(id), 經緯度, 與日昇日落時間等 : 

>>> data['city']  
{'coord': {'lat': 22.6163, 'lon': 120.3133}, 'country': 'TW', 'id': 1673820, 'name': 'Kaohsiung City', 'population': 0, 'sunrise': 1715030550, 'sunset': 1715077684, 'timezone': 28800}

預報資料放在 list 鍵裡, 其值是一個字典串列, 串列元素有 40 個, 每一個間隔 3 小時, 一天有 8 筆資料, 五天就是 40 筆 : 

>>> len(data['list'])   
40

檢視第一筆與最後一筆資料 :

>>> data['list'][0]   
{'clouds': {'all': 60}, 'dt': 1715072400, 'dt_txt': '2024-05-07 09:00:00', 'main': {'feels_like': 306.74, 'grnd_level': 1010, 'humidity': 60, 'pressure': 1011, 'sea_level': 1011, 'temp': 303.54, 'temp_kf': 1.61, 'temp_max': 303.54, 'temp_min': 301.93}, 'pop': 0.24, 'sys': {'pod': 'd'}, 'visibility': 10000, 'weather': [{'description': 'broken clouds', 'icon': '04d', 'id': 803, 'main': 'Clouds'}], 'wind': {'deg': 299, 'gust': 3.67, 'speed': 3.94}}
>>> data['list'][39]   
{'clouds': {'all': 53}, 'dt': 1715493600, 'dt_txt': '2024-05-12 06:00:00', 'main': {'feels_like': 305.66, 'grnd_level': 1008, 'humidity': 59, 'pressure': 1009, 'sea_level': 1009, 'temp': 303.08, 'temp_kf': 0, 'temp_max': 303.08, 'temp_min': 303.08}, 'pop': 0.01, 'sys': {'pod': 'd'}, 'visibility': 10000, 'weather': [{'description': 'broken clouds', 'icon': '04d', 'id': 803, 'main': 'Clouds'}], 'wind': {'deg': 295, 'gust': 4.74, 'speed': 5.17}}

我們關心的氣象資料分別在 dt_txt, main, 與 weather 等鍵底下, 存取方式以第一筆為例 : 

>>> data['list'][0]['dt_txt']   
'2024-05-07 09:00:00'
>>> data['list'][0]['main']['humidity']   
60
>>> data['list'][0]['main']['pressure']   
1011
>>> data['list'][0]['main']['temp']   
303.54
>>> data['list'][0]['weather'][0]['description']  
'broken clouds'

可以迭代 list 鍵的 40 個元素取出每 3 小時的預測氣象數據 :

>>> for i in data['list']:   
 print(f"時間 : {i['dt_txt']}")   
 print(f"溫度 : {i['main']['temp']}")   
 print(f"濕度 : {i['main']['humidity']}")   
 print(f"壓力 : {i['main']['pressure']}")   
 print(f"描述 : {i['weather'][0]['description']}")   
 print('-------------------------')   

結果節錄如下 :

時間 : 2024-05-07 09:00:00
溫度 : 303.54
濕度 : 60
壓力 : 1011
描述 : broken clouds
-------------------------
時間 : 2024-05-07 12:00:00
溫度 : 302.43
濕度 : 65
壓力 : 1012
描述 : broken clouds
-------------------------
時間 : 2024-05-07 15:00:00
溫度 : 300.9
濕度 : 71
壓力 : 1013
描述 : overcast clouds

... (略) ...

時間 : 2024-05-12 00:00:00
溫度 : 300.68
濕度 : 65
壓力 : 1012
描述 : broken clouds
-------------------------
時間 : 2024-05-12 03:00:00
溫度 : 302.24
濕度 : 60
壓力 : 1012
描述 : scattered clouds
-------------------------
時間 : 2024-05-12 06:00:00
溫度 : 303.08
濕度 : 59
壓力 : 1009
描述 : broken clouds
-------------------------

注意, 氣象預報傳回的溫度為卡氏溫度 (絕對溫度, 單位 K), 需減掉 273.15 才是攝氏溫度. 


四. 氣象爬蟲函式 :   

將上面的程式碼改寫為函式如下 :

import requests  
import json

def get_weather_now(country, city, api_key):
    url=(f'https://api.openweathermap.org/data/2.5/weather?'
         f'q={city},{country}&units=metric&lang=zh_tw&appid={api_key}')
    try:
        res=requests.get(url)
        data=json.loads(res.text)
        if data['cod']==200:    # 注意是數值
            ret={'geocode': data['id'],
                 'description': data['weather'][0]['description'],
                 'temperature': data['main']['temp'],
                 'min_temperature': data['main']['temp_min'], 
                 'max_temperature': data['main']['temp_max'],
                 'presure': data['main']['pressure'],
                 'humidity': data['main']['humidity']}
            return ret
        else:
            return None
    except Exceptions as e:
        return None

def get_weather_forecast(geocode, api_key):
    url=(f'http://api.openweathermap.org/data/2.5/forecast?'
         f'id={geocode}&appid={api_key}')
    try:
        res=requests.get(url)
        data=json.loads(res.text)
        ret=[]
        if data['cod']=='200':     # 注意是字串
            for i in data['list']:
                d={'time': i['dt_txt'],
                   'temperature': round(i['main']['temp']-273.15),    # 卡式溫度轉攝氏
                   'humidity': i['main']['humidity'],
                   'pressure': i['main']['pressure'],
                   'description': i['weather'][0]['description']}
                ret.append(d)
            return ret
        else:
            return None
    except Exceptions as e:
        return None

if __name__ == '__main__':
    country='TW'
    city='Kaohsiung'
    api_key='c1ca9d88d1ctony19664b3c021cedf7e'   # 範例金鑰
    ret=get_weather_now(country, city, api_key)
    print(f'目前氣象: {ret}')
    geocode=ret['geocode']
    print(f'未來五天氣象:')
    ret=get_weather_forecast(geocode, api_key)
    for i in ret:   
       print(f"時間 : {i['time']}")   
       print(f"溫度 : {i['temperature']}")   
       print(f"濕度 : {i['humidity']}")   
       print(f"壓力 : {i['pressure']}")   
       print(f"描述 : {i['description']}")   
       print('-------------------------')   
    
此處已經將五天氣象預測中的卡氏溫度轉成攝氏, 結果如下 :

>>> %Run get_open_weather.py   
目前氣象: {'geocode': 1673820, 'description': '多雲', 'temperature': 27.12, 'min_temperature': 25.05, 'max_temperature': 27.24, 'presure': 1013, 'humidity': 80}
未來五天氣象:
時間 : 2024-05-08 15:00:00
溫度 : 27
濕度 : 81
壓力 : 1013
描述 : light rain
-------------------------
時間 : 2024-05-08 18:00:00
溫度 : 27
濕度 : 77
壓力 : 1013
描述 : light rain
-------------------------
時間 : 2024-05-08 21:00:00
溫度 : 26
濕度 : 72
壓力 : 1013
描述 : light rain
-------------------------

... (略) ...

-------------------------
時間 : 2024-05-13 09:00:00
溫度 : 28
濕度 : 64
壓力 : 1009
描述 : light rain
-------------------------
時間 : 2024-05-13 12:00:00
溫度 : 27
濕度 : 63
壓力 : 1012
描述 : light rain
-------------------------

因我目前尚無具體應用會用到氣象資料, 故先測到這樣就好了, 有需要再於此基礎上進一步開發. 

2024年5月5日 星期日

2024 年第 18 周記事

本周末本來要去夜宿台東, 然後第二天走南橫公路從甲仙回到西部, 但因台東會下雨, 為了行車安全就取消了, 等年底氣候穩定再去. 原本想在台東吃大餐提前慶祝母親節, 改成去楠梓藍田路家樂福旁的燒烤店吃烤肉, 都是肉跟海產, 其實我比較喜歡西式自助餐, 但海港訂不到 (據說上週爆出食安問題後反而更難訂位, 菁菁說這叫反向操作, 因為餐廳會要求更嚴格, 反而較安全, 哎呀我怎麼沒想到呢).

我最近又在思考退休後要幹甚麼活 (之前計畫當水電工, 覺得要爬上爬下, 對 65 歲以後的人而言似乎有點吃不消), 二哥我很適合當老師, 還有七年多乾脆來去念個資工/資管/電機博士去教書吧? 應該退休前就能畢業了, 但找了一下我三個母校 (高科大/中山/高師大), 看了今年簡章後火就熄下來了, 要唸一堆沒興趣的課, 真是自找麻煩哩. 算了, 還是當個專職存股投資人輕鬆. 

週六早上國泰業務陳小姐來寫二哥的新保單, 過年前本來要跟菁菁與姐姐一起寫, 但因他出生時寫的南山保單有限額 5 萬的實支實付會衝突到, 所以延至這次回家才寫. 寫完後 11 點出發去逛壽山動物園, 哇, 我上次好像是菁菁低年級戶外教學來過, 一晃十多年, 壽山動物園變漂亮了, 還有天空步道與小朋友戲水區哩!

從動物園回來後跟二哥去順發, 他要將帶去實驗室的 PC 追加兩條 16GB DDR4 金士頓記憶體, 跑 IC 模擬需要多一點 DRAM, 我順便幫我的 MSI 電競主機加買一對揚聲器與 Type B 線, 因為這兩天忙著將十幾年前買的 1TB 與 2TB 3.5 吋外接硬碟裡的資料備份到新買的 18TB 硬碟裡面, 但 Type B 線放久了劣化, 有時會傳書中斷. 

這兩週將穿牆王 WiFi 信號中繼器設定好, 發現在菜園與三樓頂都能順利收到 WiFi, 最近有空要開始來進行菜園與門口的智慧監視系統, 利用上回買的直流 ATS 模組來切換太陽能電池與市電轉直流, 需要埋一條三分管接舊豬舍的市電到菜園末端. 
 

如何修改 Windows 電腦裝置名稱

上週把換機殼的舊 ASUS 桌機載回鄉下後沒時間開機設定, 今天得空把聯想 PC 換下來, 將 ASUS 桌機換上後下載下列軟體安裝 :
  • Chrome
  • Picpick
  • Fixit PDF Reader
  • Bandizip 
  • UltraViewer
  • Python
  • Thonny
這就是我常用的基本軟體. 需要設定之處如下 :
  • Windows : 到 "系統/顯示器" 設定文字圖片適當的比例 (例如 125%), 有需要的話到 Chrome 瀏覽器設定預設之文字顯示比例. 另外若要用 UltrViewer 遠端連線, 需要到 "系統/電源與與休眠" 將睡眠選項改為永不. 
  • Picpick 要去程是選項中勾選隨 Windows 啟動, 以及區域選取快捷鍵改為 Shift+F1.
在設定 UltraViewer 時發現電腦名稱與高雄的 Swift5 一樣都是 Desktop 開頭, 為了區別想改成 ASUS-PC, 作法如下 : 

首先開啟 "設定/系統/關於" 按 "重新命名此電腦" 鈕 :




 於彈出視窗中輸入新的電腦名稱後按下一步 : 





重新開啟電腦後再次到 "系統/關於"  就可看到電腦名稱已經改成設定後之新名稱 : 



2024年5月3日 星期五

料理實驗 : 用 Bruno 壓力鍋燉蘿蔔豬軟骨肋排湯

昨天中午辦公室訂罐燒排骨便當, 我以為是紅燒排骨, 吃飯時打開才知是白蘿蔔燉豬肋軟骨, 湯底有些許中藥材, 看來是藥膳排骨, 今天下班就去全聯買了兩盒軟骨肋排與白蘿蔔來試做看看, 參考下列食譜 :


材料 :
  • 豬排骨 (子排或肋排, 帶軟骨那種, 全聯一盒 200g 約 80 元) 
  • 白蘿蔔 1條
  • 藥膳排骨藥包 1 包
  • 米酒 1 米杯
  • 醬油 適量 (約 3 米杯)
  • 糖 4 茶匙
  • 薑 2~3 片
作法 :
  1. 白蘿蔔削皮切塊備用.
  2. 豬肋軟骨洗淨後放入鹽水中浸泡半小時去腥, 然後冷水煮滾 3 分鐘去除血水, 沖洗乾淨後放入 Bruno 壓力鍋內, 再放入蘿蔔塊, 藥膳包, 糖, 米酒, 醬油, 薑片等, 加水至蓋過食材.
  3. 蓋上鍋蓋, 開關轉至密封. 開啟電源, 按 "燉煮" 鈕 (15 分鐘 P15) 即可. 

露天購買 Bruno 壓力鍋

春節前水某在露天買了一個 Bruno 壓力鍋讓我回鄉下時路過岳父的農舍帶給岳母使用, 年初三回娘家時岳母用它來滷豬腳讚不絕口, 說非常好用, 特別適合離巢期或小家庭人口不多情況快速烹調 (標示 4 人份). 前陣子看到更便宜的賣 1800, 想買一個放鄉下, 適合我假日回去煮我跟爸兩人份的燉飯. 今天上露天詢問是否已賣出, 賣家答覆或還在, 所以就下標買了 : 





賣家給的匯款帳號是郵局虛擬帳號, 剛好要去郵局捐款順便問看看能否臨櫃匯款, 結果不行, 必須在 ATM, 因沒帶卡只好回來用郵局 WebATM, 但是了好幾次, 總是顯示 "請重新安裝讀卡機" 而無法匯款, 我十幾年前開始網購就超討厭用郵局 WebATM, 介面十年來都沒改善, 非常難用, 只好再跑一趟郵局用實體 ATM 匯款. 

2024年5月2日 星期四

南橫之旅取消

過年後本來 3 月要去遊南橫, 但菁菁 3/4 月客戶都已排滿, 所以改為 5/4~5, 三月底訂了台東的會館, 姊姊與二哥也訂了高鐵票, 但最近氣候不穩定, 昨天氣象預報周末台東下雨, 早上打電話到公路局關山工務段 (089-811024) 詢問路況, 雖說台 20 線周末並無管制, 但建議雨天勿行駛南橫公路, 所以早上就把會館退了 (入住前一天均可線上取消並申請退費, 但會扣掉 400 元). 

參考 : 


摘要如下 : 
  • 行前先查詢氣象與公路狀況,有些路段車道較窄或有落石,請多加留意,小心駕駛
  • 臨時便道路段(0K~44K)沒有加油站與餐廳,上山前注意油箱 (全線只有 3 個加油站)
  • 公廁有限,垃圾請自行帶走
  • 各景點即使有停車場,停車格數量都不多,路邊停車請多留意,勿阻塞交通
  • 隧道中開頭燈,以便認清前車狀況,以及引起後方車輛的注意
  • 行前應詢問最新路況,高雄市梅山口至埡口可電洽甲仙工務段 (07-6751014),臺東縣埡口至向陽可電洽關山工務段 (089-811024)
  • 東西兩端開放時間為上午7時至14時進入,並於17時車輛須離開管制路段,17時後淨空該路段,每週二、週四不開放

氣象局一周天氣預報 :





查詢公路局路況快訊 : 





昨晚看到旅遊部落客建議南橫最佳旅遊月份是 11~4 月, 那就等冬天再去吧. 

國泰的業務員昨天來訪時我說周末要去南橫, 建議我取消, 回去後傳給我這篇 : 


早上上班遇到同事一起搭電梯, 她說她先生原本 4 月底要去登玉山也喊卡, 說今年台灣地殼不太穩定, 還是別往山區走. 

2024年5月1日 星期三

Python 學習筆記 : 網頁爬蟲實戰 (四) 台北市公開資料平台 API

在上一篇擷取證交所休市日期網頁的測試中, 我們先利用瀏覽器開發人員工具從 HTTP 的 XHR 訊息中找出透過 Ajax 傳遞 JSON 資料的 URL, 然後直接用 requests.get() 擷取此資料即可, 這種取得資料的方式雖然要費點手腳, 但也總比用 BeautifulSoup 從網頁中硬解資料方便, 可以說是一種不公開的 API. 

本篇測試要從另一種網站擷取資料, 它們提供公開的 API (JSON 網址), 因此使用者. 例如台北市公開資料平台就有非常多政府公開資料供民眾下載, 我們不需要像擷取證交所休市日期網頁那樣去分析 HTTP 的回應訊息來找到資料的 JSON 網址, 也毋需註冊取得 API Key 即可擷取或下載資料, 對爬蟲程式而言, 這是最方便的資料取得方式. 



台北市公開資料平台網址如下 :


首先在左上角的輸入框輸入 youbike 按搜尋鈕 : 




然後在結果列表中找尋 "Yoybike2.0臺北市公共自行車即時資訊" 這一列 :




按超連結進入 Youbike 資料頁 :




將網頁拉到最底下 :




在 "資料即描述" 這一列右邊就可看到 JSON 資料的網址, 先複製下來備用 : 


也可以按右下角的 "下載" 超連結將此 JSON 檔下載到本機, 用記事本開啟後會發現是壓得很緊實難以閱讀的純文字內容 :




可以全部複製後貼到線上 JSON 剖析器例如 jsonfomatter.org 去解析 :




按底下的 "Format JSON" 鈕就會在右邊顯示整齊的格式 :




當然最重要的是可以用 Python 擷取 JSON 資料進行分析 :


1. 擷取 JSON 資料轉成字典串列 :  
  
先用 requests 模組擷取 JSON 資料 (不需要申請 Key/Token) :

>>> import requests   
>>> url='https://tcgbusfs.blob.core.windows.net/dotapp/youbike/v2/youbike_immediate.json'   
>>> response=requests.get(url)    
>>> response    
<Response [200]>   
>>> type(response.text)   
<class 'str'>    

接下來用內建模組 json 讀取抓下來的 JSON 字串, 將其轉換成 Python 的字典串列 :

>>> import json   
>>> ubike=json.loads(response.text)    
>>> type(ubike)   
<class 'list'>   
>>> len(ubike)  
1413   

可見目前總共有 1413 個 Ubike 站點, 檢視其中第一個 : 

>>> ubike[0]   
{'sno': '500101001', 'sna': 'YouBike2.0_捷運科技大樓站', 'tot': 28, 'sbi': 12, 'sarea': '大安區', 'mday': '2024-04-30 23:32:20', 'lat': 25.02605, 'lng': 121.5436, 'ar': '復興南路二段235號前', 'sareaen': 'Daan Dist.', 'snaen': 'YouBike2.0_MRT Technology Bldg. Sta.', 'aren': 'No.235, Sec. 2, Fuxing S. Rd.', 'bemp': 16, 'act': '1', 'srcUpdateTime': '2024-04-30 23:32:23', 'updateTime': '2024-04-30 23:32:52', 'infoTime': '2024-04-30 23:32:20', 'infoDate': '2024-04-30'}

也可以用迴圈迭代串列元素擷取關心的欄位 :

>>> i=0 
>>> for site in ubike: 
    print(f'{site["sna"]}: {site["ar"]}')   
    i += 1  
    if i > 100:  
        break  

YouBike2.0_捷運科技大樓站: 復興南路二段235號前
YouBike2.0_復興南路二段273號前: 復興南路二段273號西側
YouBike2.0_國北教大實小東側門: 和平東路二段96巷7號
YouBike2.0_和平公園東側: 和平東路二段118巷33號
YouBike2.0_辛亥復興路口西北側: 復興南路二段368號
YouBike2.0_復興南路二段280號前: 復興南路二段280號
YouBike2.0_復興南路二段340巷口: 復興南路二段342號
YouBike2.0_新生南路三段52號前: 新生南路三段52號
YouBike2.0_新生南路三段66號前: 新生南路三段66號東側
YouBike2.0_新生南路三段82號前: 新生南路三段82號
YouBike2.0_辛亥路一段30號前: 辛亥路一段30號
YouBike2.0_和平復興路口西北側: 復興南路二段236號
YouBike2.0_羅斯福路三段311號前: 羅斯福路三段311號
YouBike2.0_大安運動中心停車場: 敦南街76巷28號
... (略) ,,,

關於 json 模組用法參考 :



2. 利用 MD5 雜湊值判斷 JSON 資料有無更新 :  

我們可以利用內建模組 hashlib 計算 JSON 資料的雜湊值並與儲存在檔案中的舊雜湊值比較, 若相同表示 JSON 內容不變; 否則就是有更新, 

import requests
import hashlib
import os

def if_ubike_updated():
    old_hash=''   # 舊雜湊值初始值
    hash_file='ubike_hash.txt'    # 儲存雜湊值的檔案
    if os.path.exists(hash_file):    # 判斷雜湊值檔案有否存在, 有才讀取 (否則會錯誤)
        with open(hash_file, 'r') as f:
            old_hash=f.read()     # 讀取舊雜湊值
    url='https://tcgbusfs.blob.core.windows.net/dotapp/youbike/' +\
        'v2/youbike_immediate.json'
    response=requests.get(url)    # 取得 JSON 資料
    m=hashlib.md5()   # 建立 HASH 物件
    m.update(response.text.encode('utf-8'))   # 用取得之 JSON 資料更新 HASH 物件
    new_hash=m.hexdigest()   # 計算新雜湊值
    with open(hash_file, 'w') as f:
        f.write(new_hash)   # 將新雜湊值寫入檔案保存
    if new_hash == old_hash:   # 新舊雜湊值相等傳回 False 否則傳回 True
        return False
    else:
        return True 

if __name__ == '__main__':
    if if_ubike_updated():
        print('Youbike 即時資訊已更新')
    else:
        print('Youbike 即時資訊未更新')

執行結果如下 :

>>> %Run check_youbike_status.py   
Youbike 即時資訊已更新
>>> %Run check_youbike_status.py     
Youbike 即時資訊未更新 

可見第一次執行因尚無雜湊值檔案, old_hash 為空字串, 故與 new_hash 不相同傳回 True, 但馬上再呼叫 is_ubike_updated() 時雜湊值檔案已存在, 且其值在短時間未更新, 故 old_hash 與 new_hash 值相等而傳回 False. 

關於 hashlib 用法參考 :