Side Project - 串接高鐵API查詢、Line Bot
==
> 專案開始時間:2024/10/18
> 參考頁面:
> [Maso的萬事屋 - 用 Django 架構建置專屬的LINEBOT吧 系列文章](https://ithelp.ithome.com.tw/m/users/20121176/ironman/3023)
## 專案發想
要搭乘高鐵時需額外打開高鐵app查詢班次,但因為不是我的常用app,需額外去搜尋這個app來用,但好不容易打開高鐵app後卻發現沒有剩餘車票~~~
如果可以先在line裡面搜尋確認有票再做訂票的話,也許會比較方便?(是多懶惰!!!)
- 希望可以快速在line上查到車次時間、剩餘車票及票價
- 除了起點站及終點站外,希望可以查詢第三靠站地點車次
- 有同行旅客想搭乘同班車但上下車站點不同
## 預計開發功能
### 短期計劃
- Line Messaging API 串接
- [x] 申請帳號 - 2024/10/18
- [x] 連線測試 - 2024/10/19
- TDX API 串接
- [x] 申請TDX帳號 - 2024/10/22 ( 通過 )
- 票價查詢功能
- [x] 查詢起訖站間票價(文字)
- 起點 & 終點 - 2024/10/24
- 車廂等級 - 2024/10/24
- 票種
- 單程 / 來回票
- [ ] 模板訊息( LINE 內建功能)
- 車次查詢功能
- [ ] 查詢出發時刻班次
- [ ] 查詢抵達時刻班次
- [ ] 模板訊息( LINE 內建功能)
- 剩餘車票查詢功能
- [ ] 指定車次剩餘車票
- [ ] 指定時間剩餘車票
- 部署
- [ ] AWS 部署
### 中長期計畫
- 訂票功能
- [ ] 考量安全性問題
- 進階查詢
- [ ] 除起點終點兩個站點之外,額外指定第三靠站地點
## 使用技術
- **語言 / 框架**:Python / Django
- **API串接**:TDX API、Line Messaging API
- **使用套件**:
## 開發紀錄
### LOG
- **2024/10/18** - 開啟專案、LINE MESSAGING API 串接、TDX 帳號申請(待審核)
- **2024/10/19** - 筆記整理、測試+調整 LINE BOT、TDX帳號待審核
- **2024/10/21** - TDX帳號審核未過,重新申請
- **2024/10/22** - TDX帳號審核通過
- **2024/10/24** - 票價查詢功能(純文字)完成
-
### 建立 Django 專案
#### 1. [[note] Django 專案建立](/iatsXQi9Qk2WNPAbM2faNA)
- <font color="red"> app 名稱**不要**直接取 linebot</font>, 會和 **line-bot-sdk** 套件的內建模組衝突
#### 2. 安裝套件:
- `$ poetry add line-bot-sdk`
#### 3. core/settings.py 及 .env 檔
- 在 settings 檔內放入<font color="orange">**LINE_CHANNEL_ACCESS_TOKEN**</font> 和 <font color="orange">**LINE_CHANNEL_SECRET**</font> 欄位,並一併將內容放入 ++.env++ 檔
- 取得 token 及 secret : [[note] Line Messaging API 串接](/Vn_2nkahSK6myP7tbH79vg)
- 建議放置在 **SECRET_KEY** 前,方便管理
- 記得先安裝 `python-dotenv` 套件
```python
# core/settings.py
from dotenv import load_dotenv
load_dotenv()
LINE_CHANNEL_ACCESS_TOKEN = os.getenv("LINE_CHANNEL_ACCESS_TOKEN")
LINE_CHANNEL_SECRET = os.getenv("LINE_CHANNEL_SECRET")
SECRET_KEY = os.getenv("SECRET_KEY")
# .env
LINE_CHANNEL_ACCESS_TOKEN = "your_token"
LINE_CHANNEL_SECRET = "your_secret"
SECRET_KEY = "your_secret_key"
```
### ngrok 設定
[[note] ngrok 設定](/ur2OVmZQTtq7TWwgxBOMNw)
### API串接
1. **TDX API**
[[note] TDX API 串接](/EbeN3QZSTyuQ9niEhapyOQ)
2. **Line Messaging API**
[[note] Line Messaging API 串接](/Vn_2nkahSK6myP7tbH79vg)
### 訊息事件處理 語法筆記
#### signature
```python
signature = request.META["HTTP_X_LINE_SIGNATURE"]
```
- 用於驗證請求的安全性,防止偽造請求的攻擊
- `HTTP_X_LINE_SIGNATURE` 是 LINE 伺服器發送的請求中的一個標頭,用於驗證請求是否來自 LINE
- **和 token 作用類似**,但不同:
- ++**signature**++ 是一個用於驗證請求來源的數位簽名,++屬於單一請求的驗證++
- ++**token**++ 則是用來做身份驗證及授權,++是持久性的++
#### request.body
```python
body = request.body.decode("utf-8")
```
- body 的作用是讓我們取得LINE伺服器發送的事件資料,例如訊息、按鈕點擊等
- 先取得 body,後續去解析為 JSON 格式後,以便取得事件類型、用戶ID、訊息內容等資訊
- `request` 在 Django 框架中代表 HTTP 請求
- `request.body` 是一個屬性,包含了請求的原始內容
- `decode("utf-8")` 是將字節串 (Bytes,是二進制的數據) 轉為字符串 (String)
### 遇到的問題 (未解決)
#### 1. class-based-views 路徑無法與 webhook url 連接
- 原本想將 views.py 內 LineBot 相關的 function 都移至 class 內做統一管理,但 webhook url 會無法確實連線,所以改回原本 function-based-views 寫法
- 寫在 class 內的 function 要多帶一個參數 self(可用其他名稱),self 會帶入實體本身,不確定是不是這個因素才無法連線
#### 2. OData 語法 - filter 篩選條件只要符合單一條件的元素就會被傳回
```python
# 取得票價
# fare_class 預設為 1:成人票,ticket_type 預設為 1:單程票
def get_ticket_price(self, station_id, destination_id, cabin_class, fare_class=1, ticket_type=1):
url = f"{self.basic_url}/Rail/THSR/ODFare/{station_id}/to/{destination_id}"
params = {
"$select": "Fares,OriginStationName,DestinationStationName",
# OData 的 filter 語法,從服務器端過濾數據,只有符合條件的數據會被傳回
# 比在客戶端過濾更高效,但只要符合單一條件的票價就會被傳回(不確定原因,因為已經用 and 連接)
"$filter": f"Fares/any(f: f/CabinClass eq {cabin_class} and f/FareClass eq {fare_class} and f/TicketType eq {ticket_type})",
"$format": "JSON",
}
try:
response = self.get_response(url, params)
response.raise_for_status()
ticket_data = response.json()
if isinstance(ticket_data, list) and len(ticket_data) > 0:
# 從 ticket_data 中取出第一個元素,並取得 Fares 欄位的值,如果不存在則返回空列表
fares = ticket_data[0].get("Fares", [])
# 在客戶端過濾,較靈活,會將符合條件的票價存入 filtered_fares
filtered_fares = [fare for fare in fares if fare.get("CabinClass") == cabin_class and fare.get("FareClass") == fare_class and fare.get("TicketType") == ticket_type]
return filtered_fares[0]["Price"]
else:
print("未找到對應的票價")
return None
except requests.exceptions.HTTPError as http_error:
print(f"HTTP error occurred: {http_error}")
except Exception as error:
print(f"An error occurred: {error}")
return None
```
- 雖然在 OData 語法已經給 $filter 條件,但得到以下內容
```
ticket_data: [{
'OriginStationName': {'Zh_tw': '南港', 'En': 'Nangang'},
'DestinationStationName': {'Zh_tw': '板橋', 'En': 'Banqiao'},
'Direction': 0,
'Fares': [
{'TicketType': 1, 'FareClass': 1, 'CabinClass': 1, 'Price': 70},
{'TicketType': 1, 'FareClass': 9, 'CabinClass': 3, 'Price': 30},
{'TicketType': 1, 'FareClass': 9, 'CabinClass': 1, 'Price': 35},
{'TicketType': 1, 'FareClass': 1, 'CabinClass': 3, 'Price': 65},
{'TicketType': 1, 'FareClass': 9, 'CabinClass': 2, 'Price': 155},
{'TicketType': 1, 'FareClass': 1, 'CabinClass': 2, 'Price': 310},
{'TicketType': 8, 'FareClass': 1, 'CabinClass': 1, 'Price': 65},
{'TicketType': 8, 'FareClass': 1, 'CabinClass': 2, 'Price': 290}
],
'SrcUpdateTime': '2024-06-07T20:05:43+08:00',
'UpdateTime': '2024-10-04T12:15:17+08:00',
'VersionID': 48
}]
```