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,要求登录。
进入该页面
根据登录表单需求,共有7
个参数
1 2 3 4 5 6 7
| username password lt dllt execution _eventId rmShown
|
其中发现有2
个token参数
则需要每次爬取该页面时,获取该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部分。
从中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加密逻辑
找出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
|
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(); }
function encryptAES(data,aesKey){ if(!aesKey){ return data; } var encrypted =getAesString(randomString(64)+data,aesKey,randomString(16)); return encrypted; }
var $aes_chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678'; 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
| class AESCipher:
def __init__(self, key): self.key = key[0:16].encode('utf-8') self.iv = self.random_string(16).encode()
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
为加密盐,而加密盐是个变量。既然是个变量,那找遍所有文件总能获取到你(找了半天没找到),最终找到原来在网页源码中。
此刻有了加密盐,后面只剩下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)
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防止爬虫。
首先需要找到MOD_AUTH_CAS
,那一切Cookies都解决了。
清楚cookie重新登录页面,发现了302重定向进入https://ehall.szpt.edu.cn/publicappinternet/sys/szptpubxsjkxxbs/\*default/index.do?ticket=ST-65113-bLKuDKkv6Rg5LgHZoO0m1582649965403-G11l-cas
并且返回了一个新Cookie,这个MOD_AUTH_CAS
则就是我们需要的。
但发现多了一个GET参数ticket
,这个ticket
又需要去寻找。
由于302重定向
限制了获取重定向前获取到的信息,Python写多一项处理禁止302重定向。
1 2 3 4 5
| 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
参数
有了ticket
,那就能获取到MOD_AUTH_CAS
了,与后面的3个新Cookies。
到后面以为有此3个Cookies就能获取到昨日填报的信息了,发现并不行,还是会报403错误
,就开始找原因,最终发现前面获取到的_WEU
与填报信息获取页请求的_WEU
不一样,发现_WEU
在某个页面读取完后更新了,再重新寻找。
一直排除Cookies变化,最终找到了https://ehall.szpt.edu.cn/publicappinternet/sys/itpub/MobileCommon/getMenuInfo.do,在请求完该页面后,_WEU
发生了update。
发现请求该页面还需用户的APPID
,APPNAME
。
还是重复的步骤,最终在网页源码中找到这两个参数。
1 2 3
| APPID = re.search("APPID='(.*?)';", html, re.S).group(1) APPNAME = re.search("APPNAME='(.*?)';", html, re.S).group(1)
|
最终获取到新的_WEU
,那后面也能获取到了你昨日填报的信息了。
最后再将此数据转变一下为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)
|
提交完成,今日份的填报就完成了。(后续也增加了队列定时任务版本)