2024年5月24日 星期五

Python 學習筆記 : 網頁爬蟲實戰 (九) 市立圖書館個人書房借書資訊 (中)

由於前一篇測試的篇幅過長, 後續測試紀錄於此篇, 本系列之前的筆記參考 : 



四. 以無頭模式執行 Seleniun 爬蟲 :     

在前一篇測試中執行 Selenium 爬蟲程式時, 呼叫 webdriver.Firefox() 或 webdriver.Chrome() 會自動開啟一個瀏覽器視窗, 然後程式碼會模擬手動操作瀏覽器的程序完成網頁資料的擷取, 但這方式有兩個缺點, 一是讓人感到突兀 (雖然不會干擾桌面其他程式作業); 二是開啟實體瀏覽器需要時間, 使爬蟲執行速度變慢, 無頭模式就是用來解決這兩個問題. 

所謂無頭模式即不開啟實體瀏覽器, 而是由執行實例在背景中默默地執行. 由於 Selenium 改版, u以前開啟無頭模式的做法已被廢棄, 新的方法參考下面教學 :


首先要匯入 Options 類別 :

from selenium.webdriver.firefox.options import Options  

如果使用 Chrome :

from selenium.webdriver.chrome.options import Options  

然後建立 Options 物件, 並在呼叫其 add_argument() 方法時傳入 "--headless" 參數 :

options=Options()  
options.add_argument("--headless")  

最後在建立 WebDriver 物件時傳入這個 Options 物件 :

browser=webdriver.Firefox(options=options)  

如果使用 Chrome :

browser=webdriver.Chrome(options=options)    

將上一篇的程式碼修改如下 :

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.options import Options    
import re
import time

def get_books(account, password):
    try:
        # 登入我的書房
        options=Options()   
        options.add_argument("--headless")   
        browser=webdriver.Firefox(options=options)    
        browser.implicitly_wait(20)
        browser.get("https://webpacx.ksml.edu.tw/personal/")
        loginid=browser.find_element(By.ID, "loginid")
        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')
        browser.implicitly_wait(20)
        login_btn.click()
        # 擷取借閱紀錄
        div_redblock=browser.find_element(By.CLASS_NAME, 'redblock')
        browser.implicitly_wait(20)
        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()
            pattern=r'\d{4}-\d{2}-\d{2}'
            due_date=book.find_element(By.XPATH, './ul[4]/li[2]').text
            item['due_date']=re.findall(pattern, 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]   
            borrow_books.append(item)
        browser.back() # 回上一頁
        # 擷取預約紀錄
        div_blueblock=browser.find_element(By.CLASS_NAME, 'blueblock')
        browser.implicitly_wait(20)
        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
            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()
    account, password='amy08123', '123456'
    borrow_books, reserve_books=get_books(account, password)
    print(f'借閱書籍:\n{borrow_books}')
    print(f'預約書籍:\n{reserve_books}')
    end=time.time()
    print(f'執行時間:{end-start}')

此處黃底色的為新增的部分. 執行結果如下 :

>>> %Run ksml_personal_4.py    
借閱書籍:
[{'book_name': '一行指令學Python : 用機器學習掌握人工智慧', 'due_date': '2024-06-05', 'due_times': '0'}, {'book_name': '一本精通 Python範例應用大全 : Python詳細語法教學&100+個Python範例', 'due_date': '2024-06-05', 'due_times': '0'}]
預約書籍:
[{'book_name': 'AI黃金時期正好學 : TensorFlow 2高手有備而來', 'sequence': '2'}, {'book_name': 'Python3.x網頁資料擷取與分析特訓教材', 'sequence': '1'}, {'book_name': 'Python機器學習錦囊妙計 : 涵蓋預處理到深度學習的實務處方', 'sequence': '4'}, {'book_name': 'PyTorch深度學習攻略 : 核心開發者親授!', 'sequence': '2'}, {'book_name': '用Python學AIoT智慧聯網', 'sequence': '1'}]
執行時間:19.934646129608154

執行過程中確實不會開啟 Firefox 瀏覽器, 而是在背景中默默執行後輸出結果.  


五. 在 Mapleboard 上測試 Seleniun 爬蟲程式 :     

完成上面測試後本來想將其移植到樹莓派 Pi 3 上跑看看, 今年三月時曾於鄉下老家那台 Pi 3 測試 Selenium + Chronium 成功, 參考 : 


但這次不僅使用 Firefox 的驅動程式失敗, 連之前 OK 的 Chromium 也出現錯誤. 無奈只好轉到 Mapleboard 的 Ubuntu Mate 上試試, 經過一番折騰終於成功, 參考 :


由於在 Mapleboard 上安裝的 Selenium 與在 LG Gram 筆電 Win 11 上安裝的版本一樣都是 v4.21.0, 所以語法幾乎一樣, 只有載入 Firefox 驅動程式的方式不同, 要使用 Service 類別來做, 摘要如下 :

from selenium.webdriver.chrome.service import Service    # 匯入 Service 類別
driverpath='./geckodriver'                     # 指定驅動程式連結的路徑 
firefox_service=Service(driverpath)     # 建立 Service 物件

若要用無頭模式, 則在呼叫 webdriver() 時就要傳入 Options 與 Service 物件了 :

browser=webdriver.Firefox(options=options, service=firefox_service)   

將上面的程式修改如下 :

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.options import Options
from selenium.webdriver.firefox.service import Service  
import re
import time

def get_books(account, password):
    try:
        # 登入我的書房
        options=Options() 
        options.add_argument("--headless") 
        driverpath='./geckodriver' 
        service=Service(driverpath) 
        browser=webdriver.Firefox(options=options, service=service)  # options 必須在前
        browser.implicitly_wait(20)
        browser.get("https://webpacx.ksml.edu.tw/personal/")
        loginid=browser.find_element(By.ID, "loginid")
        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')
        browser.implicitly_wait(20)
        login_btn.click()
        # 擷取借閱紀錄
        div_redblock=browser.find_element(By.CLASS_NAME, 'redblock')
        browser.implicitly_wait(20)
        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()
            pattern=r'\d{4}-\d{2}-\d{2}'
            due_date=book.find_element(By.XPATH, './ul[4]/li[2]').text
            item['due_date']=re.findall(pattern, 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]   
            borrow_books.append(item)
        browser.back() # 回上一頁
        # 擷取預約紀錄
        div_blueblock=browser.find_element(By.CLASS_NAME, 'blueblock')
        browser.implicitly_wait(20)
        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
            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()
    account, password='amy08123', '123456'
    borrow_books, reserve_books=get_books(account, password)
    print(f'借閱書籍:\n{borrow_books}')
    print(f'預約書籍:\n{reserve_books}')
    end=time.time()
    print(f'執行時間:{end-start}')

黃底色部分就是比上面在 Win11 上跑的多出來的部分, 執行結果 OK, 同樣是在背景執行, 不會跳出 Firefox 視窗. 注意, 呼叫 webdriver.Firefox() 時 options 必須是第一參數.  

%Run ksml_books_1.py   
借閱書籍:
[{'book_name': '一行指令學Python : 用機器學習掌握人工智慧', 'due_date': '2024-06-05', 'due_times': '0'}, {'book_name': '一本精通 Python範例應用大全 : Python詳細語法教學&100+個Python範例', 'due_date': '2024-06-05', 'due_times': '0'}]
預約書籍:
[{'book_name': 'AI黃金時期正好學 : TensorFlow 2高手有備而來', 'sequence': '2'}, {'book_name': 'Python3.x網頁資料擷取與分析特訓教材', 'sequence': '1'}, {'book_name': 'Python機器學習錦囊妙計 : 涵蓋預處理到深度學習的實務處方', 'sequence': '4'}, {'book_name': 'PyTorch深度學習攻略 : 核心開發者親授!', 'sequence': '2'}, {'book_name': '用Python學AIoT智慧聯網', 'sequence': '1'}]
執行時間:47.18455147743225

也可以使用 Mapleboard 內建的 Chromium 瀏覽器來測試, 作法參考 : 


將上面的 Firefox 版程式改成 Chromium 版如下 : 

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
import time

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(20)
        browser.get("https://webpacx.ksml.edu.tw/personal/")
        loginid=browser.find_element(By.ID, "loginid")
        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')
        browser.implicitly_wait(20)
        login_btn.click()
        # 擷取借閱紀錄
        div_redblock=browser.find_element(By.CLASS_NAME, 'redblock')
        browser.implicitly_wait(20)
        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()
            pattern=r'\d{4}-\d{2}-\d{2}'
            due_date=book.find_element(By.XPATH, './ul[4]/li[2]').text
            item['due_date']=re.findall(pattern, 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]   
            borrow_books.append(item)
        browser.back() # 回上一頁
        # 擷取預約紀錄
        div_blueblock=browser.find_element(By.CLASS_NAME, 'blueblock')
        browser.implicitly_wait(20)
        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
            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()
    account, password='amy08123', '123456'
    borrow_books, reserve_books=get_books(account, password)
    print(f'借閱書籍:\n{borrow_books}')
    print(f'預約書籍:\n{reserve_books}')
    end=time.time()
    print(f'執行時間:{end-start}')

執行結果如下 :

>>> %Run ksml_books_3.py  
借閱書籍:
[{'book_name': '一行指令學Python : 用機器學習掌握人工智慧', 'due_date': '2024-06-05', 'due_times': '0'}, {'book_name': '一本精通 Python範例應用大全 : Python詳細語法教學&100+個Python範例', 'due_date': '2024-06-05', 'due_times': '0'}]
預約書籍:
[{'book_name': 'AI黃金時期正好學 : TensorFlow 2高手有備而來', 'sequence': '2'}, {'book_name': 'Python3.x網頁資料擷取與分析特訓教材', 'sequence': '1'}, {'book_name': 'Python機器學習錦囊妙計 : 涵蓋預處理到深度學習的實務處方', 'sequence': '4'}, {'book_name': 'PyTorch深度學習攻略 : 核心開發者親授!', 'sequence': '2'}, {'book_name': '用Python學AIoT智慧聯網', 'sequence': '1'}]
執行時間:22.50754141807556

哇, Mapleboard 實在令我驚艷, 本想添購 Pi 4 或 Pi 5 的打算漸漸消融了. 我反覆測試上面 Firefox 與 Chromium 版的程式, 發現 Chromium 跑得比 Firefox 快一倍, 所以正式佈署上線時應該採用 Chromium 來跑較好. 

Python 學習筆記 : 網頁爬蟲實戰 (九) 市立圖書館個人書房借書資訊 (上)

終於來到這波 Python 爬蟲學習的主要目的, 這次要爬的是高雄市立圖書館的個人書房網頁, 原因是目前市圖網站提醒用戶的機制是, 在書籍應還日期前 7 日連續 3 天與逾期第 3 天發出通知信,  但我常常一忙就忘記還書而逾期被罰停權. 如果能在到期前一周與逾期三天內都天天通知我那就好了. 但求人不如求己, 乾脆自己寫個爬蟲程式每天自動用 Line 提醒我今天有哪些書到期, 這樣就不用花時間上網查詢了.

本系列之前的筆記參考 : 



一. 檢視目標網頁 :     

高雄市立圖書館個人書房登入網址如下 :


使用 Chrome 擴充套件 Quick Javascript Switcher 檢查發現這個登入表單在取消 Javascript 執行功能後會消失, 表示登入網頁是 Javascript 生成的, 事實上, 整個市圖網站的內容都是透過 Javascript 產生的, 因此 requests 套件在此派不上用場, 必須使用 Selenium 才能解決. 

首先來看看手動查詢個人書房的程序 : 




輸入帳號密碼後按 "登入" 鈕, 成功會進入個人書房, 網頁內容以卡片容器方式呈現, 我們的目標是要取得目前的借閱與預約紀錄 :




按 "借閱紀錄/續借" 卡片任一處會顯示目前之借閱書單 : 




按 "預約紀錄" 卡片任一處會顯示預約清單 :




總之, 市圖的個人書房網頁必須先登入才能取得資料, 而且這網站看來是使用網頁設計軟體製作的, 大部分的內容是透過 Javascript 生成與編碼過, 這不是 requests 能輕易擷取的. 


二. 使用 Selenium 操作瀏覽器 :     

使用 Selenium 來取得網頁內容必須先花點時間手動操作瀏覽器, 探索與定位目標資料的位置, 確定目標元素的 id, class, 或 name 等屬性, 這主要是仰賴瀏覽器提供的開發者工具來達成. 

以 Chrome 瀏覽器開啟個人書房的登入頁面, 按 F12 開啟開發者工具頁面, 重新載入頁面後在開發者工具頁面的 Elements 頁籤中按 F12 搜尋 '帳號' 就可以找到登入表單位置 :




其 HTML 碼如下 : 

<form>
  <div class="form_grp form_inline">
    <label for="loginid">帳號</label>
    <input id="loginid" type="text" aria-label="請輸入借閱證號或身分證字號" placeholder="請輸入借閱證號或身分證字號" value="">
  </div>
  <div class="form_grp form_inline password_toggle">
      <label for="pincode">密碼</label>
      <input id="pincode" type="password" autocomplete="off" aria-label="請輸入密碼" placeholder="請輸入密碼" value="">
      <button type="button" class="btn btn-icon btn_icon_hide">開啟</button>
  </div>
  <div class="btn_grp"><input name="" type="submit" value="登入"></div>
  <div class="unable_login">
      <h3><span>無法登入</span></h3>
      <input type="button" value="忘記密碼"><input type="button" value="網路辦證">
   </div>
</form>

可見在登入表單中, 輸入帳密的兩個欄位與登入按鈕是在一個無任何屬性的 form 元素內, 帳號欄位 (input 元素) 的 id 為 loginid; 密碼欄位 (input 元素) 的 id 為 pincode, 先記錄下來備用 : 


 登入表單欄位 id 屬性
 帳號 loginid
 密碼 pincode


登入按鈕 (input 元素) 跟 form 元素一樣都是無任何屬性, 但其父元素 div 則有一個 class='btn_grp' 屬性, 我們可以先取得 div 的 WebElement 物件, 然後再用 find_element() 找到登入按鈕的 WebElement 物件. 

本篇使用的 Selenium 為 v4.11.2 版 :

>>> import selenium   
>>> selenium.__version__    
'4.11.2'     

注意, 以前寫的舊版筆記中的一些用法已被廢棄. 

首先載入 Selennium 套件中的 webdriver 模組與 By 類別 : 

>>> from selenium import webdriver   
>>> from selenium.webdriver.common.by import By   
 
新版 Selenium 大幅翻修了其 API, 許多之前用來搜尋元素的 find_element_by_xxx() 與 find_elements_by_xxx() 方法都被廢棄了, 簡化為 find_element() 與 find_elements() 方法, 然後根據傳入的第一參數 (By 類別定義的常數例如 By.TAG_NAME) 來決定搜尋對象. 

接著呼叫 webdriver 模組的 Firefox() 函式, 這會建立了一個代表瀏覽器的 WebDriver 物件, 同時開啟一個新的 Firefox 瀏覽器視窗, 此 WebDriver 物件即代表此瀏覽器視窗 : 

>>> browser=webdriver.Firefox()   
>>> type(browser)   
<class 'selenium.webdriver.firefox.webdriver.WebDriver'>

此處呼叫 Chrome() 也可以, 都會建立一個 WebDriver 物件, 但因我的 Chrome 版本較新, 與 Selenium 不匹配, 因此改用 Firefox(). 

然後在用 WebDriver 開啟網頁之前先呼叫 implicitly_wait() 方法並傳入預設等待秒數 : 

>>> browser.implicitly_wait(20)     
>>> browser.get("https://webpacx.ksml.edu.tw/personal/")     # 開啟我的書房登入頁面

這時瀏覽器會顯示我的書房登入頁面, 接下來就要利用 Selenium 來填入帳號密碼並按下登入鈕來登入網站, 這可以呼叫 WebDriver 的 find_element() 方法來達成. 從上面的探索可知, 登入表單中的帳號與密碼欄位可用 loginid 與 pincode 這兩個 id 來定位 : 

>>> loginid=browser.find_element(By.ID, "loginid")    
>>> loginid.send_keys('amy08123')     
>>> pincode=browser.find_element(By.ID, 'pincode')    
>>> pincode.send_keys('123456')   

執行完這四個指令後, Selenium 開啟的瀏覽器視窗已填入帳密, 接下來只要模擬按下 "登入" 鈕的動作即可. 但從上面的探索可知, 這個按鈕並沒有 id, name 或 class 屬性來讓我們定位它, 但它的父元素 div 有 class 屬性, 我們可以先呼叫 WebBrowser 物件的 find_element() 方法用 By.CLASS_NAME 取得 div 元素之 WebElement 物件 :

>>> div_btn_grp=browser.find_element(By.CLASS_NAME, 'btn_grp')  # 取得父物件
>>> type(div_btn_grp)   
<class 'selenium.webdriver.remote.webelement.WebElement'>

再呼叫其 find_element() 方法用 By.TAG_NAME 取得登入按鈕之 WebElement 物件 :

>>> login_btn=div_btn_grp.find_element(By.TAG_NAME, 'input')   
>>> login_btn.get_attribute('value')     # 取得 value 屬性確認為登入按鈕
'登入'   

此處呼叫 get_attribute() 方法取得登入按鈕物件之 value 屬性以確認是否為登入按鈕, 這樣只要呼叫 btn_login 的 click() 方法就能模擬按下登入鈕的動作了 :

>>> browser.implicitly_wait(20) 
>>> login_btn.click()     

因為登入時後端驗證帳密需要時間, 故先呼叫 implicitly_wait() 等待 20 秒, 其實若 5 秒就收到回應也不會硬要等 20 秒, 而是馬上結束等待. 

登入成功後即進入個人書房頁面, 我們在上面探索時已知借閱紀錄與預約紀錄分別放在紅色與藍色卡片裡, 事實上這些卡片是層疊的 div 元素組成的區塊, 我們需要取得區塊內任一元素的 WebElement 物件, 然後呼叫其 click() 方法模擬手動在卡片上點擊的動作才能進入借閱紀錄或預約紀錄的頁面. 

在我的書房頁面的開發者工具之 Elements 頁籤中按 Ctrl+F 搜尋 "借閱紀錄" 就可快速找到紅色卡片區塊 : 




逐一打開卡片區塊裡面有三個小點的 div, 其中 class='mycardlist' 的 ul 元素, 它底下的第一個 li 元素就是借閱紀錄卡片, 第二個 li 則是預約紀錄, 這就是我們的目標資料所在 : 




借閱與閱約紀錄這兩張卡片的 HTML 碼如下 :

<ul class="mycardlist">
   <li>
      <div class="cardblock redblock">
         <h3>借閱紀錄/續借</h3>
         <div class="data"><span><em>2</em>本借閱中</span><span class=""><em>0</em>本逾期</span></div>
      </div>
   </li>
   <li>
      <div class="cardblock blueblock">
         <h3>預約紀錄</h3>
         <div class="data"><span><em>5</em>本預約中</span> <span class=""><em>0</em>本預約到館</span></div>
      </div>
   </li>

   ... (略) ... 

可見每張卡片實際上是放在一個 div 元素裡, 第一個 div 元素具有 class='redblock' 屬性值, 儲存借閱紀錄的統計概況 (借閱幾本, 幾本逾期), 第二個 div 具有 class='blueblock' 是預約紀錄. 只要取得這兩個 li 元素的 WebElement 物件, 呼叫其 click() 方法就能前往借閱中的書單或預約中的書單網頁. 先前往借閱紀錄網頁 : 

>>> div_redblock=browser.find_element(By.CLASS_NAME, 'redblock')
>>> browser.implicitly_wait(20) 
>>> div_redblock.click()   

這樣 WebBrowser 物件開啟的瀏覽器就會進入借閱紀錄頁面, 檢視開發者工具視窗的 Elements 頁籤, 會發現此網頁是由層疊的 div 構成, 從 id='__next' 的 div 一路往下找 : 

class='main' -> class='' -> 'maincolumn' -> 'container' -> 'mainrightblock' -> 'rightlineblock' -> 'booklist_block' -> 'booklist' -> 'bookdata'

或者按 Ctrl+F 搜尋 bookdata 即可快速找到借閱的書單資訊 :




可見目前此帳號只借了兩本書 (只有兩個 li), 展開第一本書的 div 元素 (class='bookdata'), 發現其子元素包含 1 個 h2 與 7 個 ul 元素, 書名就放在 h2 裡面, 應還日期放在第 4 個 ul 的第 2 個 li 裡面 (用 span 包裹), 續借次數則是在第 5 個 ul 的第 1 個 li 裡面, 這種層次順序關係很適合用 XPATH 搜尋 :





展開第二本書的 div 元素 :




先以 class='bookdata' 搜尋借閱書籍數量 : 

>>> books=browser.find_elements(By.CLASS_NAME, 'bookdata')   
>>> len(books)  
2

取得書名可用 text 屬性 : 

>>> books[0].find_element(By.TAG_NAME, 'a').text    
'一行指令學Python : 用機器學習掌握人工智慧 /'
>>> books[1].find_element(By.TAG_NAME, 'a').text   
'一本精通 Python範例應用大全 : Python詳細語法教學&100+個Python範例 /'

用 XPATH 來搜尋更好理解, 關於 XPATH 用法參考下列教學 :


>>> books[0].find_element(By.XPATH, './h2/a').text    
'一行指令學Python : 用機器學習掌握人工智慧 /'
>>> books[1].find_element(By.XPATH, './h2/a').text   
'一本精通 Python範例應用大全 : Python詳細語法教學&100+個Python範例 /'

取得應還日期與續借次數, 注意, XPATH 的索引是 1 起始的, 與 Python 從 0 起始不同 : 

>>> books[0].find_element(By.XPATH, './ul[4]/li[2]').text  
'應還日期 : 2024-06-05'
>>> books[0].find_element(By.XPATH, './ul[5]/li[1]').text   
'續借次數:0'
>>> books[1].find_element(By.XPATH, './ul[4]/li[2]').text   
'應還日期 : 2024-06-05'
>>> books[1].find_element(By.XPATH, './ul[5]/li[1]').text   
'續借次數:0'

但是如果有書本有逾期, 則應還日期後面會加註已逾期幾天, 例如 '2024-05-23(共逾期1天)', 這些加註會影響到後續處理, 這可以用正規式 r'\d{4}-\d{2}-\d{2}' 來將 YYYY-mm-dd 格式的日期訊息抓出來 : 

>>> import re 
>>> due_date='2024-05-23(共逾期1天)'   
>>> re.findall(r'\d{4}-\d{2}-\d{2}', due_date)[0]     
'2024-05-23'

同樣地, 要抽取續借次數也是使用正規式, 因為次數只有 0, 1, 2 這三種可能性, 故可用正規式 r'\d{1}' 抓出來 :

>>> due_times='續借次數:0'   
>>> re.findall(r'\d{1}', due_times)[0]    
'0'

可將上述程序寫成迴圈來一次抓出所借閱的全部書籍資訊, 整理後存成字典串列 :

>>> 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()   # 去除 / 字元與頭尾空格
    pattern=r'\d{4}-\d{2}-\d{2}'   
    due_date=book.find_element(By.XPATH, './ul[4]/li[2]').text   
    item['due_date']=re.findall(pattern, 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]      
    borrow_books.append(item)    
>>> borrow_books    
[{'book_name': '一行指令學Python : 用機器學習掌握人工智慧', 'due_date': '2024-06-05', 'due_times': '0'}, {'book_name': '一本精通 Python範例應用大全 : Python詳細語法教學&100+個Python範例', 'due_date': '2024-06-05', 'due_times': '0'}]

這樣就取得我們所需要的借閱紀錄資訊了. 注意, 此處對書名作了一些整理, 去除書名尾部常有的 '/' 字元與兩端空格, 到期日期與續借次數則利用正規式來抽取後面的日期與數字部分. 

接下來要回上一頁進入點預約紀錄卡片, 進入預約書目網頁 : 

>>> browser.back()     # 回上一頁
>>> div_blueblock=browser.find_element(By.CLASS_NAME, 'blueblock')    # 預約卡片
>>> browser.implicitly_wait(20)   
>>> div_blueblock.click()    # 進入預約紀錄頁面

預約紀錄頁面的結構與上面借閱紀錄幾乎是一樣的, 都是放在 class='bookdata' 的 div 下面, 因此操作方式相同, 但是關心的欄位有些不同, 目標資料除了 h2 元素裡面的書名外, 還有第 7 個 ul 元素裡面的預約順位 :




先看看預約了幾本書 :

>>> books=browser.find_elements(By.CLASS_NAME, 'bookdata')      
>>> len(books)      
5

預約順位放在第 7 個 ul 下的第 1 個 li 內 :

>>> sequence=books[0].find_element(By.XPATH, './ul[7]/li[1]').text   
>>> sequence   
'流通狀態 : 預約中,順位第2位'

如果要取出其中的順位數字用正規式最帥 :

>>> import re 
>>> re.findall(r'\d+', sequence)    
['2']

因此取得全部預約書目資料的迴圈可從上面借約書目的修改如下 : 

>>> 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     
    item['sequence']=re.findall(r'\d+', sequence)[0]    # findall() 傳回串列
    reserve_books.append(item)    
  
>>> reserve_books    
[{'book_name': 'AI黃金時期正好學 : TensorFlow 2高手有備而來', 'sequence': '2'}, {'book_name': 'Python3.x網頁資料擷取與分析特訓教材', 'sequence': '2'}, {'book_name': 'Python機器學習錦囊妙計 : 涵蓋預處理到深度學習的實務處方', 'sequence': '4'}, {'book_name': 'PyTorch深度學習攻略 : 核心開發者親授!', 'sequence': '3'}, {'book_name': '用Python學AIoT智慧聯網', 'sequence': '2'}]

這樣便完成目標資料的擷取作業了. 


三. 將 Selenium 爬蟲寫成函式 :     

上面測試針對單一帳號登入市圖取得借閱紀錄與預約紀錄, 接下來要將其寫成一個函式, 傳入帳密會傳回該帳號的借閱與預約資訊, 完整程式碼如下 : 

from selenium import webdriver
from selenium.webdriver.common.by import By
import re
import time

def get_books(account, password):
    try:
        # 登入我的書房
        #browser=webdriver.Chrome()
        browser=webdriver.Firefox()
        browser.implicitly_wait(20)
        browser.get("https://webpacx.ksml.edu.tw/personal/")
        loginid=browser.find_element(By.ID, "loginid")
        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')
        browser.implicitly_wait(20)
        login_btn.click()
        # 擷取借閱紀錄
        div_redblock=browser.find_element(By.CLASS_NAME, 'redblock')
        browser.implicitly_wait(20)
        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()
            pattern=r'\d{4}-\d{2}-\d{2}'
            due_date=book.find_element(By.XPATH, './ul[4]/li[2]').text
            item['due_date']=re.findall(pattern, 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]   
            borrow_books.append(item)
        browser.back() # 回上一頁
        # 擷取預約紀錄
        div_blueblock=browser.find_element(By.CLASS_NAME, 'blueblock')
        browser.implicitly_wait(20)
        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
            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()
    account, password='amy08123', '123456' 
    borrow_books, reserve_books=get_books(account, password)
    print(f'借閱書籍:\n{borrow_books}')
    print(f'預約書籍:\n{reserve_books}')
    end=time.time()
    print(f'執行時間:{end-start}')

由於 Selenium 連線網路可能會有例外發生 (網站當機, 網路有問題等), 故所有爬蟲程式碼都放在 try catch 中捕捉可能的例外, 如果發生錯誤會印出例外資訊後傳回 None, 否則把擷取到的借閱與預約資訊以 tuple 傳回, 結果如下 : 

>>> %Run kslm_personal_2.py   
借閱書籍:
[{'book_name': '一行指令學Python : 用機器學習掌握人工智慧', 'due_date': '2024-06-05', 'due_times': '0'}, {'book_name': '一本精通 Python範例應用大全 : Python詳細語法教學&100+個Python範例', 'due_date': '2024-06-05', 'due_times': '0'}]
預約書籍:
[{'book_name': 'AI黃金時期正好學 : TensorFlow 2高手有備而來', 'sequence': '2'}, {'book_name': 'Python3.x網頁資料擷取與分析特訓教材', 'sequence': '2'}, {'book_name': 'Python機器學習錦囊妙計 : 涵蓋預處理到深度學習的實務處方', 'sequence': '4'}, {'book_name': 'PyTorch深度學習攻略 : 核心開發者親授!', 'sequence': '3'}, {'book_name': '用Python學AIoT智慧聯網', 'sequence': '2'}]
執行時間:15.557983636856079

結果顯示執行時間約 15 秒. 

哎呀, 不錯不錯, 忙了三天終於達成願望了. 

參考 : 



2024-07-05 補充 :

今天在借書訊息中新增擷取流通狀態, 如果出現 "有人預約" 字眼, 就把它紀錄起來以便於 Line 訊息中顯示 :






測試如下 : 

>>> from selenium import webdriver
browser=webdriver.Firefox()
browser.implicitly_wait(20)
browser.get("https://webpacx.ksml.edu.tw/personal/")
loginid=browser.find_element('id', "loginid")
loginid.send_keys('9061792') 
pincode=browser.find_element('id', 'pincode')
pincode.send_keys('6543')
div_btn_grp=browser.find_element('class name', 'btn_grp')
login_btn=div_btn_grp.find_element('tag name', 'input')
login_btn.click()
div_redblock=browser.find_element('class name', 'redblock')
div_redblock.click()

>>> books[0].find_element('tag name', 'a').text   
'一本精通 Python範例應用大全 : Python詳細語法教學&100+個Python範例 /'
>>> books=browser.find_elements('class name', 'bookdata')   
>>> books[0].find_element(By.XPATH, './ul[6]/li[1]').text    
'流通狀態:有人預約'

在 get_books() 函式中新增 state 變數 : 

            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)

注意, 因為只有在書籍有被預約情況或尚未被預約但還無法續借下才會出現流通狀態, 也才會有第六個 ul 出現, 所以此處用 try except 來捕捉沒有第 6 個 ul 的例外, 這時 state 就設為空字串, 存入 item['state'] 的也是空字串.

在主程式中則添加此流通狀態 :

            for book in borrow_books:
                book_name=book['book_name']   
                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})'
                    borrow.append(msg)
                elif delta == 0:
                    msg=f'⓿ {book_name} (今日到期, 續借次數 {due_times}{state})'
                    borrow.append(msg)
                elif delta == 1:   
                    msg=f'❶ {book_name} (明日到期, 續借次數 {due_times}{state})'
                    borrow.append(msg)
                elif delta == 2:   
                    msg=f'❷ {book_name} (後天到期, 續借次數 {due_times}{state})'
                    borrow.append(msg)
                elif 2 < delta < 8:   
                    msg=f'✦ {book_name} ({book["due_date"]} 到期, '\
                        f'續借次數 {due_times}{state})'
                    borrow.append(msg)

測試 OK : 



2024年5月22日 星期三

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

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

本系列之前的筆記參考 : 


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


一. 基本的表單 : 

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

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





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

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

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



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




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




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

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

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

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


二. 上傳欄位表單 : 

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


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


檢視原始碼如下 :

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

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



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




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




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





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

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

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


三. 登入表單 : 

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

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





檢視原始碼如下 :

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

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

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




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





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





在開發者工具視窗的 Application 頁籤可以找到伺服器傳回之 cookies 資訊 : 




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

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

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

>>> url='https://pythonscraping.com/pages/cookies/profile.php'      
>>> res=requests.post(url, cookies=res.cookies  # 提出第二個 POST 請求 (回傳 cookies)
>>> res.text   
"Hey Tony! Looks like you're still logged into the site!"

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

除了在登入成功後的後續請求中傳回 cookies 讓伺服器知道你是已登入之使用者外, 還可以在伺服端儲存 Session 變數來達到相同目的, 事實上使用 Session 比較好, 因為瀏覽器可以關閉 cookies 功能. 

>>> import requests  
>>> session=requests.Session()     # 建立 Session 物件
>>> url='https://pythonscraping.com/pages/cookies/welcome.php' 
>>> data={'username': 'Tony', 'password': 'password'}     
>>> res=session.post(url, data=data)      # 用 Session 物件提出 POST 請求
>>> print(f'cookies: {res.cookies.get_dict()}')     # 使用 Session 也會傳回 cookies
cookies: {'loggedin': '1', 'username': 'Tony'}
>>> url='https://pythonscraping.com/pages/cookies/profile.php'   
>>> res=session.post(url)      # 用 Session 物件提出第二個 POST 請求
>>> res.text   
"Hey Tony! Looks like you're still logged into the site!"

結果與上面使用 post(url, coookies) 相同.

2024年5月21日 星期二

參加 AIGC 圖表大師課程

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

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


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