顯示具有 網路爬蟲 標籤的文章。 顯示所有文章
顯示具有 網路爬蟲 標籤的文章。 顯示所有文章

2026年7月4日 星期六

在樹莓派 Pi 400 (Trixie) 虛擬環境中佈署市圖爬蟲程式 (v14)

這兩天利用 Antigravity CLI 搞定市圖爬蟲程式 v14 升版後, 陸續佈署到 kaopi3 與 pi3aplus 主機上且順利運行中, 今天輪到 Pi 400 主機了. 

我目前在 Pi 400 安裝的樹莓派 OS 是最新的 Trixie (內建 Python 3.13), 從上一版的從 Bookworm 開始, 樹莓派 OS 即遵循 PEP 668 規範, 不允許直接在系統 Python 環境用 pip 安裝第三方套件, 必須建立虛擬環境後在虛擬環境裡面才能用 pip 安裝套件. 

有別於 Pi 3 主機直接在系統 Python 3.9.2 上執行 Python 程式, 在 Pi 400 上必須在虛擬環境中安裝套件與執行 Python 程式, 目前 Pi 400 的爬蟲是在虛擬環境 myenv313 下執行, 所以先進入此虛擬環境 : 

pi@pi400:~ $ source ~/myenv313/bin/activate   
(myenv313) pi@pi400:~ $

因為之前就已在此環境下安裝 Numpy 時安裝過 OpenBLAS 及相關的底層依賴庫, 所以下面指令可以略過 :

sudo apt-get update
sudo apt update sudo apt install -y libopenblas-dev libatlas-base-dev 

接下來安裝 tesseract-ocr 套件 : 

(myenv313) pi@pi400:~ $ sudo apt-get install tesseract-ocr   
正在讀取套件清單... 完成
正在重建相依關係... 完成  
正在讀取狀態資料... 完成  
The following packages were automatically installed and are no longer required:
  alacarte gir1.2-gmenu-3.0 gnome-menus gtk-nop libevent-pthreads-2.1-7t64 libgnome-menu-3-0
  libwlroots-0.18 python3-gi-cairo retry
Use 'sudo apt autoremove' to remove them.
下列的額外套件將被安裝:
  libleptonica6 libtesseract5 tesseract-ocr-eng tesseract-ocr-osd
下列【新】套件將會被安裝:
  libleptonica6 libtesseract5 tesseract-ocr tesseract-ocr-eng tesseract-ocr-osd
升級 0 個,新安裝 5 個,移除 0 個,有 261 個未被升級。
需要下載 7,207 kB 的套件檔。
此操作完成之後,會多佔用 24.5 MB 的磁碟空間。
是否繼續進行 [Y/n]? [Y/n] y
下載:1 http://deb.debian.org/debian trixie/main arm64 libleptonica6 arm64 1.84.1-4 [994 kB]
下載:2 http://deb.debian.org/debian trixie/main arm64 libtesseract5 arm64 5.5.0-1+b1 [1,237 kB]
下載:3 http://deb.debian.org/debian trixie/main arm64 tesseract-ocr-eng all 1:4.1.0-2 [1,594 kB]
下載:4 http://deb.debian.org/debian trixie/main arm64 tesseract-ocr-osd all 1:4.1.0-2 [2,992 kB]
下載:5 http://deb.debian.org/debian trixie/main arm64 tesseract-ocr arm64 5.5.0-1+b1 [389 kB]
取得 7,207 kB 用了 3s (2,122 kB/s)      
選取了原先未選的套件 libleptonica6:arm64。
(讀取資料庫 ... 目前共安裝了 147525 個檔案和目錄。)
正在準備解包 .../libleptonica6_1.84.1-4_arm64.deb……
Unpacking libleptonica6:arm64 (1.84.1-4) ...
選取了原先未選的套件 libtesseract5:arm64。
正在準備解包 .../libtesseract5_5.5.0-1+b1_arm64.deb……
Unpacking libtesseract5:arm64 (5.5.0-1+b1) ...
選取了原先未選的套件 tesseract-ocr-eng。
正在準備解包 .../tesseract-ocr-eng_1%3a4.1.0-2_all.deb……
Unpacking tesseract-ocr-eng (1:4.1.0-2) ...
選取了原先未選的套件 tesseract-ocr-osd。
正在準備解包 .../tesseract-ocr-osd_1%3a4.1.0-2_all.deb……
Unpacking tesseract-ocr-osd (1:4.1.0-2) ...
選取了原先未選的套件 tesseract-ocr。
正在準備解包 .../tesseract-ocr_5.5.0-1+b1_arm64.deb……
Unpacking tesseract-ocr (5.5.0-1+b1) ...
設定 libleptonica6:arm64 (1.84.1-4) ...
設定 tesseract-ocr-eng (1:4.1.0-2) ...
設定 libtesseract5:arm64 (5.5.0-1+b1) ...
設定 tesseract-ocr-osd (1:4.1.0-2) ...
設定 tesseract-ocr (5.5.0-1+b1) ...
執行 man-db (2.13.1-1) 的觸發程式……
執行 libc-bin (2.41-12+rpt1+deb13u2) 的觸發程式……

最後安裝 pytesseract 與 Pillow 套件 :

(myenv313) pi@pi400:~ $ pip install pytesseract Pillow   
Looking in indexes: https://pypi.org/simple, https://www.piwheels.org/simple
Collecting pytesseract
  Downloading https://www.piwheels.org/simple/pytesseract/pytesseract-0.3.13-py3-none-any.whl.metadata (11 kB)
Requirement already satisfied: Pillow in ./myenv313/lib/python3.13/site-packages (11.3.0)
Requirement already satisfied: packaging>=21.3 in ./myenv313/lib/python3.13/site-packages (from pytesseract) (25.0)
Downloading https://www.piwheels.org/simple/pytesseract/pytesseract-0.3.13-py3-none-any.whl (14 kB)
Installing collected packages: pytesseract
Successfully installed pytesseract-0.3.13

至此執行新版爬蟲所需的套件都齊了, 測試一下 :

(myenv313) pi@pi400:~ $ python ksml_lib_14.py abc 123456
主機 : pi400
[嘗試 1/3] 驗證碼辨識結果: 0003
[嘗試 1/3] 登入失敗,準備重試...
[嘗試 2/3] 驗證碼辨識結果: 43504
登入成功(第 2 次嘗試)
擷取借閱紀錄 ... OK
擷取預約紀錄 ... OK
資源已釋放
產生借書到期摘要 ... OK
產生預約書摘要 ... OK
{'message': abc 的資料已更新', 'status': 'success'}
執行時間:61.67458128929138

爬蟲程式可以順利執行. 這樣就可以為爬蟲程式添加可執行權限 :

pi@pi400:~ $ chmod +x ksml_lib_14.py   

由於 Pi 400 主要做為支援 pi3aplus 主機的爬蟲任務之用, 所以它所綁定的後端 render.com 伺服器為 fdof, 用 nano 修改 ksml_lib_14.py 中的網址 : 

    # --- 推送資料 ---
    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())

另外, 從 render.com 截取資料的程式 get_ksml_books_messages.py 也是綁定 fdof : 

# get_ksml_books_messages.py
import requests
import socket

host_name=socket.gethostname()
print(f'主機 : {host_name}')
params={'crawler': f'{host_name}'}
# for kaopi3
# url='https://serverless-5e6i.onrender.com/function/send_books_messages'
# for pi3aplus & pi400 (備援)
url='https://serverless-fdof.onrender.com/function/send_books_messages'
res=requests.get(url, params=params)
print(res)

最後把 crontab 中的爬蟲程式由 v13 改成 v14 即可 (雖然目前都被註解掉未執行). 

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

由於 6/18 市圖網站登入網頁改版, 加上了數字圖片驗證碼功能, v13 的爬蟲程式立馬崩潰無法登入帳戶擷取借閱與預約資訊, 整個六月又在忙著復原 MSI 電競桌機, 所以爬蟲改版事宜只好擺著, 直到今天才動手, 這次我使用剛上手的 Antigravity CLI 來重構舊版程式, 加上 OCR 圖形辨識功能, 選擇 Claude Sonnet 模型來規劃與實作, 參考 :


原始碼放在 GitHub :


但因為新版程式辨識驗證碼需要用到 Numpy 與其底層依賴庫, 必須安裝 OpenBLAS 與 tesseract-ocr 等套件, 這樣  ksml_lib_14.py 才能順利在 Pi 3 與 Pi 3A+ 上執行. 

# 更新套件清單
sudo apt-get update

# 安裝 OpenBLAS 及其相關的底層依賴庫
sudo apt update sudo apt install -y libopenblas-dev libatlas-base-dev

# 安裝 tesseract-ocr 套件 :
sudo apt-get install tesseract-ocr

# 安裝 pytesseract 與 Pillow 套件 : 
pip install pytesseract Pillow

# 把 ksml_lib_14.py 傳送到樹莓派後, 先用 chmod 添加可執行權限 :
chmod +x ksml_lib_14.py

# 修改 ksml_lib_14.py 中的 render.com 網址
# kaopi3 -> 5e6i 
# pi3aplus -> fdof 
# pi400 -> fdof (備用) 
nano ksml_lib_14.py

# 測試程式功能是否正常 :
python  ksml_lib_14.py <username> <password> 

# 更改 crontab 設定, 把 ksml_lib_13.py 改成 ksml_lib_14.py
crontab -e 

2026年4月23日 星期四

Python 學習筆記 : 市圖借書與預約爬蟲程式架構

這兩天完成市圖爬蟲改版 (v13), 由於此爬蟲機制較複雜, 由地端與雲端程式共同運作來維護佈署於 Render 的 Serverless 平台上的資料庫 (kmsl_books 資料表), 這與 v10 版之前的系統完全由地端爬蟲程式一手包辦爬取與傳訊完全不同. 爬蟲任務主要由放在高雄家的樹莓派主機負責, 目前主要是市圖借書與預約狀況與母校圖書館自動續借兩個任務而已, 今天抽空畫了一張架構圖以資備忘. 

Pi 3B 對接 render.com 上的 5e6i 這個端點 : 




Pi 3A+  則對接 render.com 上的 fdof 這個端點 :




佈署於樹莓派上的市圖爬蟲程式 ksml_lib_13.py 負責每小時爬取網頁擷取借書與預約狀態後, 呼叫佈署於 render.com 的 serverless 平台函式 update_ksml_books.py, 將資訊儲存在 kmsl_books 資料表裡, 結果如下 :




另外一支本地程式 get_books_messages.py 透過 crontab 驅動定時呼叫 serverless 平台函式 send_books_messages.py 讀取 kmsl_books 資料表, 然後由發送到 Telegram, 參考最近三版異動 :


母校圖書館爬蟲 nkust_lib_11.py 的任務相對簡單, 只是登入網站後按全部續借鈕, 然後瀏覽借書頁面, 擷取已被預約的書目後用 Telegram 傳送通知訊息而已, 參考 :


至於 Pi 400 上佈署的爬蟲與 Pi 3B 的相同 (對接 5e6i), 但正常情況會關掉 crontab 內的 ksml_lib_13.py (不執行), 僅執行 get_books_messages.py 與 nkust_lib_11.py, 若 Pi 3 主機出問題再開啟 Pi 400 的市圖爬蟲. 

注意, 這兩個新版圖書館爬蟲程式都需在 /home/pi 底下有一個 .env 隱藏檔, 記錄 Telegram 與母校圖書館, 爬蟲程式會讀取裡面的資料, 格式如下 :

TELEGRAM_TOKEN=我的權杖
TELEGRAM_ID=我的聊天室
NKUST_LIB_ID=圖書館帳號
NKUST_LIB_PWD=圖書館密碼

其次, 市圖爬蟲程式的帳密寫在 crontab 程式後面的參數中, 格式如下 (密碼相同) :

0 6,12,16 * * * /usr/bin/python /home/pi/nkust_lib_11.py
5 * * * * /usr/bin/python /home/pi/ksml_lib_13.py 帳號1 密碼
15 * * * * /usr/bin/python /home/pi/ksml_lib_13.py 帳號2 密碼
25 * * * * /usr/bin/python /home/pi/ksml_lib_13.py 帳號3 密碼
35 * * * * /usr/bin/python /home/pi/ksml_lib_13.py 帳號4 密碼
45 * * * * /usr/bin/python /home/pi/ksml_lib_13.py 帳號5 密碼
55 * * * * /usr/bin/python /home/pi/ksml_lib_13.py 帳號6 密碼
0 5,12,16 * * * /usr/bin/python /home/pi/get_ksml_books_messages.py

2026-06-05 補充 :

趁重灌 kaopi3 之便, 修改一下 get_ksml_books_messages.py 程式內容, 把對應的兩個 render.com 網址都寫進去, 以便萬一其中一台需要重灌時參考, 下面是 kaopi3 上的程式, 如果要移到 pi3aplus 上須更改作用的 url 變數 : 

# get_ksml_books_messages.py
import requests
import socket

host_name=socket.gethostname()
print(f'主機 : {host_name}')
params={'crawler': f'{host_name}'}
# for kaopi3
url='https://serverless-5e6i.onrender.com/function/send_books_messages'
# for pi3aplus & pi400 (備援)
# url='https://serverless-fdof.onrender.com/function/send_books_messages'
res=requests.get(url, params=params)
print(res)


2026-07-04 補充 :

因應市圖登入網頁增加數字圖片識別功能, 爬蟲程式已改版為 v14 (利用 Antigravity CLI 完成), 因為要用到圖像識別, 所以樹莓派需安裝執行 Numpy 所需的底層套件, 參考 :


注意, 與 kaopi3 與 pi3aplus 在系統 Python 環境下執行的方式不同的是, Pi 400 的備援爬蟲是在 myenv313 虛擬環境下執行的. 

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

2025年11月12日 星期三

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

上回改版為 v8 時沒有順便處理被預約書的到期日, 收到 Telegram 訊息時只知道哪些書被預約了, 還是要登入網站去查哪一天須還, 挺麻煩的, 今天花了點時間補抓到期日資訊, 改版為 v9. 

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


到期日資訊列在借閱書籍列表的第三欄 :




到期日被包裹在一個 P 元素裡面, 可用 data-qa="automation_mlc_record_date" 屬性定位, 其 XPATH 為 .//p[@data-qa="automation_mlc_record_date"] :

<p data-qa="automation_mlc_record_date" class="normal-text" ...>
  <span translate="nui.loan.due">到期</span>:
  12/01/2025, 23:59
</p>




只要用正規表達式 r'\d{2}/\d{2}/\d{4}' 就可以將 P 元素中符合日期格式的內容抓出來, 所以需要匯入標準庫中的 re 模組. 完整程式碼如下 :

# nkust_lib_9.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

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. 嘗試抓到期日
                    try:
                        due_elem=item.find_element(By.XPATH, './/p[@data-qa="automation_mlc_record_date"]')
                        due_text=due_elem.text.strip()
                        # 擷取出「12/01/2025」格式之到期日
                        m=re.search(r'\d{2}/\d{2}/\d{4}', due_text)
                        due_date=m.group(0) if m else ''
                    except Exception:
                        due_date=''                  
                    # 4. 判斷借閱狀態是否包含 "已續借"
                    if "已續借" not in item_text:
                        unrenew_books.append((title, due_date))
                    #print(f"[DEBUG] {title} -> {item_text} / 到期日: {due_date}")
                except Exception as e:        
                    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:
        browser.close()
        return msg

if __name__ == '__main__':
    start=time.time()
    msg=get_nkust_lib()
    token='Telegram 權杖'
    chat_id='Telegram 聊天室 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}')

主要的異動部分為抓到期日的藍色字部分 (每一本借閱書籍都會抓), 在步驟四判斷若該書被預約, 則將書名與到期日組成 tuple 放入 unrenew_books 串列裡, 因為從原本的書名字串改成 tuple, 所以組成 msg 字串時 t 是一個 tuple, 要用 t[0] 取出書名, 用 t[1] 取出到期日, 執行結果如下 :

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

2025-11-11 17:03:32
被預約的書:
 1. Python原力爆擊 : OpenAI / Gemini / AWS / Ollama生成式AI應用新手指南 / 柯克(Ko Ko)等著 (c.3) 到期日: 12/02/2025
 2. AI應用程式開發 : 活用ChatGPT與LLM技術開發實作 / Olivier Caelen, Marie-Alice Blete著 ; 藍子軒譯 到期日: 12/01/2025
 3. Python金融市場賺大錢聖經 : 寫出你的專屬指標 / 張峮瑋作 到期日: 11/15/2025
執行時間:354.4999508857727

收到的 Telegram 訊息 :




最後將 v9 程式添加可執行權限, 並更改 crontab 中定時執行的爬蟲程式為 v9 : 

pi@raspberrypi:~ $ chmod +x nkust_lib_9.py
pi@raspberrypi:~ $ ls -ls nkust_lib_9.py  
8 -rwxr-xr-x 1 pi pi 6148 11月 12 08:31 nkust_lib_9.py
pi@raspberrypi:~ $ crontab -e   

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

crontab: installing new crontab

終於弄成自己想要的樣子啦, 收工. 

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

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

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.