小技巧 - 從動態網頁抓取資料
緯育的 Java 養成班是一門將近五個月的長期課程,它們機構以網頁系統的方式提供課表給學生查詢。但是,我已經很習慣用自己的 Google 行事曆了,規劃生活行程都只想用它,不想另外上一個網站查課表。那麼,有辦法將網頁上的資訊以迅速有效的方式存下來,方便我後續整理、甚至移轉到行事曆上嗎?嘿,有的,而且其實並不難。這篇就來介紹我是怎麼做的。
作法
你可以從上圖看到,課表排成月曆的形式呈現,所以右鍵複製下來也難以處理。Google 試算表內建有 IMPORTHTML() 和 IMPORTXML() 兩種用來抓取資料的函式,但是我覺得它們沒那麼好用,發揮空間不大。因此,我最後選擇:直接取用網頁呼叫後端 API 所回傳的課表資料。
現在大多數網站都有豐富的動態互動,我指的不是畫面裡的視覺元素是動態、或是動畫,而是指在整個網頁沒有重新載入的情況下,頁面呈現的內容可隨著你的操作而變化。以緯育的課表系統而言,就是按左上角的箭頭改變月份,底下的課表隨之更新。實現這樣效果的方式有很多,有簡單的、或比較複雜的。這個課表系統就是簡單的方式。
取得資料
首先,打開你任一種瀏覽器的開發人員工具,或是在畫面上找個地方按右鍵選 檢閱 進入。移動到 網路 (Networking) 這頁面,這裡就是顯示網頁載入後執行、或收到的大大小小的程式或資料。左邊區塊裡可能有非常多檔案,但通常我們的注意力先放在副檔名為 .json 或 .js 的項目即可,因為現在網頁前後端的主流資料傳輸格式都是 JSON。更進一步,我們已經知道我們要找的資料是課表,所以我們可以用跟課表、行程相關的英文關鍵字來在過濾 URL 的欄位搜尋,不用一個一個檢查。如上圖,我們可以看到 schedule-list 的某一節點內容和網頁裡 12/5 的行程十分一致,因此,可以大膽認定這就是網頁向後端查詢 12 月份的課表,所得到的 json 回傳。
然而,JSON 的結構畢竟不是用來給人閱讀的形式,我們還要再將整份資料轉化成大家習慣的表格形式,方便運用。這一步我們可以自己寫 apps script,也可以使用網路上現成的 JSON 轉換工具。
轉換資料
範例裡用的是 ConvertCSV,把 json 轉成常見的 csv 檔,便可再匯入 Google 試算表或 Excel。你會發現顯示轉換結果的表格裡有很多空欄位,這是該工具處理原本 json 裡的 null (空值) 的方式,不想要這些空欄位的話,後續刪除即可。大部分遇到的的資料整理跟編修需求,其實都可以在試算表軟體裡用內建功能或函式完成,我想只有當資料量非常大時才會一定需要寫程式。例如,Java 課程有少數的課會有兩名老師,這個在轉換 csv 結果裡就會是用兩欄呈現,一個欄位裡只有一名老師。在試算表裡你就可以用 JOIN() 將他們合併成一個字串。又或者,抓到的資料裡時間欄位是 Unix Timestamp,一個代表秒數 / 毫秒數的多位數數字,也可以用簡單的公式轉換成年月日時分秒。
最後附上我當初寫 Google Apps Script 的作法。JSON 內容後來應該有變,所以會發現程式碼裡有些地方和上面的現在版本對不起來,但這不影響原理。這個腳本雖然是用來處理緯育的課表 json,但面對其他 json 資料只要改一下鍵 (key) 的名稱跟一些細節即可,畢竟檔案結構都大同小異。
以 Apps Script 將 json 存入 Sheets
/**
* 主程式
*/
function addToSpreadsheet() {
const ws = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("TGA105_schedule"); // 存到試算表檔案裡,符合該名稱的分頁
const output = converToSheetData(tibame_23_Mar.data.scheduleList); // 一次輸入一個月份的資料
const range = ws.getRange(ws.getLastRow() + 1, 1, output.length, 14);
range.setValues(output);
}
/**
* 將整月的資料轉成二維陣列,範圍寫入 setValues() 所需
*/
function converToSheetData(data) {
const arr = [];
for (let i = 0; i < data.length; i++) {
if (data[i].eventAbbreviation != "國定假日") { // 排除課名是國定假日的項目
arr.push(convert(data[i])); // 轉換步驟
}
}
arr.sort(function (a, b) { return a[0] - b[0] }); // 按日期排序
return arr;
}
/**
* 將一節課的資料轉成一列
*/
function convert(item) {
const d = new Date(item.date); // 資料裡的 date 為 Unix Timestamp
const dateString = `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`; // 日期格式: 2022/5/12
return [item.date,
item.scheduleUid,
dateString,
item.interval,
item.classRoomUid,
item.eventUid,
item.perHours,
item.classRoomID,
item.location,
item.eventName,
item.eventAbbreviation,
item.classID,
item.hours,
list(item.teacherList)];
}
/**
* 針對老師清單部分合併
*/
function list(teacherList) {
if(teacherList.length == 0) {
return "N/A";
} else {
return teacherList.map(t => t.name)
.join(", ");
}
}
留言
張貼留言