《零基礎快速入門:GenAI 搭配 Google Apps Script 的工作自動化寶典》Chapter 5-4 YouBike 監控儀表板

前言

我們在前面的案例曾經教學過怎麼使用 Google Apps Script 來監看 YouBike 的各站點資訊。

《零基礎快速入門:GenAI 搭配 Google Apps Script 的工作自動化寶典》Chapter 4-6 自動監測 YouBike 站 示範了如何透過 API 把外部資料抓進我們的 Google Sheets。

《零基礎快速入門:GenAI 搭配 Google Apps Script 的工作自動化寶典》Chapter 4-7 自動監測 YouBike 站 Part 2 則示範了如何針對感興趣的站點每五分鐘記錄一次。

現在我們透過前幾個案例,
掌握了 Google Apps Script 的前端技巧,
在這一篇我們就要來試著在網頁上做一個 dashboard,
用精美的互動圖表來顯示出 YouBike 的資訊。

開發目標

我們的 dashboard 就先從簡單的目標開始:

  1. 顯示出隨時間變化的車輛數量折線圖
  2. 顯示出目前最新現況的可借/可還數量

我們就先做一個基礎,後續還需要什麼資訊,都可以再加上去。
反正既然食材有了 (API 抓來的資料),
要創作什麼料理就看你的創意了。

該怎麼向 ChatGPT 發問?

完整的真實對話紀錄可參見:https://chatgpt.com/share/6705360d-a588-800e-8b00-59d9d554bcc9
以下摘要我整個開發過程的幾個重點提問:

  • 我現在有一個 Google Sheets,用來紀錄 YouBike 某個站點的資訊,資料格式如下:…
  • 我現在想要在網頁上做一個 grafana 風格的 dashboard,請問你建議我可以用哪些圖表顯示哪些資訊呢?
  • 好的,請幫我依照我的資料格式,用 Google Apps Script 寫一個網頁 dashboard
  • 我發現這邊抓的資料太多了,能不能根據 infoTime 欄位,只抓過去七天的資料呢?
  • 我的資料是同一個站點的不同時間的資訊。我想要的 dashboard 是可以顯示出過去七天的車位數變化的折線圖
  • 請幫我加上「根據 infoTime 移除重複資料」的功能
  • 幫我加一個 logger.log,印出資料筆數
  • 為什麼我發現 seenTimes 在 add 之後也還是空的呢?
  • 請把網頁上的其餘資訊拿掉,只留折線圖就好
  • 為什麼折線圖的橫軸跟時間不成比例呢?
  • 我可以在網頁上加一個日間區間的選項嗎?讓使用者可以自己決定要觀看哪一段 infoTime 的資料
  • 可以在網頁上加上一個切換的選項,讓使用者決定要看「可借數」或是「可還數」嗎?
  • 請在網頁上加一個區塊,顯示當前 infoTime 最新的一筆資料的可借數量以及
  • infoTime。要採用美觀清楚的方式呈現,不要只是把資訊印出來。
  • 請把時間區間預設為過去七天
  • 注意:要顯示「當前」infoTime 最新的一筆資料的可借數量以及infoTime。不是使用者篩選日期中的最新一筆,而是距今最近的一筆。意即不管使用者篩選了哪一段時期,最新一筆的資訊都不會改變
  • 請把顯示的時間都改成 GMT+8

網頁儀表板成果

輕輕鬆鬆就有一個可以調整時間區間的折線圖,
而且是互動式的。
將滑鼠移到資料點上,可動態顯示出該點的時間與數量。

程式碼長什麼樣子

Code.js (包含之前抓資料的程式碼,以及這次為了顯示儀表板新增的程式碼)

function fetchHeaders() {
    var url = "https://tcgbusfs.blob.core.windows.net/dotapp/youbike/v2/youbike_immediate.json";

    // 設置請求的選項,method 設置為 "get",muteHttpExceptions 設置為 true 以獲取完整的響應信息
    var options = {
      "method": "get",
      "muteHttpExceptions": true // 這樣可以獲得完整的回應,包括標頭
    };

    var response = UrlFetchApp.fetch(url, options);
    var allHeaders = response.getAllHeaders(); // 獲取所有的 HTTP 標頭

    Logger.log(allHeaders); // 在 GAS 控制台中顯示所有標頭
    Logger.log(response);
  }


  function writeYouBikeDataToSheet() {
    var url = 'https://tcgbusfs.blob.core.windows.net/dotapp/youbike/v2/youbike_immediate.json?ran=123';
    var sheetId = 'YOUR_SHEET_ID';  // 請替換為您的 Google Sheets ID

    try {
      // 設置請求的選項,method 設置為 "get",muteHttpExceptions 設置為 true 以獲取完整的響應信息
      var options = {
        "method": "get",
        "muteHttpExceptions": true // 這樣可以獲得完整的回應,包括標頭
      };

      // 獲取數據
      var response = UrlFetchApp.fetch(url, options);
      Logger.log(response);
      var data = JSON.parse(response.getContentText());

      // 獲取所有工作表
      var sheets = SpreadsheetApp.openById(sheetId).getSheets();
      // 遍歷所有工作表
      for (var i = 0; i < sheets.length; i++) {
        // 獲取當前工作表
        var sheet = sheets[i];
        // 打印工作表名稱
        Logger.log('Sheet Name: ' + sheet.getName());

        // 查找特定站點的數據
        var targetStation = data.find(function(station) {
          return station.sna === sheet.getName();
        });
        Logger.log(targetStation);
        // 如果找到站點數據,寫入 Google Sheets
        if (targetStation) {
          var sheet = SpreadsheetApp.openById(sheetId).getSheetByName(sheet.getName());
          sheet.appendRow([
            targetStation.sno,
            targetStation.sna,
            targetStation.sarea,
            targetStation.mday,
            targetStation.ar,
            targetStation.sareaen,
            targetStation.snaen,
            targetStation.aren,
            targetStation.act,
            targetStation.srcUpdateTime,
            targetStation.updateTime,
            targetStation.infoTime,
            targetStation.infoDate,
            targetStation.total,
            targetStation.available_rent_bikes,
            targetStation.latitude,
            targetStation.longitude,
            targetStation.available_return_bikes
          ]);
        } else {
          Logger.log("No data found for the specified station.");
        }

      }


    } catch (e) {
      Logger.log('Error fetching or processing data: ' + e.message);
    }
  }


  function doGet() {
    // 打開 Google Sheets,請把 "YOUR_SHEET_ID" 替換為你的 Google Sheets ID
    const sheet = SpreadsheetApp.openById("YOUR_SHEET_ID").getSheetByName("YouBike2.0_松仁路121巷口");
    const data = sheet.getDataRange().getValues();
    // 取得資料的標題
    const headers = data[0];
    const rows = data.slice(1);

    // 將資料轉換為 JSON 格式
    let jsonData = rows.map(row => {
      let entry = {};
      headers.forEach((header, index) => {
        entry[header] = row[index];
      });
      return entry;
    });

    // 找到最新的一筆資料
    const latestEntry = jsonData.reduce((latest, current) => {
      return new Date(new Date(current.infoTime).getTime() + 8 * 60 * 60 * 1000) > new Date(new Date(latest.infoTime).getTime() + 8 * 60 * 60 * 1000) ? current : latest;
    }, jsonData[0]);

    // 建立 HTML 頁面
    const template = HtmlService.createTemplateFromFile('Dashboard');
    template.data = JSON.stringify(jsonData);
    template.latestEntry = JSON.stringify(latestEntry);
    return template.evaluate()
      .setTitle('YouBike Dashboard')
      .setWidth(1200)
      .setHeight(800);
  }

  // 這段函數是為了載入其他 HTML 文件
  function include(filename) {
    return HtmlService.createHtmlOutputFromFile(filename).getContent();
  }

Dashboard.html

  <!DOCTYPE html>
  <html>
    <head>
      <title>YouBike Dashboard</title>
      <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
      <script src="https://cdn.jsdelivr.net/npm/moment"></script>
      <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment"></script>
      <style>
        body {
          font-family: Arial, sans-serif;
          background-color: #f0f0f0;
          margin: 20px;
        }
        #chart-container {
          width: 100%;
          max-width: 800px;
          margin: 20px auto;
        }
        #controls {
          text-align: center;
          margin-bottom: 20px;
        }
        #latest-info {
          text-align: center;
          background-color: #ffffff;
          border-radius: 10px;
          box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
          padding: 20px;
          margin: 20px auto;
          width: 80%;
          max-width: 600px;
        }
        #latest-info h2 {
          margin: 0;
          color: #333;
        }
        #latest-info p {
          font-size: 1.5em;
          margin: 10px 0;
          color: #2b8a3e;
        }
      </style>
    </head>
    <body>
      <h1>YouBike Dashboard</h1>
      <div id="controls">
        <label for="startDate">開始日期:</label>
        <input type="date" id="startDate">
        <label for="endDate">結束日期:</label>
        <input type="date" id="endDate">
        <label for="dataType">資料類型:</label>
        <select id="dataType">
          <option value="available_rent_bikes">可租借車輛數</option>
          <option value="available_return_bikes">可還車位數</option>
        </select>
        <button onclick="updateChart()">更新圖表</button>
      </div>
      <div id="latest-info">
        <h2>最新資訊</h2>
        <p id="latestInfoTime"></p>
        <p id="latestAvailableRentBikes"></p>
      </div>
      <div id="chart-container">
        <canvas id="availabilityChart"></canvas>
      </div>
      <script>
        let chart;
        let jsonData = JSON.parse('<?= data ?>');
        let latestEntry = JSON.parse('<?= latestEntry ?>');

        function updateChart() {
          const startDateInput = document.getElementById('startDate');
          const endDateInput = document.getElementById('endDate');

          // 預設為過去七天
          if (!startDateInput.value || !endDateInput.value) {
            const today = new Date();
            const sevenDaysAgo = new Date();
            sevenDaysAgo.setDate(today.getDate() - 7);

            startDateInput.value = sevenDaysAgo.toISOString().split('T')[0];
            endDateInput.value = today.toISOString().split('T')[0];
          }

          const startDate = new Date(new Date(startDateInput.value).getTime() + 8 * 60 * 60 * 1000);
          const endDate = new Date(new Date(endDateInput.value).getTime() + 8 * 60 * 60 * 1000);
          const dataType = document.getElementById('dataType').value;

          const filteredData = jsonData.filter(entry => {
            const infoTime = new Date(entry.infoTime);
            return infoTime >= startDate && infoTime <= endDate;
          });

          const labels = filteredData.map(entry => new Date(entry.infoTime));
          const dataValues = filteredData.map(entry => entry[dataType]);

          if (chart) {
            chart.destroy();
          }

          const ctx = document.getElementById('availabilityChart').getContext('2d');
          chart = new Chart(ctx, {
            type: 'line',
            data: {
              labels: labels,
              datasets: [
                {
                  label: dataType === 'available_rent_bikes' ? '可租借車輛數' : '可還車位數',
                  data: dataValues,
                  borderColor: 'rgba(75, 192, 192, 1)',
                  fill: false
                }
              ]
            },
            options: {
              responsive: true,
              scales: {
                x: {
                  type: 'time',
  time: {
    unit: 'hour',
    displayFormats: {
      hour: 'YYYY-MM-DD HH:mm'
    }
  },
                  title: {
                  display: true,
                  text: '日期與時間'
                }
                },
                y: {
                  title: {
                    display: true,
                    text: '數量'
                  }
                }
              }
            }
          });

          // 顯示最新的資訊
          document.getElementById('latestInfoTime').innerText = `更新時間: ${new Date(latestEntry.infoTime).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' })}`;
          document.getElementById('latestAvailableRentBikes').innerText = `可租借車輛數: ${latestEntry.available_rent_bikes}`;
        }

        document.addEventListener('DOMContentLoaded', function() {
          updateChart();
        });
      </script>
    </body>
  </html>

ChatGPT Canvas 心得

鐵人賽進行到一半,
ChatGPT 突然就有了新功能:ChatGPT 4.0 with Canvas

ChatGPT 4.0 with Canvas 是一種升級版本的 ChatGPT,
具有一個特別的「畫布」功能,
可以用來進行文檔創作和編輯。

它可以把創建的內容(如文章、代碼等)顯示在畫布區域中,
這樣用戶可以更方便地查看、編輯和與 AI 互動,
進行反覆修改和改進。

這個功能非常適合用於編寫長篇文字或代碼時,
使你可以直觀地看到整個文本的結構和變化,
而不必在單一對話框中滾動尋找內容。
你可以將這些創作結果進一步修改和保存,
以便在不同的階段反覆檢查和完善。

講起來很抽象,
但是在請 ChatGPT 寫 code 的時候真的非常方便!
我在開發本篇教學的程式充份感受到了!

目前是付費版 ChatGPT Plus 才可以用,
不知道未來會不會全面開放。

已知未解問題

不知為何,從 YouBike 抓來的資料有時候會卡在過去的某一個時間點,
而且可能一卡就是好幾個小時。
例如都已經10月8日了,打 API 抓到的資料卻還顯示為9/30。

用一模一樣的抓法反覆測試,又有機會成功抓到當下最新的資料。
但再用一模一樣的抓法再試一次,又有可能抓到舊資料。

不定期發生,我還抓不準問題的成因。

關於這個問題,我也根據這個頁面的資訊,
寫信請教機關聯絡人陳心恩 ([email protected])
不過寄了兩次都未獲回應。
也許打電話會比較有機會。