武汉大学评教脚本
用 Python 写的脚本,用于 2019 年秋季评教。
Table Of Contents
- 前言
- 登录
- 获取课程信息
- 进行评价
- 食用方法
1. 前言
啊啊啊啊啊没想到真的写出来了!兴奋!!先上结果图。
其实也不难嘛,总共花了一个晚上和一个上午的时间,约 8 个小时(都怪我太菜,黑脸)。如果手动评教需要 5 分钟,那这次脚本评教所花的时间大概是手动评教的 96 倍,太亏了!不过开心就好啦哈哈哈。
2-4 为码字过程,脚本的使用方法直接看 5。
2. 登录
先上评教网站看看。在地址栏输入 http://s.ugsq.whu.edu.cn/studentpj,发现重定向到 https://cas.whu.edu.cn/authserver/login?service=http%3A%2F%2Fs.ugsq.whu.edu.cn%2Fcaslogin%2F。按快捷键 F12(或Fn + F12)打开开发者工具,切换到 Network 一栏。勾选 Preserve log,这样在页面刷新的时候请求记录还会保留,不会被刷新掉。
输入用户名和密码,点击登录。这时浏览器往后端发送的请求都显示出来了。后缀为 js 和 css 的分别是对 JavaScript 脚本和 CSS 样式的请求,用于网页渲染,与评教无关,忽略即可。筛选后看到 5 个的请求。
首先点击 login,查看右侧信息栏,选中 Headers 一栏。由 Request URL 看出它带有一个地址栏的参数 service,意思是从 http://s.ugsq.whu.edu.cn/caslogin/ 重定向过来的,一会登录了还要返回评教界面。另外,这是一个 POST 请求,返回码 302 表示重定向,拉到底端看到提交的参数。
username 是学号,password 是登录密码(信息门户登录密码,默认为身份证后 6 位)加密之后的字符串,后面 5 个参数看不懂。看不懂的参数可能是固定参数,也可能是随机生成的,多刷新几次,发现 lt 和 execution 会变。
考虑到这个请求是前面填写表单点击登录时发起的,回到前面填写表单的页面,右键查看源代码。Ctrl + F 搜索 lt,在 156 行处发现跟前面一模一样的 5 个参数!看到它们的类型为 hidden,原来是网页经常用来传递数据的隐藏参数。
细心的朋友可能会注意到,隐藏参数里 lt 与前面请求的 lt 不一样。登录之前在 Elements 中搜索 lt,这里看到的 lt 和请求参数中的 lt 是一致的。我猜想,查看源代码时这些参数进行了更新。
可以看见隐藏参数有 6 个,前面提交的参数只有 5 个,最后一个参数 pwdDefaultEncryptSalt 是用来加密的,一会再提。现在,先从源代码获取隐藏参数的值,再构造 login 请求。
import
查看 login 请求的返回内容,发现还是登录页面,登录操作失败。别急,这是肯定的,请求里明显对密码进行了加密,我们直接发送明文当然会失败啦。
现在我们重新注意第 6 个隐藏参数 pwdDefaultEncryptSalt,如果发送请求之前对密码加密,那这个用于加密的参数肯定会出现在加密的方法里(function)。但搜索发现,源代码里并没有别的地方出现过 pwdDefaultEncryptSalt,那肯定是导入的 js 文件里了。
在源代码 245 行处可以看见导入的 js 文件。第一个文件是 jQuery,与加密无关。第二个文件名称带 min,明显是某个 JavaScript 框架的轻型版本,也与加密无关。后面三个,第一直觉是 encrypt.js,打开它,搜索 pwdDefaultEncryptSalt,无结果。再打开 login.js 和 login-wisedu_v1.0.js,在 login-wisedu_v1.0.js 中找到关键代码。
找到 _etd2 的定义,发现它调用 encryptAES 函数对 password 进行加密。
再次搜索,发现 encryptAES 函数在 encrypt.js 中。
此处省略我阅读 encrypt.js 源代码,企图将它翻译成 Python 的一大段时间...
后来我突然醒悟,Python 能不能直接调用 JavaScript?既然它可以调用 Bash,调用 JavaScript 应该也没问题吧?哈哈,前方道路突然光明。
import
然而,现实比较残酷,还是回到了最初的登录界面。回头看看,发现 5 个请求我只做了 login 一个!逐个查看,caslogin,loginSSO...懵了,这是在干啥?经过一番 Google,大概了解下 CAS 实现单点登录 SSO 的流程。
在这里,http://s.ugsq.whu.edu.cn 是 CAS Client,https://cas.whu.edu.cn 是 CAS Server。我们前面发送的 login 请求就是步骤 3 的认证过程。接下来要做的是步骤 4,获取 CAS Server 传递给浏览器的 service 和 ticket,带 ticket 向 service (即 CAS Client)发送请求。而步骤 5 和步骤 6 是后端实现的,我们不用管。等 CAS Client 收到 CAS Server 传给它的数据(登录成功的页面)后,CAS Client 将数据传给浏览器。这样就成功登录啦。
现在我们实现步骤 4,并模拟浏览器的其他请求。
import requests
from lxml import etree
import execjs
# 参数
username = '2017302580***' # 学号
password = '******' # 信息门户密码,默认为身份证后 6 位
# 各种 url
base_addr = 'http://s.ugsq.whu.edu.cn/'
login = 'https://cas.whu.edu.cn/authserver/login?service=http%3A%2F%2Fs.ugsq.whu.edu.cn%2Fcaslogin%2F'
caslogin = base_addr + 'caslogin/'
loginSSO = base_addr + 'loginSSO'
studentpj = base_addr + 'studentpj'
# 使用会话保持 cookie
s = requests.Session()
# 首次请求,获取隐藏参数
start_response = s.get(login)
start_html = etree.HTML(start_response.text, parser=etree.HTMLParser())
lt = start_html.xpath('//*[@id="casLoginForm"]/input[1]/@value')[0]
dllt = start_html.xpath('//*[@id="casLoginForm"]/input[2]/@value')[0]
execution = start_html.xpath('//*[@id="casLoginForm"]/input[3]/@value')[0]
_eventId = start_html.xpath('//*[@id="casLoginForm"]/input[4]/@value')[0]
rmShown = start_html.xpath('//*[@id="casLoginForm"]/input[5]/@value')[0]
pwdDefaultEncryptSalt = start_html.xpath('//*[@id="casLoginForm"]/input[6]/@value')[0]
# 调用 JavaScript 对密码加密
with open('encrypt.js', 'r') as f:
js = f.read()
ctx = execjs.compile(js)
password = ctx.call('encryptAES', password, pwdDefaultEncryptSalt)
# 登录
data = {
'username': username,
'password': password,
'lt': lt,
'dllt': dllt,
'execution': execution,
'_eventId': _eventId,
'rmShown': rmShown
}
login_response = s.post(login, data=data, allow_redirects=False) # 重定向请求必须加上 allow_redirects=False
# 获取 Location,向 CAS 客户端发送请求
url = login_response.headers['Location']
s.get(url, allow_redirects=False)
# 对照浏览器执行相同的请求
s.get(caslogin)
s.post(loginSSO, data={'userId': username})
studentpj_response = s.get(studentpj)
print(studentpj_response.text)
这时输出了很长的 html 文本,可以写到 html 文件后用浏览器查看,但我比较喜欢直接在控制台看。出现了我的名字,登录成功!
3. 获取课程信息
此时我们来到了这个界面。
评价之前,我们首先要获取课程信息,比如我这学期选了韩波老师的系统级程序设计,获取到这门课的信息之后才能对它进行评价。点击框框查看课程列表。如果开着开发者工具会出现一个断点,点击继续即可。
观察 Network 中的请求,多出了 getkcpmid 和 evaluate2.jsp,用脚本进行模拟。
import requests
from lxml import etree
import execjs
# 参数
username = '2017302580***' # 学号
password = '******' # 信息门户密码,默认为身份证后 6 位
some_id = '41' # 某个不知道干啥用的 id
# 各种 url
base_addr = 'http://s.ugsq.whu.edu.cn/'
login = 'https://cas.whu.edu.cn/authserver/login?service=http%3A%2F%2Fs.ugsq.whu.edu.cn%2Fcaslogin%2F'
caslogin = base_addr + 'caslogin/'
loginSSO = base_addr + 'loginSSO'
studentpj = base_addr + 'studentpj'
getkcpmid = base_addr + 'getkcpmid'
evaluate2 = base_addr + 'new/student/rank/evaluate2.jsp'
# 使用会话保持 cookie
s = requests.Session()
# 首次请求,获取隐藏参数
start_response = s.get(login)
start_html = etree.HTML(start_response.text, parser=etree.HTMLParser())
lt = start_html.xpath('//*[@id="casLoginForm"]/input[1]/@value')[0]
dllt = start_html.xpath('//*[@id="casLoginForm"]/input[2]/@value')[0]
execution = start_html.xpath('//*[@id="casLoginForm"]/input[3]/@value')[0]
_eventId = start_html.xpath('//*[@id="casLoginForm"]/input[4]/@value')[0]
rmShown = start_html.xpath('//*[@id="casLoginForm"]/input[5]/@value')[0]
pwdDefaultEncryptSalt = start_html.xpath('//*[@id="casLoginForm"]/input[6]/@value')[0]
# 调用 JavaScript 对密码加密
with open('encrypt.js', 'r') as f:
js = f.read()
ctx = execjs.compile(js)
password = ctx.call('encryptAES', password, pwdDefaultEncryptSalt)
# 登录
data = {
'username': username,
'password': password,
'lt': lt,
'dllt': dllt,
'execution': execution,
'_eventId': _eventId,
'rmShown': rmShown
}
login_response = s.post(login, data=data, allow_redirects=False) # 重定向请求必须加上 allow_redirects=False
# 获取 Location,向 CAS 客户端发送请求
url = login_response.headers['Location']
s.get(url, allow_redirects=False)
# 对照浏览器执行相同的请求
s.get(caslogin)
s.post(loginSSO, data={'userId': username})
studentpj_response = s.get(studentpj)
# 进入课程列表界面
data = {
'hdid': some_id,
'xh': username
}
s.post(getkcpmid, data=data)
params = {
'hdfaid': some_id,
'overtime': 'timeNO',
'sfkdcpj': '1',
'sfqxzdf': '0',
'zbtx': '267,268,266,265',
'kkxy': '2302000',
'roid': 'SCHOOL_ADMIN'
}
evaluate2_response = s.get(evaluate2, params=params)
with open('test.html', 'w', encoding='utf8') as f:
f.write(evaluate2_response.text)
为了演示更直观,我将结果写进 html 文件,发现只获取了页面的框架,没有课程的信息。
没有 js 和 css 的页面很丑吧?这就是我一般只在控制台查看响应结果的原因...再仔细观察,发现 SCHOOL_ADMIN 请求,它是 XHR,XHR 用于动态更新网页[1]。
SCHOOL_ADMIN 的参数直接从浏览器复制,转换成 Python 里的字典就行。模拟 SCHOOL_ADMIN,顺便对课程信息进行整理。
import requests
from lxml import etree
import execjs
import json
# 参数
username = '2017302580***' # 学号
password = '******' # 信息门户密码,默认为身份证后 6 位
some_id = '41' # 某个不知道干啥用的 id
kkxy = '2302000' # 某个不知道干啥用的 key
# 各种 url
base_addr = 'http://s.ugsq.whu.edu.cn/'
login = 'https://cas.whu.edu.cn/authserver/login?service=http%3A%2F%2Fs.ugsq.whu.edu.cn%2Fcaslogin%2F'
caslogin = base_addr + 'caslogin/'
loginSSO = base_addr + 'loginSSO'
studentpj = base_addr + 'studentpj'
getkcpmid = base_addr + 'getkcpmid'
evaluate2 = base_addr + 'new/student/rank/evaluate2.jsp'
SCHOOL_ADMIN = base_addr + 'getStudentPjPf/' + some_id + '/' + kkxy + '/SCHOOL_ADMIN'
# 使用会话保持 cookie
s = requests.Session()
# 首次请求,获取隐藏参数
start_response = s.get(login)
start_html = etree.HTML(start_response.text, parser=etree.HTMLParser())
lt = start_html.xpath('//*[@id="casLoginForm"]/input[1]/@value')[0]
dllt = start_html.xpath('//*[@id="casLoginForm"]/input[2]/@value')[0]
execution = start_html.xpath('//*[@id="casLoginForm"]/input[3]/@value')[0]
_eventId = start_html.xpath('//*[@id="casLoginForm"]/input[4]/@value')[0]
rmShown = start_html.xpath('//*[@id="casLoginForm"]/input[5]/@value')[0]
pwdDefaultEncryptSalt = start_html.xpath('//*[@id="casLoginForm"]/input[6]/@value')[0]
# 调用 JavaScript 对密码加密
with open('encrypt.js', 'r') as f:
js = f.read()
ctx = execjs.compile(js)
password = ctx.call('encryptAES', password, pwdDefaultEncryptSalt)
# 登录
data = {
'username': username,
'password': password,
'lt': lt,
'dllt': dllt,
'execution': execution,
'_eventId': _eventId,
'rmShown': rmShown
}
login_response = s.post(login, data=data, allow_redirects=False) # 重定向请求必须加上 allow_redirects=False
# 获取 Location,向 CAS 客户端发送请求
url = login_response.headers['Location']
s.get(url, allow_redirects=False)
# 对照浏览器执行相同的请求
s.get(caslogin)
s.post(loginSSO, data={'userId': username})
studentpj_response = s.get(studentpj)
# 进入课程列表界面
data = {
'hdid': some_id,
'xh': username
}
s.post(getkcpmid, data=data)
params = {
'hdfaid': some_id,
'overtime': 'timeNO',
'sfkdcpj': '1',
'sfqxzdf': '0',
'zbtx': '267,268,266,265',
'kkxy': '2302000',
'roid': 'SCHOOL_ADMIN'
}
evaluate2_response = s.get(evaluate2, params=params)
# 获取课程列表数据
data = {
'sEcho': '1',
'iColumns': '6',
'sColumns': '',
'iDisplayStart': '0',
'iDisplayLength': '10',
'mDataProp_0': 'KCMC',
'mDataProp_1': 'XM',
'mDataProp_2': 'TJSJ',
'mDataProp_3': 'YZ',
'mDataProp_4': 'PJJGID',
'mDataProp_5': '',
'iSortCol_0': '0',
'sSortDir_0': 'asc',
'iSortingCols': '1',
'bSortable_0': 'false',
'bSortable_1': 'false',
'bSortable_2': 'false',
'bSortable_3': 'false',
'bSortable_4': 'false',
'bSortable_5': 'false'
}
SCHOOL_ADMIN_response = s.post(SCHOOL_ADMIN, data=data)
result = json.loads(SCHOOL_ADMIN_response.text)
all = result['iTotalRecords'] # 总课程数
count = 0 # 已评价的课程数
print('共有 {} 门课需要评价:'.format(all))
kc_list = result['aaData']
for kc in kc_list:
if kc['TJSJ']:
count += 1
print('教务系统课程号:{},课程名称:{}({}),课程类型:{},教师姓名:{}({}),评价提交时间:{},一级指标得分:{},评价结果:{}'.format(
kc['JXBDM'], kc['KCMC'], kc['KCH'], kc['KCLX'], kc['XM'], kc['GH'], kc['TJSJ'], kc['YZ'], kc['ZPDF']))
else:
print('教务系统课程号:{},课程名称:{}({}),课程类型:{},教师姓名:{}({}),未评价'.format(
kc['JXBDM'], kc['KCMC'], kc['KCH'], kc['KCLX'], kc['XM'], kc['GH']))
print('已评价:{} 门 未评价:{} 门'.format(count, all - count))
结果如下。
4. 进行评价
先手动评价一门课程,观察到 createStudentPjpf 请求。提交的参数是题号,对应的打分,和课程信息,学生信息。题号和对应的打分直接复制浏览器的参数即可,课程信息和学生信息传入上一步获取到的值。再加一个 if 判断,只对未评价的课程进行评价。
对了,注意到题号和打分参数有“一个键对应多个值”的情况,不能用字典传参,换成二元元组组成的列表。拷贝浏览器参数再转换成 Python 的格式有点麻烦,这次我是纯手工转的,可以考虑造个轮子。
为查看脚本运行结果,在所有课程都评价之后,重新获取课程信息,可以看见所有课程都已评价。再加上 click 传参,完美!
# coding=utf-8
import requests
from lxml import etree
import execjs
import json
import click
# 参数
some_id = '41' # 某个不知道干啥用的 id
kkxy = '2302000' # 某个不知道干啥用的 key
lt = ''
dllt = ''
execution = ''
_eventId = ''
rmShown = ''
# 各种 url
base_addr = 'http://s.ugsq.whu.edu.cn/'
login = 'https://cas.whu.edu.cn/authserver/login?service=http%3A%2F%2Fs.ugsq.whu.edu.cn%2Fcaslogin%2F'
caslogin = base_addr + 'caslogin/'
loginSSO = base_addr + 'loginSSO'
studentpj = base_addr + 'studentpj'
getkcpmid = base_addr + 'getkcpmid'
evaluate2 = base_addr + 'new/student/rank/evaluate2.jsp'
SCHOOL_ADMIN = base_addr + 'getStudentPjPf/' + some_id + '/' + kkxy + '/SCHOOL_ADMIN'
createStudentPjpf = base_addr + 'createStudentPjpf'
# 使用会话保持 cookie
s = requests.Session()
# 获取课程信息
def get_kc(username, password):
# 登录
data = {
'username': username,
'password': password,
'lt': lt,
'dllt': dllt,
'execution': execution,
'_eventId': _eventId,
'rmShown': rmShown
}
login_response = s.post(login, data=data, allow_redirects=False) # 重定向请求必须加上 allow_redirects=False
# 获取 Location,向 CAS 客户端发送请求
url = login_response.headers['Location']
s.get(url, allow_redirects=False)
# 对照浏览器执行相同的请求
s.get(caslogin)
s.post(loginSSO, data={'userId': username})
s.get(studentpj)
# 进入课程列表界面
data = {
'hdid': some_id,
'xh': username
}
s.post(getkcpmid, data=data)
params = {
'hdfaid': some_id,
'overtime': 'timeNO',
'sfkdcpj': '1',
'sfqxzdf': '0',
'zbtx': '267,268,266,265',
'kkxy': '2302000',
'roid': 'SCHOOL_ADMIN'
}
s.get(evaluate2, params=params)
# 获取课程列表数据
data = {
'sEcho': '1',
'iColumns': '6',
'sColumns': '',
'iDisplayStart': '0',
'iDisplayLength': '10',
'mDataProp_0': 'KCMC',
'mDataProp_1': 'XM',
'mDataProp_2': 'TJSJ',
'mDataProp_3': 'YZ',
'mDataProp_4': 'PJJGID',
'mDataProp_5': '',
'iSortCol_0': '0',
'sSortDir_0': 'asc',
'iSortingCols': '1',
'bSortable_0': 'false',
'bSortable_1': 'false',
'bSortable_2': 'false',
'bSortable_3': 'false',
'bSortable_4': 'false',
'bSortable_5': 'false'
}
SCHOOL_ADMIN_response = s.post(SCHOOL_ADMIN, data=data)
result = json.loads(SCHOOL_ADMIN_response.text)
all = result['iTotalRecords'] # 总课程数
count = 0 # 已评价的课程数
print('共有 {} 门课需要评价:'.format(all))
kc_list = result['aaData']
for kc in kc_list:
if kc['TJSJ']:
count += 1
print('教务系统课程号:{},课程名称:{}({}),课程类型:{},教师姓名:{}({}),评价提交时间:{},一级指标得分:{},评价结果:{}'.format(
kc['JXBDM'], kc['KCMC'], kc['KCH'], kc['KCLX'], kc['XM'], kc['GH'], kc['TJSJ'], kc['YZ'], kc['ZPDF']))
else:
print('教务系统课程号:{},课程名称:{}({}),课程类型:{},教师姓名:{}({}),未评价'.format(
kc['JXBDM'], kc['KCMC'], kc['KCH'], kc['KCLX'], kc['XM'], kc['GH']))
print('已评价:{} 门 未评价:{} 门'.format(count, all - count))
return kc_list
# 进行评价
def pingjia(kc):
if not kc['TJSJ']:
data = [
('dxid', '908'),
('dxid', '909'),
('dxid', '910'),
('dxid', '911'),
('dxid', '912'),
('dxid', '913'),
('dxid', '914'),
('dxid', '915'),
('dxid', '916'),
('dxid', '917'),
('dxid', '918'),
('dxid', '919'),
('dxid', '920'),
('dxid', '921'),
('dxid', '922'),
('dxid', '923'),
('dxid', '924'),
('dxid', '925'),
('dxid', '926'),
('dxid', '927'),
('dxid', '928'),
('dxid', '929'),
('dxvalue', '10'),
('dxvalue', '5'),
('dxvalue', '5'),
('dxvalue', '5'),
('dxvalue', '5'),
('dxvalue', '5'),
('dxvalue', '5'),
('dxvalue', '7'),
('dxvalue', '7'),
('dxvalue', '7'),
('dxvalue', '8'),
('dxvalue', '8'),
('dxvalue', '8'),
('dxvalue', '2'),
('dxvalue', '3'),
('dxvalue', '2'),
('dxvalue', '3'),
('dxvalue', '3'),
('dxvalue', '3'),
('dxvalue', '3'),
('dxvalue', '3'),
('dxvalue', '3'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('sfjft', '1'),
('wdid', '930'),
('wdid', '931'),
('wdid', '932'),
('wdid', '933'),
('wdvalue', '0'),
('wdvalue', '0'),
('wdvalue', '0'),
('wdvalue', '无'),
('rwid', some_id), # 不知道是啥,41
('xqid', kc['XQID']), # 不知道是啥,12
('jsgh', kc['GH']), # 教师工号
('kch', kc['KCH']), # 课程号
('bzxh', kc['BZXH']), # 不知道是啥,None
('jxbdm', kc['JXBDM']), # 教务系统课程号
('xsxh', kc['XH']), # 学号
('zf', '100.00'),
('pjjgid', kc['PJJGID']) # 评价结果 id
]
createStudentPjpf_response = s.post(createStudentPjpf, data=data)
if createStudentPjpf_response.text == '{}':
print('{}评价成功'.format(kc['KCMC']))
@click.command()
@click.option('--username', prompt='学号')
@click.option('--password', prompt='信息门户密码(默认身份证后 6 位)')
def pingjiao(username, password):
global lt
global dllt
global execution
global _eventId
global rmShown
# 首次请求,获取隐藏参数
start_response = s.get(login)
start_html = etree.HTML(start_response.text, parser=etree.HTMLParser())
lt = start_html.xpath('//*[@id="casLoginForm"]/input[1]/@value')[0]
dllt = start_html.xpath('//*[@id="casLoginForm"]/input[2]/@value')[0]
execution = start_html.xpath('//*[@id="casLoginForm"]/input[3]/@value')[0]
_eventId = start_html.xpath('//*[@id="casLoginForm"]/input[4]/@value')[0]
rmShown = start_html.xpath('//*[@id="casLoginForm"]/input[5]/@value')[0]
pwdDefaultEncryptSalt = start_html.xpath('//*[@id="casLoginForm"]/input[6]/@value')[0]
# 调用 JavaScript 对密码加密
with open('encrypt.js', 'r') as f:
js = f.read()
ctx = execjs.compile(js)
password = ctx.call('encryptAES', password, pwdDefaultEncryptSalt)
kc_list = get_kc(username, password)
for kc in kc_list:
pingjia(kc)
get_kc(username, password)
if __name__ == '__main__':
pingjiao()
结果图见前言哈。
5. 食用方法
参见 GitHub。
参考
- ^维基百科编者. XMLHttpRequest[G/OL]. 维基百科, 2019(20190212)[2019-02-12]. https://zh.wikipedia.org/zh-cn/XMLHttpRequest