新冠疫情期间,学校规定假期必须每天进行健康打卡,汇报自身各项情况,在开学前未中断且打满14天才可申请返校,而开学后虽然不管,但原则上仍需每天打卡、每周报备。

打卡?这辈子不可能手动打卡的,我决定写一个爬虫脚本来自动打卡。

登录

首先来分析一下打卡的登录逻辑:

  1. 打卡平台的网址是https://weixine.ustc.edu.cn/2020/home
  2. 点进去发现其跳转到了https://weixine.ustc.edu.cn/2020/login,其中有一条“统一身份认证登录”。
  3. 点击“统一身份认证登录”,页面跳转到https://passport.ustc.edu.cn/login?service=https%3A%2F%2Fweixine.ustc.edu.cn%2F2020%2Fcaslogin,这是打卡平台在科大统一身份认证平台注册的CAS身份认证服务链接,我们在此需要输入科大Passport的账号密码,即可登录。

因此,从这个逻辑可以得到,我们可以向上面第3点中的CAS身份认证URL发送包含登录信息的POST数据包,来实现登录。不过,事实上只要我们先在会话中登录了https://passport.ustc.edu.cn,即中科大身份认证系统,再对CAS认证URL直接发送GET请求,可以达到相同的效果,为了降低耦合,我选择了后面一种登录方法。

因此,最终的登录逻辑化为以下两步:

  1. https://passport.ustc.edu.cn/login发送学号、密码等字段信息,使会话登录上中科大身份认证系统。
  2. 直接GET请求CAS认证链接:https://passport.ustc.edu.cn/login?service=https%3A%2F%2Fweixine.ustc.edu.cn%2F2020%2Fcaslogin进行打卡平台的CAS认证。

登录界面如下:


接下来,在浏览器的F12界面中,对中科大身份认证系统的登录过程进行抓包:


发现登录过程向登录链接POST了不少内容,多试几次容易知道,其中的model、service、warn、button等参数都是固定的,showCode参数表示是否需要验证码,然而,直接把showCode取为空串就可以绕过验证码。username和password即学号、密码,用于校内身份认证(这里需要吐槽一下学校的身份认证系统居然还在使用明文传输密码,造成了很大的安全隐患)。

另外,还有一个貌似临时凭证的CAS_LT参数,初看不容易摸索出它的规律,但实际上,CAS_LT正藏在passport.ustc.edu.cn/login这个网页中,如下图:


可以使用BeautifulSoup通过id把它找出来。

打卡

登录成功以后,我们对打卡系统的CAS链接:https://passport.ustc.edu.cn/login?service=https%3A%2F%2Fweixine.ustc.edu.cn%2F2020%2Fcaslogin发起一个GET请求,即可跳转到打卡网页:https://weixine.ustc.edu.cn/2020/home,我们先手动打一下卡,看看打卡系统是如何在请求中标识用户身份的。

在Network选项卡下的众多内容中,有一条名为daliy_report的(真不是我不会拼daily这个单词),其提交表单部分内容如下:


上面省略了一部分表单的内容,但容易发现,有一条内容明显与其他内容不同,就是这个_token。短期内多打几次卡,可以发现表单的_token不会发生变化,但重新登录以后,_token则会发生变化,很显然,它用于用户身份的标识,即告诉打卡平台的服务端这条打卡内容是来自哪个同学。既然_token出现在表单内容里,那大概率它就藏在网页的表单当中,找了一下,发现还真有:


那么身份标识的问题就解决了,顺便我们也把打卡的过程研究了一遍,其实就是提交这么一个表单到https://weixine.ustc.edu.cn/2020/daliy_report


代码

接下来开始着手写代码,首先实现登录过程。下面先定义passport登录链接。

self.passport = "https://passport.ustc.edu.cn/login"

然后,由于前面这些请求之间并非独立的,而是依赖于共同的cookie,因此必须在同一个会话中发起,不能直接用requests自带的GET、POST方法来完成请求,所以我们需要先建立一个会话(requests.Session对象),使用会话的GET、POST方法。会话会主动维护一个cookie字典。

self.sess = requests.session()

然后定义login的主函数:

def login(self, username, password):
    """
    登录,需要提供用户名、密码
    """
    self.sess.cookies.clear()
    CAS_LT = self._get_cas_lt()
    login_data = {
        'username': username,
        'password': password,
        'warn': '',
        'CAS_LT': CAS_LT,
        'showCode': '',
        'button': '',
        'model': 'uplogin.jsp',
        'service': ''
    }
    self.sess.post(self.passport, login_data, allow_redirects=False)
    return self.sess.cookies.get("uc") == username

逻辑非常简单,首先把会话的cookie清空,然后通过一个函数获取前文提到的CAS_LT参数,并构造POST表单,调用Session的POST方法,把它提交给passport登录链接,如果登录成功,会话的cookie中会多出一条键为”uc”、值为登录username的键值对,可以通过它来判断是否登录成功。

接下来,来完善前面前面提到的函数:

def _get_cas_lt(self):
    """
    获取登录时需要提供的验证字段
    """
    response = self.sess.get(self.passport)
    CAS_LT = BeautifulSoup(response.text, 'html.parser').find(attrs={'id': 'CAS_LT'}).get('value')
    return CAS_LT

上述流程用Session去GET请求passport登录链接,在返回的html中即可获取到CAS_LT。

以上即是登录过程的代码,接下来给出打卡的代码:

self.login_bot = USTCPassportLogin()
self.sess = self.login_bot.sess
# CAS身份认证url
self.cas_url = 'https://passport.ustc.edu.cn/login?service=https%3A%2F%2Fweixine.ustc.edu.cn%2F2020%2Fcaslogin'
# 打卡url
self.clock_in_url = 'https://weixine.ustc.edu.cn/2020/daliy_report'
self.token = ''

健康打卡需要两个URL,第一个是打卡平台CAS身份认证的URL,在登录成功以后,对此URL进行请求,以完成CAS认证;第二个是打卡链接。同时,初始化一个login_bot,即为前面定义的登录类。最后初始化一个空字符串作为未登录状态下的token。

打卡系统的登录过程如下:

def login(self, username, password):
    """
    登录,需要提供用户名、密码
    """
    self.token = ''
    is_success = self.login_bot.login(username, password)
    if is_success:
        self.token = self._get_token()
    return is_success

若登录成功了,则通过下面的_get_token方法获取到token。

def _get_token(self):
    """
    获取打卡时需要提供的验证字段
    """
    response = self.sess.get(self.cas_url)
    s = BeautifulSoup(response.text, 'html.parser')
    token = s.find(attrs={'name': '_token'}).get('value')
    return token

在获取了token以后,我们终于可以进行打卡了:

def daily_clock_in(self, post_data_file):
    """
    打卡函数,需要提供包含表单内容的json文件
打卡成功返回True,打卡失败返回False
    """
    with open(post_data_file, 'r') as f:
        post_data = json.loads(f.read())
    post_data['_token'] = self.token
    response = self.sess.post(self.clock_in_url, data=post_data)
    return self._check_success(response)

这里我们从一个JSON文件读取打卡需要的表单,然后在字典中加入_token,对打卡的URL发起POST请求即可,最后通过下面的_check_success方法,来检查是否成功打卡。

def _check_success(self, response):
    """
    简单check一下有没有成功打卡、报备
    """
    s = BeautifulSoup(response.text, 'html.parser')
    msg = s.select('.alert')[0].text
    return '成功' in msg

这是由于:


综上,我们已经完成了一个健康打卡的脚本,同理也可以实现每周的出校报备,完整的代码见我的GitHub项目:

最后,此脚本仅供学习,希望大家为自己和他人的健康负责,在自身健康状态良好的情况下合理使用脚本,切勿上报不实信息!