更新于2022-10-7:鉴于羽毛球馆预约平台已经更换,本文所述方法已失效。新平台也太难爬了。。。调用wx.login获取code以及后面调用wx云函数获取noise参数靠抓包似乎不大可行(我的水平太菜了),如果您有相关经验,可以在底下留言或者联系我。

前面已经爬过了蜗壳的健康打卡系统,因为该系统以网页作为前端,可以在浏览器直接打开分析,前端代码也没有出现复杂的逻辑,因此爬起来并没有遇到什么阻力。

中校区体育场、游泳馆等建筑落成并开放后,吸引了一众学生前往进行各种体育活动,其中,位于综合馆的羽毛球场十分火爆,容易抢不到预约,因此我决定写个脚本来实现羽毛球场抢预约。

小程序名为“中国科大中校区体育中心”,其主界面如下所示:

拿到这么一个界面,我第一反应就是看一下主页的链接,结果发现它前端并不是h5,而是一个货真价实的小程序,不是用浏览器打开的,因此也就没有办法直接获取它的主页链接,基于此,我们需要一款抓包软件来直接分析其API接口。

软件介绍

抓包软件可以使用Fiddler、Charles等,这里我使用了Charles。Charles是一款付费软件,但可以被轻松破解,破解方法我这里就不介绍了,网上一搜一大把。后面内容都是针对Charles进行展开,不过其他抓包软件也是大同小异。

在Charles中,需要开启SSL Proxying选项(依次选择Proxy->Start SSL Proxying),然后配置SSL Proxying(依次选择Proxy->SSL Proxying Settings),在Include栏下选择Add,然后添加Host和Port均为通配符:*。接下来配置代理端口(依次选择Proxy->Proxy Settings),可以看到Port默认为8888,若无冲突,可以不做修改。

如果电脑是Windows或Mac系统,当然可以直接在上面登录PC版微信,在里面打开小程序进行抓包,可以省去下面手机配置代理的步骤。

如果电脑系统不支持微信,在只能通过手机登录微信的情况下,我们将手机和电脑接入同一个局域网下,并在手机的局域网设置中,将HTTP代理服务器设置为电脑的局域网IP,端口设置为前面配置的代理端口8888。

如果你是苹果手机,那么在Safari浏览器中打开网址:chls.pro/ssl,即可下载Charles提供的证书,将证书安装到手机上并添加信任,即配置完成。若不配置证书,会导致Charles抓到的HTTPS的包全部乱码,影响我们阅读内容。

如果你是安卓系统,应该流程也差不多,不过我并没有用过,就不误导人了。


抓包分析

配置完抓包软件及手机代理后,在手机上打开“中国科大中校区体育中心”小程序,进行登录,并进行一次手动预约,通过Charles记录到的数据包如下:

登录过程

预约过程

因为我们最终的目的是能顺利进行预约,因此先分析预约过程,看它需要提供哪些参数。首先容易找到预约操作的数据包是第二幅图中选中的submit,其请求头包含了一条疑似用于认证身份的token字段,如果将它删掉再提交请求,则会得到失败的响应。再看其表单内容:


下图是我手动提交预约的样子:


对比二者,容易把字段关系对应起来,但有两个字段无法在其中对应上:gymnasiumId、timeQuantumId。经过我的研究,我发现gymnasiumId表示体育场的ID,羽毛球场的ID为1,因此在这里只要将它固定取1就好了;而timeQuantumId是时间段的ID,其对应关系如下:

{
    3: "08:00-09:30",
    4: "09:30-11:00",
    5: "11:00-12:30",
    6: "12:30-14:00",
    7: "14:00-15:30",
    8: "15:30-17:00",
    9: "17:00-18:30",
    10: "18:30-20:00",
    11: "20:00-21:30"
}

不清楚为什么是从3开始。。。不过照着填就是了。


通过上述一顿分析,可以总结出预约的API如下:

POST https://cgyy.ustc.edu.cn/api/app/appointment/record/submit

提供的请求头中必须包含以下参数:

token:一串由服务器提供的字符串,来源尚不明

请求参数如下:

参数 类型 说明
gymnasiumId int 固定为1
sportPlaceId int 场地号
timeQuantum str 预约时间段的字符串形式
timeQuantumId int 时间段对应的ID
appointmentUserName str 预约人姓名
appointmentPeopleNumber int 入场人数
appointmentDay str 预约日期字符串
phone str 电话号码

其中,预约人姓名和电话号码参数可以设置为空串,其他参数必须提供有效值。


分析完预约API,我们需要分析token的来源,它应该藏在登录过程的一堆数据包中。在Charles记录下的登录Session中,搜索token,得到以下结果:


关注到一条Response Body中有token字段,双击打开该请求,发现内容如下:


该请求提供了两个参数,ticket与wxId,返回了一大串看上去很有意义的内容,其中就包含我们需要的token!(注:上图中的token看上去与前面预约时的token不同,这是因为我抓包的过程中不小心登录了两次,前面一个token过期了,其实两个token应该是相同的。)

将这条请求进行重发,得到{“msg”:”登录失败”,”code”:400}的响应,这说明ticket可能是一个只能使用一次的临时凭证。

不论如何,我们得到了获取token的API:

POST https://cgyy.ustc.edu.cn/api/user/login

请求参数如下:

参数 类型 说明
ticket str 一个字符串,疑似临时凭证,来源尚不明
wxId str 一个字符串,疑似小程序给予用户的唯一ID

接下来,我们继续分析ticket的来源,眼尖的朋友估计已经发现了,它好像就在前面的某个图片中出现过。

正是本文的第二张图片,我再把这张图贴一下:


在通过中科大passport进行CAS认证的请求中,在Response的Headers里面,出现了一个ticket(位于Location字段提供的URL中),这个ticket恰好也就是前面请求token时提供的ticket!(Response Headers提供的Location字段用以示意跳转到的链接)

因此,我们可以通过CAS认证请求来获取ticket临时凭证。


最后还有一个wxId,这个参数好像不太容易获取,但我发现其实前面获取token的API,只提供ticket就够了,根本不需要wxId,大概是平台的bug。


流程与代码

整体的流程就如上文所述,为了清晰起见,我画了一个简单的流程示意图:


接下来简单贴一些核心代码。

def __init__(self):
    self.login_bot = USTCPassportLogin()
    self.sess = self.login_bot.sess
    self.cas_url = 'https://passport.ustc.edu.cn/login?service=https://cgyy.ustc.edu.cn/validateLogin.html'
    self.info_url = 'https://cgyy.ustc.edu.cn/api/app/sport/place/getAppointmentInfo'
    self.token_url = 'https://cgyy.ustc.edu.cn/api/user/login'
    self.submit_url = 'https://cgyy.ustc.edu.cn/api/app/appointment/record/submit'
    self.cancel_url = 'https://cgyy.ustc.edu.cn/api/app/appointment/record/cancel/'

    self.token = ''

依旧使用前面一篇文章中提供的登录脚本进行Passport的登录,我们在初始化中定义了几个URL,分别是预约平台的CAS认证链接(cas_url)、场地信息查询链接(info_url,关于场地信息的查询,比较容易,我就不再介绍,各位可以自己抓一下包)、获取token的链接(token_url)、提交预约链接(submit_url)、取消预约链接(cancel_url,同样比较容易)。

以下按顺序给出各核心部分的代码:

登录

登录后顺便获取token,存入self.token中。

def login(self, username, password):
    """
    登录,需要提供用户名、密码,顺便返回后续表单需要提供的token
    """
    self.token = ''
    is_success = self.login_bot.login(username, password)
    if is_success:
        ticket = self._get_ticket()
        self.token = self._get_token(ticket)
    return is_success

获取ticket

def _get_ticket(self):
    response = self.sess.get(self.cas_url, allow_redirects=False)
    url = response.headers.get('Location')
    params = dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(url).query))
    return params.get('ticket')

获取token

需要在请求头中放置”content-type”: “application/json”,后面请求的data需要用json.dumps转为字符串。

def _get_token(self, ticket):
    """
    获取token
    """
    headers = {
        "content-type": "application/json"
    }
    data = {
        "ticket": ticket
    }
    r = self.sess.post(self.token_url, data=json.dumps(data), headers=headers)
    return r.json().get('data').get('token')

预约

def submit(self, gymnasium_id, sport_place_id, time_quantum_id,
            user, people_number, appointment_day, phone):
    data = {
        "gymnasiumId": gymnasium_id,
        "sportPlaceId": sport_place_id,
        "timeQuantum": self.id2time[time_quantum_id],
        "timeQuantumId": time_quantum_id,
        "appointmentUserName": user,
        "appointmentPeopleNumber": people_number,
        "appointmentDay": appointment_day,
        "phone": phone
    }
    headers = {
        "content-type": "application/json",
        "token": self.token
    }
    result = self.sess.post(self.submit_url, data=json.dumps(data), headers=headers).json()
    code = result.get('code')
    if code != 200:
        return False, result.get('msg')
    return True, result.get('data')

完整代码见下面仓库: