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

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

登录

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

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

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(),这个就是密码加密的入口了:

function encryptPassword(pwd0) {
    try {
        var pwd1 = encryptAES(pwd0, pwdDefaultEncryptSalt);
        $("#casLoginForm").find("#passwordEncrypt").val(pwd1);
    } catch (e) {
        $("#casLoginForm").find("#passwordEncrypt").val(pwd0);
    }
}

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

function encryptAES(data, aesKey) { //加密
    if (!aesKey) {
        return data;
    }
    var encrypted = getAesString(randomString(64) + data, aesKey, randomString(16)); //密文
    return encrypted;
}

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

<script type="text/javascript">
    var secure = "false";
    var pwdDefaultEncryptSalt = "AzecnAhhH7LIvnqZ";
</script>

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

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格式的密文
}	

用到的几个变量的定义:

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

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

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 实现加密:

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 和表单构成,就能模拟登陆了,以下是一种实现:

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 文件

这一部分比较好写,参考了裙主的博文

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 做代理但是不想麻烦
  • 单双周(手头没有这种课表数据,暂不实现)

参考

加载评论