2026年4月18日 星期六

Python 學習筆記 : 市圖借書與預約爬蟲程式改版 v13

將母校圖書館爬蟲升版後打鐵趁熱, 今天順便將此次優化 Selenium 爬蟲的技巧也套用在市圖爬蟲程式上, 可同時於 Pi 400, Pi 3B, 與 Pi 3A+ 上執行. 

本系列全部測試文章索引參考 :


新版 v13 程式碼如下 :

# ksml_lib_13.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
from dotenv import dotenv_values
import os
import socket

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

def get_books(account, password):
    browser=None
    result=(None, None)  # 預設回傳值   
    try:
        # 登入我的書房
        # 設定一個在 SD 卡上的暫存目錄 (for Trixie)
        chrome_tmp_path=os.path.expanduser('~/chrome_tmp')
        if not os.path.exists(chrome_tmp_path):
            os.makedirs(chrome_tmp_path)        
        options=Options()
        options.add_argument("--headless=new") # 新版無頭擬真瀏覽器
        options.add_argument("--no-sandbox") # Trixie 必加
        options.add_argument("--disable-dev-shm-usage") # 避免擠爆 /dev/shm
        options.add_argument('--disable-gpu') # 避免 GPU 驅動崩潰
        # 強迫使用 SD 卡空間 (特別是 Trixie 必須)
        options.add_argument(f'--user-data-dir={chrome_tmp_path}')
        # 限制快取大小為 100MB (防止 chrome_tmp 資料夾隨著時間變得巨大)
        options.add_argument('--disk-cache-size=104857600')
        options.binary_location='/usr/bin/chromium'       
        service=Service('/usr/bin/chromedriver')
        browser=webdriver.Chrome(service=service, options=options)
        browser.implicitly_wait(60)
        browser.set_window_size(1920, 1080)        
        # 載入網頁
        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)
        print('擷取借閱紀錄 ... OK')
        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)
        print('擷取預約紀錄 ... OK')
        result=(borrow_books, reserve_books)        
    except Exception as e:
        print(f'發生錯誤 : {e}')
    finally:
        if browser:
            try:
                browser.quit()  # 釋放記憶體
                print('資源已釋放')
            except:
                pass            
    return result   
    
if __name__ == '__main__':
    start=time.time()
    config=dotenv_values('.env')
    TELEGRAM_TOKEN=config.get('TELEGRAM_TOKEN')
    TELEGRAM_ID=config.get('TELEGRAM_ID')
    #print(TELEGRAM_TOKEN)
    #print(TELEGRAM_ID)
    host_name=socket.gethostname()
    print(f'主機 : {host_name}')    
    if len(sys.argv) != 3:
        print(f'用法: {sys.argv[0]} 帳號 密碼')
        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)  # 更新借書資訊字串
        print('產生借書到期摘要 ... OK')
    # 處理預約書
    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)  # 更新資訊字串
    print('產生預約書摘要 ... OK')
    if b_msg or r_msg:  # 任一不為空字串就更新資料表
        url="https://serverless-5e6i.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}')

此次改版也修正了 try-except-finally 結構, 把 browser.close() 改成 browser.quit(), 前者雖然會把視窗關掉, 但背景的 chromedriver 可能還在跑繼續吃 RAM, 導致背景殘留了一堆 chromedriver 的殭屍進程. 此優化版程式在 Pi 3B, 3A+ 與 Pi 400 均可順利執行. 

執行結果如下 : 

pi@kaopi3:~ $ python ksml_lib_12.py faxxxxxx 123456   
擷取借閱紀錄 ... OK
擷取預約紀錄 ... OK
產生借書到期摘要 ... OK
產生預約書摘要 ... OK
{'message': 'faxxxxxx 的資料已更新', 'status': 'success'}
執行時間:667.9564106464386

pi@pi3aplus:~ $ python ksml_lib_12.py faxxxxxx 123456
主機 : pi3aplus
擷取借閱紀錄 ... OK
擷取預約紀錄 ... OK
資源已釋放
產生借書到期摘要 ... OK
產生預約書摘要 ... OK
{'message': 'faxxxxxx 的資料已更新', 'status': 'success'}
執行時間:675.6979095935822

(myenv313) pi@pi400:~ $ python ksml_lib_12.py faxxxxxx 123456
主機 : pi400
擷取借閱紀錄 ... OK
擷取預約紀錄 ... OK
資源已釋放
產生借書到期摘要 ... OK
產生預約書摘要 ... OK
{'message': 'faxxxxxx 的資料已更新', 'status': 'success'}
執行時間:573.2406423091888

可見 Pi 3B 與 3A+ 速度差不多, 但 Pi 400 就快了 100 秒. 

注意, 此處呼叫的後端端點有兩個, 都是建置在 Render 平台上的 serverless 服務, 目前我有兩個端點, 分配如下 :
  • kaopi3 : https://serverless-5e6i.onrender.com/function/send_books_messages
  • pi3aplus : https://serverless-fdof.onrender.com/function/send_books_messages
  • pi400 : https://serverless-fdof.onrender.com/function/send_books_messages (備用)
擷取並傳送借書資訊的程式 get_ksml_books_messages.py 也是要設定對應端點, 例如 kaopi3 :

# get_ksml_books_messages.py
import requests
import socket

host_name=socket.gethostname()
print(f'主機 : {host_name}')
params={'crawler': f'{host_name}'}
url='https://serverless-5e6i.onrender.com/function/send_books_messages'
res=requests.get(url, params=params)
print(res)

此爬蟲程式會呼叫 serverless 平台上的 send_books_messages.py 函式讀取 serverless.db 上記錄的借書與預約資訊, 並送出 Telegram 訊息. send_books_messages.py 函式內容如下 :

# 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} 筆'

市圖爬蟲架構較複雜, 得畫一張圖來備忘才行. 

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

之前版本的爬蟲我都把帳號密碼金鑰等等都寫在程式碼裡, 這實在不是好的做法, 今天將爬蟲進行改版, 將帳密金鑰都存放在隱藏檔 .env 中, 於程式裡利用 dotenv 從 .env 檔讀取出來. 關於 dotenv 用法參考 :


本系列全部文章索引參考 :


先將目前的第 10 版爬蟲複製一份到第 11 版 :

pi@kaopi3:~ $ cp nkust_lib_10.py nkust_lib_11.py    

用 nano 編輯程式碼 :

pi@kaopi3:~ $ nano nkust_lib_11.py   

修改為如下 : 

# nkust_lib_11.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
import re
from dotenv import dotenv_values

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

def get_nkust_lib():
    browser=None
    msg="無法取得資料"  # ✅ 防止未賦值
    try:
        options=Options()
        options.add_argument("--headless=new")
        options.add_argument("--no-sandbox")
        options.add_argument("--disable-dev-shm-usage")
        options.binary_location="/usr/bin/chromium"
        service=Service("/usr/bin/chromedriver")
        browser=webdriver.Chrome(service=service, options=options)
        browser.implicitly_wait(60)
        browser.set_window_size(1920, 1080)

        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')
        if len(md_list) > 1:
            md_list[1].click()
            print('按其他讀者 ... OK')

        # 登入系統
        login_user_name=browser.find_element(By.ID, 'LoginUserName')   
        login_user_name.send_keys(NKUST_LIB_ID)  
        login_password=browser.find_element(By.ID, 'LoginPassword')   
        login_password.send_keys(NKUST_LIB_PWD)
        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)

            # 抓取所有借閱書目
            loan_items=browser.find_elements(By.TAG_NAME, 'md-list-item')
            unrenew_books=[]

            for item in loan_items:
                try:
                    # ✅ 1. 必須有書名才是書
                    title_elem=item.find_elements(By.CSS_SELECTOR, "h3 a")
                    if not title_elem:
                        continue
                    title=title_elem[0].text.strip()
                    item_text=item.text.strip()

                    # ✅ 2. 取得到期日
                    due_text=''
                    due_elem=item.find_elements(By.XPATH, './/p[@data-qa="automation_mlc_record_date"]')
                    if due_elem:
                        m=re.search(r'\d{2}/\d{2}/\d{4}', due_elem[0].text)
                        if m:
                            due_text=m.group(0)

                    # ✅ 3. 只收真正「被預約 / 無法續借」
                    if any(k in item_text for k in ["被預約", "無法續借", "recall"]):
                        unrenew_books.append((title, due_text))
                except Exception:
                    continue
            if unrenew_books:
                msg="被預約的書:\n" + "\n".join(
                    [f"{i+1:>2}. {t[0]} 到期日: {t[1]}" for i, t in enumerate(unrenew_books)]
                )
            else:
                msg="全部書籍皆已續借"
            print('搜尋被預約書籍 ... OK')

    except Exception as e:
        print(e)
    finally:
        if browser:
            browser.quit()  # ✅ 防呆
        return msg  # ✅ msg 一定有值

if __name__ == '__main__':
    start=time.time()
    config=dotenv_values('.env')
    NKUST_LIB_ID=config.get('NKUST_LIB_ID')
    NKUST_LIB_PWD=config.get('NKUST_LIB_PWD')
    TELEGRAM_TOKEN=config.get('TELEGRAM_TOKEN')
    TELEGRAM_ID=config.get('TELEGRAM_ID')
    #print(NKUST_LIB_ID)
    #print(NKUST_LIB_PWD)
    #print(TELEGRAM_TOKEN)
    #print(TELEGRAM_ID)
    msg=get_nkust_lib()
    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}')

黃底高亮者為修改或添加的部分, 執行結果如下 :

pi@kaopi3:~ $ python nkust_lib_11.py   
按其他讀者 ... OK
登入系統 ... OK
按名字顯現選單 ... OK
按我的借閱 ... OK
按全部續借 ... OK
訊息傳送成功!

2026-04-18 00:37:52
❖ 所有借閱資料已成功續借
執行時間:81.74577593803406


2026-04-18 補充 : 

早上又對程式碼進行局部微調, 版本號仍維持 v11, 主要修改如下 :

1. 添加主機名稱訊息 : 
利用 socket.gethostname() 即可得到主機名稱. 這樣收到 Telegram 才知道是哪台主機的爬蟲完成的.

2. 使用 SD 卡儲存 Chrome/Chromium 暫存資料 : 
由於 Pi 400 的 Trixie 仍然開發演進中, 最新版本改用 Chrome 而非 Chromium, 跑 Selenium 爬蟲時使用 DRAM 來暫存資料, 可能導致 DRAM 塞滿而使瀏覽器閃退, 於是改為強制將暫存資料放在 chrome_tmp 資料夾下, 瀏覽器設定修改為 : 

        # 設定一個在 SD 卡上的暫存目錄 (for Trixie)
        chrome_tmp_path=os.path.expanduser('~/chrome_tmp')
        if not os.path.exists(chrome_tmp_path):
            os.makedirs(chrome_tmp_path)        
        options=Options()
        options.add_argument("--headless=new") # 新版無頭擬真瀏覽器
        options.add_argument("--no-sandbox") # Trixie 必加
        options.add_argument("--disable-dev-shm-usage") # 避免擠爆 /dev/shm
        options.add_argument('--disable-gpu') # 避免 GPU 驅動崩潰
        # 強迫使用 SD 卡空間 (特別是 Trixie 必須)
        options.add_argument(f'--user-data-dir={chrome_tmp_path}')
        # 限制快取大小為 100MB (防止 chrome_tmp 資料夾隨著時間變得巨大)
        options.add_argument('--disk-cache-size=104857600') 

以上設定雖然是針對 Trixie, 但在 Bulleye (Pi 3 & Pi 3A+) 上跑也是可以的. 完整程式碼如下 :

# nkust_lib_11.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
import re
from dotenv import dotenv_values
import os
import socket

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

def get_nkust_lib():
    browser=None
    msg="無法取得資料"  # ✅ 防止未賦值
    try:
        # 設定一個在 SD 卡上的暫存目錄 (for Trixie)
        chrome_tmp_path=os.path.expanduser('~/chrome_tmp')
        if not os.path.exists(chrome_tmp_path):
            os.makedirs(chrome_tmp_path)        
        options=Options()
        options.add_argument("--headless=new") # 新版無頭擬真瀏覽器
        options.add_argument("--no-sandbox") # Trixie 必加
        options.add_argument("--disable-dev-shm-usage") # 避免擠爆 /dev/shm
        options.add_argument('--disable-gpu') # 避免 GPU 驅動崩潰
        # 強迫使用 SD 卡空間 (特別是 Trixie 必須)
        options.add_argument(f'--user-data-dir={chrome_tmp_path}')
        # 限制快取大小為 100MB (防止 chrome_tmp 資料夾隨著時間變得巨大)
        options.add_argument('--disk-cache-size=104857600') 
        options.binary_location="/usr/bin/chromium"
        service=Service("/usr/bin/chromedriver")
        browser=webdriver.Chrome(service=service, options=options)
        browser.implicitly_wait(60)
        browser.set_window_size(1920, 1080)

        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')
        if len(md_list) > 1:
            md_list[1].click()
            print('按其他讀者 ... OK')

        # 登入系統
        login_user_name=browser.find_element(By.ID, 'LoginUserName')   
        login_user_name.send_keys(NKUST_LIB_ID)  
        login_password=browser.find_element(By.ID, 'LoginPassword')   
        login_password.send_keys(NKUST_LIB_PWD)
        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)

            # 抓取所有借閱書目
            loan_items=browser.find_elements(By.TAG_NAME, 'md-list-item')
            unrenew_books=[]

            for item in loan_items:
                try:
                    # ✅ 1. 必須有書名才是書
                    title_elem=item.find_elements(By.CSS_SELECTOR, "h3 a")
                    if not title_elem:
                        continue
                    title=title_elem[0].text.strip()
                    item_text=item.text.strip()

                    # ✅ 2. 取得到期日
                    due_text=''
                    due_elem=item.find_elements(By.XPATH, './/p[@data-qa="automation_mlc_record_date"]')
                    if due_elem:
                        m=re.search(r'\d{2}/\d{2}/\d{4}', due_elem[0].text)
                        if m:
                            due_text=m.group(0)

                    # ✅ 3. 只收真正「被預約 / 無法續借」
                    if any(k in item_text for k in ["被預約", "無法續借", "recall"]):
                        unrenew_books.append((title, due_text))
                except Exception:
                    continue
            if unrenew_books:
                msg="被預約的書:\n" + "\n".join(
                    [f"{i+1:>2}. {t[0]} 到期日: {t[1]}" for i, t in enumerate(unrenew_books)]
                )
            else:
                msg="全部書籍皆已續借"
            print('搜尋被預約書籍 ... OK')

    except Exception as e:
        print(e)
    finally:
        if browser:
            browser.quit()  # ✅ 防呆
        return msg  # ✅ msg 一定有值

if __name__ == '__main__':
    start=time.time()
    config=dotenv_values('.env')
    NKUST_LIB_ID=config.get('NKUST_LIB_ID')
    NKUST_LIB_PWD=config.get('NKUST_LIB_PWD')
    TELEGRAM_TOKEN=config.get('TELEGRAM_TOKEN')
    TELEGRAM_ID=config.get('TELEGRAM_ID')
    #print(NKUST_LIB_ID)
    #print(NKUST_LIB_PWD)
    #print(TELEGRAM_TOKEN)
    #print(TELEGRAM_ID)
    host_name=socket.gethostname()
    print(f'主機 : {host_name}')
    msg=get_nkust_lib()
    if msg:
        now=datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        msg='\n' + now + '\n' + msg + '\n' + f'(host_name)'
        if asyncio.run(telegram_send_text(msg)):
            print('訊息傳送成功!')
        else:
            print('訊息傳送失敗!')
    print(msg)
    end=time.time()
    print(f'執行時間:{end-start}')

執行結果 : 

pi@kaopi3:~ $ python nkust_lib_11.py
主機 : kaopi3
按其他讀者 ... OK
登入系統 ... OK
按名字顯現選單 ... OK
按我的借閱 ... OK
按全部續借 ... OK
搜尋被預約書籍 ... OK
訊息傳送成功!

2026-04-18 11:14:19
全部書籍皆已續借
(kaopi3)
執行時間:358.6611738204956

2026年4月17日 星期五

Google Gemini API 學習索引

過去兩年來串接大語言模型 API 我最常用的是 OpenAI, 這必須要先綁信用卡購買隨用即付額度才能使用; 而 Google Gemini 則不需要, 免費仔目前 gemini-2.5-flash 享有的請求限制為 10 次/分, 25 萬 token/分, 250 次/日, 對於新手非常慷慨. 最近打算對 Gemini API 做較全面的測試, 先把過去的測試文章整理程如下索引以利查考 :


~ 進行中 ~

Google Gemini API 學習筆記 : 新舊 API 呼叫方法整理

Google 在 2024 年底推出新版的 SDK 套件 google-genai 來取代舊版的 google-generativeai, 目前我的 LG Gram 筆電的虛擬環境安裝的最新 langchain-core 就是依賴於 google-genai, 但昨天在 Pi 3A+ 的 Bulleye 上測試發現無法安裝新版的 google-genai, 只能用舊版的 google-generativeai, 所以我將呼叫原生 Gemini API 時新舊兩種用法整理如下備查. 參考 :



1. 舊版 Gemin API (google-generativeai) 用法 :

安裝 :

pip install google-generativeai 

匯入 :

import google.generativeai as genai 

設定金鑰 :

genai.configure(api_key=api_key)

建立模型 :

model=genai.GenerativeModel('gemini-2.5-flash') 

提問 :

reply=model.generate_content('你是誰?')  

取得回覆 : 

print(reply.text)


2. 新版 Gemin API (google-genai) 用法 :

安裝 :

pip install google-genai 

匯入 :

from google import genai 

建立 Client 物件 (設定金鑰) :

client=genai.Client(api_key=api_key)

建立模型 & 提問 :

reply=client.models.generate_content(
    model='gemini-2.5-flash', 
    contents='你是誰?'
    )

取得回覆 : 

print(reply.text)

樹莓派學習筆記 : 在 Bulleye 上安裝 google-generativeai 套件

昨天成功重灌 Pi 3A+ 的 Bulleye 後嘗試安裝 langchain-core, 結果因為版本衝突不順利, 且就算安裝成功, 由於 Pi 3A+ 只有 512MB DRAM, 跑 langchain 太沉重了只好放棄, 改為安裝原生 SDK. 安裝 openai 套件成功且可順利匯入 :

pi@pi3aplus:~ $ pip install openai   
Looking in indexes: https://pypi.org/simple, https://www.piwheels.org/simple
Collecting openai
...(略)...
Installing collected packages: openai
Successfully installed openai-2.32.0
pi@pi3aplus:~ $ python   
Python 3.9.2 (default, Jan 24 2026, 09:41:14) 
[GCC 10.2.1 20210110] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from openai import OpenAI   
>>> exit()  

安裝 Gemini 舊版 API 的 google-generativeai 套件 : 

pi@pi3aplus:~ $ pip install google-generativeai   
Looking in indexes: https://pypi.org/simple, https://www.piwheels.org/simple
Collecting google-generativeai
  Downloading google_generativeai-0.8.6-py3-none-any.whl (155 kB)
...(略)...
Successfully installed google-ai-generativelanguage-0.6.15 google-api-python-client-2.194.0 google-auth-httplib2-0.3.1 google-generativeai-0.8.6 grpcio-1.80.0 grpcio-status-1.71.2 httplib2-0.31.2 protobuf-5.29.6 uritemplate-4.2.0

但用 import google.generativeai as genai 匯入時會出現 grpcio 相關錯誤, 原因也是版本衝突問題, AI 建議改用下列安裝指令鎖住版本 : 

pi@pi3aplus:~ $ pip install "google-generativeai==0.3.1" \
            "google-ai-generativelanguage==0.4.0" \
            "grpcio==1.54.2" \
            "grpcio-status==1.54.2" \
            "protobuf==4.25.3" \
            --force-reinstall --no-cache-dir   
Looking in indexes: https://pypi.org/simple, https://www.piwheels.org/simple
Collecting google-generativeai==0.3.1
  Downloading google_generativeai-0.3.1-py3-none-any.whl (146 kB)
...(略)...
Successfully installed certifi-2026.2.25 cffi-2.0.0 charset-normalizer-3.4.7 cryptography-46.0.7 google-ai-generativelanguage-0.4.0 google-api-core-2.29.0 google-auth-2.49.2 google-generativeai-0.3.1 googleapis-common-protos-1.73.0 grpcio-1.54.2 grpcio-status-1.54.2 idna-3.11 proto-plus-1.27.1 protobuf-4.25.3 pyasn1-0.6.3 pyasn1-modules-0.4.2 pycparser-2.23 requests-2.32.5 tqdm-4.67.3 typing-extensions-4.15.0 urllib3-2.6.3

這樣匯入時就只報出無關緊要的 Warning 了 (這些警告只是提醒 : Python 3.9 太舊了, 以後可能不支援而已) : 

pi@pi3aplus:~ $ python  
Python 3.9.2 (default, Jan 24 2026, 09:41:14) 
[GCC 10.2.1 20210110] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import google.generativeai as genai   
/home/pi/.local/lib/python3.9/site-packages/google/api_core/_python_version_support.py:246: FutureWarning: You are using a non-supported Python version (3.9.2). Google will not post any further updates to google.api_core supporting this Python version. Please upgrade to the latest Python version, or at least Python 3.10, and then update google.api_core.
  warnings.warn(message, FutureWarning)
/home/pi/.local/lib/python3.9/site-packages/google/auth/__init__.py:54: FutureWarning: You are using a Python version 3.9 past its end of life. Google will update google-auth with critical bug fixes on a best-effort basis, but not with any other fixes or features. Please upgrade your Python version, and then update google-auth.
  warnings.warn(eol_message.format("3.9"), FutureWarning)
/home/pi/.local/lib/python3.9/site-packages/google/oauth2/__init__.py:40: FutureWarning: You are using a Python version 3.9 past its end of life. Google will update google-auth with critical bug fixes on a best-effort basis, but not with any other fixes or features. Please upgrade your Python version, and then update google-auth.
  warnings.warn(eol_message.format("3.9"), FutureWarning)

如果不想看到這些警告, 可以在程式開頭用下列程式碼隱藏 :

import warnings
warnings.filterwarnings("ignore", category=FutureWarning)

如果想永久隱藏, 就要用 nano ~/.bashrc 去修改設定檔, 在最底下加上 :

export PYTHONWARNINGS="ignore::FutureWarning"

用下列程式測試可正確載入 API :

import grpc
print(f"GRPC 版本: {grpc.__version__}") # 應該要是 1.54.2
import google.generativeai as genai
print("Gemini SDK 成功載入!")

>>> import google.generativeai as genai    
>>> print(genai.__version__)   
0.3.1
>>> try:
...     print("SDK 載入成功,準備測試屬性...")
...     model = genai.GenerativeModel('gemini-pro')
...     print("模型物件建立成功!")
... except Exception as e:
...     print(f"執行出錯: {e}")
... 
SDK 載入成功,準備測試屬性...
模型物件建立成功!

我把 Gemini API 金鑰放在環境變數檔 .env 裡的 GEMINI_API_KEY 中利用 dotenv 套件讀取後實際呼叫 Gemini API 測試 OK :

>>> import google.generativeai as genai  
>>> from dotenv import dotenv_values   
>>> config=dotenv_values('.env')     
>>> gemini_api_key=config.get('GEMINI_API_KEY')   
>>> genai.configure(api_key=gemini_api_key)    
>>> model=genai.GenerativeModel('gemini-2.5-flash')   
>>> reply=model.generate_content('你是誰?')    
>>> print(reply.text)    
我是一個大型語言模型,由 Google 訓練。
我沒有名字、沒有身體,也沒有個人情感或意識。我的目的是回答你的問題、提供資訊、進行對話,並在各種任務上提供幫助。

下面是 OpenAI API 的測試 :

>>> from openai import OpenAI   
>>> from dotenv import dotenv_values 
>>> config=dotenv_values('.env')
>>> openai_api_key=config.get('OPENAI_API_KEY')    
>>> client=OpenAI(api_key=openai_api_key)   
>>> reply=client.chat.completions.create(   
...     messages=[   
...         {"role": "user",
...          "content": "你是誰?",
...         }],
...     model="gpt-3.5-turbo",
...     )
>>> print(reply.choices[0].message.content)
我是一個AI人工智能助手,可以與你進行對話、回答問題和提供信息。有什麼我可以幫助你的嗎?

樹莓派學習筆記 : 消除 PATH 環境變數警告的方法

高雄家的 Pi 3 A+ 主機在三月下旬我去日本時不明原因當機 (應該是 8GB TF 卡掛了), 它負責的爬蟲停擺, 回來後一直沒時間修復, 昨晚找出一片 32GB TF 卡重新燒錄 Bulleye 後已重新上線. 但在更新套件時出現一個警告 : 

pi@pi3aplus:~ $ pip install --upgrade requests urllib3   
Looking in indexes: https://pypi.org/simple, https://www.piwheels.org/simple
Requirement already satisfied: requests in /usr/lib/python3/dist-packages (2.25.1)
Collecting requests
... (略)...
Installing collected packages: urllib3, charset-normalizer, requests
  WARNING: The script normalizer is installed in '/home/pi/.local/bin' which is not on PATH.
  Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.
Successfully installed charset-normalizer-3.4.7 requests-2.32.5 urllib3-2.6.3

此警告意思是套件有安裝可執行腳本 (此處為 normalizer), 但該腳本所在目錄 /home/pi/.local/bin 沒有在 PATH 裡, 所以若在終端機直接打指令 (normalizer) 時會找不到, 出現 command not found 錯誤. 如果根本不用 CLI 工具執行此套件可忽略此警告, 但以後 pip install 時仍可能出現, 為了不礙眼, 乾脆將 /home/pi/.local/bin 加入 PATH 裡一勞永逸, 做法如下 :

編輯 .bashrc : 

pi@pi3aplus:~ $ nano ~/.bashrc   

在最底下加入此目錄 : 

export PATH="$HOME/.local/bin:$PATH"




重新執行 .bashrc 即可 :

pi@pi3aplus:~ $ source ~/.bashrc   

2026年4月16日 星期四

好站 : 高見龍老師的部落格

三月時內訓課程請到六角學院的高見龍老師來講 SDD 規格驅動開發, 讓我覺得獲益匪淺, 其實課程內容的精髓大部份都可以在其部落格找到 :


部落格中與 SDD 規格驅動開發有關的是這幾篇 :


其中 Spectra 是高老師為 OpenSpec 開發的 GUI 工具, 可以讓 OpenSpec 用起來更簡單更直覺, 剛好我最近正在學 OpenSpec 用得上 (雖然我只開發小專案, 但江湖在走, SDD 還是要有). 

我去年參加公司的 Hahow 企業版線上課程時就上了不少高老師的課 (Python, Javascript, 網頁前端), 不管是口條或內容都非常棒, 雖說 AI 時代學習這些技能似乎不那麼重要了, 但有基礎的人與菜菜小白終究是不同的. 

2026年4月15日 星期三

好站 : 工程師下班有約

最近上了幾堂林鼎淵老師的 Vibe coding 內訓課程, 覺得他是實力厚實備課非常認真的老師, 這從等候程式執行的幾分鐘, 他都有準備小知識與開發經驗談來墊檔與分享可見一斑. 他經營的 YT 頻道有非常多實用與最新的 AI 技術分享影片 (尤其是 Vibe coding), 值得記下來有空時學習 : 


2026年4月14日 星期二

(補記) 購買 Accupass AI 實戰工作坊

月初 (大約 4/6) 在 Accupass 買了一堂 Vibe coding 線上課程忘了記錄下來, 今天整理截圖才發現, 4/18 (六) 13:30~16:30 上課, 馬上列入行事曆以免錯過 :




我在 Hahow 也還有四門課買了尚未啟用學習, 第二季要來完成進度了. 

如何尋找靜音中的手機

前天 (週日) 爸坐阿泉伯的車去愛心會開會, 手機掉在車上未發覺, 回到家才發現手機不見了, 我透過定位發現手機在阿泉伯家, 但去他家找了三遍都沒找到, 撥電話都沒聽到響鈴, 我懷疑爸是否誤將手機關靜音, 於是問 Gemini 如何在手機靜音狀態下讓它發出聲音? 方法如下 :
  1. 在電腦或其他行動裝置的瀏覽器網址 google.com/android/find 
  2. 登入要尋找的手機的 Google 帳號
  3. 在左側面板或選單中點擊 「播放音效」(Play Sound)
我開啟自己的手機瀏覽器, 連線 google.com/android/find, 登入爸的 Google 帳戶後, 點選 "安全性與登入" :




點選尋找遺失的裝置 : 




點選要找尋的手機 OPPO A38 :




按 "播放音效" 就會持續播放 5 分鐘的響鈴了 :





最後是在阿泉伯媳婦提醒下在車子副駕椅子下找到手機, 雖然這個方法並未建功 (在車內就算響鈴除非走近也聽不到), 但如果要在家裡某個角落找尋亂放的手機就能派上用場了.