随手搓 02:学校时间表爬取

本文最后更新于 2024年1月5日 凌晨

javascript 初见——随便写的时间表爬取脚本

先来交代下背景 (~ ̄▽ ̄)~

咱校不知道去年抽什么风,把一直沿用的星期制时间表换成了 Cycle 制,一个 Cycle 有 6 天

也就是说,每星期多出来的那一天会往后延,如此类推

虽然说学校还特别「贴心」的弄了个时间表网站,好让我们随时能查看日程表

但是负责这个的老师似乎不是很分得清生产以及测试环境的区别,导致开学刚上线的那段时间非常不稳定,十次访问有六七次是掉线的,而每次掉线都是他在更新些不知道啥  ̄へ ̄

不稳定还是其次吧,毕竟老师也不是专业搞这个的,还是得体谅体谅,主要是太不方便了,每次使用得多一个登录的步骤

前段时间翻邮箱时看到来自 Notion Cafe 的通知,突然灵光乍现——

对吼,可以爬取网页解析成 .ics 文件,这样就可以将日程表导入到原生日历软件里了 (^^ゞ

Notion Cafe 是一个在线服务,它可以定时爬取你的 Notion database 并提供一个订阅日历来更新

这样就做到一个 Notion 和日历同步的效果

于是就有了这个这个 repo

https://github.com/BlissfulAlloy79/wyhk-timetable-exporter

这个脚本主要是通过用户登录过的 cookie 来做新的 requests

再通过 icalendar 库转成 .ics 文件

就酱。。。

。。。

。。。

(。_。)

哈哈又可以水一篇文力

你以为这样就水一篇了吗?

人总不能一直呆在自己的舒适圈嘛, 于是从未接触过 javascript 的我决定是时候学习一下新的玩意力 (/▽\)

主要是这个不用一晚上就写完的脚本实在是太水了

https://github.com/BlissfulAlloy79/wyhk-timetable-exporter-js

脚本是 userscript,利用 tampermonkey 在浏览器上运行

时间表网页解析

来看看学校时间表网页背后到底在做些啥吧

开启 Chrome F12,打开时间表网页,看看都有些啥网络活动

简单地过了一下,流程大概是这样

  • 首先向登录的 login api 发送 POST 请求,提交帐号名称、密码以及 recaptcha 返回值(对没错,这玩意有 captcha ㄟ( ▔, ▔ )ㄏ)

  • login api 验证了客户端的 cookie,并且给予了一个 token 的过期期限

  • 之后的任何请求都是基于这个已验证的 cookie

而在之后的请求如下:

  • calendar,获取全年日程表

  • user,获取用户资料

  • year-and-term,通过日期获得当前学年以及学期

  • student-timetable,通过学生 id,学年以及学期获取课程表

  • student-events,通过日期获取当日活动

/api/calendar

获取全年日程表

发送 GET 请求到 calendar api 直接返回了一整年的安排,每一个日期都有对应的天数以及 Cycle 数

没有 payload

举几个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
[
{
"Date": "2023-09-01T00:00:00.000Z",
"Type": "Non Cycle Day",
"Cycle": null,
"Day": null,
"NextDay": 2,
"NextDate": "2023-09-05T00:00:00.000Z",
"Order": null,
"Display": "H",
"Icon": null
},
...
{
"Date": "2023-09-02T00:00:00.000Z",
"Type": "Week End",
"Cycle": null,
"Day": null,
"NextDay": 2,
"NextDate": "2023-09-05T00:00:00.000Z",
"Order": null,
"Display": "H",
"Icon": null
},
...
{
"Date": "2023-09-05T00:00:00.000Z",
"Type": "Cycle Day",
"Cycle": "1",
"Day": 2,
"NextDay": 3,
"NextDate": "2023-09-06T00:00:00.000Z",
"Order": "H",
"Display": "H",
"Icon": null
},
...
...
...
]

嗯。。。十分暴力

/api/user

获取用户信息

没有 payload,直接由服务器返回用户 id 以及 role (角色?)

1
2
3
4
{
"username": "s12345",
"role":"student"
}

/api/year-and-term

通过日期返回学年以及学期

payload 是 query,直接 encode 在 URL 里

1
?Thu%20Dec%2028%202023%2022:06:47%20GMT+0800%20(China%20Standard%20Time)

返回学年以及学期

1
2
3
4
5
6
[
{
"Sch_Year":2023,
"Term":2
}
]

/api/student-timetable

获取学生课程表

也是 query,URL encode

payload 中包含了刚刚返回的学年以及学期,以及用户 id

1
?year=2023&term=2&student=12345

返回例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[
{
"Day": 1,
"P1_Subj": "Maths",
...
"P10_Subj": "CS",
"P1_Teacher": "",
...
"P10_Teacher": "",
"P1_Rm": "Rm 6Y",
...
"P10_Rm": "Rm 6Y"
},
{
"Day": 2,
...
},
...
]

/api/student-events

获取当日活动

GET 请求的 payload 中需要添加当日日期,相对应的就会返回当日 event,可以为空

同样是 query

1
?date=2023-12-05

返回例子

1
2
3
4
5
[
{
"Events":"1) Mid-year Examination for F.1-5 (Day 2)"
}
]

大致逻辑

有了以上资料,要进行解析并输出成 .ics 文件其实很简单,基本没有技术含量

(我一开始还以为要解析 html,看来是多虑了 ╮(╯-╰)╭

我设想中应该是输出俩个 .ics 文件,一个储存 Cycle 和当天信息,以 fullday event 来表示

另一个则是储存每一天的课程表

日程表(Calendar.ics)

这个 .ics 文件中储存的是每日的 Cycle 数、天数以及当日活动

实现逻辑应该是最简单的了,暴力循环

calendar api 的返回值是一个列表,每个 item 则是当天的信息(这里我叫他 metadata

从 metadata 中可以提取 Cycle 数以及天数

当日活动的话就只能暴力请求 student-events api ( ̄y▽, ̄)╭

有多少天就发多少个请求

希望学校防火墙别 ban 我(

课程表(Timetable.ics)

时间表的实现反而就比较棘手

首先,本质上也是基于 calendar api 返回值的暴力循环

要做的则是对 metadata 做筛选,比如说只有上学日才需要创建时间表

以及要对全日制和半日制做一个简单的区分

没错捏,这学校居然有时候是上半天,有时候则是全天

我上了快六年学都没搞明白分这个全日和半日制的理由(

代码实践

理论存在,实践开始 ( ̄︶ ̄)↗

限制

首先,这个是 userscript,也就是说不能调用库(也不是说不能而是很麻烦,而我是为了避免当前麻烦而制造更多麻烦的人),只能用基本的 javascript 功能

不能像 python 那样直接调用 icalendar 库来自动整合 ics 格式了 /_ \

如何插入 javascript

在直接写到 tampermonkey 的 userscript editor 之前,我是先用 chrome 自带的 Snippets 编辑器来写基本功能,最后才整合到 userscirpt 里

里面写的代码可以直接在网站上运行,非常方便,而且 IDE 做的还算可以

至于其他 IDE 例如 VSCode 等,我不认为这种随手搓的脚本需要用到那些编辑器

方便就够了 (〃` 3′〃)

Snippets 可以在 DevTools -> Sources 里找到

新建一个 Snippets,就可以开始敲键盘力

手搓 icalendar 格式整合

应该一个 class 就能搞定

这个 class 只有俩个功能要实现:events 输入以及最后整合输出

这里参考了一下这个远古时期的脚本

https://greasyfork.org/en/scripts/395824-student-cafe-timetable-downloader/code

我也不知道怎么找到的(

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
class Calendar {
constructor() {
// 一些常量声明
this.SEPARATOR = '\r\n';
// icalendar 文件头尾
this.calHeader = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//WYHK TIMETABLE EXPORTER//Blissfulalloy79//"
].join(this.SEPARATOR);
this.calEnd = 'END:VCALENDAR';
// 储存 events
this.calEvents = [];
}

// 日期解析,把标准 ISO 格式转化成 icalendar 的日期格式
dateParse(d, fullday) {
// it only takes ISO 8601 format strings :(
if (typeof fullday !== 'boolean') {
throw new Error("dateParse function type error!");
}
var regex = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/;
var p = d.match(regex);

if (fullday) {
return "" + p[1] + p[2] + p[3];
}
else {
return p[1] + p[2] + p[3] + "T" + p[4] + p[5] + "00";
}

}

// 参考的主要是这个函数,虽然我自己现在都不是太看得出来参考了啥(
// 这个函数吃最基本的 icalendar event 项目
// 起始时间,结束时间,时间戳,event 名称,event 描述
// 最后的是否 fullday 是用来专门处理全天 event,icalendar 文件里面这种项目的时间日期格式有一点点不同
addEvent(dtstart, dtend, dtstamp, summary, notes, fullday) {
notes = notes || '';
if (typeof dtstart === 'undefined' ||
typeof dtend === 'undefined' ||
typeof dtstamp === 'undefined' ||
typeof summary === 'undefined' ||
typeof fullday !== 'boolean'
) {
return false;
}

dtstart = this.dateParse(dtstart, fullday);
dtend = this.dateParse(dtend, fullday);
dtstamp = this.dateParse(dtstamp, fullday);

var calEvent = [
"BEGIN:VEVENT",
"SUMMARY:" + summary,
"DESCRIPTION:" + notes,
"DTSTART:" + dtstart,
"DTEND:" + dtend,
"DTSTAMP:" + dtstamp,
"END:VEVENT"
].join(this.SEPARATOR);

if (fullday) { // 专门为全天 event 做单独处理
calEvent = [
"BEGIN:VEVENT",
"SUMMARY:" + summary,
"DESCRIPTION:" + notes,
"DTSTART;VALUE=DATE:" + dtstart,
"DTEND;VALUE=DATE:" + dtend,
"DTSTAMP;VALUE=DATE:" + dtstamp,
"END:VEVENT"
].join(this.SEPARATOR);
}

// 添加到 events 列表中
this.calEvents.push(calEvent);
return calEvent;
}

getEvents() {
return this.calEvents;
}

download(filename) {
if (this.calEvents.length < 1) {
alert("Download failed!\nThe event list is empty");
return false;
}

// 整合并输出
filename = (typeof filename !== 'undefined') ? filename : 'calendar';
const calendar_content = this.calHeader + this.SEPARATOR + this.calEvents.join(this.SEPARATOR) + this.SEPARATOR + this.calEnd;

// 使用 blob 来暂存内容,并使用 url 执行下载操作
const blob = new Blob([calendar_content], {
type: "text/calendar"
});

const link = document.createElement('a');
link.href = URL.createObjectURL(blob);

link.setAttribute("download", `${filename}.ics`);
link.click();
}
}

XHR 请求

javascript 不像 python 那样有专门的 requests 库来处理请求

如果仔细观察网络活动那张图的话,就会发现这些请求都是属于 ”xhr“ 类型

什么是 xhr ?

XHR(全称 XMLHttpRequest)是 javascript 常用于处理 http 请求的函数

同时,这个是一个 async 函数

依赖 chatGPT,我们有了一个 xhr 请求的函数封装 (。・ω・。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function xhr(rtype, url) {
if (typeof rtype !== 'string' || typeof url !== 'string') {
return new Error("Parameter type error!");
}
const xhr = new XMLHttpRequest();
xhr.open(rtype, url, true);

return new Promise(function(resolve, reject) {
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText));
}
else {
reject(new Error("" + url + " request failed with status", xhr.status));
}
}
};
xhr.send();
});
}

作为一个 async 函数但是在 declare 的时候不用在前面写 async?

这是因为它的 return 是一个 Promise,在 javascript 中默认就是 async

神不神奇(不明觉历

日程表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 获取全年日程表
async function getCalendar() {
try {
return await xhr('GET', "https://www.wahyan.edu.hk/timetable-api/api/calendar");
}
catch (error) {
console.log(error);
return error;
}
}

async function getEventOTD(date) {
const d = date.slice(0, 10)
console.log("Getting event of the day: " + d);

try {
const r = await xhr('GET', "https://www.wahyan.edu.hk/timetable-api/api/student-events?date=" + d);
if (r.length > 0) {
return r[0]["Events"];
}
return "";
}
catch (error) {
console.log(error);
return error;
}
}

getEventOTDdate 的输入是 "2023-09-05T00:00:00.000Z"

所以要通过 date.slice(0, 10) 来提取头 10 位字符,也就是 2023-09-05

如果当日没有活动,服务器会返回一个空的列表,而不是字符串,所以当列表长度为零时要单独返回空字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
async function exportCalendar() {
const scal = await getCalendar();
const calendar = new Calendar();
// 遍历全年日程表
for (var Day of scal) {
var d = Day["Date"];
var order = (Day["Display"] == "H") ? "(Half-day)" : "";
var summary;
switch (Day["Type"]) {
case "Cycle Day":
// 编排正常上课日的 event 名
// 样例:Day 1, Cycle 1
summary = "Day " + Day["Day"] + ", Cycle " + Day["Cycle"] + " " + order;
break;
case "Non Cycle Day":
summary = "Non Cycle Day";
break;
case "School Holiday":
summary = "School Holiday";
break;
default:
continue;
}
// 尝试获取当日活动
try {
const event_otd = await getEventOTD(d);
calendar.addEvent(d, d, d, summary, event_otd, true);
}
catch (error) {
console.error("Error retrieving event of ${d}", error);
}
}

console.log(calendar.getEvents());
console.log("Finished creating " + calendar.getEvents().length + " calendar events");
console.log("Exporting...");
// 输出时间表
calendar.download("Annual calendar");
console.log("Done!");
}

看起来还行,尝试运行一下

Chrome 上面看起来运作正常,那就试试在 iPad 上的 Safari 跑跑看

嗯?

为啥 Safari 就出问题了

针对 Safari 的下载优化

我以为会是像普通下载文件那样跳出一个下载确认窗口

但他只会报无法下载文件

不就是文件吗,为啥无法下载?

这个问题卡了我好久,在 Stackoverflow 上找了一下以为是 blob 的问题,于是我就尝试了下用 encodeURIComponent 把整个文件 encode 到 URI 里

用 ChatGPT 生成的代码测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function exportTextToFile(text, filename) {
const element = document.createElement('a');
// 把内容 encode 到 URI 里
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
element.setAttribute('download', filename);

element.style.display = 'none';
document.body.appendChild(element);

element.click();

document.body.removeChild(element);
}
const myText = 'Hello, world!';
const myFilename = 'myFile.txt';

exportTextToFile(myText, myFilename);

运行结果

不错,在 Safari 上能用了

这时候再把 data:text/plain; 换成 data:text/calendar 并 integrate 到原本的 download 函数里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
download(filename) {
if (this.calEvents.length < 1) {
alert("Download failed!\nThe event list is empty");
return false;
}

// 整合并输出
filename = (typeof filename !== 'undefined') ? filename : 'calendar';
const calendar_content = this.calHeader + this.SEPARATOR + this.calEvents.join(this.SEPARATOR) + this.SEPARATOR + this.calEnd;

const link = document.createElement('a');

// 这里使用的是 encodeURIComponent,不是 blob
element.setAttribute('href', 'data:text/calendar;charset=utf-8,' + encodeURIComponent(text));

link.setAttribute("download", `${filename}.ics`);
link.click();
}

尝试运行一下

怎么把 data:text/plain 换成 data:text/calendar 就不行了?

明明刚刚的测试代码都没问题

百思不得其解。。。

 ̄へ ̄

就是下个文件啊,为啥 Safari 那么麻烦

。。。

诶,

.ics 文件,URI encode,我似乎想起来什么

我之前下过一个 shortcut,是专门将 .ics 文件导入到 Apple Calendar 的

没错,神奇的果果,能打开 .ics 文件但是没办法直接导入到 Apple Calendar 里

唯一的方法就是用 自带的 Mail 收到 .ics 文件为附件,才能导入

是不是很离谱?(ノへ ̄、)

不过好在有民间大神开发了个 shortcut,让 .ics 文件可以直接导入到 Apple Calendar

链接我放这里

这个 shortcut,原理就是把 .ics 文件内容 encode 成 URI 并在 Safari 里打开

注意看中间的那几步,它首先将 input 文件转成了 Event.txt 再将这些 plain text 进行 URI encode

也就是说。。。

只要把文件名改成 .txt 后缀就行了吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
download(filename) {
if (this.calEvents.length < 1) {
alert("Download failed!\nThe event list is empty");
return false;
}

filename = (typeof filename !== 'undefined') ? filename : 'calendar';
const calendar_content = this.calHeader + this.SEPARATOR + this.calEvents.join(this.SEPARATOR) + this.SEPARATOR + this.calEnd;

const link = document.createElement('a');

element.setAttribute('href', 'data:text/calendar;charset=utf-8,' + encodeURIComponent(text));

// 这里改成 .txt 后缀
link.setAttribute("download", `${filename}.txt`);
link.click();
}

再次尝试运行脚本

真的诶,沃德发???

还不是下载文件,是直接 import 到 Apple Calendar 里

一石二鸟 ˋ( ° ▽、° )

话又说回来

也就是说,问题并不在 javascript 的 blob 里,而是在 encode 成 URI 时候的文件格式

那就回到之前的几步,试试用回 blob

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
download(filename) {
if (this.calEvents.length < 1) {
alert("Download failed!\nThe event list is empty");
return false;
}

filename = (typeof filename !== 'undefined') ? filename : 'calendar';
const calendar_content = this.calHeader + this.SEPARATOR + this.calEvents.join(this.SEPARATOR) + this.SEPARATOR + this.calEnd;

const blob = new Blob([calendar_content], {
type: "text/calendar"
});

const link = document.createElement('a');
link.href = URL.createObjectURL(blob);

link.setAttribute("download", `${filename}.txt`);

link.click();
}

运行结果

真的诶?问题就处在文件格式里

真有你的果果 ( ̄︶ ̄

现在只要加多一个判断是否为 Safari 浏览器就好了

照样参考 chatGPT = ̄ω ̄=

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
download(filename) {
if (this.calEvents.length < 1) {
alert("Download failed!\nThe event list is empty");
return false;
}

filename = (typeof filename !== 'undefined') ? filename : 'calendar';
const calendar_content = this.calHeader + this.SEPARATOR + this.calEvents.join(this.SEPARATOR) + this.SEPARATOR + this.calEnd;

const blob = new Blob([calendar_content], {
type: "text/calendar"
});

const link = document.createElement('a');
link.href = URL.createObjectURL(blob);

// Safari 浏览器检查表达式
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (isSafari) {
// 如果是 Safari,文件后缀为 .txt
link.setAttribute("download", `${filename}.txt`);
}
else {
// 非 Safari 则后缀依旧保留 .ics
link.setAttribute("download", `${filename}.ics`);
}

link.click();
}

到这里,基本的 icalendar 格式整合以及输出就完成了 ヾ(•ω•`)o

。。。

对吧。。。

把生成好的 iCal 文件导入到 Calendar 里

???

怎么少了几天

10 月 3,10 和 11 都不见了

不止这三天,某些日期的 event 也不见了

这些都不是周末,而且控制台日志显示这些日期是有创建的啊

反斜杠处理

作为对比,我一开始用 Python 库写的脚本却能正确导出所有 events,没有任何缺失

实在不知道是什么原因导致的,我只好打开 Calendar.ics 文件查看内容格式是否有问题

以下是我用 Python 库生成的文件(正常):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
BEGIN:VEVENT
SUMMARY:Day 4\, Cycle 3
DTSTART;VALUE=DATE:20231003
DTEND;VALUE=DATE:20231003
DTSTAMP;VALUE=DATE:20231003
DESCRIPTION:1) Publicity Days & recruitment for SA Clubs / Societies & Sch
ool Organizations (lunchtime & after school)\n2) F.1 Music Instrumental Tr
aining Classes starts
END:VEVENT
...
BEGIN:VEVENT
SUMMARY:Day 3\, Cycle 4
DTSTART;VALUE=DATE:20231010
DTEND;VALUE=DATE:20231010
DTSTAMP;VALUE=DATE:20231010
DESCRIPTION:1) Data Entry of OLE record for F.1-5 in the Homeroom period\n
2) Flexible periods (please refer to a separate schedule) - Data Entry of
OLE record for F.1-5
END:VEVENT
BEGIN:VEVENT
SUMMARY:Day 4\, Cycle 4
DTSTART;VALUE=DATE:20231011
DTEND;VALUE=DATE:20231011
DTSTAMP;VALUE=DATE:20231011
DESCRIPTION:1) Data Entry of OLE record for F.1-5 in the Homeroom period\n
2) Inter-School Swimming Competition\, Day 1 – Division One (Kowloon Par
k Swimming Pool)
END:VEVENT

而这是我现在有问题的 Calendar.ics 文件(不正常):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
BEGIN:VEVENT
SUMMARY:Day 4, Cycle 3
DESCRIPTION:1) Publicity Days & recruitment for SA Clubs / Societies & School Organizations (lunchtime & after school)
2) F.1 Music Instrumental Training Classes starts
DTSTART;VALUE=DATE:20231003
DTEND;VALUE=DATE:20231003
DTSTAMP;VALUE=DATE:20231003
END:VEVENT
...
BEGIN:VEVENT
SUMMARY:Day 3, Cycle 4
DESCRIPTION:1) Data Entry of OLE record for F.1-5 in the Homeroom period
2) Flexible periods (please refer to a separate schedule) - Data Entry of OLE record for F.1-5
DTSTART;VALUE=DATE:20231010
DTEND;VALUE=DATE:20231010
DTSTAMP;VALUE=DATE:20231010
END:VEVENT
BEGIN:VEVENT
SUMMARY:Day 4, Cycle 4
DESCRIPTION:1) Data Entry of OLE record for F.1-5 in the Homeroom period
2) Inter-School Swimming Competition, Day 1 – Division One (Kowloon Park Swimming Pool)
DTSTART;VALUE=DATE:20231011
DTEND;VALUE=DATE:20231011
DTSTAMP;VALUE=DATE:20231011
END:VEVENT

不知道有没有留意到,正常那个文件的 description 是以 \n 来分割项目

我们来参考一下 api 的返回结果

1
2
3
[{
"Events": "1) Data Entry of OLE record for F.1-5 in the Homeroom period\r\n2) Flexible periods (please refer to a separate schedule) - Data Entry of OLE record for F.1-5"
}]

可见,本身的返回结果就是以 \n 作为行分割

而我在做格式整合的时候也是用 \n 做行分割

1
this.SEPARATOR = '\r\n';
1
2
3
4
5
6
// icalendar 文件头
this.calHeader = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//WYHK TIMETABLE EXPORTER//Blissfulalloy79//"
].join(this.SEPARATOR); // 这里
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var calEvent = [
"BEGIN:VEVENT",
"SUMMARY:" + summary,
"DESCRIPTION:" + notes,
"DTSTART:" + dtstart,
"DTEND:" + dtend,
"DTSTAMP:" + dtstamp,
"END:VEVENT"
].join(this.SEPARATOR); // 和这里

if (fullday) { // 专门为全天 event 做单独处理
calEvent = [
"BEGIN:VEVENT",
"SUMMARY:" + summary,
"DESCRIPTION:" + notes,
"DTSTART;VALUE=DATE:" + dtstart,
"DTEND;VALUE=DATE:" + dtend,
"DTSTAMP;VALUE=DATE:" + dtstamp,
"END:VEVENT"
].join(this.SEPARATOR); // 还有这里
}

写入 DESCRIPTION 的时候没有专门保留 \n 换行

就导致日历软件解码的时候无法正确 parse 这个 event

最后这些无法正确被 parse 的 event 就没被读取到

要怎么解决呢?方法也很简单

getEventOTD 这个函数返回时把 \n 换成 \\n 就好了

加个 replace(/\r\n/g, "\\n");解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function getEventOTD(date) {
const d = date.slice(0, 10)
console.log("Getting event of the day: " + d);

try {
const r = await xhr('GET', "https://www.wahyan.edu.hk/timetable-api/api/student-events?date=" + d);
if (r.length > 0) {
return r[0]["Events"].replace(/\r\n/g, "\\n"); // 保留 \n
}
return "";
}
catch (error) {
console.log(error);
return error;
}
}

测试一下

搞腚!

当然,如果你也像我这么无聊去翻 iCalendar 标准的话,你会发现即使纠正过的格式并不完全正确 -O-

标准格式是这里所指的:

  • 每行不超过 75 个 8 位字节

  • 以 CRLF 定界

可以看到我用 python 库生成的 .ics 文件完全符合规定

而 javascript 手搓的格式整合几乎不符合规定,但依然可用?

我认为是现在日历软件读取 .ics 文件的时候容错相对较高,不会太在意一行是否多于 8 位字节

加上如果把有问题的放到 https://icalendar.org/validator.html 上去测试,它也只是报 warning 而不是 error (。・∀・)ノ

反正是随手写的,就不要在意这些小细节啦(逃

课程表

1
2
3
4
5
6
7
8
9
10
11
12
13
// 获取时间表
async function getTimetable(year, term, student) {
if (typeof year !== 'string' || typeof term !== 'string' || typeof student !== 'string') {
return new Error("Parameter type error!");
}
try {
return await xhr('GET', "https://www.wahyan.edu.hk/timetable-api/api/student-timetable?year=" + year + "&term=" + term + "&student=" + student);
}
catch (error) {
console.log(error);
return error;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
async function exportTimetable() {
// --- 常量声明和获取 ---
// 以下分别为全日制上下课时间以及半日制上下课时间
const FDAY_START = ["08:15", "08:55", "09:50", "10:30", "11:25", "12:05", "14:05", "14:10", "14:20", "15:00"];
const FDAY_END = ["08:55", "09:35", "10:30", "11:10", "12:05", "12:45", "14:10", "14:20", "15:00", "15:40"];
const HDAY_START = ["08:10", "08:45", "09:30", "10:05", "10:55", "11:30", "12:15", "12:50", "13:25"];
const HDAY_END = ["08:45", "09:20", "10:05", "10:40", "11:30", "12:05", "12:50", "13:25", "13:35"];

const SCAL = await getCalendar();
const calendar = new Calendar();
var sid = "";
var year = "";
var term = "";
try {
// 尝试获取用户信息
const user = await xhr('GET', "https://www.wahyan.edu.hk/timetable-api/api/user");
sid = user["username"].slice(-5);
const tdy = new Date().toString();
// 尝试获取当前学年以及学期
const ynt = await xhr('GET', "https://www.wahyan.edu.hk/timetable-api/api/year-and-term?date=" + escape(tdy));
year = ynt[0]["Sch_Year"].toString();
term = ynt[0]["Term"].toString();
}
catch (error) {
console.log("Error getting data", error);
}
// 利用刚刚获取的三个信息请求课程表
const TIMETABLE = await getTimetable(year, term, sid);

// --- 主要功能部分 ---
// 遍历日程表
for (const item of SCAL) {
// 排除非上课日
if (item["Type"] != "Cycle Day") {
continue;
}
// 判断全日/半日制
const ORDER = item["Display"];
const DATE = item["Date"].slice(0, 10);
const LESSON_OTD = TIMETABLE[item["Day"] - 1];
// 全日制
if (ORDER == "F") {
for (let i = 1; i <= 10; i ++) {
let summary = LESSON_OTD[`P${i}_Subj`];
let dtstart = DATE + "T" + FDAY_START[i - 1];
let dtstamp = dtstart;
let dtend = DATE + "T" + FDAY_END[i - 1];
calendar.addEvent(dtstart, dtend, dtstamp, summary, "", false);
}
}
// 半日制
else if (ORDER == "H") {
for (let i = 1; i <= 9; i ++) {
let summary = LESSON_OTD[`P${i}_Subj`];
if (i == 7 || i == 8) {
summary = LESSON_OTD[`P${i + 2}_Subj`];
}
else if (i == 9) {
summary = LESSON_OTD["P7_Subj"] + " & " + LESSON_OTD["P8_Subj"];
}
let dtstart = DATE + "T" + HDAY_START[i - 1];
let dtstamp = dtstart;
let dtend = DATE + "T" + HDAY_END[i - 1];
calendar.addEvent(dtstart, dtend, dtstamp, summary, "", false);
}
}
}

console.log(calendar.getEvents());
console.log("Finished creating " + calendar.getEvents().length + " lesson events");
console.log("Exporting...");

calendar.download("Lesson timetable");
console.log("Done!");
}

所有功能性问题都在写日程表的时候排除了,因此没什么 bug

好了,功能部分写完,咱可以进入下一个阶段了

前端元素插入

总得有个用户能交互的地方罢

正好,时间表上半部分的功能交互部分就有些空隙可以让我插入元素

右边的那些 dropdown menu 不就留了些空位给我吗

F12,启动!

获取目标元素

这些 css-bxxxxx-container 想必就是那三个 dropdown menu

咱要做的就是获取这些元素的父类然后添加多一个子元素就行了

参考 ChatGPT,添加一个测试按钮

1
2
3
4
5
6
7
8
9
10
11
// button 创建
const btn_calendar = document.createElement("button");
btn_calendar.textContent = "Export Annual Calendar as .ics";

btn_calendar.onclick = () => {
alert("Clicked!");
}

// 插入 button 元素
const targetEle = document.querySelector(".css-b62m3t-container");
targetEle.parentElement.appendChild(btn_calendar);

看起来不错

完善一下 .onclick 的功能性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 改成 async 函数
btn_timetable.onclick = async () => {
// 弹窗确认
const t_result = confirm("Download the tiemtable file?");
btn_timetable.disabled = true;
if (t_result) {
// 一些在按钮上小小的过场效果
btn_timetable.textContent = "Fetching data..."
btn_timetable.style.backgroundColor = "hsl(0, 0%, 89%)";
await exportTimetable();
btn_timetable.textContent = "Done!"
await new Promise((resolve, reject) => setTimeout(resolve, 1000));
btn_timetable.disabled = false;
btn_timetable.style.backgroundColor = "hsl(0, 0%, 100%)";
}
else {
console.log("user cancelled")
btn_timetable.disabled = false;
}
btn_timetable.textContent = "Export timetable as .ics";
}

使用默认的按钮样式有点奇怪,保持界面和谐,那就抄一下那些 dropdown 的样式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const styles = {
minHeight: "40px",
outline: "09!important",
backgroundColor: "hsl(0, 0%, 100%)",
borderColor: "hsl(0, 0%, 80%)",
borderRadius: "5px",
borderStyle: "solid",
borderWidth: "1px",
boxSizing: "border-box",
textAlign: "left",
color: "hsl(0, 0%, 50%)",
cursor: "pointer"
}

// assign 按钮样式
Object.assign(btn_calendar.style, styles);

十分和谐 \( ̄︶ ̄*\))

另外,我发现原本的那些 dropdown menu 是有鼠标悬浮效果的(也就是一点点的变色动画罢了

为了全局统一性。。。咱也加罢

依然是求教 ChatGPT

1
2
3
4
5
6
7
8
9
10
11
btn_timetable.addEventListener('mouseenter', function() {
if (!this.disabled){
// 边框颜色并没有完全抄原本的,而是提取页面上的颜色
// 我认为既可以保持界面和谐也带有一定的区分度
this.style.borderColor = "#1a6387";
}
});

btn_timetable.addEventListener('mouseleave', function() {
this.style.borderColor = "hsl(0, 0%, 80%)";
});

最后课程表按钮也照葫芦画瓢

按钮元素插入代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
// 按钮样式
const styles = {
minHeight: "40px",
outline: "09!important",
backgroundColor: "hsl(0, 0%, 100%)",
borderColor: "hsl(0, 0%, 80%)",
borderRadius: "5px",
borderStyle: "solid",
borderWidth: "1px",
boxSizing: "border-box",
textAlign: "left",
color: "hsl(0, 0%, 50%)",
cursor: "pointer"
}

function timetableBtnInsertion() {
// 日程表按钮创建
const btn_calendar = document.createElement("button");
btn_calendar.textContent = "Export Annual Calendar as .ics";

Object.assign(btn_calendar.style, styles);

btn_calendar.onclick = async () => {
const cal_result = confirm("Downlaod the Annual calendar file?");
btn_calendar.disabled = true;
if (cal_result) {
btn_calendar.textContent = "Fetching data...";
btn_calendar.style.backgroundColor = "hsl(0, 0%, 89%)";
// await new Promise((resolve, reject) => setTimeout(resolve, 3000));
await exportCalendar();
btn_calendar.textContent = "Done!";
await new Promise((resolve, reject) => setTimeout(resolve, 1000));
btn_calendar.disabled = false;
btn_calendar.style.backgroundColor = "hsl(0, 0%, 100%)";
}
else {
console.log("user cancelled")
btn_calendar.disabled = false;
}
btn_calendar.textContent = "Export Annual Calendar as .ics";

}

btn_calendar.addEventListener('mouseenter', function() {
if (!this.disabled){
this.style.borderColor = "#1a6387";
}
});

btn_calendar.addEventListener('mouseleave', function() {
this.style.borderColor = "hsl(0, 0%, 80%)";
});


// 课程表按钮创建
const btn_timetable = document.createElement("button");
btn_timetable.textContent = "Export timetable as .ics";
Object.assign(btn_timetable.style, styles);

btn_timetable.onclick = async () => {
const t_result = confirm("Download the tiemtable file?");
btn_timetable.disabled = true;
if (t_result) {
btn_timetable.textContent = "Fetching data..."
btn_timetable.style.backgroundColor = "hsl(0, 0%, 89%)";
await exportTimetable();
btn_timetable.textContent = "Done!"
await new Promise((resolve, reject) => setTimeout(resolve, 1000));
btn_timetable.disabled = false;
btn_timetable.style.backgroundColor = "hsl(0, 0%, 100%)";
}
else {
console.log("user cancelled")
btn_timetable.disabled = false;
}
btn_timetable.textContent = "Export timetable as .ics";
}

btn_timetable.addEventListener('mouseenter', function() {
if (!this.disabled){
this.style.borderColor = "#1a6387";
}
});

btn_timetable.addEventListener('mouseleave', function() {
this.style.borderColor = "hsl(0, 0%, 80%)";
});

// 元素插入
const targetEle = document.querySelector(".css-b62m3t-container");
targetEle.parentElement.appendChild(btn_calendar);
targetEle.parentElement.appendChild(btn_timetable);
}

运行一下,最终效果不错

按钮的功能也正常

基本上都完成了,测试下来也没啥 bug

可以写到 userscript 里了

Userscript 及前端

直接开抄,把所有代码复制到 Tampermonkey 的编辑器里

最后在主函数里加上执行函数

1
2
3
4
5
(function() {
'use strict';
// Your code here...
timetableBtnInsertion();
})();

在文件头加上 @match URI,否则脚本不会自动检测运行

保存,timetable 启动

。。。

诶,怎么什么都没有

空空如也

我的按钮元素呢?

来看看控制台日志

#418#423

咱来看看都有些啥

#418:

Hydration failed because the initial UI does not match what was rendered on the server.

#423:

There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.

Hydration?

Rendered on the server?

Client rendering?

这些都啥跟啥啊,我一个 web dev 萌新看得一愣一愣的

果然,跳出舒适圈准没好事。。。

再次求教万能的 ChatGPT

The error message you’re seeing, “React js Hydration failed because the initial UI does not match what was rendered on the server,” typically occurs when there’s a mismatch between the HTML rendered on the server and the HTML generated by React on the client.

React uses a process called hydration to attach event handlers and other necessary data to the HTML generated on the server. During hydration, React compares the server-rendered HTML with the client-rendered HTML to ensure they match. If there’s a mismatch, React throws this error.

我的理解是,React 有个叫做 Hydration 的 process,他会比较服务端渲染的 html 以及客户端渲染的 html 来确认是否相符,这样可以确保 UI 的一致性

然而,当网页加载时 Tampermonkey 的插件也同时执行了,他往 html 文件中插入了元素

因为客户端的 html 和服务端的不一致,这就导致了 Hydration fail,于是报 #418

#423呢,就是因为 Hydration fail,所以 fallback 到了纯客户端渲染

咱来参考一下稀土掘金上的这篇文章

这个错误通常是在使用服务端渲染(SSR)时出现的,它的原因是 React 在服务端渲染完成后生成的 HTML 和客户端渲染时生成的 HTML 不一致导致的。

在 React 服务端渲染中,React 会将组件渲染成 HTML 字符串,然后发送到客户端。在客户端,React 会重新创建组件并将其挂载到 DOM 上。如果服务端渲染时生成的 HTML 和客户端渲染时生成的 HTML 不一致,就会出现“hydration failed”(水合失败)的错误。

看来我的理解在某种程度上是对的 (~ ̄▽ ̄)~

说道 SSR 和 CSR(服务端渲染和客户端渲染),我似乎在一个博客上看到过相关的文章

当时没咋留意,是时候恶补一下 web dev 了

https://blog.huli.tw/2023/11/27/server-side-rendering-ssr-and-isomorphic/

https://life.huli.tw/2018/05/04/introduction-mvc-spa-and-ssr-545c941669e9/

Huli 大佬的俩篇文章很清楚的解释了有关网页渲染的问题

对问题有一个基础认知之后,那又该怎么解决呢

再再次请教 ChatGPT,以及参考了一下 tampermonkey 上的其他 userscript

解决方法也是很简单

由于 hydration check 只会在网页刚开始加载的时候执行,那加个 delay 就解决了

1
2
3
4
5
6
7
(function() {
'use strict';
// Your code here...
setTimeout(() => {
timetableBtnInsertion();
}, 500);
})();

保存到 tampermonkey 再试几遍,基本上都没问题了

登录页面检测

last but not least,还有最后最后一个小问题

如果用户是未登录情况下加载网页,会先有一个登录界面

蓝鹅这个脚本会在网页被加载时就执行

因为网页没有被重新加载,脚本不会在登录后自动运行来插入元素

咱要最后做多一步登录检测

怎么检测已经是在时间表页面了呢?很简单,留意界面右上角有个 log off 按钮

让脚本等到这个按钮元素出现的时候再执行就好了

让咱再再再次借助 ChatGPT 的力量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function waitForLogin(callback) {
console.log("observing...");
const cName = ".LogOffButton_button__1Cye6";
const element = document.querySelector(cName);
if (element) {
callback();
return;
}

// 创建一个MutationObserver
// 这是一个用来监听 DOM 更改的,有任何变动都会执行 callback 函数
const observer = new MutationObserver(function(mutationsList, observer) {
const element = document.querySelector(cName);
if (element) {
observer.disconnect();
callback();
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
1
2
3
4
5
6
7
8
9
10
(function() {
'use strict';
// Your code here...
waitForLogin(function() {
console.log('logged in');
setTimeout(() => {
timetableBtnInsertion();
}, 500);
});
})();

咱把前面的代码都扔到 callback 里了,如果是 login 界面的话就等他加载到时间表页面

反之,如果已经登录完成了,那就直接执行 callback

至此,应该完成所有需求了

呼~ (* ̄3 ̄)╭

如果有翻过我 github 的话,会发现实际的脚本和这篇文章写的有不同

似乎多了些东西

那部分是属于考试时间表的,基本功能和逻辑一样,如果再把那部分加进来的话我觉得有些多于

就当个彩蛋吧 😉

小结

说实话,这是我人生中第一次接触 javascript,

可能是它性质本身就和 python 类似,所以比较容易上手

写起来也有种熟悉的感觉

还是那句话,人应该要跳出自己的舒适圈,不断的去尝试新的东西,况且在互联网时代学习的成本已经低了许多,现在还有 ChatGPT 加持,从零着手一个未知的领域未必是一个很大的难题

说到 ChatGPT,咱也来唠俩句

我见过有评论说 ChatGPT 只是废话制造器,没有实际用途;也有另一个极端认为 ChatGPT 已经非常聪明了,可以取代目前绝大部分工作

俩边都过于极端,我以前没怎么去用 ChatGPT,认为出来的结果过于千篇一律,写出来的东西看似很高级,但实际上缺乏灵魂,一看就知道是 AI 写的

用也只是在一些文件上用,没感觉到巨硬某些人所宣传的提高生产力

但是在这次写小脚本的时候,我才真正感受到了 ChatGPT 如何显著提升所谓的「生产力」

先来讲讲搜索,它的搜索能力是爆杀搜索引擎的,虽然有时候会出幻觉开始编造东西

以前,比如搜一个问题的时候不知道关键字是啥,一直搜不到想要的结果,以为这个问题前所未有而卡死在这里

但实际上这个问题早就被解决了,只是用了些不同的词汇,你和搜索答案就差一个单词的距离

后者的情况非常常见,再怎么熟悉如何利用搜索引擎的人都会有这个问题

这很大程度上减少了你的 debug 以及学习知识的效率,因为时间都卡在想应该怎么搜的问题上

ChatGPT 的出现很大程度上解决了这个问题,因为它在某些程度上能「理解」你在问什么,有「认知」偏差也能通过修改 prompt 解决

而且能够很快的给出你想要的答案

即使答案有时候会因为幻觉开始乱来,但是这很快就能通过再次利用搜索引擎找到真正正确的答案

因为它的答案都会有能被搜索到的关键字

再举一个例子,文档理解

阅读文档是 debug 以外最占用时间的,要充分理解一个库该怎么使用是非常费事费力的事情,通常要花上俩三天才能理解,而实际上你的代码只是用到其中一个小功能,你所吸收到多余的知识也只能当个未来投资

阅读一段代码时,常常会引用到不同的库,没可能把引用到的库的文档全都读一边吧

上网搜,也只能搜个大概,想用的话还没能完全理解这个功能

而你去问 ChatGPT,它不但会给你解释这个功能是做什么,还会有相应的示例

一个文档写得差一点的库都未必会有示例,还得靠自己去搜其他人的教程

ChatGPT 很好的解决了以上的问题,它帮你阅读并消化某个文档,同时也消化了网上其他人提供的的资源

出来的结果非常容易理解,一看就懂,还有其他问题就再提问并优化它提供的示例

马上就能知道该如何应用在自己的代码里

把写程序里最耗时的俩部分解决掉,生产力不就提升了吗?

这个脚本就是一个最好的证明,我从一个完全不懂 javascript 的萌新到写一个脚本出来用不了很久

中间还顺便学了不少有关 web 的概念

有模糊的概念都是先问 ChatGPT,再根据结果上搜索引擎查,效率比以前高不少

只能说,有好有坏吧

虽然效率提高了,但是学习的机会也少了

苦苦钻研文档,是会花不少时间,但是其中会学到不少知识

而直接问 ChatGPT,省下了钻研文档的时间,但是也限制了你所吸收知识的程度

看文档或许能知道一些库在某些情况下的特殊用途,可能会更高效率以及聪明地解决你目前遇到的难题,而直接问 ChatGPT 只会局限在现有的解决思路上

任何事物都是一把双刃剑,和互联网一样,看似能随时获取任何资讯,但也造就了信息茧房,认知被过滤过的知识所蒙蔽

你看似能知道更多的东西,但是目光也就更短浅了

说的好像有点多了,也有些跑题

那就这样吧,下篇博文再见

ヾ( ̄▽ ̄)ByeBye


随手搓 02:学校时间表爬取
https://blissfulalloy79.github.io/10-simplecode02/
作者
BlissfulAlloy79
发布于
2023年11月24日
许可协议