2025年10月31日 星期五

render.com 學習筆記索引

前陣子發現 render.com 這個雲端主機, 它有提供免費方案可佈署 Python 網路服務, 它是基於遠端 Git 儲存庫 (GitHub, GitLab, BitBucket) 的佈署設計 (專案 App 需存放於 repo). 免費帳戶可建立任意多個 web app, 但有如下限制 :
  • 帳戶的總使用時數為 750 小時/月
  • 資料庫只能有一個是 active 狀態
  • App 若 15 分鐘內沒有任何請求就會進入休眠. 
我使用樹莓派做為 heartbeat 在 15 分鐘內週期性地向 App 提出一個 HTTP 請求以避免休眠, 實測下來覺得還算堪用 (雖然偶而還是會重啟), 主機持續運作的話每個月總使用時數為 30*24=720 小時, 所以 750 小時也夠用, 最重要的是 : 所建立的 App 具有 https 網址, 可做為 LINE Bot 後端伺服器測試之用. 

茲將測試紀錄整理成如下索引方便查找 :




~ 進行中 ~

好書 : 工頭堅的京都時光

此書是我 9/12~9/19 去日本關西旅遊前, 到捷運高鐵站的智慧圖書館借出來的, 作者工頭堅以歷史編年體為主軸, 撰寫一系列京都景點的導覽文章, 讓以前愛看大河劇的我在行前又腦補了逐漸淡忘的日本史, 對於遊歷京都這樣有豐富歷史印記的地方, 此書為不可多得的好書. 此番自由行乃倉促決定, 準備的時間並不充分, 我大部分的景點安排皆得益於此書, 例如龍安寺, 大覺寺, 與仁和寺讓我印象深刻, 書中都有介紹. 


Source : 博客來


因借期將至, 我把書中此次遊京都未能一訪的古蹟與寺院摘要整理如下 :
  • 相國寺 & 大聖寺 :
    此兩寺為室町幕府三代將軍足利義滿所建, 義滿於今日河原町, 烏丸今出川路口, 與同志社大學一帶興建名為 "花之御所" 的將軍府邸, 那裡原名室町, 此為室町幕府名稱由來, 而大聖寺即當時府邸內的寺院, 花之御所在後來的應仁之亂中被燒毀, 但大聖寺庭園樹下還留有 "花之御所" 石碑. 相國寺乃參考宋朝開封大相國寺所建, 占地廣闊, 但作者認為無大可觀之處. 
  • 本能寺跡 : 
    本能寺為織田信長殞命之處, 如今在寺町通一帶有座本能寺, 但那並非戰國時代的本能寺原址, 原來的本能寺位置在現今京都市役所附近, 有本能寺跡石碑, 從塩小路走過去約 10 幾分鐘. 如今寺町一帶的本能寺為後來豐臣秀吉整飭京都街道時, 隨其他寺院一同遷建於此. 
  • 建仁寺 :
    建仁寺為曾前往宋朝的榮西禪師 (日本茶道之祖) 於 13 世紀初在鎌倉二代將軍源賴家支持下所建, 是京都最早的禪寺 (日本最早的禪寺是福岡博多的聖福寺, 也是榮西禪師所創建), 位置在祇園與花街附近, 是臨濟禪, 東密, 與天臺宗兼學之道場. 其方丈室後方的潮音庭以迴游式池泉庭園聞名.
    戰國時代的外交僧與大名之一的安國寺惠瓊曾在此修行並成為住持, 之後在京都建立安國寺, 他出身毛利家, 也是豐臣家的重臣, 在關原之戰中支持西軍, 戰敗後被德川家康下令與石田三成, 小西行長等人於京都六條河原斬首示眾, 其首級就葬於建仁寺. 
  • 頂法寺 (六角堂) :
    乃聖德太子為了尋找興建大阪四天王寺所需木材來到京都時所建, 以其本堂為六角形而得名六角堂, 位於京都市中心鬧區 (近地鐵烏丸線與東西線交會之御池站). 聖德太子曾於此處沐浴並建立池坊, 後來成為住持居所, 日本花道池坊流即發源於此. 寺中有一塊肚臍石, 據說所在位置為京都之中心點. 
    頂法寺也是親鸞上人創建淨土真宗 (一向宗) 的發心之地, 親鸞年輕時曾是比叡山延曆寺僧侶, 後來對自力修行法門感到困惑, 於是來到頂法寺閉關, 於夢中見觀音菩薩以聖德太子像現身, 示以二十九條教誨, 並指示他前往京都東山拜法然上人為師學習淨土宗, 因此而創立淨土真宗, 所以頂法寺也被真宗信徒視為聖地.  
書中還有許多值得一探的名勝古蹟, 例如位於三十三間堂對面的法住寺, 此寺為日本第 77 代後白河天皇所建, 其陵墓亦位於寺內, 可惜因為屬於宮內廳管轄, 所以並不開放參觀. 後白河天皇雖然只當了四年天皇即傳位給孫子後鳥羽天皇, 但他退位為上皇後卻建立所謂院政掌權長達 30 年 (院政的始祖是他的曾祖父白河天皇), 以法住寺為根據地在背後發號施令, 運用他超高的權術繼續掌握政治權力, 透過挑撥以源平兩家為首的各方勢力互鬥來維持天皇家的權威, 他高超的政治手腕與狡猾難測的性格使他贏得日本第一天狗稱號, 在平安末期與鎌倉時代初期的紛亂中能掌握政治實權而不倒. 


2025-11-07 補充 :

此番日本之行除了這本書外還從母校借了這本 (帶去日本) :


2025年10月30日 星期四

Python 學習筆記 : 母校圖書館借書與預約爬蟲程式改版 v8

我在去年六月寫了一個母校圖書館爬蟲程式做為那年春季爬蟲練功項目之一, 主要功能是自動幫我做線上續借並用 LINE Notify 通報所借書籍有哪些被預約, 但那時其實只有第一個功能達成目標而已, 被預約書之資訊一直無法抓到, 參考 :


今年四月因應 LINE Notify 終止服務, 我將爬蟲程式改版為 v7, 但只是改用 Telegram 傳送訊息而已, 並未解決無法爬取被預約書問題, 參考 : 


今天花了很多時間與 ChatGPT 協作, 經過多次的來回修改程式碼, 終於能抓到被預約書資訊了. 由於母校圖書館網站使用 Angular 做為前端框架, 網頁內容都是動態載入的, 必須用 Selenium 模擬瀏覽器才能抓到渲染後的內容, 被預約書資訊因為層層包覆尤其難抓, 還好有 ChatGPT 相助, 這次終於搞定了.

這是未按 "全部續借" 前 : 




按 "全部續借" 後 : 




程式碼如下 :

# nkust_lib_8.py
from selenium import webdriver   
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
import time
import requests
from datetime import datetime
import asyncio
from telegram import Bot

async def telegram_send_text(text):
    bot=Bot(token=token)
    try:
        await bot.send_message(
            chat_id=chat_id,
            text=text
            )
        return True
    except Exception as e:
        print(f'Error sending text: {e}')
        return False

def get_nkust_lib():
    try:
        options=Options()
        options.add_argument("--headless")
        driverpath='/usr/lib/chromium-browser/chromedriver'
        service=Service(driverpath)
        browser=webdriver.Chrome(options=options, service=service)
        browser.implicitly_wait(60)
        browser.maximize_window()
        url='https://nkust.primo.exlibrisgroup.com/discovery/login?'  +\
            'vid=886NKUST_INST:86NKUST&lang=zh-tw'   
        browser.get(url)
        # 按其他讀者
        md_list=browser.find_elements(By.TAG_NAME, 'md-list-item')
        md_list[1].click()
        print('按其他讀者 ... OK')
        # 登入系統
        login_user_name=browser.find_element(By.ID, 'LoginUserName')   
        login_user_name.send_keys('我的帳號')  
        login_password=browser.find_element(By.ID, 'LoginPassword')   
        login_password.send_keys('我的密碼')
        login_btn=browser.find_element(By.CLASS_NAME, 'button-large')
        login_btn.click()
        print('登入系統 ... OK')
        # 按名字顯現選單
        user_btn=browser.find_element(By.CLASS_NAME, 'user-button')
        actions=ActionChains(browser)
        actions.move_to_element(user_btn)
        actions.click(user_btn)
        actions.perform()
        print('按名字顯現選單 ... OK')
        # 按我的借閱鈕
        xpath='/html/body/div[3]/md-menu-content/md-menu-item[3]/button'    
        my_borrow=browser.find_element(By.XPATH, xpath)   
        actions.move_to_element(my_borrow)
        actions.click(my_borrow)
        actions.perform()
        print('按我的借閱 ... OK')
        # 按全部續借
        xpath='/html/body/primo-explore/div/prm-account/md-content' +\
              '/div[2]/prm-account-overview/md-content/md-tabs/' +\
              'md-tabs-content-wrapper/md-tab-content[2]/div/' +\
              'div/prm-loans/div[1]/div[2]/div[2]/button'
        all_borrow=browser.find_element(By.XPATH, xpath)
        actions.move_to_element(all_borrow)
        actions.click(all_borrow)
        actions.perform()
        print('按全部續借 ... OK')
        # 檢查續借結果
        xpath='/html/body/primo-explore/div/prm-account/md-content' +\
              '/div[2]/prm-account-overview/md-content/md-tabs' +\
              '/md-tabs-content-wrapper/md-tab-content[2]/div/div' +\
              '/prm-loans/div[2]/prm-alert-bar/div/div/span'
        alert_span=browser.find_element(By.XPATH, xpath)
        if '所有借閱資料已成功續借' in alert_span.text:
            msg='❖ 所有借閱資料已成功續借'
        else:
            msg='❖ 只有部分借閱資料已成功續借'
            # 檢查是否有 "載入更多結果" 按鈕
            for i in range(3):  # 最多 3 頁
                load_more=browser.find_elements(By.CLASS_NAME, 'button-confirm')
                if not load_more:  # 已無 "載入更多結果" 按鈕 : 跳出迴圈
                    break
                load_more[0].click()  # 按 "載入更多結果" 按鈕
                time.sleep(2)  # 等待 2 秒
            # 抓取所有借閱書目
            loan_items=browser.find_elements(By.TAG_NAME, 'md-list-item')
            unrenew_books=[]  # 儲存被預約而不可續借之書名  
            for item in loan_items:
                try:
                    # 1. 抓書名
                    title_elem=item.find_element(By.CSS_SELECTOR, "h3 a")
                    title=title_elem.text.strip()
                    # 2. 取得整個 item 的文字
                    item_text=item.text.strip()
                    # 3. 判斷借閱狀態是否包含 "已續借"
                    if "已續借" not in item_text:
                        unrenew_books.append(title)
                    #print(f"[DEBUG] {title} -> {item_text}")
                except Exception as e:        
                    continue # 若抓不到書名就跳過
            if unrenew_books:
                msg="被預約的書:\n" + "\n".join([f"{i+1:>2}. {t}" for i, t in enumerate(unrenew_books)])
            else:
                msg="全部書籍皆已續借"
            print('搜尋被預約書籍 ... OK')
    except Exception as e:
        print(e)
    finally:
        browser.close()
        return msg

if __name__ == '__main__':
    start=time.time()
    msg=get_nkust_lib()
    token='Telegram 權杖'
    chat_id='Telegramd 聊天室ID'  
    if msg:
        now=datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        msg='\n' + now + '\n' + msg
        if asyncio.run(telegram_send_text(msg)):
            print('訊息傳送成功!')
        else:
            print('訊息傳送失敗!')
    print(msg)
    end=time.time()
    print(f'執行時間:{end-start}')

程式利用 Selenium 模擬操作瀏覽器, 在借閱書目第一頁按全部續借鈕後持續按底下的 "載入更多結果" 鈕持續疊加載入其他頁直到最後一頁 (我只能借 30 本所以最多三頁, 每頁 10 本), 這時被預約而無法再續借的書會列在最後面. 此程式將每個書目的文字內容抓出來, 取出書名, 然後判斷其餘內容是否含有 '已續借' 字樣, 若無即表示該書已被預約, 將其書名放入串列以便後續串接成字串, 測試結果如下 : 

pi@raspberrypi:~ $ python3 nkust_lib_8.py     
按其他讀者 ... OK
登入系統 ... OK
按名字顯現選單 ... OK
按我的借閱 ... OK
按全部續借 ... OK

2025-10-30 20:16:20
被預約的書:
 1. Python金融市場賺大錢聖經 : 寫出你的專屬指標 / 張峮瑋作
 2. 少年Py的大冒險 : 成為Python AI深度學習達人的第一門課 / 蔡炎龍等著
 3. 統計學 : 使用Python語言 / 林進益著
執行時間:362.01835203170776

檢查手機有收到 Telegram 訊息 : 




更改程式檔權限為可執行 :

pi@raspberrypi:~ $ chmod +x nkust_lib_8.py  
pi@raspberrypi:~ $ ls -ls nkust_lib_8.py   
8 -rwxr-xr-x 1 pi pi 5408 10月 30 20:10 nkust_lib_8.py   

修改高雄 Pi 3 的 crontab : 

將原先定時執行的 v7 版程式改為 v8 版即可 :

pi@raspberrypi:~ $ crontab -e   

0 6,16 * * * /usr/bin/python3 /home/pi/nkust_lib_8.py

這樣便完成改版工程啦! 收工. 

蝦皮購買樹莓派 Pi 400 套件組

我在 2022 年時曾在 Aliexpress 訂購 Pi 400 (單買主機價格約 2576 台幣), 但因為 DHL 將其寄送到錯誤的地方 (桃園), 被我取銷訂單退款, 之後才改買台灣製的 Mapleboard 來架站, 參考 :


今年中在蝦皮看到有人賣二手 Pi 400 全套組, 原先標價 2500, 我詢問過後竟然馬上調為 3000, 於是就打消購買意願了. 最近覺得我的兩台 Pi 3 系統似乎太舊了, 有時要安裝新版套件都碰壁, 跑得順順的又不想刷新版 OS, 於是回頭查看這賣家, 發現商品還沒賣出, 價格降至 2700, 覺得這樣還可以就下標買了 : 


而且今天用手機 App 結帳有滿額與免運優惠券, 共優惠 146 元, 實付 2619 元 (JCB 卡) : 






此套件組盒裝內容如下 :

•1× Raspberry Pi 400(US規格鍵盤)
•1× Raspberry Pi USB-C電源(US規格.台灣可)
•1× Raspberry Pi 滑鼠
•1× 1M HDMI 傳輸線
•1× 16GB micro-SD卡 (已預載作業程式)
•1× Raspberry Pi入門指南(英文)

此套件組配件齊全, 只要插上螢幕即可使用, 鍵盤是英文鍵盤, 我還有注音貼紙, 但我打算用英文就好, 因為上回在 Pi 3 上安裝注音後中英文切換似乎不是很順. 

原本有考慮買莓亞的 4GB Pi 5 :


即使買 4GB 版, 加上外殼 (約 500), 27W 電源 (約 550), micro-HDMI (約 100), 64GB micro SD 卡 (約 150), 鍵鼠組 (約 500), 總成本約需 4100 元, 若買 8GB 版則約 5000 元, 但效能是 Pi 400 的 2~3 倍. 不過 Pi 5 平均功耗約 7W 較 Pi 400 的 4.5W 略高, 且須裝散熱風扇, 較以鋁板被動冷卻的 Pi 400 噪音大. 

2025年10月29日 星期三

市圖還書 1 本 : 股市肥羊

今天到市圖還下面這本書 (有人預約) :  


此書是我偶然在鄉下的市圖分館的新書架上發現的, 由於書名很聳動, 所以就借回來瞧瞧, 但剛開始也是放著, 直到被人預約走, 自己再去預約, 才發現進書雖多, 但幾乎都有人借, 才發現原來還蠻熱門的, 於是有空就看, 斷斷續續終於看完了, 拿去還之前做個簡單札記. 


作者是一名醫師, 也是投資達人, 它的投資標的以金融股為主, 精確地說, 是前三大金控 : 富邦金, 國泰金, 與中信金. 他的論點是, 你的錢存在銀行拿那一點點利息, 還不如當銀行的股東拿配股配息, 前者是當客戶, 後者是當股東, 獲利是天差地遠. 金融股與 0050/0056 一樣適合長期投資, 因為國家比你更怕他們倒閉, 他們是特許行業, 所以各方面的稽核很嚴格. 這點我蠻認同的, 但 ETF 是靠一籃子股票來分散風險; 而金融股則是靠金管會的稽核來控制風險. 

其次, 作者認為當加權指數下跌 10% 以上時可以開槓桿融資去買金融股, 例如用房屋增貸或股票質押去借款, 這樣才能擴大獲利. 這點與價值投資者不舉債的原則相悖, 不過從其多年交易紀錄來看, 每次還款後的獲利都是借款利息的好幾倍, 似乎是很穩當的策略, 但要注意維持率就是了. 

陪病記

經過三周的門診與檢查程序, 爸的下肢靜脈曲張問題經林俊堯醫師安排於周日下午入住榮總心外 (W55) 病房先做 X 光, 周一做心電圖, 心臟超音波與麻醉訪視, 周二中午進行手術, 下午林醫師來病房巡視並穿上彈性襪表示可辦理出院, 回到鄉下時已晚上 8 點. 

這次單人病房與尊榮病房全滿, 只有 1900 元的兩人病房, 第一天隔壁床是要做心導管的阿伯, 夫妻倆約 70 多歲, 兩人一直在看陸劇, 手機聲音雖然拉很低, 但我都聽得到劇情, 陪病的太太似乎整晚都在看手機, 還好我入睡快, 也沒啥影響. 

阿伯第二天 7 點就進手術房開第一台, 聽說要 8 小時, 下午去做麻醉訪視時又遇到他太太, 說還在開刀中, 出來後直接去 ICU, 所以床位就清出來, 換成一位腹部主動脈瘤破裂, 約 60 多歲住在梓官的大哥, 他因肚子劇痛先送海總再轉榮總緊急手術, 在 ICU 十幾天後轉一般病房, 但一進病房就狂吐, 食慾不振, 聽他太太說才知道, 因為動脈瘤不夠大, 健保給付有限, 這番就醫所費超過 56 萬! 

這次爸住一般病房三天兩夜, 需要帶的生活用品不多, 我出發前有擬了一張清單, 但還是會掛一漏萬, 回來後把陪病物品清單補全如下 : 
  • 拖鞋
  • 牙刷+牙膏+漱口杯
  • 香皂
  • 酒精噴瓶
  • 衛生紙
  • 筷子+湯匙+保鮮盒+小瓶洗碗精
  • 水杯
  • 小棉被
  • 塑膠袋
  • 行動電源 + 充電線
 雖然醫院超商或周邊店鋪都可以買到, 但有時又不方便離開病房, 最好還是從家裡帶過去, 畢竟住院最重要的是醫療本身. 這次很感謝和藹的林醫師與 55 病房護理師的照顧, 順利完成爸靜脈曲張問題的處理.   

2025年10月27日 星期一

Google Gemini API 學習筆記 (二) : 有記憶的聊天機器人

以前曾對 Gemini 做過初步文字聊天測試, 但後來主要重心放在 OpenAI API 的串接測試, 所以就沒繼續做 Gemini 的測試, 參考 : 


前陣子在 render.com 上佈署的 serverless 平台撰寫了一個 LINE Bot 聊天機器人程式 linebot_gemini 來串接 Gemini, 參考 : 


但這兩個測試中的每個對話都是獨立無記憶的, 如果要讓聊天機器人記得前後上下文對話脈絡 (即保留前幾輪對話), 必須自行管理對話記憶 (模型本身無記憶). 


1. 使用串列管理對話記憶 : 

使用串列來對話紀錄是最直覺的方法,  首先匯入 google.generativeai 模型的 API 套件與 dotenv 套件來從環境變數檔 .env 讀取 Gemini 金鑰 : 

>>> import google.generativeai as genai   
>>> from dotenv import dotenv_values   

讀取 Gemini 金鑰 : 

>>> config=dotenv_values('.env')   
>>> api_key=config.get('GEMINI_API_KEY')   

然後自訂一個 GeminiChat 類別來串接 Gemini API : 

>>> class GeminiChat: 
    def __init__(self, api_key, model='gemini-2.5-flash', max_history=20):
        genai.configure(api_key=api_key)
        self.model=genai.GenerativeModel(model)
        self.max_history=max_history
        self.history=[]  # 用 list 儲存 (user, reply) 對話
    def ask(self, prompt):
        # 整理上下文(最近 N 輪對話)
        context=''  # 初始值
        for user_msg, ai_msg in self.history[-self.max_history:]:  # 拜訪記憶串列
            context += f'使用者:{user_msg}\n助理:{ai_msg}\n'  # 串接對話記憶為字串
        # 組成最終 prompt
        full_prompt=f'{context}使用者:{prompt}\n助理:'
        # 發送給 Gemini (同時驗證金鑰)
        response=self.model.generate_content(full_prompt)
        reply=response.text.strip()  # 去除左右空格
        # 將 (提示詞, 回應) 放入記憶串列
        self.history.append((prompt, reply))  
        # 限制記憶長度 : 刪除記憶串列中的舊對話, 只留最後 max_history 個對話
        if len(self.history) > self.max_history:
            self.history=self.history[-self.max_history:] 
        return reply

在此自訂類別的初始化函式 __init__() 中, 我們先呼叫 genai.configure() 來將傳入的 API Key 存進 SDK 的全域設定裡, 這樣之後所有透過 genai.GenerativeModel, genai.list_models(), model.generate_content() 等方法發出的 API 請求都會自動帶上這個 API Key (只有呼叫這些方法時才會驗證金鑰是否有效). 然後呼叫 genai.GenerativeModel() 建構式建立模型物件, 用來與指定 之 Gemini 模型互動. 最後定義 history 與 max_history 屬性來記錄與管控對話記憶, history 是一個串列, 用來儲存對話紀錄; max_history 則是一個整數, 用來設定記憶長度, 預設是 20 個對話. 

接著定義一個 ask() 方法來處理對話與管理記憶, 每次詢問都會從記憶串列重新組成上下文字串, 串接目前提問後組成完整之提問上下文, 然後呼叫 Gemini 模型物件的 generate_content() 方法生成回應, 測試如下 : 

>>> print(chat.ask('你好'))  
你好!有什么我能帮助你的吗?
>>> print(chat.ask('我叫 Tony'))    
很高兴认识你,Tony!有什么我能为你做的吗?
>>> print(chat.ask('我叫什麼名字?'))     
你叫 Tony。
>>> print(chat.ask('我有兩隻貓, 名叫萬萬與小咪'))  
好的,Tony。我記下了你有兩隻貓,名叫萬萬和小咪。還有什麼我能為你做的嗎?
>>> print(chat.ask('你是誰?'))  
我是一个大型语言模型,由 Google 训练。
>>> print(chat.ask('我有幾隻貓? 名叫甚麼?'))     
你有兩隻貓,名叫萬萬和小咪。

可見模型透過記憶串列中的脈絡得知上下文資訊, 故能回答正確答案. 

完整程式碼如下 : 

# gemini_chat_memory_1.py
import google.generativeai as genai
from dotenv import dotenv_values

class GeminiChat:
    def __init__(self, api_key, model='gemini-2.5-flash', max_history=20):
        genai.configure(api_key=api_key)
        self.model=genai.GenerativeModel(model)
        self.max_history=max_history
        self.history=[]  # 用 list 儲存 (user, reply) 對話
    def ask(self, prompt):
        # 整理上下文(最近 N 輪對話)
        context=''  # 初始值
        for user_msg, ai_msg in self.history[-self.max_history:]: 拜訪記憶串列
            context += f'使用者:{user_msg}\n助理:{ai_msg}\n'  # 串接對話記憶為字串
        # 組成最終 prompt
        full_prompt=f'{context}使用者:{prompt}\n助理:'
        # 發送給 Gemini (同時驗證金鑰)
        response=self.model.generate_content(full_prompt)
        reply=response.text.strip()  # 去除左右空格
        # 將 (提示詞, 回應) 放入記憶串列
        self.history.append((prompt, reply))  
        # 限制記憶長度:刪除記憶串列中的舊對話, 只留最後 max_history 個對話
        if len(self.history) > self.max_history:
            self.history=self.history[-self.max_history:]  
        return reply

if __name__ == '__main__':
    config=dotenv_values('.env')
    api_key=config.get('GEMINI_API_KEY')
    chat=GeminiChat(api_key, max_history=5)
    print(chat.ask('你好'))
    print(chat.ask('請記住我叫 Tony'))
    print(chat.ask('我叫什麼名字?'))


2. 使用 ChatSession 管理對話記憶 : 

除了使用串列自行管理對話記憶, Gemini 官方文件建議使用 genai.GenerativeModel 類別實例的 start_chat() 方法建立一個有上下文的 ChatSession 對話物件來達成同樣目的, 程式碼如下 :

# gemini_chat_memory_2.py
import google.generativeai as genai
from dotenv import dotenv_values

class GeminiChat:
    def __init__(self, api_key, model='gemini-2.5-flash', max_history=20):
        genai.configure(api_key=api_key)
        self.model=genai.GenerativeModel(model)
        self.max_history=max_history
        self.chat=self.model.start_chat(history=[])  # 初始化空歷史紀錄
    def ask(self, prompt):
        # 若超過 max_history 則移除最舊的訊息
        if len(self.chat.history) > self.max_history * 2:
            # 限制歷史對話長度 :每輪對話有 user+model 各一筆故要乘以 2
            self.chat.history=self.chat.history[-self.max_history * 2:]
        response=self.chat.send_message(prompt)
        return response.text

if __name__ == "__main__":
    config=dotenv_values('.env')
    api_key=config.get('GEMINI_API_KEY')
    chat=GeminiChat(api_key, max_history=20)
    print(chat.ask('你好'))
    print(chat.ask('請記住我叫 Tony'))
    print(chat.ask('我叫什麼名字?'))

首先在 GeminiChat 類別初始化時, 呼叫 GenerativeModel 物件的 start_session() 方法並傳入一個 history 參數 (預設空串列) 來建立一個 ChatSession 對話物件, 所以此物件實際上也是使用串列來記錄對話歷史, 然後將此 ChatSession 儲存在 GeminiChat 物件的 chat 屬性裡, 這樣便能自動記錄對話歷史, 在 ask() 方法中只要管理對話歷史的長度即可, 毋須像上例那樣手動串接上下文. 測試如下 : 

>>> %Run gemini_chat_memory_2.py
你好!有什么可以帮助你的吗?
好的,我記住了。你叫 Tony。

很高興認識你!
你叫 Tony。  

可見效果與上例相同, 但程式碼更簡潔. 


3. 加入系統提示 system_instruction : 

從上面範例可知, 即使 prompt 是繁體中文, Gemini 的回應幾乎都是用殘體中文回應, 如果要強制它用正體中文回應, 可在呼叫 GenerativeModel() 建構式時傳入系統提示詞參數 system_instruction 解決 :

# gemini_chat_memory_3.py
import google.generativeai as genai
from dotenv import dotenv_values

class GeminiChat:
    def __init__(self, api_key, model='gemini-2.5-flash', max_history=20,
                 system_instruction=None):
        genai.configure(api_key=api_key)
        # 在建立模型時傳入 system_instruction
        self.model=genai.GenerativeModel(
            model,
            system_instruction=system_instruction
            )
        self.max_history=max_history
        self.chat=self.model.start_chat(history=[])
    def ask(self, prompt):
        # 限制歷史紀錄長度(每輪 user+model 各一筆)
        if len(self.chat.history) > self.max_history * 2:
            self.chat.history=self.chat.history[-self.max_history * 2:]
        response=self.chat.send_message(prompt)
        return response.text

if __name__ == '__main__':
    config=dotenv_values('.env')
    api_key=config.get('GEMINI_API_KEY')
    # 加上系統提示
    system_instruction='你是一個繁體中文AI助理,請以台灣人的習慣用語回答。'
    chat=GeminiChat(api_key, max_history=20, system_instruction=system_instruction)
    print(chat.ask('你好'))
    print(chat.ask('請記住我叫 Tony'))
    print(chat.ask('我叫什麼名字?'))

測試結果確實能生成繁體中文回應 : 

>>> %Run gemini_chat_memory_3.py   
哈囉,你好!有什麼需要我幫忙的嗎?
好的,Tony 我記住了!之後就叫你 Tony 囉。

有什麼需要我幫忙的嗎?
你叫 Tony 呀!

2025年10月26日 星期日

2025 年第 43 周記事

本周是光輝十月的最後一個連假, 周五光復節放假, 本周只上四天班, 上班族笑哈哈, 當老闆的則是苦哈哈, 十月份的假老實說放得也太過分了, 但勞工福利沒人會說不好. 

最近大部分時間都花在圖書館爬蟲的改版上, 為此我又為 serverless 平台添加了資料庫管理功能, 把爬取結果儲存在雲端, 以利後續 LINE Bot 測試之用. 打通這關之後, 我的 LINE 就能恢復之前 LINE Notify 時的被動收訊功能 (雖然每個月只有 200 則限額). 

很久沒上公文系統 (真的很久), 雖然幾乎都與我無關, 但還是從上周起開始清理, 到周三終於把積累的公文全清空, 過程中看到一些認識的人 (例如學長與同學) 的升遷公文, 對於這年紀的我而言早已波瀾不興, 但當時若聽前上司的勸留在台北, 應該差不多也到主任甚至處長級了 ... , 不過, 想到當台北人年節返鄉之苦, 職場頭銜對我並無誘因, 況且我的心性也不適合管理職, 我志在練武功而已, 並不想當掌門. 

鄉下老家的貓咪家族目前已膨脹到 9 口之家 (小灰, 大貓公, 毛小妹與它的兩個弟妹, 以及毛小妹生的四隻小貓), 小灰升格為外婆之後白天較少在家, 只有早晚回來吃飯才現身. 現在一家之主似乎換成毛小妹了. 它的四隻小貓約 1.5 個月, 超級可愛, 看它們在車庫玩鬧超療癒.




本周已完成前門路邊的小米 cw300 安裝, 下周工作項目 :
  • 廚房太陽能燈
  • 二樓祖堂定時控制照明燈
  • 路旁屋側定時控制照明燈
  • 大門口儲水桶安裝
  • 雨水收集桶分接管
無法全部完成就往後延. 

今天下午一點載爸從鄉下出發, 兩點多完成住院手續, 下午先做 X 光, 明日要做心臟超音波與麻醉評估, 周二手術周三出院, 比預計多一天. 我昨日下午去阿運伯母家, 請英仔這幾天每日抽空去我家餵貓. 

2025年10月25日 星期六

Koee 全球定位器安裝測試

上周在 momo 買了 koee 定位器 :


光復節連假剛好帶回鄉下測試, 盒裝內除定位器外, 有產踭保固卡與 OmyTag2 使用說明書一張, CR2032 鈕扣電池兩顆 (一顆出廠已安裝於定位器內) : 




首先掃描條碼下載 myTag App :





安裝好後開啟 myTag App, 打開手機藍芽, 按 App 左下角的 + 鈕新增設備, 如果有兩個以上的定位器, 最好一個一個來, 先將定位器的絕緣塑膠片拔掉, 這時會聽到嗶一聲表示定位器已通電開機, 這時 myTag App 會透過藍芽找到定位器 : 





 然後輸入設備用途, 例如 "紅色 Cue 100", 以及手機號碼或 Email, 按左下角三條線, 點選分享此設備取得分享網址 (內含 8 個字元的分享碼), 傳送給分享對象. 分享網址格式為 :

http://share.mytagcc.com/sharetag?code=分享碼

受分享對象也要安裝 myTag, 打開分享連結輸入分享碼即可在地圖上看到定位器的位置. 我的紅色 Cue 100 輸入的是爸的手機號碼; 藍色 Cue 100 輸入的是我的手機號碼. 



Python 學習筆記 : f 字串跳行串接的方法

Python 自 3.6 版開始支援 f 字串, 可以在字串中直接嵌入變數與運算式, 比原本的 format() 方法要直覺簡潔, 但是如果 f 字串很長須換行時要如何串接? 最簡單的方法就是用 + 串接, 但須在最後面加上換行串接符號 \, 例如 :

>>> name='Tony'   
>>> age=18  
>>> text=f'我名叫 {name}' +\
     f'今年 {age} 歲'
>>> text  
'我名叫 Tony今年 18 歲'

注意, 串接符號 \ 後面必須直接 enter 跳行, 不可有空白字元. 

第二個方法更簡單, 直接將要串接的 f 字串用小括符包起來, 不用 + 串接 (用也無妨, 但多此一舉), 更不用加上換行串接符號 \, 例如 :

>>> text=(f'我名叫 {name}'
     f'今年 {age} 歲')
>>> text  
'我名叫 Tony今年 18 歲'

事實上, 小括號在 Python 語法中有三大主要用途 : 
  1. 用來表示 Tuple (元組) :
    元組是以逗號隔開的序列, 例如 x = (1, 2, 3)
  2. 敘述的分組 (Expression grouping) :
    用來改變運算順序 (括號內優先運算), 例如 x = (a + b) * c
  3. 字串或程式多行接續 (Multi-line literal continuation) : 
    讓多行字串或程式碼自動連接起來, 例如 text=(f"你好 " f"{name}")
注意, tuple 與 grouping 的差別關鍵在於有沒有逗號, 序列有逗號隔開才是 tuple, 例如 :

a=(1)      # 這不是 tuple 而是單一元素的分組, 等同於 a=1
a=(1,)     # 這是含一個元素的 tuple

小括號的字串多行接續語法是 Python 的編譯期自動拼接 (compile-time concatenation), 不限於 f 字串, 任何 Python 字串 (一般字串, f-string, r-string, b-string) 都可以用, 只要是字面字串 (literal string), 中間沒有 + 或 ,運算子都會自動串接.

注意喔, () 內的字串不須用 + 串接, 雖然用也不會錯, 結果也一樣, 但是兩者機制不同, 效能也有差別, 有用 + 會在執行時期做字串加法, 有運算開銷效能略低, 例如 :

>>> text=(f"你好 " + f"{name}")     # 須在執行期做字串加法運算, 效能較低
>>> text   
'你好 Tony'

沒有用 + 是在編譯時期自動拼接, 效率較高 :

>>> text=(f"你好 " f"{name}")    # 編譯期拼接速度快
>>> text  
'你好 Tony'

所以用 () 串接字串時別畫蛇添足使用 + 串接. 

2025年10月24日 星期五

安裝前門路旁 CW300 攝影機

八月時曾到裕誠路小米店買了一組 CW300 攝影機, 打算安裝在鄉下老家前門靠路邊的牆上原 CRV 攝影機下方, 但帶回鄉下後一直沒時間用. 

上周去小漢買了一條約 20 尺的電線與插座插頭, 趁今日光復節連假首日, 把電鑽鐵鎚尖嘴鉗等工具備妥, 先在窗戶塑膠接合處鑽一個洞, 把房間的電引出來, 沿著牆面鋪線, 然後在牆上鑽兩個洞, 將基座鎖緊後安裝攝影鏡頭, 最後送電在手機設定米家 App, 順利添加了此新裝置 : 

 



前門路燈晚上很亮, 攝影效果不錯. 

2025年10月23日 星期四

蝦皮購買三輪自行車

爸平時的運動是在曬穀場騎自行車, 最近小舅來菜園工作時提到他鄰居老人家騎自行車跌倒傷得很嚴重, 建議買三輪自行車給爸騎以策安全, 小舅推薦蝦皮這家位於苓雅區的賣家, 徵求小狐狸們意見後選黑色款 :








刷 LINE pay JCB 卡支付, 免運費送至鄉下老家. 

露天購買自行車手機架

時序已入秋, 正是適合旅行時節, 今天上露天買了一個手機支架, 準備裝在我的單車上, 秋冬單車分段環島之旅時導航用 :





小七取貨免運 465 元 : 

下面這款可選把立款鎖龍頭不占車把空間 :


下面這款 momo 也是半截不遮鏡頭 :

2025-11-22 補充 :

此商家寄來的東西與商品頁不符, 向他們反映後說會跟倉庫與物流那邊查詢, 然後撐過一個月露天交易就結束了. 切勿與此賣家 juderuby01 買東西. 在露天買東西風險極高, 最好去 momo 買, 交貨快又有保障. 

2025年10月21日 星期二

露天購買小米監視器 C200x2

因要更換鄉下老家的舊監視器, 上露天購買兩組 : 





小七取貨免運共 1076 元. 


2025-11-22 補充 :

此商家寄來的東西與商品頁不符, 向他們反映後說會跟倉庫與物流那邊查詢, 然後撐過一個月露天交易就結束了. 切勿與此賣家 chestherguadian 買東西. 在露天買東西風險極高, 最好去 momo 買, 交貨快又有保障. 

明台機車任險續保 (BKC & PPY)

這兩台機車今日任意險到期, 上明台產險續保一年 : 
  • BKC (水某使用)
  • PPY (鄉下 Cue 100 紅色) 
都登記在我名下直接網路投保無須傳真要保書, 兩台保額與保費都一樣 :



Python 學習筆記 : 用 UTC + 8 取得台灣時間的方法

最近在進行市圖爬蟲程式改版時發現一個紀錄本地日期時間的問題, 新版爬蟲程式抓到資料後會用 POST 方法呼叫我的 serverless 平台 API 將借書與預約資訊存入資料庫, 資料表中有一個欄位 updated_at 用來記錄更新時間, 最初是用 datetime.datetime.now() 取得時間再呼叫 strftime('%Y-%m-%d %H:%M:%S') 指定以 YYYY-mm-dd HH:MM:SS 格式字串儲存 :

from datetime import datetime  

now_str=datetiome.now().strftime('%Y-%m-%d %H:%M:%S')   

問題是當程式佈署在 render.com 時 now() 取得的是 UTC 時間, 雖然主機位於新加坡, 但可能其主機的時區設為 UTC 之故. 而我的 Maplebord 已經將時區設為台灣時區, 所以 now() 會傳回台灣時間, 同樣的程式佈署在不同主機上就會記錄不同的時間, 最簡單的解決辦法就是不要呼叫 now(), 而是呼叫 utcnow(), 它會傳回 UTC 時間, 只要加上 8 小時即為台灣時間, 這需要匯入 timedelta 函式來協助加上 8 小時 :

from datetime import datetime, timedelta  

utc_now=datetime.utcnow()   # UTC 現在時間
taiwan_now=utc_now + timedelta(hours=8)   # 加 8 小時得到台灣時間
now_str=taiwan_now.strftime('%Y-%m-%d %H:%M:%S')   # 指定日期時間格式

這樣程式不論佈署在哪個主機, 都會得到例如 2025-10-21 10:09:41 的台灣時區日期時間字串了. 

momo 購買定位器 + 64GB TF 卡 x 3

為了安裝監視器之用上 momo 買 64GB microSD 卡與兩個定位器 :





使用 momo 幣 27 元, 實付 1392 元. 

定位器打算放鄉下的兩台機車, 預防爸再次出現迷航現象時追蹤用. 台灣買對 Miday 也有一款定位器, 但價格較貴, 約台幣 743 元 :


2025年10月20日 星期一

Python 學習筆記 : 市圖借書與預約爬蟲程式改版 v11 (上)

前一篇測試中已修改爬蟲程式順利抓到典藏地資訊, 改成一次只抓一個借書證帳戶也讓 Selenium 爬蟲的處理程序較為單純, 讓網頁擷取的成功率上升. 本篇旨在紀錄如何將抓取到的資訊儲存在 Mapleboard 或 render.com 的 serverless 平台的 ksml_books 資料表裡, 以便後續讓 LINE Bot 程式隨時能讀取到最新借書資訊. 作為前置作業, 這個周末我先將 serverless 平台生版為 v5, 加入資料表線上管理功能, 參考 :


本系列之前的文章參考 : 



1. 建立 ksml_books 資料表 : 

儲存市圖爬蟲結果的資料表取名為 ksml_books, 具有四個欄位 : 
  • account : 借書證帳號 (主鍵)
  • borrow_books : 借書資訊摘要
  • reserve_books : 預約資訊摘要
  • updated_at : 最近更新時間
在 serverless 平台的輸入下列 Schema 建立此資料表 :

account PRIMARY KEY, borrow_books TEXT, reserve_books TEXT, updated_at TEXT





2. 撰寫資料表更新程式 update_ksml_books.py : 

此程式接收來自爬蟲程式的的 POST 請求, 將爬取結果 (借書與預約資訊) 存入 ksml_books 資料表, 程式碼如下 :

# update_ksml_books.py
import sqlite3
from datetime import datetime

def main(request, **kwargs):
    """
    POST 請求範例:
    {
        "account": "tony",
        "borrow_books": "書名1 (到期日 2025-10-22); 書名2 ...",
        "reserve_books": "書名A (已到館); 書名B (順位 2) ..."
    }
    """
    DB_PATH='./serverless.db'
    try:  # 從 POST 請求 body 中解析 JSON 格式資料並轉成 Python 字典
        data=request.get_json(force=True)
    except Exception as e:
        return {"status": "error", "message": f"解析 JSON 失敗: {str(e)}"}
    # 從字典中取得參數值
    account=data.get('account')
    borrow_books=data.get('borrow_books', '')
    reserve_books=data.get('reserve_books', '')
    # 檢查主鍵 account 
    if not account:
        return {"status": "error", "message": "缺少帳號資訊"}
    try:  # 更新 ksml_books 資料表
        conn=sqlite3.connect(DB_PATH)
        cur=conn.cursor()
        # 建立 ksml_books 資料表 (若不存在)
        cur.execute("""
            CREATE TABLE IF NOT EXISTS ksml_books (
                account TEXT PRIMARY KEY,
                borrow_books TEXT,
                reserve_books TEXT,
                updated_at TEXT
            )
        """)
        # 取得現在時間
        now_str=datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        # 使用 INSERT OR REPLACE 寫入紀錄,如果帳號已存在就更新
        cur.execute("""
            INSERT OR REPLACE INTO ksml_books (account, borrow_books, reserve_books, updated_at)
            VALUES (?, ?, ?, ?)
        """, (account, borrow_books, reserve_books, now_str))
        conn.commit()
        conn.close()
        return {"status": "success", "message": f"{account} 的資料已更新"}
    except Exception as e:
        return {"status": "error", "message": str(e)}

此程式首先用 request.get_json() 從 POST 本體中將 JSON 字串解析為字典, 然後字典物件的呼叫 get() 方法取得 borrow_books (借閱資訊) 與 reserve_books (預約資訊) 後以 account 為主鍵寫入 ksml_books 表裡面. 在 render.com 的 serverless 平台新增此程式 :




在執行連結上按滑鼠右鍵即可複製此 API 端點網址 ~/function/update_ksml_books.

接下來在本機撰寫一個測試程式來呼叫此 API, 看看能否成功地將資料寫入資料表裡, 只要用 requests.post() 攜帶一個 JSON payload 呼叫 ~/function/update_ksml_books 即可, 程式碼如下 :

# ksml_test.py
import requests

url="https://serverless-fdof.onrender.com/function/update_ksml_books"
payload={
    "account": "tony",
    "borrow_books": "京都時光 (到期日 2025-10-22)",   
    "reserve_books": "LINE Bot 全攻略 (順位 1)"
    }
res=requests.post(url, json=payload)
print(res.json())

執行此程式成功地將資料寫入資料表 :

>>> %Run ksml_test.py   
{'message': 'tony 的資料已更新', 'status': 'success'}

這時檢視資料表列表, 會發現 ksml_books 資料表已有一筆紀錄 :




按檢視即可看到全部記錄 :




3. 修改爬蟲程式 : 

將上面的測試程式寫進前一篇的市圖爬蟲程式 (v10) 裡改為 v11 版, 把爬取到的借書與預約資訊寫入 ksml_books 資料表 :

# ksml_personal_11.py
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.options import Options
import re
from datetime import datetime
import time
import requests
import asyncio
from telegram import Bot
import sys

async def telegram_send_text(text):
    bot=Bot(token=token)
    try:
        await bot.send_message(
            chat_id=chat_id,
            text=text
            )
        return True
    except Exception as e:
        print(f'Error sending text: {e}')
        return False

def get_books(account, password):
    try:
        # 登入我的書房
        options=Options()
        options.add_argument("--headless")
        driverpath='geckodriver.exe'
        browser=webdriver.Firefox(options=options)
        browser.implicitly_wait(60)
        browser.get('https://webpacx.ksml.edu.tw/personal/')
        loginid=browser.find_element(By.ID, 'logxinid')
        loginid.send_keys(account)
        pincode=browser.find_element(By.ID, 'pincode')
        pincode.send_keys(password)
        div_btn_grp=browser.find_element(By.CLASS_NAME, 'btn_grp')
        login_btn=div_btn_grp.find_element(By.TAG_NAME, 'input')
        login_btn.click()
        # 擷取借閱紀錄
        div_redblock=browser.find_element(By.CLASS_NAME, 'redblock')
        div_redblock.click()
        books=browser.find_elements(By.CLASS_NAME, 'bookdata')
        borrow_books=[]
        for book in books:
            item=dict()
            book_name=book.find_element(By.XPATH, './h2/a').text    
            item['book_name']=book_name.replace('/', '').strip()
            book_site=book.find_element(By.XPATH, './ul[3]/li[1]').text
            reg=r'典藏地:(\S+)'
            item['book_site']=re.findall(reg, book_site)[0]
            reg=r'\d{4}-\d{2}-\d{2}'
            due_date=book.find_element(By.XPATH, './ul[4]/li[2]').text
            item['due_date']=re.findall(reg, due_date)[0] 
            due_times=book.find_element(By.XPATH, './ul[5]/li[1]').text
            item['due_times']=re.findall(r'\d{1}', due_times)[0]
            try: 
                state=book.find_element(By.XPATH, './ul[6]/li[1]').text
            except:
                state=''
            finally:
                if '有人預約' in state:
                    item['state']=', 有人預約'
                else:
                    item['state']=''
            borrow_books.append(item)
        browser.back() # 回上一頁
        # 擷取預約紀錄
        div_blueblock=browser.find_element(By.CLASS_NAME, 'blueblock')
        div_blueblock.click()
        books=browser.find_elements(By.CLASS_NAME, 'bookdata')
        reserve_books=[]
        for book in books:
            item=dict()
            book_name=book.find_element(By.XPATH, './h2/a').text    
            item['book_name']=book_name.replace('/', '').strip()
            sequence=book.find_element(By.XPATH, './ul[7]/li[1]').text
            if '預約待取' in sequence:  # 已到館
                item['ready_for_pickup']=True
                reg=r'\d{4}-\d{2}-\d{2}'
                item['expiration']=re.findall(reg, sequence)[0]
                item['sequence']='0'
            else: # 預約中
                item['ready_for_pickup']=False
                item['expiration']=''
                item['sequence']=re.findall(r'\d+', sequence)[0]
            reserve_books.append(item)
        browser.close()
        return (borrow_books, reserve_books)        
    except Exception as e:
        print(e)
        return None, None
    
if __name__ == '__main__':
    start=time.time()
    token='Telegram 權杖'
    chat_id='Telegram 聊天室 ID'
    if len(sys.argv) != 3:
        print('用法: python3 ksml_personal_10.py 帳號 密碼')
        sys.exit(1)
    # 取得傳入的帳密參數
    account=sys.argv[1]
    password=sys.argv[2]
    # 呼叫 get_books() 取得借書與預約書        
    borrow_books, reserve_books=get_books(account, password)
    b_msg=''  # 借書資訊字串初始值
    r_msg=''  # 預約資訊字串初始值
    # 處理借書 
    if borrow_books: 
        borrow=[]
        for book in borrow_books:
            book_name=book['book_name']
            book_site=book['book_site'] 
            due_times=book['due_times']
            due_date=book['due_date']
            state=book['state']
            due_date=datetime.strptime(due_date, '%Y-%m-%d') # 到期日   
            today_str=datetime.today().strftime('%Y-%m-%d')   
            today=datetime.strptime(today_str, "%Y-%m-%d")   
            delta=(due_date-today).days  # 計算離到期日還有幾天
            if delta < 0:  # 負數=已逾期
                msg=f'🅧 {book_name} (逾期 {abs(delta)} 天{state}, {book_site})'
                borrow.append(msg)
            elif delta == 0:  # 0=今天到期
                msg=f'⓿ {book_name} (今日到期, 續借次數 {due_times}{state}, {book_site})'
                borrow.append(msg)
            elif delta == 1:  # 1=明天到期 
                msg=f'❶ {book_name} (明日到期, 續借次數 {due_times}{state}, {book_site})'
                borrow.append(msg)
            elif delta == 2:  # 2=後天到期 
                msg=f'❷ {book_name} (後天到期, 續借次數 {due_times}{state}, {book_site})'
                borrow.append(msg)
            elif 2 < delta < 8:  # 3 天以上一周內到期
                msg=f'✦ {book_name} ({book["due_date"]} 到期, '\
                    f'續借次數 {due_times}{state}, {book_site})'
                borrow.append(msg)
        # 傳送借閱書到期摘要至 Telegram 
        if len(borrow) != 0:
            borrow.insert(0, f'\n❖ {account} 的借閱 :')
            b_msg='\n'.join(borrow)  # 更新借書資訊字串
            if asyncio.run(telegram_send_text(b_msg)):
                print('訊息傳送成功!')
            else:
                print('訊息傳送失敗!')
    # 處理預約書
    if reserve_books:
        reserve=[]
        i=0
        j=['①', '②', '③', '④', '⑤']
        k=['❶', '❷', '❸', '❹', '❺']
        # 預約狀態
        for book in reserve_books:
            book_name=book['book_name']
            sequence=book['sequence']
            ready_for_pickup=book['ready_for_pickup'] # 已到館
            expiration=book['expiration']  # 取書截止日
            if ready_for_pickup:
                msg=f'{k[i]} {book_name} (已到館, 保留期限 {expiration})'
            else:
                msg=f'{j[i]} {book_name} (順位 {sequence})'
            reserve.append(msg)
            i += 1
        # 傳送預約書狀態摘要至 Telegram    
        if len(reserve) != 0:
            reserve.insert(0, f'\n❖ {account} 的預約 :')
            r_msg='\n'.join(reserve)  # 更新資訊字串
            if asyncio.run(telegram_send_text(r_msg)):
                print('訊息傳送成功!')
            else:
                print('訊息傳送失敗!')
    if b_msg or r_msg:  # 任一不為空字串就更新資料表
        url="https://serverless-fdof.onrender.com/function/update_ksml_books"
        payload={
            "account": account,
            "borrow_books": b_msg,   
            "reserve_books": r_msg
            }
        res=requests.post(url, json=payload)
        print(res.json())        
    end=time.time()
    print(f'執行時間:{end-start}')

主要的變更在於把用來儲存借書與預約資訊的 msg 變數分成 b_msg (借書) 與 r_msg (預約), 初始值設為空字串以便在最後面用來判別是否需要更新 ksml_books 資料表 (兩者均為空字串不用更新), 使用兩張借書證測試結果如下 : 




資料表列表顯示 ksml_books 有兩筆紀錄, 按檢視會顯示全部紀錄 :





為了測試方便我仍然保留將借書與預約資訊傳送到 Telegram 的功能, 但這部分程式碼在正式佈署到樹莓派 Pi 3 時會被刪除, 因為市圖爬蟲可能每一或二小時會爬一次來更新 ksml_books 資料表, 如果每次爬完都傳一次訊息感覺聊天室同樣訊息會太多, 取而代之是另外用一個 crontab 專責讀取資料表傳訊息, 傳訊機器人一天只需要中午與晚上各傳一次 (因應預約書到館時間), 會分別傳送到 LINE 與 Telegram. 

佈署樹莓派 Pi 3 的程式碼如下 (使用無頭模式的 Chronium) :

# ksml_personal_11_deploy.py
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
import re
from datetime import datetime
import time
import requests
import sys

async def telegram_send_text(text):
    bot=Bot(token=token)
    try:
        await bot.send_message(
            chat_id=chat_id,
            text=text
            )
        return True
    except Exception as e:
        print(f'Error sending text: {e}')
        return False

def get_books(account, password):
    try:
        # 登入我的書房
        options=Options()
        options.add_argument("--headless")
        driverpath='/usr/lib/chromium-browser/chromedriver'
        service=Service(driverpath)
        browser=webdriver.Chrome(options=options, service=service)
        browser.implicitly_wait(60)
        browser.get('https://webpacx.ksml.edu.tw/personal/')
        loginid=browser.find_element(By.ID, 'logxinid')
        loginid.send_keys(account)
        pincode=browser.find_element(By.ID, 'pincode')
        pincode.send_keys(password)
        div_btn_grp=browser.find_element(By.CLASS_NAME, 'btn_grp')
        login_btn=div_btn_grp.find_element(By.TAG_NAME, 'input')
        login_btn.click()
        # 擷取借閱紀錄
        div_redblock=browser.find_element(By.CLASS_NAME, 'redblock')
        div_redblock.click()
        books=browser.find_elements(By.CLASS_NAME, 'bookdata')
        borrow_books=[]
        for book in books:
            item=dict()
            book_name=book.find_element(By.XPATH, './h2/a').text    
            item['book_name']=book_name.replace('/', '').strip()
            book_site=book.find_element(By.XPATH, './ul[3]/li[1]').text
            reg=r'典藏地:(\S+)'
            item['book_site']=re.findall(reg, book_site)[0]
            reg=r'\d{4}-\d{2}-\d{2}'
            due_date=book.find_element(By.XPATH, './ul[4]/li[2]').text
            item['due_date']=re.findall(reg, due_date)[0] 
            due_times=book.find_element(By.XPATH, './ul[5]/li[1]').text
            item['due_times']=re.findall(r'\d{1}', due_times)[0]
            try: 
                state=book.find_element(By.XPATH, './ul[6]/li[1]').text
            except:
                state=''
            finally:
                if '有人預約' in state:
                    item['state']=', 有人預約'
                else:
                    item['state']=''
            borrow_books.append(item)
        browser.back() # 回上一頁
        # 擷取預約紀錄
        div_blueblock=browser.find_element(By.CLASS_NAME, 'blueblock')
        div_blueblock.click()
        books=browser.find_elements(By.CLASS_NAME, 'bookdata')
        reserve_books=[]
        for book in books:
            item=dict()
            book_name=book.find_element(By.XPATH, './h2/a').text    
            item['book_name']=book_name.replace('/', '').strip()
            sequence=book.find_element(By.XPATH, './ul[7]/li[1]').text
            if '預約待取' in sequence:  # 已到館
                item['ready_for_pickup']=True
                reg=r'\d{4}-\d{2}-\d{2}'
                item['expiration']=re.findall(reg, sequence)[0]
                item['sequence']='0'
            else: # 預約中
                item['ready_for_pickup']=False
                item['expiration']=''
                item['sequence']=re.findall(r'\d+', sequence)[0]
            reserve_books.append(item)
        browser.close()
        return (borrow_books, reserve_books)        
    except Exception as e:
        print(e)
        return None, None
    
if __name__ == '__main__':
    start=time.time()
    token='Telegram 權杖'
    chat_id='Telegram 聊天室 ID'
    if len(sys.argv) != 3:
        print('用法: python3 ksml_personal_10.py 帳號 密碼')
        sys.exit(1)
    # 取得傳入的帳密參數
    account=sys.argv[1]
    password=sys.argv[2]
    # 呼叫 get_books() 取得借書與預約書        
    borrow_books, reserve_books=get_books(account, password)
    b_msg=''  # 借書資訊字串初始值
    r_msg=''  # 預約資訊字串初始值
    # 處理借書 
    if borrow_books: 
        borrow=[]
        for book in borrow_books:
            book_name=book['book_name']
            book_site=book['book_site'] 
            due_times=book['due_times']
            due_date=book['due_date']
            state=book['state']
            due_date=datetime.strptime(due_date, '%Y-%m-%d') # 到期日   
            today_str=datetime.today().strftime('%Y-%m-%d')   
            today=datetime.strptime(today_str, "%Y-%m-%d")   
            delta=(due_date-today).days  # 計算離到期日還有幾天
            if delta < 0:  # 負數=已逾期
                msg=f'🅧 {book_name} (逾期 {abs(delta)} 天{state}, {book_site})'
                borrow.append(msg)
            elif delta == 0:  # 0=今天到期
                msg=f'⓿ {book_name} (今日到期, 續借次數 {due_times}{state}, {book_site})'
                borrow.append(msg)
            elif delta == 1:  # 1=明天到期 
                msg=f'❶ {book_name} (明日到期, 續借次數 {due_times}{state}, {book_site})'
                borrow.append(msg)
            elif delta == 2:  # 2=後天到期 
                msg=f'❷ {book_name} (後天到期, 續借次數 {due_times}{state}, {book_site})'
                borrow.append(msg)
            elif 2 < delta < 8:  # 3 天以上一周內到期
                msg=f'✦ {book_name} ({book["due_date"]} 到期, '\
                    f'續借次數 {due_times}{state}, {book_site})'
                borrow.append(msg)
        # 製作借書到期摘要字串 
        if len(borrow) != 0:
            borrow.insert(0, f'\n❖ {account} 的借閱 :')
            b_msg='\n'.join(borrow)  # 更新借書資訊字串
    # 處理預約書
    if reserve_books:
        reserve=[]
        i=0
        j=['①', '②', '③', '④', '⑤']
        k=['❶', '❷', '❸', '❹', '❺']
        # 預約狀態
        for book in reserve_books:
            book_name=book['book_name']
            sequence=book['sequence']
            ready_for_pickup=book['ready_for_pickup'] # 已到館
            expiration=book['expiration']  # 取書截止日
            if ready_for_pickup:
                msg=f'{k[i]} {book_name} (已到館, 保留期限 {expiration})'
            else:
                msg=f'{j[i]} {book_name} (順位 {sequence})'
            reserve.append(msg)
            i += 1
        # 製作預約書摘要字串    
        if len(reserve) != 0:
            reserve.insert(0, f'\n❖ {account} 的預約 :')
            r_msg='\n'.join(reserve)  # 更新資訊字串
    if b_msg or r_msg:  # 任一不為空字串就更新資料表
        url="https://serverless-fdof.onrender.com/function/update_ksml_books"
        payload={
            "account": account,
            "borrow_books": b_msg,   
            "reserve_books": r_msg
            }
        res=requests.post(url, json=payload)
        print(res.json())        
    end=time.time()
    print(f'執行時間:{end-start}')        

此處已拿掉 Telegram 傳送訊息的程式碼, 只將借書與預約資訊更新資料表. 

用 chmod 指令將此佈署版爬蟲改為可執行 :

pi@raspberrypi:~ $ sudo chmod +x ksml_personal_11_deploy.py  
pi@raspberrypi:~ $ ls -ls ksml_personal_11_deploy.py  
8 -rwxr-xr-x 1 pi pi 7218 10月 20 23:29 ksml_personal_11_deploy.py  


4. 撰寫 serverless 平台的傳訊程式 : 

此程式不是樹莓派本地端程式, 而是要佈署在 serverless 平台上的函式模組, 當它被呼叫時會從 serverless.db 資料庫的 ksml_books 資料表裡讀取各借書證之借書與預約欄位, 然後傳送到 Telegram, 程式碼如下 (後續會加上傳送至 LINE 聊天室之功能) :

# send_ksml_books_messages.py
import asyncio
import sqlite3
from telegram import Bot

async def telegram_send_text(token, chat_id, text):
    """非同步傳送 Telegram 訊息"""
    try:
        bot=Bot(token=token)
        await bot.send_message(chat_id=chat_id, text=text)
        return True
    except Exception as e:
        print(f"傳送失敗: {e}")
        return False

def main(request, **kwargs):
    DB_PATH='./serverless.db'
    config=kwargs.get('config', {})
    telegram_token=config.get('TELEGRAM_TOKEN')
    telegram_id=config.get('TELEGRAM_ID')
    if not telegram_token or not telegram_id:
        return '未設定 TELEGRAM_TOKEN 或 TELEGRAM_ID'
    try:  # 連線資料庫
        conn=sqlite3.connect(DB_PATH)
        cur=conn.cursor()
        cur.execute("SELECT borrow_books, reserve_books FROM ksml_books;")
        rows=cur.fetchall()
        conn.close()
    except Exception as e:
        return f'資料庫讀取失敗: {e}'
    if not rows:
        return '沒有任何資料可傳送'
    # 傳送訊息
    success_count=0
    fail_count=0
    for borrow_books, reserve_books in rows:
        for msg in [borrow_books, reserve_books]:
            if msg and msg.strip():
                ok=asyncio.run(telegram_send_text(telegram_token, telegram_id, msg))
                if ok:
                    success_count += 1
                else:
                    fail_count += 1
    return f'傳送完成:成功 {success_count} 筆,失敗 {fail_count} 筆'

此程式會先從主程式 serverless.py 傳來的 kwargs 參數取出 config 字典, 從中取得 Telegram 的權杖與聊天室 id, 然後從 sml_books 資料表取得全部借書證之借書與預約資訊, 用迴圈拜訪每個紀錄逐一傳送到 Telegram 聊天室. 

在 serverless 平台 (例如 render.com) 新增函式 send_books_messages.py 後, 在它的 "執行" 連結上按滑鼠右鍵選取 "複製連結網址" 取得 API 網址 : 




只要在樹莓派寫個 get_books_messages.py 程式, 用 requests 送出 HTTP GET 請求到此 serverless API 網址, 則 send_books_messages.py 就會將最新的借書與預約資訊送到 Telegram 聊天室, 這個本機程式如下 : 

# get_books_messages.py
import requests

url="https://serverless-fdof.onrender.com/function/send_books_messages"
res=requests.get(url)
print(res)

用 chmod 指令更改此程式權限為可執行 :

pi@raspberrypi:~ $ sudo chmod +x get_books_messages.py   
pi@raspberrypi:~ $ ls -ls get_books_messages.py   
4 -rwxr-xr-x 1 pi pi 167 10月 22 23:37 get_books_messages.py

接下來就可以將它放在 crontab 中定期執行. 


3. 修改樹莓派 Pi 3 的 crontab : 

接下來要修改樹莓派的 crontab, 之前的爬蟲程式 (v9) 將爬取與傳訊一手包, 每爬完一張借書證就將結果傳到 Telegram 聊天室, crontab 設定為一天爬兩次; 上面新版 v11 程式只單純地將爬到的結果儲存到 serverless 平台的資料表裡, 所以可以提高爬取頻率為每小時爬一次 (每張借書證間從 0 分起間隔 4 分鐘), 設定修改如下 : 

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_2.py
0 8,18 * * * /usr/bin/python3 /home/pi/btc_eth_prices_line_notify.py
1 12,17 * * * /usr/bin/python3 /home/pi/technews_3.py
0 9 * * * /usr/bin/python3 /home/pi/books.com.tw_66_telegram.py
0 * * * * /usr/bin/python3 /home/pi/ksml_personal_11_deploy.py f-----7 5-----1
4 * * * * /usr/bin/python3 /home/pi/ksml_personal_11_deploy.py 0-----8 9-----7
8 * * * * /usr/bin/python3 /home/pi/ksml_personal_11_deploy.py 0-----9 8-----6
12 * * * * /usr/bin/python3 /home/pi/ksml_personal_11_deploy.py 0------0 8-----4
16 * * * * /usr/bin/python3 /home/pi/ksml_personal_11_deploy.py 9------2 6-----3
20 * * * * /usr/bin/python3 /home/pi/ksml_personal_11_deploy.py S------7 3-----0
30 12, 21 * * * /usr/bin/python3 /home/pi/ksml_personal_11_deploy.py
0 6,16 * * * /usr/bin/python3 /home/pi/nkust_lib_7.py
*/5 * * * * ~/duckdns/duck.sh >/dev/null 2>&1

此處設定每天 12:30 與 21:30 會各傳送一次借書與預約資訊到 Telegram.