2024年5月9日 星期四

Python 學習筆記 : 網頁爬蟲實戰 (六) 博客來書店每日一書 66 折網頁

本篇要爬的對象是博客來網路書店的每日一書 66 折活動網頁, 可以結合 Line Notify 服務將今日 66 折訊息傳到 Line, 對於愛書的人來說, 能用 66 折買到想看的書是一件很爽的事. 



一. 檢視目標網頁 : 

博客來網路書店的每日一書 66 折優惠活動網址如下 :





右上角即為今日 66 折的書, 檢視網頁原始碼可知這是一個 utf-8 編碼的網頁, 但找不到今日 66 折書籍的內容, 事實上它是在網頁載入後透過 XHR (Ajax) 非同步技術向伺服器提出 Ajax 請求所回應的動態內容, 回應網頁會被嵌入到 id=E180629000000001_94 的 div 容器中 : 

<h2>今日66折</h2>
<div class="countdown" id="cntdwn">05/09(四)</div>
<div class="wrap" id=E180629000000001_94>
</div></div><script type="text/javascript">
    $(document).ready(function () {
        var module_id = "E180629000000001_94";//模組編碼參數
        var E180629000000001_94 = new ajax_info(module_id,'getBooks66OfTheDayAjax','P','{}');
    });
</script>

底下的那段 Javascript 程式碼就是利用 jQuery 提出 Ajax 請求並將回應內容載入 div 元素中. 這種利用 Javascript 動態嵌入內容的網頁可以利用 Chrome 擴充套件 Quick Javascript Switcher 來確認 : 關閉 Javascript 時今日 66 折的內容會消失; 開啟時才會出現, 參考 : 


活動網頁下方為未來 6 天 66 折書籍預告, 這部分為靜態網頁內容, 不是 Ajax 動態嵌入, 故可以用 BeautifulSoup 剖析後將訊息擷取出來. 


二. 尋找目標網頁之 XHR 回應網址 : 

首先來處理右上方今日 66 折的部分, 因為透過 Ajax 取得的動態內容一定會出現在回應資源的網址中, 只要找到該網址就能取得目標網頁內容. 

在 Chrome 瀏覽器按 F12 開啟開發者視窗, 然後重新整理 66 折活動網頁, 切換至 Network 與 Fetch/XHR 頁籤, 點選右下角的 Response 頁籤就可以在左邊看到所有回應資源的網址 :




一個個去點選左邊的回應網址, 然後觀察右邊 Response 頁籤內容, 就能找到下面這個才是傳回今日 66 折書籍資訊的網址 : 


將此網址貼到瀏覽器網址列確認這確實是我們要找的目標網頁 : 




這樣直接去抓這個網址就可以了. 


三. 擷取目標網頁 : 

接下來使用 requests.get() 擷取目標網頁, 然後將回應網頁內容丟給 BeautifulSoup 剖析以便能從其建立之語法樹中取得目標資訊 : 

>>> import requests   
>>> from bs4 import BeautifulSoup     
>>> url='https://activity.books.com.tw/crosscat/ajaxinfo/getBooks66OfTheDayAjax/P?uniqueID=E180629000000001_94'    
>>> res=requests.get(url)   
>>> soup=BeautifulSoup(res.text, 'lxml')   

呼叫 BeautifulSoup 物件的 prettify() 方法印出目標網頁內容 : 

>>> print(soup.prettify())     
<html>
 <body>
  <div class="table">
   <div class="table-tr">
    <div class="table-td">
     <a href="https://www.books.com.tw/products/0010971532?loc=P_books66_title_002">
      <img alt="誘因設計:精準傳遞訊號,讓人照著你的想法行動" src="https://im1.book.com.tw/image/getImage?i=https://www.books.com.tw/img/001/097/15/0010971532.jpg&amp;v=6531053bk&amp;w=330&amp;h=330"/>
     </a>
    </div>
    <div class="table-td">
     <h1>
      <a href="https://www.books.com.tw/products/0010971532?loc=P_books66_title_002">
       誘因設計:精準傳遞訊號,讓人照著你的想法行動
      </a>
     </h1>
     <ul class="price clearfix">
      <li>
       定價480元
      </li>
      <li>
       <b>
        66
       </b>
       折優惠價
       <b>
        316
       </b>
       元
      </li>
     </ul>
     <p>
      <a class="btn btn-cart" href='javascript:ajaxCartItem("0010971532", "P", "316");' onclick='dataLayer.push({ecommerce:null});dataLayer.push({"event":"add_to_cart","ecommerce":{"currency":"TWD","value":316,"items":[{"item_id":"0010971532","item_name":"\u8a98\u56e0\u8a2d\u8a08\uff1a\u7cbe\u6e96\u50b3\u905e\u8a0a\u865f\uff0c\u8b93\u4eba\u7167\u8457\u4f60\u7684\u60f3\u6cd5\u884c\u52d5","item_brand":"\u5929\u4e0b\u6587\u5316","item_category":"001","item_category2":"\u5546\u696d\u7406\u8ca1","price":316,"quantity":1}]}});'>
       放入購物車
      </a>
     </p>
    </div>
   </div>
  </div>
  <blockquote>
   誘因是激勵他人的力量還是巨大陷阱?
  </blockquote>
  <p class="txt-msg">
   <em>
    優惠組合
   </em>
   <a href="//www.books.com.tw/exep/activity/promote/2007_promote/promote_activity.php?id=PKG0218503&amp;loc=P_books66">
    【5/9限定】改變的力量,今日66折加購!
   </a>
  </p>
 </body>
</html>

我們要抓的訊息是 class='table-td' 的 div 裡面的書籍與圖片 URL, 以及定價與優惠價等. 用 BeautifulSoup 搜尋語法樹時可使用 find(), find_all(), select(), select_one() 這四個方法來取得 Tag 物件, 因 Tag 物件也繼承了這些方法, 故可以連續呼叫這些方法來找出 HTML 中位於深層嵌套結構中的元素. 從 Tag 物件中取得屬性值可呼叫其 get() 方法; 而取得元素內容則可呼叫 get_text() 方法, 

例如先找出目標網頁中的第一個 a 元素的 Tag 物件, 然後呼叫其 get() 方法取得 href 屬性值  :

>>> tag_a=soup.find('a')    
>>> tag_a.get('href', None)     # 注意要傳入兩個參數, None 是無此參數時之傳回值
'https://www.books.com.tw/products/0010971532?loc=P_books66_title_002'

由於 img 元素是被 a 元素包起來, 所以接著呼叫 a 元素 Tag 物件的 find() 方法搜尋它, 然後呼叫其 get() 方法分別取得書名 (放在 alt 屬性中) 與圖檔網址 (放在 src 屬性中) :

>>> tag_img=soup.find('img')    # 也可以用 tag_a.find('img')
>>> tag_img.get('alt', None)   
'誘因設計:精準傳遞訊號,讓人照著你的想法行動'
>>> tag_img.get('src', None)    
'https://im1.book.com.tw/image/getImage?i=https://www.books.com.tw/img/001/097/15/0010971532.jpg&v=6531053bk&w=330&h=330'

而價格則是放在 class='price' 的 ul 元素中, 可呼叫 select_one() 取得其 Tag 物件, 然後呼叫其 get_text() 方法取得去除標籤後之文字內容即可取得價格訊息 :

>>> tag_ul=soup.select_one('ul.price')     
>>> tag_ul.get_text()     
'\n定價480元\n66折優惠價316元\n'   

可用字串的 replace() 去除跳行字元並於 66 前面插入一個空格 : 

>>> tag_ul.get_text().replace('\n', '')   
'定價480元66折優惠價316元'
>>> tag_ul.get_text().replace('\n', '').replace('66', ' 66')   
'定價480元 66折優惠價316元'

這樣便完成了我們的主要的爬蟲任務了 (次要任務是未來 6 天 66 折書目). 

將上面測試寫成如下的函式 : 

import requests   
from bs4 import BeautifulSoup

def get_today_66():
    url='https://activity.books.com.tw/crosscat/ajaxinfo/' +\
        'getBooks66OfTheDayAjax/P?uniqueID=E180629000000001_94'
    try:
        res=requests.get(url)
        soup=BeautifulSoup(res.text, 'lxml') 
        book_url=soup.find('a').get('href', None)
        book_name=soup.find('img').get('alt', None) 
        book_img=soup.find('img').get('src', None)
        book_price=soup.select_one('ul.price').get_text()
        book_price=book_price.replace('\n', '').replace('66', ' 66')
        return {'name': book_name,
                'url': book_url,
                'img': book_img,
                'price': book_price}
    except Exception as e: 
        return None 

if __name__ == '__main__':
    get_today_66()

執行結果如下 : 

>>> get_today_66()   
{'name': '誘因設計:精準傳遞訊號,讓人照著你的想法行動', 'url': 'https://www.books.com.tw/products/0010971532?loc=P_books66_title_002', 'img': 'https://im1.book.com.tw/image/getImage?i=https://www.books.com.tw/img/001/097/15/0010971532.jpg&v=6531053bk&w=330&h=330', 'price': '定價480元 66折優惠價316元'}


四. 用 Line Notify 推播每日 66 折書訊 :    

因為上面這個爬蟲程式對我蠻實用的, 所以利用 Line Notify 將其推播到手機, 用法參考 :


不過因為這是功能專一, 所以我修改了 get_today_66() 的傳回值, 直接把要傳給 Line Notify 伺服器的訊息做在函式裡而非主程式 (雖然傳回字典較泛化) : 

# books.com.tw_66.py
import requests   
from bs4 import BeautifulSoup  

def line_notify(msg, token):
    url="https://notify-api.line.me/api/notify"
    headers={"Authorization": "Bearer " + token,
             "Content-Type": "application/x-www-form-urlencoded"
             }
    payload={"message": msg}
    r=requests.post(url, headers=headers, params=payload)
    return r.status_code

def get_today_66():
    url='https://activity.books.com.tw/crosscat/ajaxinfo/' +\
        'getBooks66OfTheDayAjax/P?uniqueID=E180629000000001_94'
    try:
        res=requests.get(url)
        soup=BeautifulSoup(res.text, 'lxml') 
        book_url=soup.find('a').get('href', None)
        book_name=soup.find('img').get('alt', None)
        book_img=soup.find('img').get('src', None)
        book_price=soup.select_one('ul.price').get_text()
        book_price=book_price.replace('\n', '').replace('66', ' 66')
        msg=f'\n❖ {book_name}\n{book_price}\n▶ {book_url}'
        return msg 
    except Exception as e: 
        return None

if __name__ == '__main__':
    token='ud7PaDL45fz849A0e1f5oaMCbRIkxMXapQCt7PfNkzz'    # 範例權杖
    msg=get_today_66()
    if msg:  
        code=line_notify(msg, token)
        if code==200:
            print('Line 訊息發送成功!')
        else:
            print(f'Line 訊息發送失敗! (code={code})')
    else:
        print('無資料')
    
執行結果如下 :




測試 OK!


五. 在樹莓派 Pi 3 佈署爬蟲程式 :    

將上面的 books.com.tw_66.py 程式複製到高雄家那台 24H 運轉的 Pi 3 : 

pi@raspberrypi:~ $ nano books.com.tw_66.py 

執行程式 OK : 

pi@raspberrypi:~ $ python3 books.com.tw_66.py  
Line 訊息發送成功!

但是在放入 Crontab 定時執行前必須用 chmod 指令將此程式改為可執行 : 

pi@raspberrypi:~ $ sudo chmod +x /home/pi/books.com.tw_66.py  
pi@raspberrypi:~ $ ls -ls books.com.tw_66.py     
4 -rwxr-xr-x 1 pi pi 1376  5月  9 21:38 books.com.tw_66.py

這樣就可以編輯 Crontab, 設定此程式每日早上 9 點執行 : 

pi@raspberrypi:~ $ crontab -e    

加入一筆定時執行設定 :  




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.py
0 8,18 * * * /usr/bin/python3 /home/pi/btc_eth_prices_line_notify.py
1 12,17 * * * /usr/bin/python3 /home/pi/technews_2.py
0 9 * * * /usr/bin/python3 /home/pi/books.com.tw_66.py

這樣便完成佈署了. 


2024-06-02 補充 :

由於上面我使用一對一將訊息推播至個人 Line 帳號, 導致與另一個接收 Pi 3 外網 IP 的訊息夾雜在一起, 這個 66 折訊息會被 IP 訊息淹沒 : 




比較好的做法是為每一種爬蟲訊息接收任務建立一個群組, 然後選擇推播至群組 (那個接收 Pi 3 外網 IP 的爬蟲也是要改). 首先在 Line 的 "主頁" 中按 "群組" :




然後按 "建立群組" : 




然後在 "選擇好友" 中點選一個想要讓他也接收此訊息的好友, 此處我要讓備用手機 iPhone 也能收到所以點選了貓先生 (這樣就有兩個人會收到); 如果沒有就跳過直接按右上角的下一步 (這樣只有自己會收到) :




這時會出現 "群組已存在" 的視窗, 表示有同樣成員的群組已存在, 問要不要前往該群組, 意思是既然成員相同, 有必要建立新的嗎? 沒錯, 就是要, 按底下的 "建立群組" 鈕 : 



填寫群組名稱後按 "建立" 即可 :






這樣便建立好群組了. 但最重要的是必須邀請 Line Notify 加入此群組成為好友才會收到推播的訊息, 在聊天室中按右上角的三條槓設定鈕後按 "邀請" : 




點選 Line Notify 後按右上角的 "邀請" 即可 :






這樣就完成手機端的操作了. 

接下來是用 Line 帳號登入 Line Notify 網站 :


按右上角帳號名稱會出現下拉式選單, 點選 "個人頁面" :




將網頁拉到底, 按 "發行權杖" 鈕 : 




填寫權杖名稱並選擇推播對象為 "網路書店每日66折" 群組後按 "發行" 鈕 :




這樣便得到一組權杖了, 務必先按 "複製" 鈕將其儲存在檔案中後才能按 "關閉" 鈕 :




然後修改上面的程式更換權杖後重新執行程式確認推播訊息已經改送至群組了 :

pi@raspberrypi:~ $ python3 books.com.tw_66.py    
Line 訊息發送成功!



大功告成啦 (crontab 不用改)! 

沒有留言 :