[开发实录]小爱同学课程表 - 强智系统与HEU教务系统的开发

时间:2024-03-02 17:19:35

不得不说,小爱课程表这个功能是十分好用的。特别是每天在室友面前喊“小爱同学,明天有什么课”,结合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,别打我(逃