中科大羽毛球场预约小程序脚本
更新于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')
完整代码见下面仓库: