讓LINE幫忙提醒PChome口罩可購買狀態 – 基礎篇

口罩應該是這是疫情下大家最需要的物資,通常一上架都被快速搶購一空,而商品補貨開賣時間相當不一定,就算知道有時一忙也會忘記,因此如果用LINE可以通知口罩開賣就有機會在第一時間買到,因此這個案例就是來讓LINE幫忙提醒PChome口罩可購買狀態,此外也可以用於監測商品是否有降價或出現折扣

這篇python案例將會詳細說明3種的實現方式,分別是使用個人電腦樹梅派架設於Heroku的方法。

在開始之前先檢視一下會用到的模組(module):

  • linebot
  • selenium
  • flask
  • BeautifulSoup

整個運作的原理跟流程如下:

  1. 用flask建立網站框架linebot API間的通訊
  2. 收到指令後,使用selenium開啟PChome的網頁,再利用BeautifulSoup爬蟲資料
  3. 比對網頁內容是否有變動,若是有變動即代表商品正在開賣或價格有變動
  4. 利用linebot的 REPLY_MESSAGE 與 PUSH_MESSAGE 在LINE端進行推播提醒

基本上個人電腦、樹梅派與Heroku都是上述這樣的程序,但是個別的執行環境在設定上則會有所差異,所以會將個人電腦(PC)、樹梅派(RPI)與Heroku的個別安裝設定分開做說明。而本文則會是將共通的基礎設定與觀念先做解說,首先要完成下列3個重要步驟

STEP 1 flask建立網站與linebot API的帳號申請

模組flask可以建立輕量型的web server,並與模組line-bot-sdk所建立的API進行通訊,安裝的指令是:

pip3 install Flask 

pip3 install line-bot-sdk

要能夠使用Linebot與我們的LINE帳號做溝通,會需要開啟LINE Messaging API的功能,可以從LINE Developers的專屬網站進去申請一個@開頭的帳號,詳細的申請流程這裡就不多述,LINE的網站有詳細說明或是在網路上也可收尋得到各種教學說明。

完成帳號申請後,同樣在LINE Developers登錄後就可以看到申請帳號的相關訊息,這裡我們要看的是在Basic settings Messaging API這2個頁面。

首先在①Basic settings頁面,拉到下方我們要將幾個重要的資訊記起來,分別是:Channel secret Your user ID

再到Messaging API頁面,請紀錄Channel access token裡的一長串資訊,有了這三項的關鍵資訊就可以跟LINE做溝通與推播,因此請妥善保存也小心不要外洩。

接下來我們就開始編寫linebot的程式碼了,我們先來看看LINE在github上的範例程式

from flask import Flask, request, abort

from linebot import (
    LineBotApi, WebhookHandler
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage,
)

app = Flask(__name__)

line_bot_api = LineBotApi('YOUR_CHANNEL_ACCESS_TOKEN')
handler = WebhookHandler('YOUR_CHANNEL_SECRET')

@app.route("/callback", methods=['POST'])
def callback():
    # get X-Line-Signature header value
    signature = request.headers['X-Line-Signature']

    # get request body as text
    body = request.get_data(as_text=True)
    app.logger.info("Request body: " + body)

    # handle webhook body
    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        print("Invalid signature. Please check your channel access token/channel secret.")
        abort(400)

    return 'OK'

@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text=event.message.text))

if __name__ == "__main__":
    app.run()

第15、16行就填入上面提到的Channel access tokenChannel secret資訊;第19行為監聽所有來自 /callback 的 Post 請求,而第20行的callback()函式基本上可以不用去更動它,我們主要會是針對第39行的 handle_message(event)函式進行調整。

STEP 2 selenium開啟網頁並用BeautifulSoup爬蟲

這裡我們會利用handle_message(event)函式來接受LINE傳遞過來的指令並進行網頁爬蟲。雖然BeautifulSoup是相當優秀的爬蟲利器,但部分網站的網頁資料需要滾動捲軸才會載入,這裡就需要能夠模擬網頁瀏覽器的行為,這裡我們用的網頁瀏覽器是chrome所以需要安裝Chromium WebDriver的執行檔並會利用到selenium這個套件做瀏覽模擬。

由於PC、RPI與Heroku在安裝Chromium WebDriver執行檔的方式都不同,會在各篇中進行詳細描述

安裝BeautifulSoupselenium 的指令如下:

pip3 install beautifulsoup4

pip3 install selenium

selenium會藉由Chromium WebDriver去驅動chrome瀏覽器去開啟網頁,這裡會需要一些設定才能讓開啟網頁過程不會出錯與中斷,另外我們也用headless模式讓chrome在背景執行,不會看到chrome的啟動與運作,這點尤其在Heroku上更是必要。這些設定的程式碼如下:

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from bs4 import BeautifulSoup

chrome_options = Options()
chrome_options.add_argument('--disable-gpu')
chrome_options.add_argument('--headless')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument("--disable-notifications")
chrome_options.add_argument('--disable-extensions')
chrome_options.add_argument('start-maximized')
chrome_options.add_argument('disable-infobars')
chrome_options.add_argument('disable-blink-features=AutomationControlled')
chrome_options.add_argument('user-agent=Type user agent here')
chrome_options.add_experimental_option("excludeSwitches", ["enable-logging"])

chrome = webdriver.Chrome('/usr/lib/chromium-browser/chromedriver', options=chrome_options)

必要的設定為第6-8行,其它的設定可以依照需求決定是否加上,而第17行是Chromium WebDriver的路徑,這裡我先用RPI的設定,PC與Heroku的環境需參考另外兩篇文章就調整。

再來我們就要使用BeautifulSoup進行網頁資料的爬蟲,由於考量可能要進行監視的商品頁面不只一個,這裡我們建立一個函式pageview()以便能重複呼叫:

def pageview(url, view_item, view_ID, view_contain):
	global chrome
	chrome.get(url)
	time.sleep(3)
	
	chrome.execute_script("window.scrollTo(0,document.body.scrollHeight)")
	soup = BeautifulSoup(chrome.page_source, 'html.parser')
	mainBlock = soup.find(view_item,{view_ID: view_contain})
	return mainBlock

函式pageview()的運作流程說明如下:

  1. (第3行)導入的url進行網頁開啟
  2. (第4行)等3秒鐘待網頁完整讀取
  3. (第6行)讓網頁完整滾動到最底部
  4. (第7行)BeautifulSoup讀取所得到的網頁資料
  5. (第8行)利用find()爬行找尋關鍵的網頁資料
  6. (第9行)回傳網頁資料

第1行中的函式pageview()會導入有4個參數:

  • url : 商品網址,提供給第3行webdriver開啟該網頁
  • view_item, view_ID, view_contain : 配合在BeautifulSoup中使用find()這個函式,我們所自定義的參數

為了解釋函式pageview()導入的參數意義,下面會以兩個應用做說明,並以PChome線上購物某3C商品的頁面為例:

例1:查看商品是否有補貨上架

在Chrome或Edge開啟網頁後按下F12就會出現網頁HTML原始碼與相關的元素資料,這裡我們針對“售完,補貨中!”的按鈕處找尋對應的程式碼:

<ul class="fieldset_box orignbutton"> 
 ....... 
 售完,補貨中!
 ....... 
</ul>

而之後就要用BeautifulSoup來爬這段程式碼,當商品上架販售時,<ul>…</ul>中的“售完,補貨中!”文字就會改變,所以我們就用這段程式碼有沒有變化來偵測商品是否上架販售。而BeautifulSoup的find()就可以進行html程式碼的爬行,一般針對這段程式碼的爬行程式會類似如下的寫法:

soup.find("ul",{"class": "fieldset_box orignbutton"})

執行後會回傳我們要的<ul>…</ul>的資料。這段程式碼就是搜尋在”ul”標籤中,屬性”class”名稱”fieldset_box orignbutton”內的資料。

例2:查看商品價格是否有變動(變便宜)

類似於例1,就是找尋標示商品價格的html原始碼,一樣用BeautifulSoup的find()來做爬行:

soup.find("div",{"id": "PaymentContainer"})

執行後會回傳我們要的<div>…</div>的資料。這段程式碼就是搜尋在”div”標籤中,屬性”id”名稱”PaymentContainer”的資料。

從例1與例2都使用到的find()程式碼,可以看到會搜尋不同的標籤id名稱,所以就將這3項分別定義為變數view_item, view_ID, view_contain並指定為函式pageview()導入參數。

STEP 3 檢查網頁內容是否有變動並進行LINE訊息推播

為了讓LINE有推播功能,需要針對函式handle_message()內容進行撰寫,開始之前我們要先了解LINE的訊息推播有2種:REPLY_MESSAGE PUSH_MESSAGE。reply就是回應訊息,當LINE API接收到我們傳送的訊息就會根據我們設定的REPLY_MESSAGE做回應,而push則是可以任意傳遞訊息給LINE用戶端,但有一點要注意,push是有使用次數的限制,所以push的使用必須要謹慎,而根據LINE Official Account Manager頁面的資訊,免費方案可以使用push是每個月500則,裡面也可以看到目前已經使用多少則。

接下來我們就對handle_message()內容進行程式撰寫:

HTMLs=[ 
	["https://24h.pchome.com.tw/prod/DABCE5-1900AQKMT","ul","class","fieldset_box orignbutton",""],
	["https://24h.pchome.com.tw/prod/DYAM2M-A900AHXUD","div","id","PaymentContainer",""]
]

doGrap = 0

def handle_message(event):
	global doGrap
	msg = event.message.text
	if msg == "1":		
		doGrap = 1
		line_bot_api.reply_message(event.reply_token, TextSendMessage(text='start grapping!\n(with '+str(len(HTMLs))+' websites...)'))
		while doGrap:
			for thePage in HTMLs:
				print(thePage[0])
				hpage = pageview(thePage[0], thePage[1], thePage[2], thePage[3])
				if thePage[4]!='' and hpage != thePage[4]:
					line_bot_api.push_message('YOUR_USER_ID', TextSendMessage(text='Page changed!\n'+str(thePage[0])))
					print("change!!!!!")
				thePage[4] = hpage
				time.sleep(1)
			time.sleep(30)
	elif msg == "Stop":
		line_bot_api.reply_message(event.reply_token, TextSendMessage(text='stop!'))
		print("stop!")
		doGrap = 0	

我們將要爬取的網址與變數view_item, view_ID, view_contain都存放在HTMLs這個二維的list當中,第2行是查看口罩商品是否上架,第3行是察看3C商品價格是否有變動。另外第6行的全域變數doGrap則是用來執行爬蟲動作與否,稍後會做說明。第10行則是接收來自我們LINE傳送出來的指令,當我們在LINE的聊天視窗輸入”go”,就會開始進行網頁爬行,這時第13行會用reply的方式通知開始爬行與列出要爬行的網頁數量。

第14 – 23行就是網頁爬行的重點,第15行會將HTMLs所列的網址一一進行爬行,第17行會將爬到的網頁資料存在hpage這個變數中,第18行就是判斷跟上一次爬行到的網頁資料比對有無變化,有變化的話就用push推播LINE通知,第19行’YOUR_USER_ID’就填入上面提到的 Your user ID資訊,第21行會將這次爬到的hpage資料存起來,留待下次爬行的資料比對,第23行就是每爬行完所有網頁後會暫停多久,這裡是30秒,30秒後就會再次爬行。

最後第24行就是當我們再LINE的聊天視窗輸入任何文字(除了”Go”之外)就會停止爬蟲行為,不過要等到第14行while loop執行到第23行才會真正結束,當我們再次輸入”Go”又可以再次爬行。

以下是完整的程式碼:

from flask import Flask, request, abort
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from bs4 import BeautifulSoup
import time, os
from linebot import (
	LineBotApi, WebhookHandler
)
from linebot.exceptions import (
	InvalidSignatureError
)
from linebot.models import *

# "PaymentContainer" => 價格有無變動 / 或可用折價券
# "fieldset_box orignbutton" => 有無貨
HTMLs=[ 
    ["https://24h.pchome.com.tw/prod/DABCE5-1900AQKMT","ul","class","fieldset_box orignbutton",""],
    ["https://24h.pchome.com.tw/prod/DYAM2M-A900AHXUD","div","id","PaymentContainer",""]
]

# Channel Access Token
line_bot_api = LineBotApi('YOUR_CHANNEL_ACCESS_TOKEN')
# Channel Secret
handler = WebhookHandler('YOUR_CHANNEL_SECRET')

chrome_options = Options()
chrome_options.add_argument('--disable-gpu')
chrome_options.add_argument('--headless')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument("--disable-notifications")
chrome_options.add_argument('--disable-extensions')
chrome_options.add_argument('start-maximized')
chrome_options.add_argument('disable-infobars')
chrome_options.add_argument('disable-blink-features=AutomationControlled')
chrome_options.add_argument('user-agent=Type user agent here')
chrome_options.add_experimental_option("excludeSwitches", ["enable-logging"])

chrome = webdriver.Chrome('/usr/lib/chromium-browser/chromedriver', options=chrome_options)

doGrap = 0

app = Flask(__name__)

def pageview(url, view_item, view_ID, view_contain):
	global chrome
	chrome.get(url)
	time.sleep(3)
	
	chrome.execute_script("window.scrollTo(0,document.body.scrollHeight)")
	soup = BeautifulSoup(chrome.page_source, 'html.parser')
	mainBlock = soup.find(view_item,{view_ID: view_contain})
	return mainBlock

# 監聽所有來自 /callback 的 Post Request
@app.route("/callback", methods=['POST'])
def callback():
	# get X-Line-Signature header value
	signature = request.headers['X-Line-Signature']
	# get request body as text
	body = request.get_data(as_text=True)
	app.logger.info("Request body: " + body)
	
	# handle webhook body
	try:
		handler.handle(body, signature)
	except InvalidSignatureError:
		print("Invalid signature. Please check your channel access token/channel secret.")
		abort(400)        
	return 'OK'

# 處理訊息
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
	global doGrap
	msg = event.message.text
	if msg == "Go":		
		doGrap = 1
		line_bot_api.reply_message(event.reply_token, TextSendMessage(text='start grapping!\n(with '+str(len(HTMLs))+' websites...)'))
		while doGrap:
			for thePage in HTMLs:
				print(thePage[0])
				hpage = pageview(thePage[0], thePage[1], thePage[2], thePage[3])
				if thePage[4]!='' and hpage != thePage[4]:
					line_bot_api.push_message('YOUR_USER_ID', TextSendMessage(text='Page changed!\n'+str(thePage[0])))
					print("change!!!!!")
				thePage[4] = hpage
				time.sleep(1)
			time.sleep(30)
	elif msg == "Stop":
		line_bot_api.reply_message(event.reply_token, TextSendMessage(text='stop!'))
		print("stop!")
		doGrap = 0		

if __name__ == "__main__":
	app.run()

這裡我們盡量減少使用push,如果要表達的訊息超過1行就可以用斷行”\n”符號,讓訊息集中在同一則push內完成。另外這裡的print()是不會顯示在LINE中,只會在server端顯示,可以用來除錯或是觀看執行狀況。

了解了上述觀念就可以選定適合的裝置與平台實現了,請依照個人需要前往對應的教學頁:

另外關於Selenium用於爬蟲的更多資訊,可以參考這篇文章

ref: 資料工程師的日常, yan