[开发实录]小爱同学课程表 - 强智系统与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,别打我(逃
作者发布、转载的任何文章中所涉及的技术、思路、工具仅供以安全目的的学习交流,并严格遵守《中华人民共和国网络安全法》、《中华人民共和国数据安全法》等网络安全法律法规。
任何人不得将技术用于非法用途、盈利用途。否则作者不对未许可的用途承担任何后果。
本文遵守CC BY-NC-SA 3.0协议,您可以在任何媒介以任何形式复制、发行本作品,或者修改、转换或以本作品为基础进行创作
您必须给出适当的署名,提供指向本文的链接,同时标明是否(对原文)作了修改。您可以用任何合理的方式来署名,但是不得以任何方式暗示作者为您或您的使用背书。
同时,本文不得用于商业目的。混合、转换、基于本作品进行创作,必须基于同一协议(CC BY-NC-SA 3.0)分发。
如有问题, 可发送邮件咨询.