不得不說,小愛課程表這個功能是十分好用的。特別是每天在室友面前喊“小愛同學,明天有什么課”,結合MIUI的AI助理技術,能讓室友體會檸檬的酸味(doge)。
然而某些學校可能暫時沒有適配該系統導致導入課程表失敗,這不要緊,只需要一點點JS知識以及一點點jQuery知識,加上三四天,你也可以搞出自己學校的課程表適配(當然某些奇形怪狀的課程表除外)
聲明:本項目適配 湖南強智科技教務系統
一、項目地址/參考
項目位於倉庫:https://github.com/Holit/HEU_edusys_miui
項目里已經包含了網頁文件,是我的課程表,可以在web_ref內找到
官方文檔:https://ldtu0m3md0.feishu.cn/docs/doccnhZPl8KnswEthRXUz8ivnhb
二、詳細教程
0x00 開始工作
配置Chrome開發環境
1. 按照要求下載Chrome插件:https://cnbj1.fds.api.xiaomi.com/aife/AISchedule/AISchedule.zip
2. 下載Chrome,打開鏈接 chrome://extensions/ ,打開開發者模式,導入AISchedule DevTools文件夾。
如果成功可以看到
准備開始開發
1. 定向到教務網站系統,筆者這里以哈爾濱工程大學(下稱HEU)本科生教務系統為例
2.在此頁右鍵,打開Chrome檢查頁。亦可以按下Ctrl+Shift+I啟動檢查頁。之后定位到AISchedule標簽
2. 點擊右上角進行登錄,此處登陸的是自己的小米賬號,便於以后的E2E測試和項目保存、同步
3.成功登錄之后關閉檢查頁再打開,就可以看到左側的開發版面已經更新。如果是第一次創建本校的課程表端口,左側可能是空的。
點擊左側“添加”,彈出“適配項目”窗口
按照要求填寫相關內容,注意這里的教務系統url最好填成外網URL(即非ip地址形式)
4.新建后可以看到
點擊這個選項卡內部,瀏覽器自動切換到Sources選項卡。此時開發環境配置結束
0x01 准備Provider
Provider的作用在於從指定的教務系統url獲取頁面文檔並傳遞給Praser。
為了適配HEU的教務系統,我們使用GET方法。GET是一個HTTP方法,目的是從指定的資源請求數據。
此處我的Provider代碼如下
1 function scheduleHtmlProvider(iframeContent = "", frameContent = "", dom = document) { 2 let http = new XMLHttpRequest() 3 http.open('GET', '/jsxsd/xskb/xskb_list.do?Ves632DSdyV=NEW_XSD_PYGL', false) 4 http.send() 5 return http.responseText 6 }
代碼說明:
函數體
1 function scheduleHtmlProvider(iframeContent = "", frameContent = "", dom = document) { 2 }
是默認函數體,在官方文檔中給出的示例為
1 function scheduleHtmlProvider(iframeContent = "", frameContent = "", dom = document) { 2 //除函數名外都可編輯 3 //以下為示例,您可以完全重寫或在此基礎上更改 4 5 const ifrs = dom.getElementsByTagName("iframe"); 6 const frs = dom.getElementsByTagName("frame"); 7 8 if (ifrs.length) { 9 for (let i = 0; i < ifrs.length; i++) { 10 const dom = ifrs[i].contentWindow.document; 11 iframeContent += scheduleHtmlProvider(iframeContent, frameContent, dom); 12 } 13 } 14 if (frs.length) { 15 for (let i = 0; i < frs.length; i++) { 16 const dom = frs[i].contentDocument.body.parentElement; 17 frameContent += scheduleHtmlProvider(iframeContent, frameContent, dom); 18 } 19 } 20 if (!ifrs.length && !frs.length) { 21 return dom.querySelector('body').outerHTML 22 } 23 return dom.getElementsByTagName('html')[0].innerHTML + iframeContent + frameContent 24 }
要查看獲取的結果是不是正確,需要在Praser里查看是不是傳遞了正確的參數。
0x2 編寫Praser
Praser主要工作在於解析Provider傳入的參數並將該解析結果傳出。該函數接受html作為參數,傳出一個list
官方docs給出的示例為
1 function scheduleHtmlParser(html) { 2 //除函數名外都可編輯 3 //傳入的參數為上一步函數獲取到的html 4 //可使用正則匹配 5 //可使用解析dom匹配,工具內置了$,跟jquery使用方法一樣,直接用就可以了,參考:https://juejin.im/post/5ea131f76fb9a03c8122d6b9 6 //以下為示例,您可以完全重寫或在此基礎上更改 7 let result = [] 8 let bbb = $('#table1 .timetable_con') 9 for (let u = 0; u < bbb.length; u++) { 10 let re = { 11 sections: [], 12 weeks: [] 13 } 14 let aaa = $(bbb[u]).find('span') 15 let week = $(bbb[u]).parent('td')[0].attribs.id 16 if (week) { 17 re.day = week.split('-')[0] 18 } 19 for (let i = 0; i < aaa.length; i++) { 20 21 if (aaa[i].attribs.title == '上課地點') { 22 23 for (let j = 0; j < $(aaa[i]).next()[0].children.length; j++) { 24 re.position = $(aaa[i]).next()[0].children[j].data 25 } 26 } 27 if (aaa[i].attribs.title == '節/周') { 28 29 for (let j = 0; j < $(aaa[i]).next()[0].children.length; j++) { 30 31 let lesson = $(aaa[i]).next()[0].children[j].data 32 for (let a = Number(lesson.split(')')[0].split('(')[1].split('-')[0]); a < Number(lesson.split(')')[0].split('(')[1].split('-')[1].split('節')[0]) + 1; a++) { 33 34 re.sections.push({ 35 section: a 36 }) 37 } 38 for (let a = Number(lesson.split(')')[1].split('-')[0]); a < Number(lesson.split(')')[1].split('-')[1].split('周')[0]) + 1; a++) { 39 40 re.weeks.push(a) 41 } 42 } 43 } 44 45 if (aaa[i].attribs.title == '教師') { 46 47 for (let j = 0; j < $(aaa[i]).next()[0].children.length; j++) { 48 re.teacher = $(aaa[i]).next()[0].children[j].data 49 } 50 } 51 52 if (aaa[i].attribs.class == 'title') { 53 54 for (let j = 0; j < $(aaa[i]).children()[0].children.length; j++) { 55 re.name = $(aaa[i]).children()[0].children[j].data 56 57 } 58 } 59 60 } 61 result.push(re) 62 } 63 console.log(result) 64 65 return { 66 courseInfos: result 67 } 68 }
我們發現這個示例並不能很好的適配我們的教務系統,因此我們開始從零編寫這個Praser。
1x00 分析文檔結構
TODO:一步步篩選Html的內容,並讓內容最終精簡到我們所需要的文本。
這里我們使用jQuery,當然你也可以使用正則表達式進行匹配
這里我們分析Elements選項卡,在課程內容上右鍵
展開對應元素的html設計
Chrome已經幫我們定位了這個元素的div,我們展開慢慢查詢
觀察元素,可以發現這個html中我們想獲取的內容都有如下代碼環繞:(刪去了</div>等結構)
1 <table id="kbtable" border="1" width="100%" cellspacing="0" cellpadding="0" class="Nsb_r_list Nsb_table"> 2 <div id="5180478CC0C746CC94534AF06163E808-3-2" style="" class="kbcontent">線性代數與解析幾何A<br><font title="老師">廉春波</font><br><font title="周次">4-18(周)</font><br><font title="節次">[0102節]</font><br><font title="教室">21B 502中</font><br></div>
因此,我們所希望定位的內容位於一個table里,這個table的id是kbtable。這個table的內部存在一個class為kbcontent的div。
此時我們要應用jQuery大法獲取這段內容。
1x01 jQuery介紹
1.jQuery 選擇器
jQuery 選擇器允許您對 HTML 元素組或單個元素進行操作。基於元素的 id、類、類型、屬性、屬性值等"查找"(或選擇)HTML 元素。 它基於已經存在的 CSS 選擇器,除此之外,它還有一些自定義的選擇器。
jQuery 中所有選擇器都以美元符號開頭:$()。
2.#id 選擇器
jQuery #id 選擇器通過 HTML 元素的 id 屬性選取指定的元素。
頁面中元素的 id 應該是唯一的,所以您要在頁面中選取唯一的元素需要通過 #id 選擇器。
通過 id 選取元素語法如下:
1 $("#test")
3..class 選擇器
jQuery 類選擇器可以通過指定的 class 查找元素。
語法如下:
1 $(".test")
4.更多語法
1 $("*") //選取所有元素 2 $(this) //選取當前 HTML 元素 3 $("p.intro") //選取 class 為 intro 的 <p> 元素 4 $("p:first") //選取第一個 <p> 元素 5 $("ul li:first") //選取第一個 <ul> 元素的第一個 <li> 元素 6 $("ul li:first-child") //選取每個 <ul> 元素的第一個 <li> 元素 7 $("[href]") //選取帶有 href 屬性的元素 8 $("a[target='_blank']") //選取所有 target 屬性值等於 "_blank" 的 <a> 元素 9 $("a[target!='_blank']") //選取所有 target 屬性值不等於 "_blank" 的 <a> 元素 10 $(":button") //選取所有 type="button" 的 <input> 元素 和 <button> 元素 11 $("tr:even") //選取偶數位置的 <tr> 元素 12 $("tr:odd") //選取奇數位置的 <tr> 元素
1x02 正則表達式語法簡介
由於本項目不使用正則表達式,因此我不會詳細講解這部分。這段內容留給您參考
正則表達式測試:https://tool.oschina.net/regex/
語法:http://c.biancheng.net/view/5632.html
1x03 使用jQuery獲取元素
我們分析了我們想要的東西的父節點id為kbtable,class為kbcontent。因此我們寫出下述jQuery代碼
1 let $raw = $('#kbtable .kbcontent').toArray();
為了便於之后我查看和引用變量,我在所有jQuery獲取的變量前面都加上了$以標示它的來源是jQuery。
此處加不加.toArray()都可以,因為返回的就是一個Object數組,它長這樣
ps:如果要進行運行時調試,你可以使用console.info()。鑒於console.log()在移動端不可用,我建議一樓都使用info,發布時再去掉。當然去不去掉沒關系。
我們看到,這個數組剛好返回了35個值。這個值滿足5*7=35,也就是說7天,每天5節課都被包含在這個list內部。我們展開一個object查看
很不幸這個元素內沒有符合要求的信息。但這並不代表我們錯了,而是這節本來就沒課。
如果我們展開第2個元素:
熟悉的線代老師名字出現了!(逃
這里我們分析這個Object就會發現這玩意真是符合哈爾濱風格,一層套一層(沒有貶低哈爾濱的意思
1x04 分析元素並啟動篩選
接下來就是整個適配系統中的核心部分,即適配這些children。這個時候你就會遇到一堆又一堆的坑。讓我們開始吧。
第一,我們首先要檢查我們訪問的Object是不是存在,因為如果不存在我們是無法對其進行操作、讀取以及獲取children的。因此我們加上
1 for (index in $raw) { 2 data = $raw[index] 3 if (data.children != undefined) { 4 if (data.children.length == 1) { 5 continue; 6 } 7 } 8 } 9 }
代碼說明:
我們對$raw的每個元素都檢查一邊,如果$raw其中的某個Object有children的話,就檢查這個children是不是為空。
如果為空,就說明這節沒課。
第二個坑此時出現了,HEU的課程表有一個奇怪的不知道為什么這樣寫的東西,也就是
這一節課相同老師相同時間不同教室????
實際上這是一節主播課程,也就是一間主播教室,幾間不同的錄播教室。又由於每節課地點不同,因此每節課上課前班長才會通知具體上課地點。因此我們無法處理這個課程。
我們添加下述邏輯
1 if (data.children.length == 12) { 2 name = name + '[待定]'; 3 }
這段邏輯是說,如果數據的長度是12的話,說明是這樣的錄播課,我們便在這節課的課名后面加上一個“待定”來區別。
...
經歷了各項痛苦的邏輯查詢和運行時調試,我們得到下述代碼訪問所需要的內容
1 //please notice these data are from object, therefore please check whether they are existed. 2 //for rigorous, please check undefined 3 teacher = data.children[2].children[0].data; 4 weeks = data.children[4].children[0].data; 5 sections = data.children[6].children[0].data; 6 position = data.children[8].children[0].data;
1x05 文本分析和輸出正確的數據格式
1.數據格式要求
根據官方文檔,要求輸出下述兩個數據:courses和sections,這里先講courses,sections將在2x00開始講。
示例為

1 示例 2 { 3 "courseInfos": [ 4 { 5 "name": "數學", 6 "position": "教學樓1", 7 "teacher": "張三", 8 "weeks": [ 9 1, 10 2, 11 3, 12 4, 13 5, 14 6, 15 7, 16 8, 17 9, 18 10, 19 11, 20 12, 21 13, 22 14, 23 15, 24 16, 25 17, 26 18, 27 19, 28 20 29 ], 30 "day": 3, 31 "sections": [ 32 { 33 "section": 2, 34 "startTime": "08:00",//可不填 35 "endTime": "08:50"//可不填 36 } 37 ], 38 }, 39 { 40 "name": "語文", 41 "position": "基礎樓", 42 "teacher": "荊州", 43 "weeks": [ 44 1, 45 2, 46 3, 47 4, 48 5, 49 6, 50 7, 51 8, 52 9, 53 10, 54 11, 55 12, 56 13, 57 14, 58 15, 59 16, 60 17, 61 18, 62 19, 63 20 64 ], 65 "day": 2, 66 "sections": [ 67 { 68 "section": 2, 69 "startTime": "08:00",//可不填 70 "endTime": "08:50"//可不填 71 }, 72 { 73 "section": 3, 74 "startTime": "09:00",//可不填 75 "endTime": "09:50"//可不填 76 } 77 ], 78 } 79 ], 80 "sectionTimes": [ 81 { 82 "section": 1, 83 "startTime": "07:00", 84 "endTime": "07:50" 85 }, 86 { 87 "section": 2, 88 "startTime": "08:00", 89 "endTime": "08:50" 90 }, 91 { 92 "section": 3, 93 "startTime": "09:00", 94 "endTime": "09:50" 95 }, 96 { 97 "section": 4, 98 "startTime": "10:10", 99 "endTime": "11:00" 100 }, 101 { 102 "section": 5, 103 "startTime": "11:10", 104 "endTime": "12:00" 105 }, 106 { 107 "section": 6, 108 "startTime": "13:00", 109 "endTime": "13:50" 110 }, 111 { 112 "section": 7, 113 "startTime": "14:00", 114 "endTime": "14:50" 115 }, 116 { 117 "section": 8, 118 "startTime": "15:10", 119 "endTime": "16:00" 120 }, 121 { 122 "section": 9, 123 "startTime": "16:10", 124 "endTime": "17:00" 125 }, 126 { 127 "section": 10, 128 "startTime": "17:10", 129 "endTime": "18:00" 130 }, 131 { 132 "section": 11, 133 "startTime": "18:40", 134 "endTime": "19:30" 135 }, 136 { 137 "section": 12, 138 "startTime": "19:40", 139 "endTime": "20:30" 140 }, 141 { 142 "section": 13, 143 "startTime": "20:40", 144 "endTime": "21:30" 145 } 146 ] 147 }
注意這里有個坑:
sections的結構為
1 "sections": [ 2 { 3 "section": 2, 4 "startTime": "08:00",//可不填 5 "endTime": "08:50"//可不填 6 }, 7 { 8 "section": 3, 9 "startTime": "09:00",//可不填 10 "endTime": "09:50"//可不填 11 } 12 ],
也就是說元素sections是一個list,包含了節數信息和課程開始、節數時間信息。不要填成
"sections":{1,2,3}
這是不通過的。
2.輸出正確的數據格式
首先我們要解析文本數據,即“周”和“節次”數據,他們原本是這樣的:5-18(周)、[030405節],我們要搞成上面要求的樣子
因此我借鑒了我們學校研究生系統中的一個function:
1 function _create_array(rangeNum) { 2 //rangeNum should be inputed as '7-18' and will output {7,8,9,10,11,12,13,14,15,16,17,18} 3 let resultArray = []; 4 let begin = rangeNum.split('-')[0]; 5 let end = rangeNum.split('-')[1]; 6 for (let i = Number(begin); i <= Number(end); i++) { 7 resultArray.push(i); 8 } 9 return resultArray; 10 }
這段function將接受一個str作為參數,參數標記為"?-?"。之后輸出一個數組,這個數組是參數對應的數組。
例如輸入"7-18",輸出{7,8,9,10,11,12,13,14,15,16,17,18}
這樣我們就能處理5-18這樣的數字了,然而我們發現有些周數是分開的,即,4,5-18。這很簡單,我們只需要分割字符串就好。注意,這里要求的傳入必須去掉"(周)",這只需要replace就可以了
最終我們得到函數
1 function _get_week(data) { 2 //weeks data will be inputed as '4,7-18', to handle ,we will split them by ',' and operate seperately. 3 let result = []; 4 let raw = data.split(','); 5 for (i in raw) { 6 7 if (raw[i].indexOf('-') == -1) { 8 //create array 9 result.push(parseInt(raw[i])); 10 } else { 11 let begin = raw[i].split('-')[0]; 12 let end = raw[i].split('-')[1]; 13 for (let i = parseInt(begin); i <= parseInt(end); i++) { 14 result.push(i); 15 } 16 } 17 } 18 //sort the array, 19 return result.sort(function (a, b) { 20 return a - b 21 }); 22 23 }
為了美觀,最后我排了個序。
觀察節數信息,我們發現"01020304"是主要信息,因此我們把它篩選出來,即replace其他的固定字符得到這樣的文本。
我們使用str的索引獲取這些節數信息並輸出array:
1 function _get_section(data) { 2 //section info will be inputed as '01020304',we will devide them into {01,02,03,04} and then create array. 3 let section = [] 4 let num = 0; 5 let i = 0; 6 do { 7 num = parseInt(data.substr(i, 2)); 8 //this will push an array such as {section:1},{section:2}... 9 section.push({"section":num}); 10 //jump to next number, such as 11 //010203 12 //| 13 // | 14 i = i + 2; 15 } while (i < data.length); 16 return section; 17 }
3.
綜上,我們得到下述數據格式處理代碼:
//replace for creating the arry weeks=weeks.replace('(周)', ''); sections = sections.replace('[', ''); sections = sections.replace('節]', ''); //correct structure. let courseInfo = { "name": name, "position": position, "day": _get_day(index), "teacher": teacher, "sections":_get_section(sections), "weeks": _get_week(weeks) }; courses.push(courseInfo);
2x00 sections
這個就太簡單了,復制下述代碼改一改就好,這里是按照HEU的軍工作息時間搞的
1 function createSectionTimes() { 2 //this is the HEU standard section time. 3 //get it on the official website. 4 let sectionTimes = [{ 5 "section": 1, 6 "startTime": "08:00", 7 "endTime": "08:45" 8 }, { 9 "section": 2, 10 "startTime": "08:50", 11 "endTime": "09:35" 12 }, { 13 "section": 3, 14 "startTime": "09:55", 15 "endTime": "10:40" 16 }, { 17 "section": 4, 18 "startTime": "10:45", 19 "endTime": "11:30" 20 }, { 21 "section": 5, 22 "startTime": "11:35", 23 "endTime": "12:20" 24 }, { 25 "section": 6, 26 "startTime": "13:30", 27 "endTime": "14:15" 28 }, { 29 "section": 7, 30 "startTime": "14:20", 31 "endTime": "15:05" 32 }, { 33 "section": 8, 34 "startTime": "15:25", 35 "endTime": "16:10" 36 }, { 37 "section": 9, 38 "startTime": "16:15", 39 "endTime": "17:00" 40 }, { 41 "section": 10, 42 "startTime": "17:05", 43 "endTime": "17:50" 44 }, { 45 "section": 11, 46 "startTime": "18:30", 47 "endTime": "19:15" 48 }, { 49 "section": 12, 50 "startTime": "19:20", 51 "endTime": "20:05" 52 }, { 53 "section": 13, 54 "startTime": "20:10", 55 "endTime": "20:55" 56 } 57 ] 58 return sectionTimes 59 }
3x00 最終成型
經過上面的編寫和調試,我們得到下述代碼
1 function scheduleHtmlParser(html) { 2 //analyse the element and you will see this. 3 /* 4 <div id="5180478CC0C746CC94534AF06163E808-3-2" style="" class="kbcontent"> 5 線性代數與解析幾何A<br> 6 <font title="老師">廉春波</font><br> 7 <font title="周次">4-18(周)</font><br> 8 <font title="節次">[0102節]</font><br> 9 <font title="教室">21B 502中</font><br> 10 </div> 11 12 this is the main entrance. 13 */ 14 let $raw = $('#kbtable .kbcontent').toArray(); 15 console.info($raw); 16 let courses = []; 17 18 let name = ""; 19 let teacher = ""; 20 let weeks = ""; 21 let sections = ""; 22 let position = ""; 23 24 for (index in $raw) { 25 data = $raw[index] 26 if (data.children != undefined) { 27 if (data.children.length == 1) { 28 continue; 29 } 30 name = data.children[0].data; 31 //for courses includes '---------------------' which I dont understand, thier array will be longer than others, therefore we use length to flag them. 32 //reminder:courses includes '---------------------' will be only different in the position, and the position is not determined until its going to begin. 33 //therefore we use '[待定]' to mark them out. 34 if (data.children.length == 12) { 35 name = name + '[待定]'; 36 } 37 38 //please notice these data are from object, therefore please check whether they are existed. 39 //for rigorous, please check undefined 40 teacher = data.children[2].children[0].data; 41 weeks = data.children[4].children[0].data; 42 sections = data.children[6].children[0].data; 43 position = data.children[8].children[0].data; 44 45 //replace for creating the arry 46 weeks=weeks.replace('(周)', ''); 47 sections = sections.replace('[', ''); 48 sections = sections.replace('節]', ''); 49 //correct structure. 50 let courseInfo = { 51 "name": name, 52 "position": position, 53 "day": _get_day(index), 54 "teacher": teacher, 55 "sections":_get_section(sections), 56 "weeks": _get_week(weeks) 57 }; 58 courses.push(courseInfo); 59 } 60 } 61 //optional: for debug only 62 //console.info(courses); 63 64 finalResult = { 65 "courseInfos": courses, 66 "sectionTimes": createSectionTimes() 67 }; 68 return finalResult; 69 }
4x00 調試階段
由於這段代碼已經正常工作了,我在調試的時候也就沒有遇到什么困難。
你只需要在教務系統頁面右鍵選擇"運行函數"然后查看console,如果是
那么Chrome就測試通過了。
4x01 E2E調試
項目寫完之后點擊“上傳”,左側便會產生一個帶版本號的工作空間,這代表在自己的手機上可以進行E2E測試了
在手機上打開vConsole即可看到相關Console的輸出,便於判斷錯誤
常見錯誤說明:如果是Unexpected token o... 那么請檢查你的數據結構是不是正確傳出了。
以上
第一次做JS,別打我(逃