台中公車雙向即時資訊系統 (TaichungBusLiveQuery) Node-red 程式
Python Tkinter 是單機桌面 UI 程式,主動發起 API 請求並渲染視窗。
Node-RED 是基於事件驅動的流程(Flow-based)系統,適合在背景執行、定時抓取資料,並透過 Dashboard (UI 介面) 提供網頁端網頁視窗供使用者選擇與檢視。
Node-RED 實現架構說明
在 Node-RED 中,我們可以使用 node-red-dashboard(或新版的 node-red-dashboard-2)來建構網頁 UI。
整體流程分為 3 大核心區塊:
取得 Access Token:啟動時及每小時候自動向 TDX 取得 Access Token。
載入業者與路線選單:系統啟動時載入路線資料,並依「業者 $\rightarrow$ 路線」構建動態下拉選單。
30 秒定時輪詢與資料渲染:當使用者選定路線後,啟動 30 秒定時輪詢,並依去程/返程將結果透過 HTML 樣式(紅/綠/黑文字)呈現於介面上。
Node-RED Flow JSON 程式碼
請複製下方 JSON 程式碼,並在 Node-RED 菜單中選擇 Import(匯入) 貼上即可:
[{"id":"inject_init","type":"inject","z":"ece6aff5c6943929","name":"啟動初始化","props":[],"repeat":"3600","crontab":"","once":true,"onceDelay":0.1,"topic":"","x":170,"y":160,"wires":[["func_get_token"]]},{"id":"func_get_token","type":"function","z":"ece6aff5c6943929","name":"準備 Auth 請求","func":"msg.url = 'https://tdx.transportdata.tw/auth/realms/TDXConnect/protocol/openid-connect/token';\nmsg.headers = {'content-type': 'application/x-www-form-urlencoded'};\nmsg.payload = {\n 'grant_type': 'client_credentials',\n 'client_id': 'alex9ufo-d431e6e81-d1850-4d96',\n 'client_secret': '925521bb-86014-40610-b38c-8bead1e8ae704'\n};\nreturn msg;","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":370,"y":160,"wires":[["http_auth"]]},{"id":"http_auth","type":"http request","z":"ece6aff5c6943929","name":"取得 Token","method":"POST","ret":"obj","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":550,"y":160,"wires":[["func_save_token"]]},{"id":"func_save_token","type":"function","z":"ece6aff5c6943929","name":"儲存 Token 並請求路線資料","func":"global.set('tdx_token', msg.payload.access_token);\n\nmsg.url = 'https://tdx.transportdata.tw/api/basic/v2/Bus/Route/City/Taichung?$format=JSON';\nmsg.headers = {'Authorization': 'Bearer ' + msg.payload.access_token};\nmsg.payload = {};\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":790,"y":160,"wires":[["http_routes"]]},{"id":"http_routes","type":"http request","z":"ece6aff5c6943929","name":"取得公車路線資料","method":"GET","ret":"obj","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":1050,"y":160,"wires":[["func_parse_operators"]]},{"id":"func_parse_operators","type":"function","z":"ece6aff5c6943929","name":"解析業者與路線資料","func":"let routes = msg.payload;\nlet operators_map = {};\n\nroutes.forEach(r => {\n let routeName = r.RouteName ? r.RouteName.Zh_tw : '未知路線';\n let routeUID = r.RouteUID;\n let operators = r.Operators || [];\n \n operators.forEach(op => {\n let opName = op.OperatorName ? op.OperatorName.Zh_tw : '其他業者';\n if (!operators_map[opName]) {\n operators_map[opName] = {};\n }\n if (!operators_map[opName][routeName]) {\n operators_map[opName][routeName] = [];\n }\n operators_map[opName][routeName].push(routeUID);\n });\n});\n\nglobal.set('operators_map', operators_map);\n\nlet opList = Object.keys(operators_map).sort();\nmsg.options = opList;\nreturn [msg, null];","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":380,"y":220,"wires":[["ui_select_operator"]]},{"id":"ui_select_operator","type":"ui_dropdown","z":"ece6aff5c6943929","name":"1. 選擇業者","label":"1. 選擇公車業者","tooltip":"","place":"請選擇業者","group":"taichung_bus_group_select","order":1,"width":4,"height":1,"passthru":false,"multiple":false,"options":[],"payload":"","topic":"operator_selected","topicType":"str","className":"","x":590,"y":220,"wires":[["func_update_routes_dropdown"]]},{"id":"func_update_routes_dropdown","type":"function","z":"ece6aff5c6943929","name":"更新路線下拉選單","func":"let selectedOp = msg.payload;\nflow.set('selected_op', selectedOp);\nlet operators_map = global.get('operators_map') || {};\n\nif (operators_map[selectedOp]) {\n let routes = Object.keys(operators_map[selectedOp]).sort();\n msg.options = routes;\n} else {\n msg.options = [];\n}\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":770,"y":220,"wires":[["ui_select_route"]]},{"id":"ui_select_route","type":"ui_dropdown","z":"ece6aff5c6943929","name":"2. 選擇路線","label":"2. 選擇公車路線","tooltip":"","place":"請選擇路線","group":"taichung_bus_group_select","order":4,"width":4,"height":1,"passthru":false,"multiple":false,"options":[],"payload":"","topic":"route_selected","topicType":"str","className":"","x":990,"y":220,"wires":[["func_trigger_fetch_eta"]]},{"id":"func_trigger_fetch_eta","type":"function","z":"ece6aff5c6943929","name":"記錄選擇路線並觸發抓取","func":"flow.set('selected_route', msg.payload);\nreturn { payload: 'TRIGGER' };","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":390,"y":320,"wires":[["func_prepare_eta_request"]]},{"id":"inject_30s_loop","type":"inject","z":"ece6aff5c6943929","name":"每 30 秒定時觸發","props":[],"repeat":"30","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":390,"y":380,"wires":[["func_prepare_eta_request"]]},{"id":"func_prepare_eta_request","type":"function","z":"ece6aff5c6943929","name":"準備 ETA 請求","func":"let token = global.get('tdx_token');\nlet selectedRoute = flow.get('selected_route');\n\nif (!token || !selectedRoute) {\n return null;\n}\n\nmsg.url = 'https://tdx.transportdata.tw/api/basic/v2/Bus/EstimatedTimeOfArrival/City/Taichung?$format=JSON';\nmsg.headers = {'Authorization': 'Bearer ' + token};\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":600,"y":320,"wires":[["http_eta"]]},{"id":"http_eta","type":"http request","z":"ece6aff5c6943929","name":"取得 ETA 到站資料","method":"GET","ret":"obj","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":790,"y":320,"wires":[["func_render_eta_tables"]]},{"id":"func_render_eta_tables","type":"function","z":"ece6aff5c6943929","name":"解析 ETA 並生成 HTML 表格","func":"let etaData = msg.payload || [];\nlet selectedOp = flow.get('selected_op');\nlet selectedRoute = flow.get('selected_route');\nlet operators_map = global.get('operators_map') || {};\n\nif (!selectedOp || !selectedRoute) return null;\n\nlet targetUIDs = (operators_map[selectedOp] && operators_map[selectedOp][selectedRoute]) ? operators_map[selectedOp][selectedRoute] : [];\n\nlet matchedEtas = etaData.filter(e => targetUIDs.includes(e.RouteUID));\n\nlet outwardList = matchedEtas.filter(e => e.Direction === 0).sort((a,b) => (a.StopSequence || 0) - (b.StopSequence || 0));\nlet returnList = matchedEtas.filter(e => e.Direction === 1).sort((a,b) => (a.StopSequence || 0) - (b.StopSequence || 0));\n\nconst ETA_STATUS_MAP = { 0: \"正常預估\", 1: \"尚未發車\", 2: \"交管不停靠\", 3: \"末班車已過\", 4: \"今日未營運\" };\n\nfunction generateHTMLTable(list, isOutward) {\n let defaultColor = isOutward ? 'darkgreen' : 'black';\n let html = '<table style=\"width:100%; border-collapse: collapse;\">';\n html += '<tr style=\"border-bottom: 2px solid #ddd;\"><th style=\"text-align:center;\">順序</th><th style=\"text-align:left;\">站牌名稱</th><th style=\"text-align:center;\">預估到站</th></tr>';\n \n list.forEach((eta, idx) => {\n let stopSeq = eta.StopSequence || (idx + 1);\n let stopName = eta.StopName ? eta.StopName.Zh_tw : '未知站牌';\n let status = eta.EstimateTime;\n let statusStr = '';\n let textColor = defaultColor;\n let isBold = false;\n \n if (status !== undefined && status !== null) {\n let minutes = Math.floor(status / 60);\n if (minutes <= 10) {\n textColor = 'red';\n isBold = true;\n }\n statusStr = minutes < 1 ? '即將進站' : `${minutes} 分鐘`;\n } else {\n let code = eta.EstimateStatus !== undefined ? eta.EstimateStatus : 1;\n statusStr = ETA_STATUS_MAP[code] || '尚未開出';\n }\n \n let fontStyle = `color: ${textColor}; ${isBold ? 'font-weight: bold;' : ''}`;\n html += `<tr style=\"border-bottom: 1px solid #eee; ${fontStyle}\">`;\n html += `<td style=\"text-align:center; padding: 6px;\">${stopSeq}</td>`;\n html += `<td style=\"text-align:left; padding: 6px;\">${stopName}</td>`;\n html += `<td style=\"text-align:center; padding: 6px;\">${statusStr}</td>`;\n html += '</tr>';\n });\n html += '</table>';\n return html;\n}\n\nlet outwardHTML = generateHTMLTable(outwardList, true);\nlet returnHTML = generateHTMLTable(returnList, false);\n\nreturn [\n { payload: outwardHTML },\n { payload: returnHTML }\n];","outputs":2,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1020,"y":320,"wires":[["ui_outward_template"],["ui_return_template"]]},{"id":"ui_outward_template","type":"ui_template","z":"ece6aff5c6943929","group":"taichung_bus_group_outward","name":"往程表格渲染","order":1,"width":8,"height":10,"format":"<div ng-bind-html=\"msg.payload\"></div>","storeOutMessages":true,"fwdInMessages":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":1240,"y":280,"wires":[[]]},{"id":"ui_return_template","type":"ui_template","z":"ece6aff5c6943929","group":"taichung_bus_group_return","name":"返程表格渲染","order":1,"width":8,"height":10,"format":"<div ng-bind-html=\"msg.payload\"></div>","storeOutMessages":true,"fwdInMessages":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":1240,"y":340,"wires":[[]]},{"id":"taichung_bus_group_select","type":"ui_group","name":"路線選擇","tab":"taichung_bus_tab","order":1,"disp":true,"width":5},{"id":"taichung_bus_group_outward","type":"ui_group","name":"往程 (去程)","tab":"taichung_bus_tab","order":2,"disp":true,"width":8},{"id":"taichung_bus_group_return","type":"ui_group","name":"返程","tab":"taichung_bus_tab","order":3,"disp":true,"width":8},{"id":"taichung_bus_tab","type":"ui_tab","name":"台中公車即時通","icon":"bus","order":1},{"id":"ce7c20be6a357e08","type":"global-config","env":[],"modules":{"node-red-dashboard":"3.6.6"}}]
Node-RED 流程中,已經包含了「30 秒定期更新」的機制。
它是透過流程中的 inject(注入節點) 來實現的。
🔍 它是如何在 Node-RED 中運作的?
在匯入的流程中,有一個名為 每 30 秒定時觸發 的節點:
[ 啟動/選擇路線 ] ───┐
├───> [ 準備 ETA 請求 ] ───> [ 抓取 TDX API ] ───> [ 更新畫面 HTML ]
[ 每 30 秒觸發 ] ───┘
第一次觸發:當您在網頁下拉選單選擇了公車號碼時,流程會立即發起第一次查詢並顯示結果。
後續自動循環:
每 30 秒定時觸發節點會每隔 30 秒自動發送一個訊號給準備 ETA 請求節點,重新向 TDX 抓取最新的公車預估到站資料並更新網頁畫面。
⚙️ 如何在 Node-RED 檢查或修改更新時間?
如果您想確認設定或調整時間(例如改為 15 秒或 60 秒):
在 Node-RED 工作區雙擊開啟
每 30 秒定時觸發節點。查看 Repeat(重複) 屬性:
模式設定為
at interval(固定間隔)。時間設定為
every 30 seconds(每 30 秒)。
修改完畢後點擊右上角的 Deploy(部署) 即可生效。




沒有留言:
張貼留言