今天是大年初五,原本计划出门玩,但是天气比较糟糕就放弃了。想到第一篇博客里面预告了要给 thu-learn-lib
写一个小介绍,已经过去了好几天。正好我也不太想写代码,就回来把这个坑填上。它发布在 GitHub 和 npm 上。
前言
顾名思义,这个库是帮助像我这样的菜鸡在清华大学学习程序访问清华大学网络学堂。最早网络学堂是在2001年上线的,这个版本使用最广,页面简介,速度也不错。然而对于程序员,它很不友好:完全没有前端,网页是服务器纯动态生成的,想拿到结构化的信息的唯一办法就是解析DOM元素。由于2001版网络学堂交互逻辑不是很方便,访问各种课程内容(四大核心板块是公告、作业、文件、讨论)都需要较多的点击,出现了一系列助手软件来统一这些信息,让同学们不至于迷失在作业的海洋中。比较好用的一个 Chrome 插件是 Learn Helper,设计精美,功能丰富。
2015年,学校上线了一个新版的网络学堂(https://learn.cic.tsinghua.edu.cn ,目前已经下线了)。2015版学堂的最大特点是:慢。页面的动态内容几乎都是 AJAX 的,然而由于无法解释的原因速度十分的缓慢,并且非常容易出错。这些接口的数据格式相当糟糕,结构化程度甚至还不如直接解析DOM。用2015版的课程不多,很多老师在(不可逆的)升级以后都表示了由衷的后悔之意。Learn Helper也添加了对此版本的支持,但是经常导致加载出错,因此我干脆在其中屏蔽了所有这个版本的课程。
学校似乎也意识到2015版过于丢人,所以在2018年下半年推出了目前最新的2018版网络学堂,并在学期结束后一举下线了两个旧版本,把数据都迁移了过去。也就是说,从2018-2019年春季学期开始,就只剩下这一个线上版本了。遇到这么激进的迁移,同学们的各种作业下载/学堂助手小工具都不灵光了,急需一波更新。于是我挺身而出闲来无事,就用 JS 写了一个库,来做信息爬取。
踩坑:网络访问
说实话,这是我自己写的第一个 JS 项目,听了大家的意见,用 TypeScript 写并编译成 ECMAScript 2016,在现代的浏览器 / JS 引擎上应该都没有语言兼容性问题。我天真地以为如今 JS 已经是天下一统了,没想到上来就踩了个大坑。
都说现在 fetch
API 非常好用,于是我写了一通以后,丢进 nodeJS
一跑,上来就是一个 'fetch' not defined
。这下才知道原来只有浏览器才有 window.fetch
,于是又找了一个叫做 cross-fetch
的 ponyfill 库:现在 node 能跑了,然而不能记录 Cookie 信息,于是又需要一个包装,用一个 CookieJar 来做拦截存下这些东西。相反的,在浏览器那边,开发者不需要也不能控制这些东西(甚至读不到 Set-Cookie
的 header,只管请求就是了,浏览器爸爸啥都做好了)。这时候我就非常难受了,在放弃的边缘试探。好在最后找到了一个比较小众的库叫 real-isomorphic-fetch
,解决了上述所有不一致的地方。
这个库的设计是还是比较不错的,每一个 fetch
实例对应不同的 CookieJar
,可以很方便的做多 session 管理。当然我没有直接支持这个特性,而是在类中暴露出来了对应的 cookieJar
对象,用户也可以在初始化的时候传进自己的。拿回来以后,可以用来做文件的下载,或者自行实现多用户的功能。
不过美中不足的是,这个库可能用的人太少了,没有 typing 信息,导致我不得不用 require
的方法去用它,并且需要关掉 tslint
的 no-var-requires
这条规则。自然,用的时候也会失去一切关于类型的补全信息。
踩坑:网络学堂
开启疯狂吐槽网络学堂2018模式。
2018 版网络学堂是学校信息化技术中心研发部门自行开发的。我们要肯定,它的UI比较美观,甚至还做了响应式设计和移动版;在使用体验上也很好,不管是全局还是课程都有一定的信息汇总,不用像以前那样一个一个页面点开。当然,作为程序员的我心里清楚,用户感觉良好的背后,可能是……*一样的实现。
首先令人惊喜的是,网络学堂的大部分API都是基于GET请求+返回JSON的设计,这对于程序员来说可以说是大礼包了。但是先不要高兴的太早,因为API返回的数据格式大部分类似像这样(一些数据隐去):
{
"kssj": 1545926400000,
"kssjStr": "2018-12-28 00:00",
"jzsj": 1546617599000,
"jzsjStr": "2019-01-04 23:59",
"bt": "第四单元书面作业",
"wz": 13,
"zywcfs": 1,
"zytjfs": 2,
"jffs": 1,
"mxdxmc": "全体学生-全体",
"wlkcid": "2018-2019-126ef84e7689a14e101689a618d890549",
"xszyid": "26ef84e7689a14e101689a61975b08b4",
"xh": "[REDACTED]",
"zyid": "sjqy_26ef84e7689a14e101689a618d890549881519",
"zynr": null,
"zynrStr": "",
"zyfjid": "[REDACTED]_XSZY_1548778313_1034e0eb-bca0-4555-914f-ed6d0a943572_sjqy01-admin",
"scr": "",
"scsj": 1546087464000,
"scsjStr": "2018-12-29 20:44",
"gzzh": "2018210974",
"pysj": 1547618929000,
"pysjStr": "2019-01-16 14:08",
"pynr": "",
"pylj": "",
"cj": 10,
"zt": "已交",
"pyzt": "已批改",
"qzid": "15061969",
"bz": "01-admin",
"qzmc": "全体学生-全体组",
"xm": "[REDACTED]",
"dwmc": "[REDACTED]",
"bm": "[REDACTED]",
"djzcj": "",
"jsm": "[REDACTED]",
"id": null
}
第一次看到这些东西的时候,我的脑中充满了黑人问号的表情。是的,拼音首字母命名成的字段。但是人民的智慧是无穷的,我发现拼音输入法+群众的力量对付这些奇怪的名称还是比较有效的。据不科学统计,本次开发约 20% 的时间用于研究这些字段究竟是什么。
如果仅仅如此还好,更可怕的是,这些API显然不是同一批人开发的,格式不同,比如很明显带有 sj
后缀的字段是一个时间,但有些地方它的内容是字符串,有些地方它的内容是 epoch 以来的毫秒数,还有些地方,它的内容始终是 null
,而相应的 sjStr
内容代替了它。还有比如代表内容的 nrStr
字段中的 HTML entity 需要解码,而另有一个 nr
字段存放了未经 entity 编码的,而是经过 base64 编码的相应内容。再次据不科学统计,本次开发还有约 30% 的时间用于对付这些奇奇怪怪的返回内容。
老师,能再给力一点吗?
其实原本我做的是对于每一个板块(公告、作业、文件、讨论)独立地爬取内容,但是每个板块的API居然都不!一!!样!!!有一些是 RESTful 的,大部分是 GET 加上请求参数,还有一些甚至是 POST(并且参数极其冗长,还包括了一些数据库的表名等信息,虽然实验证明传过去的这些东西并没有用)。好在最终我发现其实课程的总览页面用到的API全都是基于 GET 的,并且内容非常全,就统一换上了这些。
然而我还忽略了一些问题:API返回的内容不全!还是有一些重要的信息,比如学生提交的作业内容、公告的附件等,被直接嵌入在页面里返回。这意味着我必须要做 DOM 的解析,用到了 cheerio
这个轻量级的,接口类似于 jQuery
的 DOM 解析库。顺便一提,使用它的时候最好关掉 decodeEntities
这个选项,否则所有的中文字都会变成对应的 HTML entity(即 Unicode 码点)。DOM 解析一大麻烦的问题是可能对方的一个小改动就会给我带来大影响,anyway,有问题再更新吧。
如你所见,至少有 40% 时间用来解决这些麻烦的细节问题,还有剩下的 10% 用在和 npm 抗争上。原本这个项目叫做 thu-learn2018-lib
,但是 npm 始终认为我是 SPAM (可能因为默认创建的项目里面就有年份,我就被误伤了)。不想发邮件抗议,于是就改成了现在这个名字发布了。
事实上,网络学堂2018还给我们带来了一些惊喜的小feature,比如SQL注入、任意文件下载、用户信息泄露,等等。这篇博客本意不是讲安全,就按下不表了。
效果
我认为这个库的用法还是很简单的,虽然写了 README,这里还是贴一下。
import { Learn2018Helper } from 'thu-learn-lib';
// in JS engines, each instance owns different cookie jars
const helper = new Learn2018Helper();
// all following methods are async
// first login
const loginSuccess = await helper.login('user', 'pass');
// take out cookies (e.g. for file download), which will not work in browsers
// its type is require('tough-cookie-no-native').CookieJar
console.log(helper.cookieJar);
// get ids of all semesters that current account has access to
const semesters = await helper.getSemesterIdList();
// get get semester info
const semester = await helper.getCurrentSemester();
// get courses of this semester
const courses = await helper.getCourseList(semester.id);
const course = courses[0];
// get detail information about the course
const discussions = await helper.getDiscussionList(course.id);
const notifications = await helper.getNotificationList(course.id);
const files = await helper.getFileList(course.id);
const homework = await helper.getHomeworkList(course.id);
const questions = await helper.getAnsweredQuestionList(course.id);
// logout if you want, which has no effect in browsers
helper.logout();
拿到的数据格式是类似这样的(用一个作业为例):
{
"id":"sjqy_26ef84e7689a14e101689a5040182f5a879985",
"studentHomeworkId":"26ef84e7689a14e101689a504b173611",
"title":"PA5(选做) ",
"url":"[REDACTED]",
"deadline":"2019-01-06T15:59:59.000Z",
"submitUrl":"[REDACTED]",
"submitTime":"2019-01-04T15:46:05.000Z",
"grade":5,
"graderName":"[REDACTED]",
"gradeContent":"nan",
"gradeTime":"2019-01-14T20:26:43.000Z",
"submittedAttachmentUrl":"[REDACTED]",
"submitted":true,
"graded":true,
"description":"第五阶段实验编程作业(选做),具体要求可参见附件文件包中的说明(Decaf PA5 README.pdf)",
"submittedContent":"<!-- <span style=\"line-height: 24px;\"> -->\n\t\t\t\t\t\t\t\t\t\t<span style=\"line-height:2;\">\n\t\t\t\t\t\t\t\t\t\t</span>",
"attachmentName":"[REDACTED]",
"attachmentUrl":"[REDACTED]",
"submittedAttachmentName":"[REDACTED]"
}
自我感觉还是比原本的格式好了很多的(其中有一个 nan 的确就是字面值,并不是解析出了问题)。
总结
学校的研发都放假了,我还在写代码,学校应该给我发工资才对。
希望这个库能给大家的学习生活带来一些方便,欢迎随时 PR。下面是一些可能的小目标:
- 支持提交作业、讨论等
- 支持助教/教师功能
- 支持直接下载(吞并 这个项目?)
此外,我正在以此为后端开发支持2018版网络学堂的 Learn Helper,几乎重写了所有的UI和逻辑。预计开学前发布,敬请期待。