├── .gitignore ├── SJTUAppointment ├── __init__.py ├── config.py ├── utils │ ├── messages.py │ └── captcha_rec.py ├── SJTUVenueTabLists.py └── SJTUAppointment.py ├── template.json ├── README.md └── main.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | geckodriver.exe 3 | sport.log -------------------------------------------------------------------------------- /SJTUAppointment/__init__.py: -------------------------------------------------------------------------------- 1 | # __init__.py 2 | from .SJTUAppointment import SJTUAppointment -------------------------------------------------------------------------------- /template.json: -------------------------------------------------------------------------------- 1 | { 2 | "venue": "南区体育馆", 3 | "venueItem": "乒乓球", 4 | "date": [3,4,5,6,7], 5 | "time": [19,20,21] 6 | } -------------------------------------------------------------------------------- /SJTUAppointment/config.py: -------------------------------------------------------------------------------- 1 | # Jaccount username and password 2 | JACCOUNT_USERNAME = '' 3 | JACCOUNT_PASSWORD = '' 4 | 5 | # Fang Tang Key 6 | FANGTANG_KEY = '' -------------------------------------------------------------------------------- /SJTUAppointment/utils/messages.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib.parse 3 | import urllib.request 4 | 5 | # 微信通知API 6 | def send_message_fangtang(text, desp='', key='[SENDKEY]'): 7 | postdata = urllib.parse.urlencode({'text': text, 'desp': desp}).encode('utf-8') 8 | url = f'https://sctapi.ftqq.com/{key}.send' 9 | req = urllib.request.Request(url, data=postdata, method='POST') 10 | with urllib.request.urlopen(req) as response: 11 | result = response.read().decode('utf-8') 12 | return result -------------------------------------------------------------------------------- /SJTUAppointment/utils/captcha_rec.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from PIL import Image 3 | import pytesseract 4 | import requests 5 | 6 | # 识别图形验证码 7 | def captcha_rec(captcha): 8 | imgByteArr = captcha.screenshot_as_png 9 | imgByteArr2 = BytesIO(imgByteArr) 10 | captcha_img = Image.open(imgByteArr2) 11 | captcha_img = captcha_img.resize((100, 40)) 12 | captcha_img = captcha_img.convert('L') 13 | captcha = pytesseract.image_to_string(captcha_img, lang='eng') 14 | captcha = captcha.strip() # Remove trailing whitespaces 15 | return captcha -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SJTUAppointment 2 | 3 | 基于 python selenium 实现的SJTU体育场馆自动预约脚本 4 | 5 | ## 实现思路 6 | 7 | python 的 selenium 库可以方便地利用id、class、css来定位元素定位,模拟预约的点击操作。交大基本体育场馆的预约流程为: 8 | 1. 打开上海交通大学体育场馆预约平台 https://sports.sjtu.edu.cn/ 9 | 2. 输入jaccount账号与密码,识别图形验证码,点击登录 10 | 3. 选择场馆(Venue)、细分项目(VenueItem)、日期、时间(Time) 11 | 4. 如果有空余场地,则点击预约按钮,确认预约; 如果没有就刷新网页继续检测 12 | 5. 若成功预约则发消息给手机,提醒用户付款 13 | 14 | 利用 selenium 库实现以上操作即可。 15 | 16 | ## 环境配置(Anaconda) 17 | 18 | python环境配置 19 | ``` 20 | conda create -n sjtu python=3.8 21 | ``` 22 | 23 | 安装对应的包 24 | ``` 25 | conda activate sjtu 26 | pip install selenium 27 | pip install pillow 28 | pip install pytesseract 29 | pip install requests 30 | ``` 31 | 32 | 安装与浏览器对应版本的 webDriver (以Firefox为例) 33 | - 下载地址 https://github.com/mozilla/geckodriver/releases 34 | 35 | 安装 tesseract-ocr,用于识别图形验证码 36 | - 下载地址 https://digi.bib.uni-mannheim.de/tesseract/ 37 | 38 | ## 使用方式 39 | 40 | 首先在`SJTUAppointment/config.py`中设定个人的jaccount账号与密码。之后可在`SJTUAppointment/SJTUVenueTabLists.py`中查看各个场馆及其细分项目,选择自己需要预约的场馆和项目。 41 | 42 | 以下提供了2种预约方式: 43 | 44 | 1、**使用命令行参数进行预约** 45 | ``` 46 | python main.py --venue '气膜体育中心' --venueItem '羽毛球' --date '[2,3]' --time '[19,21]' --head 47 | ``` 48 | 49 | 2、**使用json配置文件进行预约** 50 | ``` 51 | python main.py --json template.json --head 52 | ``` 53 | 54 | > 若要开启无头模式则可去掉参数 `--head` 55 | 56 | 抢到场地通知方式 57 | 58 | 1、**方糖API**:一个用python给微信发送信息的API,可以通过注册登录获取AppKey,然后将AppKey填入`SJTUAppointment/config.py`中的`FANGTANG_KEY`变量中 59 | - API地址 https://sct.ftqq.com/ 60 | 61 | ## 问题与改进方向 62 | 63 | - selenium 库框架相当于模拟浏览器的各种操作,它的请求发送速度和响应速度都比较慢,所以在抢羽毛球这样的热门场地时并不占优势。 64 | - selenium 使用时会很占电脑资源,所以这个脚本其实并不适合部署在服务器上长时间运行。 65 | - 之后会基于HTTP协议写一个更快速轻量的抢场地脚本。 66 | 67 | ## 致谢 68 | 69 | > 感谢以下项目作者: 70 | > - [**ifarewell/jAutoVenue**](https://github.com/ifarewell/jAutoVenue) 71 | > - [**PhotonQuantum/jaccount-captcha-solver**](https://github.com/PhotonQuantum/jaccount-captcha-solver) -------------------------------------------------------------------------------- /SJTUAppointment/SJTUVenueTabLists.py: -------------------------------------------------------------------------------- 1 | venueTabLists = { 2 | '子衿街学生活动中心': { 3 | '舞蹈': 'tab-019335a6-9d67-4d7d-923e-92ecea740c7b', 4 | '健身房': 'tab-0a349309-1734-4507-98bd-4c30bf33c6bc', 5 | '棋牌室': 'tab-151ba6a9-2cb1-489d-8cbd-2d2fe5e12f7c', 6 | '钢琴': 'tab-57fbed57-d8b9-4247-a6ed-b2088a9e8a37', 7 | '烘焙': 'tab-7bffbb9b-4999-49e4-9025-6012d3524da8', 8 | '琴房兼乐器': 'tab-bbd8cc4b-f3b5-4714-af53-f61a8f1c2fba' 9 | }, 10 | '学生服务中心': { 11 | '台球': 'tab-417dc5ed-aba7-4abb-bbdc-efef8446dbdb', 12 | '健身房': 'tab-c3b3b14e-46c8-4e42-b687-5da310762a60' 13 | }, 14 | '徐汇校区体育馆': { 15 | '健身房': 'tab-0f71f5e1-2e24-4c15-b437-cc9f82a1343e', 16 | '羽毛球': 'tab-84e4aeaf-0c8c-431c-a64d-7ccd9cc381f6', 17 | '乒乓球': 'tab-92c28497-7437-4d5d-ab55-55721009db45' 18 | }, 19 | '气膜体育中心': { 20 | '羽毛球': 'tab-29942202-d2ac-448e-90b7-14d3c6be19ff', 21 | '篮球': 'tab-8dc0e52c-564a-4d9a-9cb2-08477f1a18d4' 22 | }, 23 | '南区体育馆': { 24 | '乒乓球': 'tab-28d3bea9-541d-4efb-ae46-e739a5f78d72', 25 | '排球': 'tab-3770c0b3-1060-41f4-ae12-8df38d48c8b1', 26 | '篮球': 'tab-7f11b6af-cb2e-47ac-9a51-9cd0df885736' 27 | }, 28 | '胡法光体育场': { 29 | '舞蹈': 'tab-a810f3f6-f5c8-4ab3-b57c-e9372f40649b' 30 | }, 31 | '霍英东体育中心': { 32 | '羽毛球': 'tab-49629b20-71fb-4bae-8675-fdae0831e861', 33 | '篮球': 'tab-561d43a3-338e-4834-b35f-747bdc578366', 34 | '健身房': 'tab-b3dce013-3a0e-45e0-a0c2-425a364ac90f' 35 | }, 36 | '致远游泳馆东侧足球场': { 37 | '足球': 'tab-6a157dec-2f89-4f5c-ba10-dd28db67292b' 38 | }, 39 | '笼式足球场': { 40 | '足球': 'tab-ad666603-a47e-488d-b913-d5304a880ced' 41 | }, 42 | '子衿街南侧网球场': { 43 | '网球': 'tab-67b30431-4185-433f-802d-278687b257a9' 44 | }, 45 | '东区网球场': { 46 | '网球': 'tab-4dd7ae28-cf27-4369-9bc4-ee75b8e3cc76' 47 | }, 48 | '胡晓明网球场': { 49 | '网球': 'tab-19f69e5c-872f-4fbb-b9fe-70d6337c2d93' 50 | } 51 | } -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import argparse 3 | import json 4 | import ast 5 | import os 6 | import winsound 7 | 8 | from SJTUAppointment import SJTUAppointment 9 | from SJTUAppointment.config import FANGTANG_KEY 10 | from SJTUAppointment.utils.messages import send_message_fangtang 11 | 12 | def main(str, args): 13 | ## 解析参数 14 | # 1. json文件模式 15 | if str == 'json': 16 | with open(args.json, 'r', encoding='utf-8') as f: 17 | task = json.load(f) 18 | # 2. 命令行模式 19 | elif str == 'terminal': 20 | try: 21 | args.date = ast.literal_eval(args.date) 22 | args.time = ast.literal_eval(args.time) 23 | except: 24 | raise Exception("Date and Time should be list.") 25 | task = { 26 | "venue": args.venue, 27 | "venueItem": args.venueItem, 28 | "date": [int(item) for item in args.date], 29 | "time": [int(item) for item in args.time] 30 | } 31 | # 3. 默认 32 | else: 33 | task = { 34 | "venue": "气膜体育中心", 35 | "venueItem": "羽毛球", 36 | "date": [3,4,5,6,7], 37 | "time": [19,20,21] 38 | } 39 | 40 | # 创建任务 41 | worker = SJTUAppointment(task, headless=not args.head) 42 | 43 | try: 44 | worker.login() 45 | print("Login Successfully!") 46 | except Exception as e: 47 | print(f"[Login ERROR]: {e}") 48 | # 预约 49 | try: 50 | worker.book() 51 | print("Booking Venue!") 52 | send_message_fangtang('抢到场地了!', '第一行\n\n第二行', FANGTANG_KEY) 53 | duration = 10000 # millisecond 54 | freq = 440 # Hz 55 | winsound.Beep(freq, duration) 56 | except Exception as e: 57 | print(f"[Booking ERROR]: {e}") 58 | send_message_fangtang('抢场地失败!', '第一行\n\n第二行', FANGTANG_KEY) 59 | 60 | 61 | if __name__ == "__main__": 62 | print("Start SJTU Sport Appointment") 63 | 64 | # Baic Logging Config 65 | currentPath = os.path.dirname(os.path.abspath(__file__)) 66 | logfilePath = os.path.join(currentPath, "sport.log") 67 | logging.basicConfig( 68 | filename=logfilePath, 69 | level='INFO', 70 | format='%(asctime)s %(filename)s : %(levelname)s %(message)s', 71 | datefmt='%Y-%m-%d %A %H:%M:%S', 72 | ) 73 | logging.info("=================================") 74 | logging.info("Log Started") 75 | 76 | # 解析参数 77 | parser = argparse.ArgumentParser() 78 | parser.add_argument('--head', action='store_true') 79 | parser.add_argument('--json', help='json file') 80 | parser.add_argument('--venue', help='场馆名称') 81 | parser.add_argument('--venueItem', help='细分项目名称') 82 | parser.add_argument('--date', help='日期,用方括号表示,例如 [2,3]') 83 | parser.add_argument('--time', help='时间,用方括号表示,例如 [19,21]') 84 | 85 | args = parser.parse_args() 86 | if args.json: 87 | main('json', args) 88 | elif args.venue: 89 | main('terminal', args) 90 | else: 91 | main('default', args) -------------------------------------------------------------------------------- /SJTUAppointment/SJTUAppointment.py: -------------------------------------------------------------------------------- 1 | from selenium import webdriver 2 | from selenium.webdriver.firefox.options import Options 3 | from selenium.webdriver.support.ui import WebDriverWait 4 | from selenium.webdriver.support import expected_conditions as EC 5 | from time import sleep 6 | from datetime import datetime, timedelta 7 | 8 | 9 | from .utils.captcha_rec import captcha_rec 10 | from .SJTUVenueTabLists import venueTabLists 11 | from .config import JACCOUNT_USERNAME, JACCOUNT_PASSWORD 12 | 13 | class SJTUAppointment: 14 | def __init__(self, task, headless=True): 15 | self.task = task 16 | self.tryTimes = 0 17 | self.ordered_flag = False 18 | self.venue = task['venue'] 19 | self.venueItem = task['venueItem'] 20 | self.date = task['date'] 21 | self.time = task['time'] 22 | 23 | self.user_name = JACCOUNT_USERNAME 24 | self.password = JACCOUNT_PASSWORD 25 | 26 | self.options = Options() 27 | if headless: 28 | self.options.add_argument("-headless") # 无头模式(不显示浏览器界面) 29 | self.driver = webdriver.Firefox( 30 | options=self.options) 31 | self.gen_date() 32 | 33 | # 生成真实日期 34 | def gen_date(self): 35 | deltaDays = self.date 36 | today = datetime.now() 37 | date = [today + timedelta(days=i) for i in deltaDays] 38 | self.date = [i.strftime('%Y-%m-%d') for i in date] 39 | 40 | 41 | # 打开体育预约网站 42 | def open_website(self): 43 | url = 'https://sports.sjtu.edu.cn' 44 | self.driver.get(url) 45 | if not self.driver.title == '上海交通大学体育场馆预约平台': 46 | raise Exception('Target site error.') 47 | 48 | # 登录 49 | def login(self): 50 | self.open_website() 51 | sleep(3) 52 | # 进入登录界面 53 | try: 54 | btn = self.driver.find_element('css selector', '#app #logoin button') 55 | btn.click() 56 | except: 57 | raise Exception('Failed to enter login page.') 58 | # Try 10 times in case that the captcha recognition process goes wrong 59 | times = 0 60 | while self.driver.title != '上海交通大学体育场馆预约平台' and times < 10: 61 | self.driver.refresh() 62 | sleep(1) # Wait for the captcha image to load 63 | times += 1 64 | 65 | userInput = self.driver.find_element('name', 'user') 66 | userInput.send_keys(self.user_name) 67 | passwdInput = self.driver.find_element('name', 'pass') 68 | passwdInput.send_keys(self.password) 69 | captcha = self.driver.find_element('id', 'captcha-img') 70 | captchaVal = captcha_rec(captcha) # captcha recognition 71 | userInput = self.driver.find_element('id', 'input-login-captcha') 72 | userInput.send_keys(captchaVal) 73 | btn = self.driver.find_element('id', 'submit-password-button') 74 | btn.click() 75 | 76 | assert times < 10, '[ERROR]: Tryed 10 times, but failed to login, please check the captcha recognition process.' 77 | 78 | # 选择场馆 79 | def searchVenue(self): 80 | sleep(1) 81 | # self.driver.get('https://sports.sjtu.edu.cn/pc/#/Venue/1') 82 | # btn = wait.until(EC.presence_of_element_located(('class name', 'el-button el-button--primary'))) 83 | wait = WebDriverWait(self.driver, 10) 84 | # next steps are doubled to avoid the bug of the website 85 | venueInput = wait.until(EC.presence_of_element_located(('class name', 'el-input__inner'))) 86 | venueInput.send_keys(self.venue) 87 | btn = wait.until(EC.presence_of_element_located(('class name', 'el-button--default'))) 88 | btn.click() 89 | 90 | self.driver.refresh() 91 | sleep(1) 92 | venueInput = wait.until(EC.presence_of_element_located(('class name', 'el-input__inner'))) 93 | venueInput.send_keys(self.venue) 94 | btn = wait.until(EC.presence_of_element_located(('class name', 'el-button--default'))) 95 | btn.click() 96 | 97 | sleep(1) 98 | btn = wait.until(EC.presence_of_element_located(('class name', 'el-card__body'))) 99 | # btn = self.driver.find_element('class name', 'el-card__body') 100 | btn.click() 101 | sleep(1) 102 | 103 | # 选择项目 104 | def searchVenueItem(self): 105 | wait = WebDriverWait(self.driver, 10) 106 | btn = wait.until(EC.presence_of_element_located(('id', venueTabLists[self.venue][self.venueItem]))) 107 | # btn = self.driver.find_element('id', venueTabLists[self.venue][self.venueItem]) 108 | btn.click() 109 | 110 | # 选择日期 111 | def searchTime(self): 112 | wait = WebDriverWait(self.driver, 10) 113 | for date in self.date: 114 | dateID = 'tab-' + date 115 | btn = wait.until(EC.presence_of_element_located(('id', dateID))) 116 | btn.click() 117 | for time in self.time: 118 | if self.ordered_flag == False: 119 | try: 120 | timeSlotId = time - 7 121 | # wrapper = self.driver.find_element('class name', 'inner-seat-wrapper') 122 | wrapper = wait.until(EC.presence_of_element_located(('class name', 'inner-seat-wrapper'))) 123 | timeSlot = wrapper.find_elements('class name', 'clearfix')[timeSlotId] 124 | seats = timeSlot.find_elements('class name', 'unselected-seat') 125 | if len(seats) > 0: 126 | seats[0].click() 127 | self.confirmOrder() 128 | self.ordered_flag = True 129 | sleep(1) 130 | except Exception as e: 131 | print(f'[Strange ERROR]: {e}') 132 | sleep(1) 133 | 134 | # 确认预约 135 | def confirmOrder(self): 136 | btn = self.driver.find_element('css selector', '.drawerStyle>.butMoney>.is-round') 137 | btn.click() 138 | 139 | # process notice 140 | btn = self.driver.find_element('css selector', '.dialog-footer>.tk>.el-checkbox>.el-checkbox__input>.el-checkbox__inner') 141 | btn.click() 142 | btn = self.driver.find_element('css selector', '.dialog-footer>div>.el-button--primary') 143 | btn.click() 144 | sleep(1) 145 | 146 | def book(self): 147 | print("Start Booking") 148 | print(f"venue: {self.venue}\n venueItem: {self.venueItem}\n date: {self.date}\n time: {self.time}") 149 | try: 150 | self.searchVenue() 151 | self.searchVenueItem() 152 | while self.ordered_flag == False: 153 | self.searchTime() 154 | print(f"try {self.tryTimes} times") 155 | self.tryTimes += 1 156 | self.driver.refresh() 157 | except Exception as e: 158 | print(f"[Book ERROR]: {e}") 159 | sleep(1) 160 | 161 | def close(self): 162 | self.worker.close() --------------------------------------------------------------------------------