将强智教务系统课表导出为 ics 文件

将强智教务系统的学生课表导出为 ics 文件的一些思路和记录。GitHub:bolitao/CSUFT_classs_schedule

登录

首先在浏览器进行一次登录,登录成功后到 network 里找 post 方式的请求,事实上也只有一条这样的 post 请求:

表单如下(删除了一些个人数据):

1
2
3
4
5
6
7
username: xxx
password: xxx
lt: xxx
dllt: userNamePasswordLogin
execution: xxx
_eventId: submit
rmShown: 1

表单里用户名密码好理解,其他几个字段在登录 HTML 文件中 id 为 casLoginForm 的表单末尾都有定义,直接拿来用就好。

先关注 password,密码经过加密,首先想到由前端 JavaScript 进行的加密,跳到 JS 选项卡找找看有没有相关代码:

需要关注的有 encrypt.wisedu.js, login-wisedu_v1.0.js, aes.js 这几个 js,最后是一个开源加密库,总之下载下来慢慢分析。

这几个 js 代码还蛮体贴的,有注释。不过拷到 WebStorm 里后满屏的修复和不规范提示还是把我震撼到了。

返回到登录界面的 HTML,form id 叫 casLoginForm,全局搜索这个值发现对这个表单相关的操作全在 login-wisedu_v1.0.js 这个 js 里,表单提交绑定了 doLogin() 函数,这个函数里有调用 encryptPassword(),这个就是密码加密的入口了:

1
2
3
4
5
6
7
8
function encryptPassword(pwd0) {
try {
var pwd1 = encryptAES(pwd0, pwdDefaultEncryptSalt);
$("#casLoginForm").find("#passwordEncrypt").val(pwd1);
} catch (e) {
$("#casLoginForm").find("#passwordEncrypt").val(pwd0);
}
}

encryptAES() 函数,对密码 data 进行加密:

1
2
3
4
5
6
7
function encryptAES(data, aesKey) { //加密
if (!aesKey) {
return data;
}
var encrypted = getAesString(randomString(64) + data, aesKey, randomString(16)); //密文
return encrypted;
}

全局搜索一下 pwdDefaultEncryptSalt,就能发现这个值定义在登录页面 HTML 的头部:

1
2
3
4
<script type="text/javascript">
var secure = "false";
var pwdDefaultEncryptSalt = "AzecnAhhH7LIvnqZ";
</script>

getAesString() 函数,AES-128 CBC 加密,这里面调库就完事了:

1
2
3
4
5
6
7
8
9
10
11
function getAesString(data, key0, iv0) { //加密
key0 = key0.replace(/(^\s+)|(\s+$)/g, "");
var key = CryptoJS.enc.Utf8.parse(key0);
var iv = CryptoJS.enc.Utf8.parse(iv0);
var encrypted = CryptoJS.AES.encrypt(data, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return encrypted.toString(); // 返回的是base64格式的密文
}

用到的几个变量的定义:

1
2
3
var $aes_chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
/****默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1****/
var aes_chars_len = $aes_chars.length;

randomString() 函数,从 $aes_chars 这个字符串随机挑 len 次(每次一个 char)组成字符串:

1
2
3
4
5
6
7
function randomString(len) {
var retStr = '';
for (i = 0; i < len; i++) {
retStr += $aes_chars.charAt(Math.floor(Math.random() * aes_chars_len));
}
return retStr;
}

用 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
29
30
import base64
from Crypto.Cipher import AES
import random

aes_chars = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"


def random_str(length):
ret = ""
for i in range(length):
ret += random.choice(aes_chars)
return ret


def pad(password): # 把密码补充成符合 AES-128 CBC 规范形式
password = random_str(64) + password
password_length = len(password)
add_count = AES.block_size - password_length % AES.block_size
if add_count == 0:
add_count = AES.block_size
_pad = chr(add_count)
return password + _pad * add_count


def password_encrypt(aes_key, user_password):
iv = random_str(16)
user_password = pad(user_password).encode("utf8")
aes_key = str(aes_key.strip())
cipher = AES.new(aes_key.encode("utf8"), AES.MODE_CBC, iv.encode("utf8"))
return base64.b64encode(cipher.encrypt(user_password))

知道了登录的 POST URL 和表单构成,就能模拟登陆了,以下是一种实现:

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
import requests
from bs4 import BeautifulSoup
import encrypt

login_page_url = "http://authserver.csuft.edu.cn/authserver/login?service=http%3A%2F%2Fjwgl.csuft.edu.cn%2F"
login_post_url = "http://authserver.csuft.edu.cn/authserver/login?service=http%3A%2F%2Fjwgl.csuft.edu.cn%2F"
table_page = "http://jwgl.csuft.edu.cn/jsxsd/xskb/xskb_list.do"


def login(username, password):
_session = requests.session()
response = _session.get(login_page_url)
content = BeautifulSoup(response.text, "lxml")
# 截取第二个 script 标签
script = str(content.select('script')[1])
script = script.replace(" ", "")
script = script.replace("\n", "")
salt = script.split("=")[3][1:1 + 16] # TODO: 比较死板的取 pwdDefaultEncryptSalt 方法,待完善
encrypted_password = encrypt.password_encrypt(salt, password)
form_content = content.form.find_all("input")
for input_tag in form_content: # 表单中其他字段
if input_tag.has_attr("name"):
if input_tag["name"] == "lt":
lt = input_tag["value"]
elif input_tag["name"] == "dllt":
dllt = input_tag["value"]
elif input_tag["name"] == "execution":
execution = input_tag["value"]
elif input_tag["name"] == "_eventId":
_eventID = input_tag["value"]
elif input_tag["name"] == "rmShown":
rm_shown = input_tag["value"]
form_data = {
"username": username,
"password": encrypted_password,
"lt": lt,
"dllt": dllt,
"execution": execution,
"_eventId": _eventID,
"rmShown": rm_shown
}
login_response = _session.post(login_post_url, data=form_data)
return _session

课表解析

这部分有几点要注意:

  • 有的格子里有分隔符:---------------------,即前半学期上某课,后半学期上另一门课
  • 有的课程在期中考试周停课,所以会有 1-8,10-17(周) 这种周期
  • 有形如 1-6,8,10-17 或被分割更多的课程周次,没想到比较简洁的解决办法,留到日后重构再解决

我的解析实现都是 dirty code,还有些没清理的测试代码,总之写完回过来看把自己恶心吐了:

CSUFT_classs_schedule/table_parse.py at csuft · bolitao/CSUFT_classs_schedule

将课表数据转为 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
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
import datetime

start_year = 2019
start_month = 9
start_day = 2
school_term_start = datetime.date(start_year, start_month, start_day)

start_time = ['08:00', '09:55', '14:00', '15:55', '19:00']
end_time = ['09:40', '11:35', '15:40', '17:35', '20:40']
week_name = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']


def info_to_ics(class_info, username):
VCALENDAR = '''BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:%(username)s 的课程表
X-WR-TIMEZONE:Asia/Shanghai
X-WR-CALDESC:%(username)s 的课程表
BEGIN:VTIMEZONE
TZID:Asia/Shanghai
X-LIC-LOCATION:Asia/Shanghai
BEGIN:STANDARD
TZOFFSETFROM:+0800
TZOFFSETTO:+0800
TZNAME:CST
DTSTART:19700101T000000
END:STANDARD
END:VTIMEZONE''' % {'username': username}
file = open('课程表.ics', 'w', encoding="utf-8")
file.write(VCALENDAR)
for info in class_info:
vevent = '\nBEGIN:VEVENT\n'
# 课程开始日期
class_start_date = school_term_start + datetime.timedelta(weeks=int(info['start']) - 1) + datetime.timedelta(
days=int(info['day_of_week']) - 1)
# 课程开始时间
class_start_time = datetime.datetime.strptime(start_time[int(info['period']) - 1], '%H:%M').time()
# 课程开始 datetime
class_start_datetime = datetime.datetime.combine(class_start_date, class_start_time)
# 课程结束时间
class_end_time = datetime.datetime.strptime(end_time[int(info['period']) - 1], '%H:%M').time()
# 课程结束 datetime
class_end_datetime = datetime.datetime.combine(class_start_date, class_end_time)
vevent += 'DTSTART;TZID=Asia/Shanghai:{class_start_datetime}\n'.format(
class_start_datetime=class_start_datetime.strftime('%Y%m%dT%H%M%S'))
vevent += 'DTEND;TZID=Asia/Shanghai:{class_end_datetime}\n'.format(
class_end_datetime=class_end_datetime.strftime('%Y%m%dT%H%M%S'))
# 循环 TODO: 单双周
# count 循环次数 interval 间隔 byday 日程所在星期
count = int(info['end']) - int(info['start']) + 1
interval = 0
byday = week_name[int(info['day_of_week']) - 1]
vevent += 'RRULE:FREQ=WEEKLY;WKST=MO;COUNT={count};INTERVAL={interval};BYDAY={byday}\n'.format(
count=count, interval=interval, byday=byday)
# 地点
vevent += ('LOCATION:' + info['place'] + '\n')
# 名称
vevent += ('SUMMARY:' + info['subject'] + '\n')
vevent += 'END:VEVENT\n'
file.write(vevent)
file.write('END:VCALENDAR')
file.close()
print("OK: 课程表.ics")

TODO

  • 解决周次为 1-6,8,10-17 或分割次数更多的奇葩
  • 多次输错密码时对验证码的处理
  • 使用 Java + Jsoup 重构,提供 web 页面,突然想起学校教务处不支持外网访问,挂服务器上也没啥用,虽然可以用树莓派 + frp 做代理但是不想麻烦
  • 单双周(手头没有这种课表数据,暂不实现)

参考

Author: Boli Tao
Link: https://www.bolitao.xyz/archives/e7a76002.html
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.