AtmosphereMao

SZPT.Ehall 防疫期间学生每日信息线上填报爬虫

源码

关于

根据填写用户名与密码,模拟登录https://ehall.szpt.edu.cn/,并进入填报信息处爬取昨日提交的信息,并发送为今日的填报信息。

外部库

urllib3,crypto,schedule

逻辑解析

Authserver 模拟登陆

首先,根据班级通知群里给出的信息,需前往https://ehall.szpt.edu.cn/publicappinternet/sys/szptpubxsjkxxbs/\*default/index.do,进行每日的信息填报。

进入了该地址,如未登录则会被重定向到https://authserver.szpt.edu.cn/authserver/login?service=https://ehall.szpt.edu.cn/publicappinternet/sys/szptpubxsjkxxbs/\*default/index.do,要求登录。

avatar

进入该页面
avatar
avatar
根据登录表单需求,共有7个参数

1
2
3
4
5
6
7
username
password
lt
dllt
execution
_eventId
rmShown

avatar

其中发现有2个token参数

1
2
lt
excution

则需要每次爬取该页面时,获取该token参数作为POST表单参数中

1
2
3
# 获取登录参数
lt = re.search('name="lt" value="(.*?)"/>', html, re.S).group(1)
execution = re.search('name="execution" value="(.*?)"/>', html, re.S).group(1)

并且发现密码经过了前端加密上传,这时候则去寻找js部分。

avatar

从中js排除,发现有关加密的js文件为:

1
2
3
aes.js  (AES加密源码)
encrypt.wisedu.js (AES加密逻辑模式)
mobile-login_v1.0.js (登录)

mobile-login_v1.0.js中找出加密部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...
encryptPassword(password.value);//密码加密
...
//密码加密
function encryptPassword(pwd0) {

try{
var pwd1 = encryptAES(pwd0,pwdDefaultEncryptSalt);
getObj("mobilePasswordEncrypt").value=pwd1;
}catch(e){
getObj("mobilePasswordEncrypt").value=pwd0;
}
}
...

可发现它使用了AES加密

高级加密标准(AES,Advanced Encryption Standard)为最常见的对称加密算法(微信小程序加密传输就是用这个加密算法的)。对称加密算法也就是加密和解密用相同的密钥

AES加密逻辑

avatar

找出emcryptAES函数方法,encrypt.wisedu.js 文件

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
//var aesKey  = 'rjBFAaHsNkKAhpoi';  //密钥

//AES-128-CBC加密模式,key需要为16位,key和iv可以一样
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格式的密文
}

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

var $aes_chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';/****默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1****/
var aes_chars_len = $aes_chars.length;
function randomString(len) {
var retStr = '';
for (i = 0; i < len; i++) {
retStr += $aes_chars.charAt(Math.floor(Math.random() * aes_chars_len));
}
return retStr;
}

观察该文件,发现使用了AES-128的CBC加密模式,而密钥key长度是16位字符,加密结果返回的是base64字符串。既然知道了加密模式,则可以将此代码翻译成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
31
32
33
34
35
36
37
38
39
40
41
# AES
class AESCipher:

def __init__(self, key):
self.key = key[0:16].encode('utf-8') # 只截取16位
self.iv = self.random_string(16).encode() # 16位字符,用来填充缺失内容,可固定值也可随机字符串,具体选择看需求。

def __pad(self, text):
"""填充方式,加密内容必须为16字节的倍数,若不足则使用self.iv进行填充"""
text_length = len(text)
amount_to_pad = AES.block_size - (text_length % AES.block_size)
if amount_to_pad == 0:
amount_to_pad = AES.block_size
pad = chr(amount_to_pad)
return text + pad * amount_to_pad

def __unpad(self, text):
pad = ord(text[-1])
return text[:-pad]

def encrypt(self, text):
"""加密"""
raw = self.random_string(64) + text
raw = self.__pad(raw).encode()
cipher = AES.new(self.key, AES.MODE_CBC, self.iv)
return base64.b64encode(cipher.encrypt(raw))

def decrypt(self, enc):
"""解密"""
enc = base64.b64decode(enc)
cipher = AES.new(self.key, AES.MODE_CBC, self.iv)
return self.__unpad(cipher.decrypt(enc).decode("utf-8"))

@staticmethod
def random_string(length):
aes_chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678'
aes_chars_len = len(aes_chars)
retStr = ''
for i in range(0, length):
retStr += aes_chars[math.floor(random.random() * aes_chars_len)]
return retStr

有了加密逻辑并不行,AES上述讲了,高级加密标准为最常见的对称加密算法。对称加密算法也就是加密和解密用相同的密钥。那则还需要有加密盐(密钥),而密钥是基本上都是从服务端获取,可是从抓包中并没有发现有获取密钥的痕迹,则仔细观察了mobile-login.js中加密方法。

1
2
3
4
5
6
7
8
9
10
//密码加密
function encryptPassword(pwd0) {

try{
var pwd1 = encryptAES(pwd0,pwdDefaultEncryptSalt);
getObj("mobilePasswordEncrypt").value=pwd1;
}catch(e){
getObj("mobilePasswordEncrypt").value=pwd0;
}
}

在观察后发现,pwd0为明文密码,pwd1为加密后密码,pwdDefaultEncryptSalt为加密盐,而加密盐是个变量。既然是个变量,那找遍所有文件总能获取到你(找了半天没找到),最终找到原来在网页源码中。

avatar

此刻有了加密盐,后面只剩下POST测试了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 获取登录参数
lt = re.search('name="lt" value="(.*?)"/>', html, re.S).group(1)
execution = re.search('name="execution" value="(.*?)"/>', html, re.S).group(1)
aes_key = re.search('pwdDefaultEncryptSalt = "(.*?)";', html, re.S).group(1)
password_aes = pwdEncrypt(aes_key)
# print(password_aes)
params = {
'username': username,
'password': password_aes,
'lt': lt,
'dllt': 'userNamePasswordLogin',
'execution': execution,
'_eventId': 'submit',
'rmShown': '1'
}
# 登录提交
request = urllib.request.Request(url=POST_URL, data=urllib.parse.urlencode(params).encode(encoding='UTF-8'), method='POST')
response = opener.open(request)

此时发现,登录就成功了。

Ehall 信息填报

登录成功后,发现还是不能正常进入填报页面,会出现403拒绝访问错误,Ehell做了反爬虫检测,此时加上header User-Agent也无法进入,则猜测可能另set cookies。

开始往页面抓包,发现信息填报页返回了3个Cookies,而信息填报正需要该3个Cookies,在爬虫中并无发现有该3个Cookies,已经肯定了根据Cookies防止爬虫。
avatar

avatar

首先需要找到MOD_AUTH_CAS,那一切Cookies都解决了。

清楚cookie重新登录页面,发现了302重定向进入https://ehall.szpt.edu.cn/publicappinternet/sys/szptpubxsjkxxbs/\*default/index.do?ticket=ST-65113-bLKuDKkv6Rg5LgHZoO0m1582649965403-G11l-cas

avatar
并且返回了一个新Cookie,这个MOD_AUTH_CAS则就是我们需要的。
avatar

但发现多了一个GET参数ticket,这个ticket又需要去寻找。

由于302重定向限制了获取重定向前获取到的信息,Python写多一项处理禁止302重定向。

1
2
3
4
5
# 禁止302重定向处理
class NoRedirHandle(urllib.request.HTTPRedirectHandler):
def http_error_302(self, req, fp, code, msg, headers):
return fp
http_error_301 = http_error_302

再重新登录,获取到了302重定向前的信息,其中则有ticket参数

avatar

有了ticket,那就能获取到MOD_AUTH_CAS了,与后面的3个新Cookies。

到后面以为有此3个Cookies就能获取到昨日填报的信息了,发现并不行,还是会报403错误,就开始找原因,最终发现前面获取到的_WEU与填报信息获取页请求的_WEU不一样,发现_WEU在某个页面读取完后更新了,再重新寻找。

avatar

一直排除Cookies变化,最终找到了https://ehall.szpt.edu.cn/publicappinternet/sys/itpub/MobileCommon/getMenuInfo.do,在请求完该页面后,_WEU发生了update。

avatar

发现请求该页面还需用户的APPIDAPPNAME

avatar

还是重复的步骤,最终在网页源码中找到这两个参数。

avatar

1
2
3
# 获取js中的APPID与APPNAME参数
APPID = re.search("APPID='(.*?)';", html, re.S).group(1)
APPNAME = re.search("APPNAME='(.*?)';", html, re.S).group(1)

最终获取到新的_WEU,那后面也能获取到了你昨日填报的信息了。

avatar

最后再将此数据转变一下为https://ehall.szpt.edu.cn/publicappinternet/sys/szptpubxsjkxxbs/mrxxbs/saveReportInfo.do里的POST数据格式,再提交即可。

1
2
3
4
5
6
7
# 提交信息
params = {
'formData': data["datas"]
}
request = urllib.request.Request(url=SAVE_INFO_POST_URL,
data=urllib.parse.urlencode(params).encode(encoding='UTF-8'), method='POST', headers=header_getinfo)
response = opener.open(request)

提交完成,今日份的填报就完成了。(后续也增加了队列定时任务版本)