2024年5月24日 星期五

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 : 



沒有留言 :