创新港体育场馆自动预定

项目由来

创新港羽毛球场馆每天的预定需求较大。在线预定系统每天早上 8:40 左右开放,并且开放时间不定。这些因素给手动预定场馆带来了很大的麻烦,因此考虑由脚本自动化实现场馆预定过程。

项目构思

体育场馆预定系统需要通过交大身份认证系统来确认身份。考虑携带登录之后的身份信息来访问体育场馆预定系统。这里的身份认证参考了果果的图书馆订座脚本:

XJTU图书馆抢座位脚本--requests库 | 果果的博客 (gwyxjtu.github.io)

此外,体育场馆预定系统开放时间为 8:40-21:40,并非全天开放。因此需要进行连通性检测来进行预定控制。

项目流程架构如下所示:

  1. 检测连通性确认体育场馆预定系统是否开放
  2. 通过体育场馆查询api获取所有场馆信息
  3. 设定一定的条件来筛选出需要预定的场地
  4. 通过交大身份认证获得身份信息
  5. 向场馆预定系统发送预定post请求来进行预定。

项目实现

1. 连通性检测

使用requests库里的urlopen库来进行连通性检测:

1
2
3
4
5
6
def check_net(testserver):
try:
ret = request.urlopen(url=testserver, timeout=3.0)
except:
return False
return True

在主函数里的检测等待代码如下:

1
2
3
while True:
if check_net('http://202.117.17.144/'):
break

2. 查询场馆信息

首先构建一个session来进行后面的访问,通过session访问可以保证访问的连续型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests
from urllib3.util.retry import Retry
from requests.adapters import HTTPAdapter

MAX_RETRIES = 10

SLEEP_INTERVAL=0.1

retries=Retry(
total=MAX_RETRIES,
backoff_factor=SLEEP_INTERVAL,
status_forcelist=[403, 500, 502, 503, 504],
)

session = requests.Session()
session.mount("http://", HTTPAdapter(max_retries=retries))
session.mount("https://", HTTPAdapter(max_retries=retries))

headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 Edg/98.0.1108.62",
"Accept-Encoding": "gzip, deflate",
}

接下来就是查询场馆信息了。以创新港乒乓球场馆为例(因为未被预定的场地较多,便于测试 ^_^)。

通过观察链接以及进行测试,可以推测出每个场馆通过一个 id 来进行标识,后续的场馆信息查询和预定申请都需要用到该信息。

通过查看页面请求,找到向 /findtime.html发送 GET 请求可以查询到需要查询的信息。

但是该请求返回的信息是不完全的,有一个场次 id 没有被返回到,而这个场次 id 在之后的预定中是要用到的,经过多番查找,最终在网球场预定页面找到如下api:

从该 api 中可以找到三个关键信息:

  • id 每个场地的每个时间场次的标识符
  • status 场地状态,1为可预订,2为已被预订
  • stockid 每个场地的标识符

由此,便可以通过该 api 查询每个场馆的场地信息。实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
def get_avaliable_seats(court_id,date):

params={'s_date':date,
'serviceid':court_id}
response=session.get('http://202.117.17.144/product/findOkArea.html',params=params,headers=headers).json()

available_court_list=[]
for item in response['object']:
if item['status']==1:
available_court_list.append(item)

return available_court_list

3. 筛选出需要的场地

这一步比较容易,直接在上一步得到的场次进行筛选即可。

1
2
3
4
5
6
def get_suitable_seats(seat_list,time):
result=[]
for item in seat_list:
if item['stock']['time_no']==time:
result.append(item)
return result

4. 通过交大认证获得身份信息

这里参考了果果的图书馆订座脚本XJTU图书馆抢座位脚本--requests库 | 果果的博客 (gwyxjtu.github.io)。唯一不同之处在于,登录身份认证系统后跳转的应用不同。在浏览器中通过体育场馆预定系统跳转到身份认证页面,查询页面cookie即可得到相应的appid

将对应的 appid 填充到相应位置,便可进行登录和跳转。登录部分代码如下:

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def encrypt_pwd(raw_pwd, publicKey='0725@pwdorgopenp'):
''' AES-ECB encrypt '''
publicKey = publicKey.encode('utf-8')
# pkcs7 padding
BS = AES.block_size
pad = lambda s: s + (BS - len(s) % BS) * chr(BS - len(s) % BS)
pwd = pad(raw_pwd)
# zero padding
'''
pwd = raw_pwd
while len(raw_pwd.encode('utf-8')) % 16 != 0:
pwd += '\0'
'''
cipher = AES.new(publicKey, AES.MODE_ECB)
pwd = cipher.encrypt(pwd.encode('utf-8'))
return str(base64.b64encode(pwd), encoding='utf-8')

def login():
# get cookie route
session.get('https://org.xjtu.edu.cn/openplatform/login.html')

# get JcaptchaCode and cookie JSESSIONID & sid_code
r_JcaptchaCode = session.post('https://org.xjtu.edu.cn/openplatform/g/admin/getJcaptchaCode',
headers=headers)

# is_JcaptchaCode_show
url = 'https://org.xjtu.edu.cn/openplatform/g/admin/getIsShowJcaptchaCode'
params = {
'userName': config['username'],
'_': str(int(time.time() * 1000))
}
r = session.get(url, params=params, headers=headers)
# print(r.text)
# login
url = 'https://org.xjtu.edu.cn/openplatform/g/admin/login'
cookie = {
'cur_appId_':'n0C/SQT28fY='
}
data = {
"loginType": 1,
"username": config['username'],
"pwd": encrypt_pwd(config['password']),
"jcaptchaCode": ""
}
headers['Content-Type'] = 'application/json;charset=UTF-8'
r = session.post(url, data=json.dumps(data), headers=headers,cookies=cookie)
print(r.text)
print('身份认证成功,正在跳转...')
token = json.loads(r.text)['data']['tokenKey']
memberId=json.loads(r.text)['data']['orgInfo']['memberId']

cookie = {
'cur_appId_':'n0C/SQT28fY=',
'open_Platform_User' : token,
'memberId': str(memberId)
}
r=session.get('http://org.xjtu.edu.cn/openplatform/oauth/auth/getRedirectUrl?userType=1&personNo=3121154016&_=1590998261976',cookies = cookie)
r=session.get(json.loads(r.text)['data'])

5. 预定场馆

最后一步就是预定场地了,手动预定场馆的流程如下:

  1. 选择场地
  2. 点击确认场地按钮
  3. 跳转到新的页面确认场地信息
  4. 点击继续预定按钮弹出验证码
  5. 输入验证码并确认
  6. 返回预定状态信息(成功或失败)

通过对这些流程中的页面请求进行查看和分析,关系到预定成功的只有两个过程:①获取并识别验证码,②带着验证码提交一个预定post

还是以乒乓球场地的预定页面为例,其验证码生成方式如下:

也就是访问地址http://202.117.17.144/login/yzm.html?+(任意一个float随机数)即可得到验证码图像。

post请求一步步通过函数嵌套即可进行定位:

可以看出,最终提交的post请求地址为/order/book,参数有两个,一个为预定信息转换成的字符串,一个是验证码。下面分别介绍如何获取这两个参数。

预定信息中有大量的参数,但是大量参数为null ,不用进行考虑。需要构造的有以下几个参数:

1
2
3
4
5
6
7
8
param={"activityPrice":0,	//价格信息,保持不变即可
"address":'102', //场馆id
"extend":{}, //额外信息,保持不变即可
"flag":"0", //保持不变即可
"stock":{'169111':'1'}, //第一个数字为上文提到的场地stockid,第二个数字填1即可
"stockdetail":{'169111':'1711756'}, //第二个数字为场次id
"stockdetailids":'1711756' //场次id
}

验证码部分通过一些在线识别的 api 进行在线验证即可,这个验证码比较简单,成功率还挺高的,本次项目所用的 api 地址为:https://aicode.my-youth.cn

至此,便可以成功对场馆进行预定,预定代码为:

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
def order(seat_info):

param={"activityPrice":0,
"address":seat_info['stock']['serviceid'],
"extend":{},
"flag":"0",
"stock":{str(seat_info['stockid']):'1'},
"stockdetail":{str(seat_info['stockid']):str(seat_info['id'])},
"stockdetailids":str(seat_info['id'])
}

# 获取验证码图片
img=session.get('http://202.117.17.144/login/yzm.html?0.16003635332777866')
with open('yzm.jpg?x-oss-process=style/webp','wb') as f:
f.write(img.content)
f.close()
# 验证码识别

with open('yzm.jpg?x-oss-process=style/webp','rb') as f:
img_base64=str(base64.b64encode(f.read()))[2:-1]
f.close()

headers['Content-Type']='application/x-www-form-urlencoded'
headers['Origin']='https://aicode.my-youth.cn'
response=session.post('https://aicode.my-youth.cn/base64img',data={'data':"image/jpeg;base64,"+img_base64},headers=headers)
yzm=response.json()['data']

# 预定场馆
headers['Content-Type']='application/x-www-form-urlencoded; charset=UTF-8'
headers['Origin']='http://202.117.17.144'
response=session.post('http://202.117.17.144/order/book.html',params={'id':seat_info['stock']['serviceid']},data={'param':str(param),'yzm':yzm},headers=headers).json()

return response

项目小结

在开发过程中也走了不少弯路。例如在预定阶段的实现中,研究了很久页面跳转的逻辑。但实际上只有最后一个页面发送的最后一次请求是有效的。之后在进行脚本编写时要注意以结果为导向而不是过程为导向。从而避免很多不必要的开发。