[開發實錄]小愛同學課程表 - 強智系統與HEU教務系統的開發


不得不說,小愛課程表這個功能是十分好用的。特別是每天在室友面前喊“小愛同學,明天有什么課”,結合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,別打我(逃


 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM