├── requirements.txt ├── config.py ├── .gitignore ├── pyproject.toml ├── utils ├── PUExceptions.py ├── single.py ├── pu_sign.py ├── headers.py ├── user_data_manager.py ├── tools.py └── activity_bot.py ├── main.py ├── README.md └── uv.lock /requirements.txt: -------------------------------------------------------------------------------- 1 | requests~=2.32.5 2 | loguru~=0.7.3 3 | python-dotenv~=1.1.1 4 | pycryptodome -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | """ 2 | 配置信息 3 | """ 4 | 5 | # 是否开启邮件通知,默认为True 6 | ENABLE_EMAIL_NOTIFICATION = True # True or False -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .git 2 | config.ini 3 | .cryptwood 4 | .cryptUserData 5 | .idea 6 | __pycache__ 7 | .venv 8 | .gitignore 9 | .qoder 10 | 11 | # 用户隐私数据 12 | user_data.json -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pu-signupbot" 3 | version = "0.1.0" 4 | description = "pu活动报名机器人" 5 | requires-python = ">=3.13" 6 | dependencies = [ 7 | "loguru>=0.7.3", 8 | "python-dotenv>=1.1.1", 9 | "requests>=2.32.5", 10 | ] 11 | 12 | [[tool.uv.index]] 13 | name = "tuna" 14 | url = "https://pypi.tuna.tsinghua.edu.cn/simple/" 15 | -------------------------------------------------------------------------------- /utils/PUExceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class BaseException(Exception): 3 | """ 4 | Base Exception class 5 | """ 6 | def __init__(self, msg,**kwargs): 7 | super().__init__(msg) 8 | self.kwargs = kwargs 9 | self.msg = msg 10 | def __str__(self): 11 | return f"ERROR INFO:{self.msg}" 12 | 13 | 14 | 15 | class ActivityIDsEmptyError(BaseException): 16 | """ 17 | 用户没有要加入的活动 18 | """ 19 | def __init__(self, username): 20 | self.username = username 21 | super().__init__(msg = f"用户:{username} 的活动列表为空!!") 22 | 23 | -------------------------------------------------------------------------------- /utils/single.py: -------------------------------------------------------------------------------- 1 | """ 2 | 对单独账号的查询活动并抢活动 3 | """ 4 | import threading 5 | from utils.activity_bot import ActivityBot 6 | from utils.PUExceptions import ActivityIDsEmptyError 7 | from loguru import logger 8 | 9 | def single_account(user_data:dict): 10 | logger.info(f"开始处理用户 {user_data['userName']} 的报名请求") 11 | bot = ActivityBot(user_data) 12 | try: 13 | activity_ids = user_data.get('activity_ids',[]) # 获取用户要报名的所有活动id 14 | if not activity_ids: 15 | raise ActivityIDsEmptyError(user_data['userName']) 16 | logger.info(f"用户 {user_data['userName']} 需要报名的活动ID: {activity_ids}") 17 | bot.sync_server_time(activity_ids[0])# 同步服务器时间 18 | threads = [] 19 | for activity_id in activity_ids: 20 | logger.info(f"用户 {user_data['userName']} 创建活动 {activity_id} 的报名线程") 21 | thread = threading.Thread(target=bot.signup, args=(activity_id,)) 22 | threads.append(thread) 23 | thread.start() 24 | 25 | logger.info(f"用户 {user_data['userName']} 等待所有活动报名线程完成") 26 | for thread in threads: 27 | thread.join() 28 | logger.info(f"用户 {user_data['userName']} 所有活动报名线程已完成") 29 | except ActivityIDsEmptyError as e: 30 | logger.warning(f"用户 {user_data['userName']} 获取到的活动id为空,可能是因为没有可报名的活动,或者程序出现错误") 31 | logger.info(f"用户数据: {user_data}") 32 | return 33 | 34 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from utils.user_data_manager import UserDataManager 5 | from loguru import logger 6 | 7 | # 增加全局日志配置 8 | logger.remove() # 清掉默认配置 9 | 10 | # 1. 日志文件:只写 WARNING 和 ERROR 11 | logger.add( 12 | "logs/{time:YYYY-MM-DD}.log", 13 | rotation="00:00", 14 | retention="7 days", 15 | compression="zip", 16 | enqueue=True, 17 | encoding="utf-8", 18 | filter=lambda rec: rec["level"].no >= 30 # 30=WARNING 19 | ) 20 | 21 | # 2. 控制台:保持 INFO 及以上 22 | logger.add( 23 | sys.stdout, 24 | format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", 25 | level="INFO" 26 | ) 27 | 28 | def main(): 29 | user_data_file = 'user_data.json' 30 | user_manager = UserDataManager(user_data_file) 31 | os.makedirs("logs", exist_ok=True) 32 | 33 | if not user_manager.user_datas: 34 | logger.warning("未找到用户数据文件或用户数据为空,将创建新的用户数据") 35 | user_manager.user_datas = [] 36 | else: 37 | logger.info("加载用户数据成功") 38 | for user in user_manager.user_datas: 39 | logger.info(f"用户: {user['userName']}") 40 | 41 | add_user = input("是否新增用户 (y/n): ").strip().lower() 42 | if add_user == 'y': 43 | logger.info("开始添加新用户") 44 | user_manager.add_new_user() 45 | logger.info("新用户添加完成") 46 | 47 | 48 | logger.info("开始处理用户数据") 49 | for user in user_manager.user_datas: 50 | logger.info(f"处理用户: {user['userName']}") 51 | user_manager.process_user(user) 52 | logger.info("用户数据处理完成") 53 | 54 | logger.info("正在保存用户数据") 55 | user_manager.write_user_data() 56 | logger.info("用户数据保存完成") 57 | 58 | logger.info("开始处理用户报名任务") 59 | user_manager.sign_up() 60 | logger.info("所有用户任务处理完成") 61 | 62 | if __name__ == "__main__": 63 | main() 64 | -------------------------------------------------------------------------------- /utils/pu_sign.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import time 4 | import secrets 5 | import string 6 | from typing import Dict, Any 7 | 8 | from Crypto.Cipher import AES 9 | from Crypto.Util.Padding import pad, unpad 10 | from Crypto.Random import get_random_bytes 11 | 12 | # 固定 16 字节密钥 13 | PSK = bytes([121, 121, 0, 19, 5, 49, 2, 43, 13, 17, 11, 9, 4, 29, 60, 11]) 14 | 15 | 16 | def generate_random_echo(length: int = 16) -> str: 17 | """生成与 pu.js generateRandomString 等价的 62 字符随机串。""" 18 | alphabet = string.ascii_letters + string.digits 19 | return ''.join(secrets.choice(alphabet) for _ in range(length)) 20 | 21 | 22 | def current_timestamp_str() -> str: 23 | """当前时间戳(秒)字符串。""" 24 | return str(int(time.time())) 25 | 26 | 27 | def _aes_cbc_encrypt_pkcs7(plaintext_bytes: bytes, key: bytes, iv: bytes) -> bytes: 28 | cipher = AES.new(key, AES.MODE_CBC, iv=iv) 29 | return cipher.encrypt(pad(plaintext_bytes, AES.block_size)) 30 | 31 | 32 | def _aes_cbc_decrypt_pkcs7(ciphertext_bytes: bytes, key: bytes, iv: bytes) -> bytes: 33 | cipher = AES.new(key, AES.MODE_CBC, iv=iv) 34 | return unpad(cipher.decrypt(ciphertext_bytes), AES.block_size) 35 | 36 | 37 | def encrypt_payload_to_n(payload: Dict[str, Any], iv: bytes | None = None) -> str: 38 | """ 39 | 将明文 payload(字典)加密为 n(base64)。 40 | - 算法:AES-128-CBC + PKCS7;n = base64( IV(16) + CIPHER ) 41 | - 若未提供 iv,则生成随机 16 字节 IV。 42 | """ 43 | if iv is None: 44 | iv = get_random_bytes(16) 45 | if len(iv) != 16: 46 | raise ValueError("IV 必须是 16 字节") 47 | 48 | plaintext = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") 49 | ciphertext = _aes_cbc_encrypt_pkcs7(plaintext, PSK, iv) 50 | combined = iv + ciphertext 51 | return base64.b64encode(combined).decode("ascii") 52 | 53 | 54 | def generate_x_sign(echo: str | None = None, timestamp: str | None = None, client: str = "web", 55 | iv: bytes | None = None) -> str: 56 | """ 57 | 生成 X-Sign: 58 | - echo:默认随机 16 位字母数字 59 | - timestamp:默认当前秒 60 | - client:默认 'web' 61 | - iv:可传固定 16 字节;不传则随机 62 | 返回:n(base64) 63 | """ 64 | if echo is None: 65 | echo = generate_random_echo() 66 | if timestamp is None: 67 | timestamp = current_timestamp_str() 68 | payload = {"echo": echo, "timestamp": timestamp, "client": client} 69 | return encrypt_payload_to_n(payload, iv=iv) 70 | -------------------------------------------------------------------------------- /utils/headers.py: -------------------------------------------------------------------------------- 1 | HEADERS_ACTIVITY_INFO = { 2 | # "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0", 3 | # "Accept": "application/json, text/plain, */*", 4 | # "Accept-Language": "en-US,en;q=0.5", 5 | # "Accept-Encoding": "gzip, deflate, br", 6 | # "Content-Type": "application/json", 7 | # "Authorization": "", 8 | # "Origin": "https://class.pocketuni.net", 9 | # "Connection": "keep-alive", 10 | # "Referer": "https://class.pocketuni.net/", 11 | # "Sec-Fetch-Dest": "empty", 12 | # "Sec-Fetch-Mode": "cors", 13 | # "Sec-Fetch-Site": "same-site", 14 | # "Pragma": "no-cache", 15 | # "Cache-Control": "no-cache" 16 | "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.87 Safari/537.36", 17 | "Content-Type": "application/json", 18 | "Accept": "application/json, text/plain, */*" 19 | } 20 | 21 | HEADERS_ACTIVITY = { 22 | # "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0", 23 | # "Accept": "application/json, text/plain, */*", "Accept-Language": "en-US,en;q=0.5", 24 | # "Accept-Encoding": "gzip, deflate, br", "Content-Type": "application/json", 25 | # "Authorization": "", 26 | # "Origin": "https://class.pocketuni.net", "Connection": "keep-alive", 27 | # "Referer": "https://class.pocketuni.net/", "Sec-Fetch-Dest": "empty", 28 | # "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-site", "Pragma": "no-cache", 29 | # "Cache-Control": "no-cache" 30 | "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.87 Safari/537.36", 31 | "Content-Type": "application/json", 32 | "Accept": "application/json, text/plain, */*" 33 | } 34 | 35 | HEADERS_LOGIN = { 36 | # "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0", 37 | # "Accept": "application/json, text/plain, */*", 38 | # "Accept-Language": "en-US,en;q=0.5", 39 | # "Accept-Encoding": "gzip, deflate, br", 40 | # "Content-Type": "application/json; charset=utf-8", 41 | # "Origin": "https://class.pocketuni.net", 42 | # "Connection": "keep-alive", 43 | # "Referer": "https://class.pocketuni.net/", 44 | # "Sec-Fetch-Dest": "empty", 45 | # "Sec-Fetch-Mode": "cors", 46 | # "Sec-Fetch-Site": "same-site", 47 | # "Pragma": "no-cache", 48 | # "Cache-Control": "no-cache" 49 | "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.87 Safari/537.36", 50 | "Content-Type": "application/json", 51 | "Accept": "application/json, text/plain, */*" 52 | } 53 | 54 | HEADERS_GET_SCHOOL = { 55 | # 'sec-ch-ua':'"Chromium";v="124", "Microsoft Edge";v="124", "Not-A.Brand";v="99"', 56 | # 'sec-ch-ua-mobile':'?0', 57 | # 'sec-ch-ua-platform':'"Windows"', 58 | # 'Upgrade-Insecure-Requests':'1', 59 | # 'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0', 60 | # 'Accept':'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', 61 | # 'Sec-Fetch-Site':'same-origin', 62 | # 'Sec-Fetch-Mode':'navigate', 63 | # 'Sec-Fetch-User':'?1', 64 | # 'Sec-Fetch-Dest':'document', 65 | # 'Referer':'https://pocketuni.net/', 66 | # 'Accept-Encoding':'gzip, deflate, br, zstd', 67 | # 'Accept-Language':'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6', 68 | "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.87 Safari/537.36", 69 | "Content-Type": "application/json", 70 | "Accept": "application/json, text/plain, */*" 71 | } -------------------------------------------------------------------------------- /utils/user_data_manager.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from concurrent.futures import ThreadPoolExecutor 4 | from typing import Dict 5 | 6 | from utils.single import single_account 7 | from loguru import logger 8 | 9 | 10 | class UserDataManager: 11 | def __init__(self, file_path): 12 | self.file_path = file_path 13 | self.user_datas = self.read_user_data() 14 | 15 | def read_user_data(self): 16 | """ 17 | 读取用户数据 18 | :return: 用户数据 or None 19 | """ 20 | logger.info("开始加载用户数据") 21 | if not os.path.exists(self.file_path): 22 | logger.warning("未找到用户数据文件,请检查文件路径是否正确") 23 | return None 24 | 25 | with open(self.file_path, 'r') as file: 26 | try: 27 | data = json.load(file) 28 | if not data: 29 | logger.warning("用户数据为空") 30 | return None 31 | return data 32 | except json.JSONDecodeError: 33 | logger.error("用户数据格式解析错误") 34 | return None 35 | 36 | def write_user_data(self) -> None: 37 | """ 38 | 写入用户数据 39 | :return: None 40 | """ 41 | with open(self.file_path, 'w', encoding='utf-8') as file: 42 | json.dump(self.user_datas, file, indent=4) 43 | 44 | def add_new_user(self): 45 | """ 46 | 添加新用户 47 | :return: None 48 | """ 49 | logger.info("开始添加新用户") 50 | 51 | from utils.tools import get_sid 52 | sid = get_sid() 53 | 54 | if sid is None: 55 | logger.error("获取学校SID失败,无法添加用户") 56 | return 57 | 58 | 59 | user_name = input("请输入userName: ") 60 | password = input("请输入password: ") 61 | 62 | new_user = { 63 | 'userName': user_name, 64 | 'password': password, 65 | 'sid': sid, 66 | 'device': 'pc', 67 | 'activity_ids': [], # 想要报名的活动id, 自动获取 68 | 'categorys': [],# 想要报名的类别id 69 | 'oids':[], # 想要报名的阻止id 70 | 'cids':[], # 想要报名的院系id 71 | 'allowYears':[] # 想要报名的参与年级 72 | } 73 | 74 | token = "" 75 | from utils.tools import get_token 76 | for i in range(3): 77 | logger.info("正在尝试登录...") 78 | token = get_token(new_user) 79 | if token: 80 | break 81 | else: 82 | logger.error("登录失败,请检查用户名密码是否正确") 83 | logger.info(f'您还有{2-i}次重试') 84 | new_user['userName'] = input("请重新输入userName: ") 85 | new_user['password'] = input("请重新输入password: ") 86 | 87 | if not token: 88 | logger.error("多次登录失败,请检查用户名密码是否正确。") 89 | logger.info("或者请去官网重置用户密码") 90 | return 91 | 92 | new_user["college"] = input("请输入您所在院系的名称,务必保证为全名(如经济管理学院、计算机科学与工程学院...): ") 93 | email = input("如果您想在报名成功后通知您,请输入您的邮箱: ") 94 | new_user["email"] = email if email else "" 95 | self.user_datas.append(new_user) 96 | logger.info(f"新用户添加成功: {new_user.get('userName')}") 97 | 98 | def process_user(self,user : Dict) -> None: 99 | """ 100 | 处理用户报名信息数据,获取用户预报名信息并获取筛选后的活动列表 101 | :return: None 102 | """ 103 | logger.info(f"开始处理用户{user.get('userName')}报名信息数据") 104 | from utils.tools import get_token 105 | token = get_token(user) 106 | if not token: 107 | logger.error("获取token失败,请检查用户名密码是否正确") 108 | return 109 | user['token'] = token 110 | 111 | flag = input(f"是否为用户{user.get('userName')}获取活动列表? [y/n]") 112 | if flag == 'y': 113 | from utils.tools import get_allowed_activity_list, filter_activity_type 114 | flag = input(f"是否为用户{user.get('userName')}获取指定类型的活动列表? [y/n]") 115 | activity_ids = [] 116 | if flag == 'y': 117 | filter_activity_type( user) 118 | activities = get_allowed_activity_list(user) 119 | else: 120 | activities = get_allowed_activity_list(user) 121 | 122 | print(f"共找到了{len( activities)}个满足需求的活动,以下是详细信息:") 123 | for i, activity in enumerate(activities): 124 | print(f"{i+1}: ") 125 | for key, value in activity.items(): 126 | print(f"{key}: {value}") 127 | if input("是否添加该活动? [y/n]") == 'y': 128 | activity_ids.append(activity.get('activity_id')) 129 | 130 | user['activity_ids'] = activity_ids 131 | logger.info(f"用户{user.get('userName')}处理完毕") 132 | 133 | 134 | def sign_up(self): 135 | """ 136 | 处理用户报名 137 | :return: None 138 | """ 139 | logger.info("开始处理用户报名任务") 140 | with ThreadPoolExecutor() as executor: 141 | futures = [] 142 | for user in self.user_datas: 143 | 144 | futures.append(executor.submit(single_account, user)) 145 | for future in futures: 146 | future.result() # 等待所有线程完成 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PU-SignUpBot 2 | 3 | --- 4 | 5 | ## What's new 6 | 7 | 1. 增加邮件通知功能~~pu口袋校园的移动端广告太多了,查看报名是否成功时一不小心就点到,故开发此功能~~ 8 | 2. 增加自动获取活动id的功能。可以自定义筛选条件,快速简洁地获得满足用户筛选需求的所有活动,无需手动复制id 9 | 3. 优化用户存储逻辑,实现每个用户可以根据自己的activity_ids报名。 10 | 4. 优化报名策略: 11 | - 报名前先与服务器时间同步。 12 | - 持续监控活动信息,可以在一定程度上避免因活动时间变更而无法及时报名的问题。⚠️注意:此功能可以在config里配置,间隔时间越短越准确,同样封号或封ip的可能性也更大,非必要不建议更改 13 | - 报名时采用三段式策略,每个快速线程发送5个报名请求。第一轮快速启动5个线程;第二轮每0.4秒启动一个一个线程,总计启动15个;第三轮每0。8秒启动一个线程,总计45个。 14 | - 如果60秒内该活动为报名成功,说明活动人数已满,报名失败,结束该报名线程 15 | 16 | --- 17 | 18 | ## ⚠️ 使用前要知道的事 ⚠️ 19 | 20 | **网络要求**:需要稳定的网络,不能使用外网环境 21 | 22 | **账号安全**:只在可信的电脑上使用,注意保护 `user_data` 用户数据的安全 23 | 24 | --- 25 | 26 | ## 🚀 快速开始(5分钟搞定) 27 | 28 | ### 第1步:下载工具 29 | 30 | **方法A - 直接下载(推荐新手)** : 31 | 32 | 1. 点击页面上绿色的 `Code` 按钮 33 | 2. 选择 `Download ZIP` 34 | 3. 解压到你电脑的任意文件夹 35 | 36 | **方法B - 用Git下载(适合有基础的用户)** : 37 | 38 | ```bash 39 | git clone -b refactor/core-and-add-features https://github.com/yiqjffeng/PU-SignUpBot.git 40 | ``` 41 | 42 | --- 43 | 44 | ### 第2步:安装运行环境(两种方法,推荐用uv更简单) 45 | 46 | 1. 打开下载的文件夹 47 | 2. 在空白处点击右键,选择 `在终端中打开` 48 | 49 | #### 方法一:用uv安装(推荐,最简单) 50 | 51 | **什么是uv?** 这是一个超简单的Python环境管理工具,比传统pip快很多! 52 | 53 | 如果你没有uv,可以先用pip安装: 54 | 55 | 1. **先安装uv**: 56 | - 打开终端 57 | - 输入: 58 | ```bash 59 | pip install uv 60 | ``` 61 | 62 | 2. **用uv安装依赖**: 63 | - 进入你下载的项目文件夹 64 | - 在终端输入: 65 | ```bash 66 | uv sync 67 | ``` 68 | - 等待完成就行啦! 69 | 70 | **然后激活虚拟环境**: 71 | 72 | - Windows用户: 73 | ```bash 74 | .venv/Scripts/activate 75 | ``` 76 | - Mac/Linux用户: 77 | ```bash 78 | source .venv/bin/activate 79 | ``` 80 | 81 | #### 方法二:用传统pip安装 82 | 83 | > [!NOTE] 84 | > 85 | > 如果您是第一次使用pip,且没有科学上网的环境,可能会遇到网络连接的错误或者下载速度很慢很慢。 86 | > 87 | > 这时建议您配置国内的镜像仓库,[请参考这里](https://www.runoob.com/w3cnote/pip-cn-mirror.html) 88 | > 89 | > 使用uv的小伙伴不需要在意,我已经在项目里配置好了uv的国内镜像源 90 | 91 | **Windows用户看这里** : 92 | 93 | 依次输入以下命令: 94 | ```bash 95 | python -m venv .venv 96 | .venv\Scripts\activate 97 | pip install -r requirements.txt 98 | ``` 99 | 100 | **Mac/Linux用户看这里** : 101 | 102 | 1. 打开终端,进入本项目文件夹(如果你没有重命名应该是PU-SignUpBot) 103 | 2. 依次输入以下命令: 104 | ```bash 105 | python -m venv .venv 106 | source .venv/bin/activate 107 | pip install -r requirements.txt 108 | ``` 109 | 110 | #### 为什么推荐uv? 111 | 112 | - **速度超快**:安装依赖比pip快好几倍 113 | - **一键搞定**:一个命令就能装好所有东西 114 | - **自动管理**:自动帮你处理环境配置 115 | - **节省空间**:占用更少的硬盘空间 116 | 117 | --- 118 | 119 | ### 第3步:运行工具 120 | 121 | 在刚才的终端里输入: 122 | ```bash 123 | python main.py 124 | ``` 125 | 126 | --- 127 | 128 | ## 详细使用教程 129 | 130 | ### 第一步:添加你的PU账号 131 | 132 | 运行程序后会问你是否要添加新用户,输入 `y` 然后按提示填写: 133 | 134 | 1. **学校名称**:输入你学校的完整名字(比如"山东科技大学") 135 | 2. **PU用户名**:你在PU口袋校园的登录账号 ~~通常为学号~~ 136 | 3. **PU密码**:你的登录密码 137 | 4. **院系名称**:你就读的院系,请务必使用你所在院系的全称(比如"经济管理学院") 138 | 5. **邮箱地址**(可选):填写后报名结果会发邮件通知你 139 | 140 | **小贴士**:信息一定要填准确,错了会导致报名失败哦! 141 | 142 | --- 143 | 144 | ### 第二步:找你想报名的活动 145 | 146 | 程序会问你要不要获取活动列表,输入 `y` 后: 147 | 148 | 1. 先选择要不要用高级筛选(推荐用,可以精准找到想要的活动) 149 | 150 | 2. 如果选高级筛选,会问你: 151 | - 想参加什么类型的活动(如思想政治与道德修养、文化艺术与身心发展等),y是添加,n是不添加。示例如下: 152 | ```txt 153 | 当前类型: 活动分类 154 | 需要筛选此类型的活动吗?[y/n]y 155 | 0: 156 | 名称:思想政治与道德修养 157 | 是否添加至筛选? [y/n] n 158 | 1: 159 | 名称:社会实践与志愿服务 160 | 是否添加至筛选? [y/n] n 161 | 2: 162 | 名称:文化艺术与身心发展 163 | 是否添加至筛选? [y/n] n 164 | 3: 165 | 名称:学术科技与创新创业 166 | 是否添加至筛选? [y/n] n 167 | 4: 168 | 名称:社会工作与技能拓展 169 | 是否添加至筛选? [y/n] n 170 | 该类型已添加完毕。 171 | ``` 172 | 173 | 174 | 175 | > [!WARNING] 176 | > 177 | > 注意,每个学校的活动类型名称可能不一样,作者只有山东科技大学的pu账号,无法知晓其他学校的活动类型信息,故此方面可能有错误。 178 | > 179 | > 如果遇到错误请再issue里提出并附上日志,作者会尽快修复。 180 | 181 | - 你的年级(23、24、25等),程序会列出来选择你对应年级的索引即可。索引从0开始位于左上角,示例如下: 182 | 183 | ```txt 184 | 当前类型: 参与年级 185 | 0: 186 | 年级:25 187 | 1: 188 | 年级:24 189 | 2: 190 | 年级:23 191 | 3: 192 | 年级:22 193 | 4: 194 | 年级:21 195 | 5: 196 | 年级:20 197 | 6: 198 | 年级:19 199 | 7: 200 | 年级:18 201 | 请选择您的年级:2 202 | ``` 203 | 204 | 205 | 206 | - 想参加哪个院系举办的活动 和上方添加活动类型相似,故不再例举 207 | 208 | 3. 程序会列出所有符合条件的活动,每个活动会显示: 209 | - 活动名称 210 | - 开始报名时间 211 | - 活动分类 212 | - 举办组织 213 | - 能获得的分数 214 | 215 | 4. 看到想报名的活动就输入 `y` 添加到列表 216 | 217 | > [!TIP] 218 | > 本项目任然保留了旧版手动填写活动id的功能。 219 | > 请在 `user_data.json` 中找到你的信息,然后在 `"activity_ids"` 后方的 `[]` 中添加活动id。 220 | > 多个活动id之间用英文逗号隔开。`,` 221 | > 222 | > 223 | 224 | > [!NOTE] 225 | > 注意活动时间和你的课表等活动不要冲突哦! 226 | 227 | --- 228 | 229 | ### 第三步:坐等自动报名 230 | 231 | 添加完活动后,程序会自动: 232 | 233 | **提前准备** - 活动实时监控,避免主办方更改时间导致报名失败 234 | 235 | **精准计时** - 自动对时,确保报名时机分毫不差 236 | 237 | **快速报名** - 活动一开始就用多个线程同时帮你报名 238 | 239 | **及时通知** - 报名成功会发邮件告诉你好消息 240 | 241 | > [!IMPORTANT] 242 | > 在程序运行报名期间,请确保电脑正常运行,不要关机或者休眠。 243 | > 因为这样可能会终止或暂停线程,导致报名失败哦。💔 244 | 245 | --- 246 | 247 | ## 邮件通知设置(可选但推荐) 248 | 249 | 电脑不在身边,不想打开app查看结果~~广告太多了,一不小心就给我下个赖安装包🙄~~,但想要即时收到报名结果通知?跟着下面的步骤设置: 250 | 251 | 1. 在工具文件夹里新建一个文件,名字叫 `.env` 252 | 2. 打开这个文件,输入以下内容: 253 | 254 | ``` 255 | INFO_EMAIL_SERVER=smtp.qq.com 256 | INFO_EMAIL_PORT=465 257 | INFO_EMAIL_HOST=你的QQ邮箱@qq.com 258 | INFO_EMAIL_SMTP_PASS=你的QQ邮箱授权码 259 | ``` 260 | 261 | **怎么获取QQ邮箱授权码?** 262 | 263 | 1. 登录你的[QQ邮箱]([登录QQ邮箱](https://mail.qq.com/)) 264 | 2. 点击`设置` → `账户` 265 | 3. 找到`POP3/SMTP服务`,点击`开启` 266 | 4. 按提示发送短信,就能获得授权码了 267 | 268 | **其他邮箱设置** : 269 | 270 | - **163邮箱**:服务器改成 `smtp.163.com` 271 | - 其余的邮箱请自行登录在设置里查看smtp服务器地址和端口号 272 | 273 | ## 自定义配置 274 | 275 | - 如果你想修改报名策略:先进入`/utils/activity_bot.py`,然后找到`_start_signup_threads`(启动发送报名请求线程)和`_signup_worker`(发送报名请求),根据注释信息适当进行修改。 276 | 277 | - 邮箱启用开关在根目录下的`config.py`里。 278 | 279 | - 如果你想调整活动监控时间,任然先进入`/utils/activity_bot.py`,找到`_monitor_start_time`函数,修改`min_minutes`和`max_minutes`参数。 280 | 281 | --- 282 | 283 | ## 报名成功秘诀 284 | 285 | ### 提高成功率的小技巧: 286 | 287 | 1. **网络要稳定** - 避免使用外网,同时确保网络稳定 288 | > [!TIP] 289 | > 如果不知道是否畅通,请登录 **[PU口袋校园官网](https://class.pocketuni.net/)** 进行验证 290 | 291 | 2. **时间要准确** - 工具会自动对时,但电脑时间也要设置正确 292 | 3. **信息要准确** - 学校、院系等信息一定要填对 293 | 4. **提前添加活动** - 不要等到最后一刻才设置 294 | 295 | --- 296 | 297 | ### 常见问题和解决办法: 298 | 299 | **Q: 登录失败了怎么办?** 300 | A: 检查用户名密码对不对,确认能在PU网页正常登录,检查网络环境 301 | 302 | **Q: 找不到想报名的活动?** 303 | A: 检查筛选条件是不是太严格了,试试放宽条件 304 | 305 | **Q: 报名失败了怎么办?** 306 | A: 可能是名额满了,或者不符合参加条件 307 | 308 | **Q: 收不到邮件通知?** 309 | A: 检查邮箱设置对不对,看看logs里的错误日志信息,检查垃圾邮件文件夹 310 | 311 | --- 312 | 313 | ## 安全使用指南 314 | 315 | ✅ **可以做的**: 316 | - 在自己的电脑或者服务器上使用 317 | - 帮同学朋友报名(经过他们同意) 318 | - 合理使用,不要恶意刷活动 319 | 320 | > [!WARNING] 321 | > 由于账密是明文存储的,泄露可能导致账号封禁、身份信息泄露等一系列后果。 322 | > **请务必在相信的环境下使用** 🔐 323 | 324 | ❌ **不能做的**: 325 | - 不要在公共电脑上保存账号密码 🚫 326 | - 不要泄露他人的账号信息 🚫 327 | - 不要用来干扰正常的活动报名秩序 🧨 328 | 329 | --- 330 | 331 | ## 遇到问题怎么办? 332 | 333 | 1. **先看看常见问题** - 上面的解决办法能解决大部分问题 334 | 2. **检查网络连接** - 确保网络正常,能访问PU口袋校园 335 | 3. **重新运行试试** - 有时候重新启动就能解决问题 336 | 4. **寻求帮助** - 可以在GitHub上提交问题,大家一起解决 337 | 338 | --- 339 | 340 | **祝您大学生活不再受PU困扰!** -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 3 3 | requires-python = ">=3.13" 4 | 5 | [[package]] 6 | name = "certifi" 7 | version = "2025.10.5" 8 | source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" } 9 | sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } 10 | wheels = [ 11 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, 12 | ] 13 | 14 | [[package]] 15 | name = "charset-normalizer" 16 | version = "3.4.4" 17 | source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" } 18 | sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } 19 | wheels = [ 20 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, 21 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, 22 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, 23 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, 24 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, 25 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, 26 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, 27 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, 28 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, 29 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, 30 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, 31 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, 32 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, 33 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, 34 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, 35 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, 36 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, 37 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, 38 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, 39 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, 40 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, 41 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, 42 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, 43 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, 44 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, 45 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, 46 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, 47 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, 48 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, 49 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, 50 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, 51 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, 52 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, 53 | ] 54 | 55 | [[package]] 56 | name = "colorama" 57 | version = "0.4.6" 58 | source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" } 59 | sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 60 | wheels = [ 61 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 62 | ] 63 | 64 | [[package]] 65 | name = "idna" 66 | version = "3.11" 67 | source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" } 68 | sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } 69 | wheels = [ 70 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, 71 | ] 72 | 73 | [[package]] 74 | name = "loguru" 75 | version = "0.7.3" 76 | source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" } 77 | dependencies = [ 78 | { name = "colorama", marker = "sys_platform == 'win32'" }, 79 | { name = "win32-setctime", marker = "sys_platform == 'win32'" }, 80 | ] 81 | sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } 82 | wheels = [ 83 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, 84 | ] 85 | 86 | [[package]] 87 | name = "pu-signupbot" 88 | version = "0.1.0" 89 | source = { virtual = "." } 90 | dependencies = [ 91 | { name = "loguru" }, 92 | { name = "python-dotenv" }, 93 | { name = "requests" }, 94 | ] 95 | 96 | [package.metadata] 97 | requires-dist = [ 98 | { name = "loguru", specifier = ">=0.7.3" }, 99 | { name = "python-dotenv", specifier = ">=1.1.1" }, 100 | { name = "requests", specifier = ">=2.32.5" }, 101 | ] 102 | 103 | [[package]] 104 | name = "python-dotenv" 105 | version = "1.1.1" 106 | source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" } 107 | sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } 108 | wheels = [ 109 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, 110 | ] 111 | 112 | [[package]] 113 | name = "requests" 114 | version = "2.32.5" 115 | source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" } 116 | dependencies = [ 117 | { name = "certifi" }, 118 | { name = "charset-normalizer" }, 119 | { name = "idna" }, 120 | { name = "urllib3" }, 121 | ] 122 | sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } 123 | wheels = [ 124 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, 125 | ] 126 | 127 | [[package]] 128 | name = "urllib3" 129 | version = "2.5.0" 130 | source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" } 131 | sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } 132 | wheels = [ 133 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, 134 | ] 135 | 136 | [[package]] 137 | name = "win32-setctime" 138 | version = "1.2.0" 139 | source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" } 140 | sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } 141 | wheels = [ 142 | { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, 143 | ] 144 | -------------------------------------------------------------------------------- /utils/tools.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | import requests 4 | from loguru import logger 5 | from typing import Dict, List 6 | 7 | from utils.headers import HEADERS_GET_SCHOOL, HEADERS_ACTIVITY 8 | 9 | 10 | def get_token(userData: Dict) -> str | None: 11 | """ 12 | 登录获取token 13 | :return: token | None 14 | """ 15 | import requests 16 | try: 17 | logger.info(f"用户 {userData['userName']} 开始登录") 18 | from utils.headers import HEADERS_LOGIN 19 | 20 | login_url = "https://apis.pocketuni.net/uc/user/login" 21 | payload = { 22 | "userName": userData['userName'], 23 | "password": userData['password'], 24 | 'sid': int(userData.get("sid")), 25 | "device": "pc", 26 | } 27 | response = requests.post(login_url, headers=HEADERS_LOGIN, json=payload) 28 | response.raise_for_status() 29 | 30 | token = response.json().get("data", {}).get("token") 31 | 32 | if token: 33 | logger.info(f"用户 {userData['userName']} 登录成功,Token: {token}") 34 | return token 35 | else: 36 | logger.error(f"用户 {userData['userName']} 获取Token失败,响应: {response.text}") 37 | raise ValueError("Token获取失败") 38 | except requests.exceptions.HTTPError as e: 39 | logger.error(f"用户 {userData['userName']} 登录失败,HTTP错误: {str(e)}") 40 | return None 41 | except requests.exceptions.ConnectionError as e: 42 | logger.error(f"用户 {userData['userName']} 登录失败,网络错误: {str(e)},请检查是否是国内的网络环境") 43 | return None 44 | except Exception as e: 45 | logger.error(f"用户 {userData['userName']} 登录失败,未知错误: {str(e)}") 46 | return None 47 | 48 | 49 | def get_sid() -> int | None: 50 | """ 51 | 获取用户sid,即学校id 52 | :return: 如果匹配返回sid,否则返回None 53 | """ 54 | logger.info("开始获取学校SID") 55 | 56 | def get_school_list() -> Dict: 57 | """ 58 | 获取学校列表 59 | :return: 所有学校列表 60 | """ 61 | url = 'https://pocketuni.net/index.php?app=api&mod=Sitelist&act=getSchools' 62 | response = requests.get(url, headers=HEADERS_GET_SCHOOL) 63 | return response.json() 64 | 65 | def find_schools(school_list, school_name) -> List[Dict]: 66 | """ 67 | 根据学校名称查找学校 68 | :param school_list: 学校列表 69 | :param school_name: 学校名称 70 | :return: 匹配的学校列表 71 | """ 72 | matching_schools = [school for school in school_list if school_name in school['name']] 73 | return matching_schools 74 | 75 | school_name = input("请输入学校全称:") 76 | for _ in range(3): 77 | school_list = get_school_list() 78 | matching_schools = find_schools(school_list, school_name) 79 | 80 | if not matching_schools: 81 | logger.warning("未找到匹配的学校。") 82 | school_name = input("请重新输入学校全称:") 83 | continue 84 | 85 | if len(matching_schools) == 1: 86 | selected_school = matching_schools[0] 87 | logger.info(f"自动选择学校: {selected_school['name']}") 88 | else: 89 | logger.info(f"找到 {len(matching_schools)} 个匹配的学校,请选择:") 90 | for i, school in enumerate(matching_schools, start=1): 91 | print(f"{i}. {school['name']}") 92 | choice = int(input("请输入选择的序号:")) - 1 93 | selected_school = matching_schools[choice] 94 | logger.info(f"用户选择学校: {selected_school['name']}") 95 | 96 | sid = int(selected_school['go_id']) 97 | logger.info(f"获取学校SID成功: {sid}") 98 | return sid 99 | else: 100 | logger.error("获取学校SID失败,请检查网络或重新运行程序。") 101 | return None 102 | 103 | 104 | def get_activity_type(token: str, sid: str) -> List | None: 105 | """ 106 | 获取本学校的活动类型 107 | :param token: 用户当前会话token 108 | :param sid: 用户学校id 109 | :return: 用户学校的活动类型信息 110 | """ 111 | logger.info("开始获取本学校的活动类型") 112 | type_url = "https://apis.pocketuni.net/apis/mapping/data" 113 | payload = { 114 | "key": "eventFilter", 115 | "puType": 0 116 | } 117 | headers = HEADERS_ACTIVITY.copy() 118 | headers['Authorization'] = f"Bearer {token}:{sid}" 119 | try: 120 | response = requests.post(type_url, headers=headers, json=payload) 121 | response.raise_for_status() 122 | res = [] 123 | data = response.json().get("data", {}).get("list", []) 124 | for d in data: 125 | if d.get("name","未知") in ["活动分类","参与年级","归属院系"]: 126 | res.append(d) 127 | return res 128 | except requests.exceptions.HTTPError as e: 129 | logger.error(f"获取活动类型失败,HTTP错误: {str(e)}") 130 | return None 131 | except Exception as e: 132 | logger.error(f"获取活动类型失败,未知错误: {str(e)}") 133 | return None 134 | 135 | 136 | def get_info(activity_id : str, token : str, sid : str): 137 | """ 138 | 获得单个活动的详细信息 139 | :param activity_id: 活动id 140 | :return: 当前id活动的详细信息 141 | """ 142 | headers = HEADERS_ACTIVITY.copy() 143 | headers['Authorization'] = f"Bearer {token}" + ":" + str(sid) 144 | payload = {"id": int(activity_id)} 145 | try: 146 | response = requests.post("https://apis.pocketuni.net/apis/activity/info", headers=headers, json=payload) 147 | response.raise_for_status() 148 | if response.status_code != 200: 149 | logger.error(f"获取活动信息失败,响应: {response.text}") 150 | return {} 151 | except requests.exceptions.HTTPError as e: 152 | logger.error(f"获取活动信息失败,HTTP错误: {str(e)}") 153 | return {} 154 | return response.json().get("data", {}).get("baseInfo", {}) 155 | 156 | def get_single_activity(activity_id : str, info : Dict): 157 | """ 158 | 筛选获取单个活动的信息 159 | :param activity_id: 活动id 160 | :param info: 当前活动的详细信息 161 | :return: 162 | """ 163 | logger.info(f"正在解析活动 {activity_id} 的信息") 164 | a = {"activity_id": activity_id, "分数": info.get("credit"), 165 | "活动分类": info.get("categoryName"), "举办组织": info.get("creatorName"), 166 | "活动名称": info.get("name"), "开始报名时间": info.get("joinStartTime"), 167 | "活动开始时间": info.get("startTime"), "活动结束时间": info.get("endTime"), 168 | "活动地址": info.get("address"), "可报名人数": info.get("allowUserCount") - info.get("joinUserCount")} 169 | logger.info(f"活动{activity_id} 的信息为解析完成") 170 | return a 171 | 172 | def get_allowed_activity_list(user : Dict) -> List: 173 | """ 174 | 获取满足用户筛选需求的活动 175 | 176 | :return: 满足要求的活动id列表 177 | """ 178 | logger.info("开始获取满足用户筛选条件的活动") 179 | activity_url = "https://apis.pocketuni.net/apis/activity/list" 180 | headers = HEADERS_ACTIVITY.copy() 181 | headers['Authorization'] =f"Bearer {user.get('token')}" + ":" + str(user.get("sid")) 182 | payload = { 183 | "page": 1, 184 | "limit":20, 185 | "sort": 0, 186 | "puType": 0, 187 | "status": 1, # 1未开始,2进行中,3已结束 188 | "isAudit":[0] # 0不需要审核,1需要审核 189 | } 190 | 191 | categorys = user.get("categorys",[]) 192 | if categorys: 193 | payload['categorys'] = categorys 194 | allowYears = user.get("allowYears",[]) 195 | if allowYears: 196 | payload['allowYears'] = allowYears 197 | oids = user.get("oids",[]) 198 | if oids: 199 | payload['oids'] = oids 200 | 201 | logger.info(f"正在获取满足用户{user.get('userName')}筛选条件的活动,请求参数: {payload}") 202 | try: 203 | response = requests.post(activity_url, headers=headers, json=payload) 204 | response.raise_for_status() 205 | except requests.exceptions.HTTPError as e: 206 | logger.error(f"获取活动列表失败,HTTP错误: {str(e)}") 207 | return [] 208 | except Exception as e: 209 | logger.error(f"获取活动列表失败,未知错误: {str(e)}") 210 | return [] 211 | 212 | def is_valid(info : Dict, college : str) -> bool: 213 | """ 214 | 判断当前活动是否满足用户筛选条件 215 | :param info: 当前活动的详细信息 216 | :return: True | False 217 | """ 218 | if info.get("allowUserCount") - info.get("joinUserCount") <= 0: 219 | return False 220 | if info.get("allowTribe"): # 如果有allowTribe(活动部落)直接返回,这种是指定班级的,不需要抢 221 | return False 222 | # 虽然在请求时已经指定了状态为1,但是返回活动任然可能不是未开始,所以需要再次判断 223 | if not info.get("statusName") == '未开始': 224 | return False 225 | if info.get("allowCollege") and not college in [t.get("name") for t in info.get("allowCollege")]: 226 | return False 227 | return True 228 | 229 | try: 230 | pages = int(response.json().get('data').get('pageInfo').get("total",0)) 231 | except Exception as e: 232 | logger.error(f"获取活动列表失败,返回的数据格式错误: {str(e)}") 233 | return [] 234 | activity_list = [] 235 | try: 236 | for page in range(1, pages+1): 237 | payload['page'] = page 238 | response = requests.post(activity_url, headers=headers, json=payload) 239 | response.raise_for_status() 240 | for activity in response.json().get("data", {}).get("list", []): 241 | info = get_info(activity.get("id"), user.get('token'), user.get('sid')) 242 | if not is_valid(info,user.get("college")): 243 | continue 244 | activity_list.append(get_single_activity(activity.get("id"), info)) 245 | 246 | time.sleep(0.5 + random.random() * (2 - 0.5)) 247 | except requests.exceptions.HTTPError as e: 248 | logger.error(f"获取活动列表失败,HTTP错误: {str(e)}") 249 | return [] 250 | except Exception as e: 251 | logger.error(f"获取活动列表失败,未知错误: {str(e)}") 252 | return [] 253 | 254 | logger.info(f"获取满足用户筛选条件的活动成功,共有{len(activity_list)}个活动") 255 | return activity_list 256 | 257 | def filter_activity_type(user : Dict) -> None: 258 | """ 259 | 获取用户需要筛选的活动类型 260 | :param user: 261 | :return: None 262 | """ 263 | activity_types = get_activity_type(token=user.get('token'), sid=user['sid']) 264 | 265 | for activity_type in activity_types: 266 | print(f"当前类型: {activity_type.get('name', '未知')}") 267 | key = activity_type.get('key') 268 | if not activity_type.get("infoList",[]): 269 | logger.warning(f"当前类型: {activity_type.get('name', '未知')} 没有信息") 270 | logger.warning(f"将使用默认信息") 271 | user[key] = [] 272 | continue 273 | # 指定参与年级 274 | if key == 'allowYears': 275 | for i,info in enumerate(activity_type.get('infoList', [])): 276 | print(f"{i}:\n 年级:{info.get('name', '未知')}") 277 | year = input("请选择您的年级:") 278 | user[key].append(activity_type.get('infoList')[int(year)].get('id')) 279 | continue 280 | 281 | f = input(f"需要筛选此类型的活动吗?[y/n]") 282 | if f == 'n': 283 | print("该类型已添加完毕。") 284 | print("=" * 20) 285 | continue 286 | user[key] = [] 287 | for idx, info in enumerate(activity_type.get('infoList', [])): 288 | print(f"{idx}:\n 名称:{info.get('name', '未知')}") 289 | flag = input("是否添加至筛选? [y/n] ").lower() 290 | if flag == 'y': 291 | user[key].append(info.get('id')) 292 | 293 | print("该类型已添加完毕。") 294 | print("=" * 20) 295 | 296 | def make_success_email(activity_id : str, user : Dict) -> str: 297 | """ 298 | 制作报名成功邮件信息 299 | :param activity_id: 活动id 300 | :param user: 用户信息 301 | :return: 邮件信息 302 | """ 303 | logger.info("开始制作报名成功邮件信息") 304 | info = get_single_activity(activity_id, get_info(activity_id, user.get('token'), user.get('sid'))) 305 | 306 | # 创建邮件内容 307 | email_content = f""" 308 | 309 | 310 | 311 | 319 | 320 | 321 |
322 |
323 |

🎉 报名成功通知

324 |
325 | 326 |
327 |

亲爱的 {user.get('userName', '用户')},

328 | 329 |

恭喜您!您已成功报名参加以下活动:

330 | 331 |
332 |

📋 活动详情

333 |

活动名称:{info.get('活动名称', '未知活动')}

334 |

活动分类:{info.get('活动分类', '未分类')}

335 |

举办组织:{info.get('举办组织', '未知组织')}

336 |

活动地址:{info.get('活动地址', '待定')}

337 |

活动分数:{info.get('分数', '0')} 分

338 |

开始报名时间:{info.get('开始报名时间', '待定')}

339 |

活动开始时间:{info.get('活动开始时间', '待定')}

340 |

活动结束时间:{info.get('活动结束时间', '待定')}

341 |
342 | 343 |

💡 温馨提示:

344 | 349 | 350 |

祝您活动愉快!

351 |
352 | 353 | 356 |
357 | 358 | 359 | """ 360 | logger.info("邮件制作完毕") 361 | return email_content.strip() 362 | 363 | def make_fail_email(activity_id: str, user: dict) -> str: 364 | """ 365 | 制作报名失败邮件信息 366 | :param activity_id: 活动id 367 | :param user: 用户信息 368 | :return: 邮件信息(HTML 字符串) 369 | """ 370 | logger.info("开始制作报名失败邮件信息") 371 | info = get_single_activity(activity_id, get_info(activity_id, user.get('token'), user.get('sid'))) 372 | 373 | email_content = f""" 374 | 375 | 376 | 377 | 385 | 386 | 387 |
388 |
389 |

😔 报名未成功通知

390 |
391 | 392 |
393 |

亲爱的 {user.get('userName', '用户')},

394 | 395 |

很抱歉,您本次报名未能成功,具体信息如下:

396 | 397 |
398 |

📋 活动详情

399 |

活动名称:{info.get('活动名称', '未知活动')}

400 |

活动分类:{info.get('活动分类', '未分类')}

401 |

举办组织:{info.get('举办组织', '未知组织')}

402 |

活动地址:{info.get('活动地址', '待定')}

403 |

活动分数:{info.get('分数', '0')} 分

404 |

开始报名时间:{info.get('开始报名时间', '待定')}

405 |

活动开始时间:{info.get('活动开始时间', '待定')}

406 |

活动结束时间:{info.get('活动结束时间', '待定')}

407 |
408 | 409 |

💡 温馨提示:

410 | 415 | 416 |

感谢您的关注,期待下次与您相遇!

417 |
418 | 419 | 422 |
423 | 424 | 425 | """ 426 | logger.info("报名失败邮件制作完毕") 427 | return email_content.strip() 428 | 429 | 430 | def send_email(email_info : str, addressee : str) -> bool: 431 | """ 432 | 发送报名成功邮件 433 | :param email_info: 邮件内容(HTML格式) 434 | :param addressee: 收件人邮箱地址 435 | :return: 发送成功返回True,失败返回False 436 | """ 437 | from dotenv import load_dotenv 438 | load_dotenv() 439 | import smtplib 440 | import os 441 | from email.mime.text import MIMEText 442 | from email.mime.multipart import MIMEMultipart 443 | from email.header import Header 444 | try: 445 | # 从环境变量获取邮件配置 446 | smtp_server = os.getenv("INFO_EMAIL_SERVER") # QQ邮箱SMTP服务器 447 | smtp_port = int(os.getenv("INFO_EMAIL_PORT", "465")) # 默认465端口 448 | sender_email = os.getenv("INFO_EMAIL_HOST", "").strip('"') 449 | sender_password = os.getenv("INFO_EMAIL_SMTP_PASS", "").strip('"') 450 | 451 | # 检查配置是否完整 452 | if not sender_email or not sender_password: 453 | logger.warning("邮件配置不完整,请检查 .env 文件中的 INFO_EMAIL_HOST 和 INFO_EMAIL_SMTP_PASS 配置") 454 | return False 455 | 456 | if not addressee or addressee.strip() == "": 457 | logger.warning("收件人邮箱地址为空,无法发送邮件") 458 | return False 459 | 460 | # 创建邮件对象 461 | msg = MIMEMultipart('alternative') 462 | msg['Subject'] = Header('🎉 PU活动报名成功通知', 'utf-8') 463 | from email.utils import formataddr 464 | msg['From'] = formataddr(('PU活动助手 ', sender_email)) 465 | 466 | msg['To'] = formataddr(("你", addressee)) 467 | 468 | 469 | # 添加HTML内容 470 | html_part = MIMEText(email_info, 'html', 'utf-8') 471 | msg.attach(html_part) 472 | 473 | # 连接SMTP服务器并发送邮件 474 | logger.info(f"正在发送邮件到 {addressee}...") 475 | 476 | with smtplib.SMTP_SSL(smtp_server, smtp_port) as server: 477 | server.login(sender_email, sender_password) 478 | server.send_message(msg) 479 | server.quit() 480 | 481 | logger.success(f"邮件发送成功!收件人: {addressee}") 482 | return True 483 | 484 | except smtplib.SMTPAuthenticationError as e: 485 | logger.error(f"邮件发送失败:SMTP认证错误,请检查邮箱账号和授权码是否正确 - {str(e)}") 486 | return False 487 | except smtplib.SMTPException as e: 488 | logger.error(f"邮件发送失败:SMTP错误 - {str(e)}") 489 | return False 490 | except Exception as e: 491 | logger.error(f"邮件发送失败:未知错误 - {str(e)}") 492 | return False 493 | 494 | -------------------------------------------------------------------------------- /utils/activity_bot.py: -------------------------------------------------------------------------------- 1 | import random 2 | import threading 3 | import requests 4 | import time 5 | import json 6 | from datetime import datetime, timedelta,timezone 7 | from utils.headers import HEADERS_ACTIVITY, HEADERS_ACTIVITY_INFO 8 | from loguru import logger 9 | from typing import Dict, Optional, Tuple 10 | from concurrent.futures import ThreadPoolExecutor 11 | from email.utils import parsedate_to_datetime 12 | from utils.pu_sign import generate_random_echo, current_timestamp_str, generate_x_sign 13 | 14 | 15 | class ActivityBot: 16 | def __init__(self, userData: Dict): 17 | """ 18 | 活动报名机器人 19 | :param userData: 用户数据,包含 userName、password、sid、token、email 等 20 | """ 21 | self.user_data = userData 22 | self.cur_token = userData.get("token", "") 23 | self.activity_url = "https://apis.pocketuni.net/apis/activity/join" 24 | self.info_url = "https://apis.pocketuni.net/apis/activity/info" 25 | self.email = userData.get("email", "") 26 | self.signup_flags = {} # 记录每个活动的报名状态 27 | self.debug = False 28 | self.debug_time = datetime.now() + timedelta(seconds=15) 29 | self.server_time_offset = 0.0 # 服务器时间偏差 30 | 31 | # 线程锁,避免多线程同时写入 32 | self._lock = threading.Lock() 33 | 34 | # 初始化 token 和时间同步 35 | if not self.cur_token: 36 | self._refresh_token() 37 | 38 | 39 | def sync_server_time(self,activity_id: str) -> None: 40 | """同步服务器时间,获取时间偏差""" 41 | max_retries = 3 42 | headers = HEADERS_ACTIVITY.copy() 43 | headers["Authorization"] = f"Bearer {self.cur_token}:{self.user_data.get('sid')}" 44 | payload = {"id": activity_id} 45 | 46 | for attempt in range(max_retries): 47 | try: 48 | start_time = time.time() 49 | response = requests.post( 50 | url=self.info_url, 51 | timeout=5, 52 | headers=headers, # 防止被拦截 53 | json=payload 54 | ) 55 | end_time = time.time() 56 | 57 | # 检查响应状态 58 | response.raise_for_status() 59 | 60 | server_time_str = response.headers.get('Date') 61 | if not server_time_str: 62 | raise ValueError("服务器未返回Date头") 63 | 64 | # 解析服务器时间 65 | server_time = parsedate_to_datetime(server_time_str) 66 | if server_time.tzinfo is None: 67 | server_time = server_time.replace(tzinfo=timezone.utc) 68 | 69 | # 计算网络延迟和本地时间 70 | network_delay = end_time - start_time 71 | local_utc_time = datetime.fromtimestamp( 72 | start_time + network_delay / 2, 73 | tz=timezone.utc 74 | ) 75 | 76 | # 计算时间偏差 77 | self.server_time_offset = (server_time - local_utc_time).total_seconds() 78 | 79 | logger.info( 80 | f"用户 {self.user_data['userName']} 时间同步成功 " 81 | f"(尝试 {attempt + 1}/{max_retries}): " 82 | f"偏差={self.server_time_offset:.3f}秒, " 83 | f"延迟={network_delay * 1000:.1f}ms" 84 | ) 85 | return # 成功后退出 86 | 87 | except requests.RequestException as e: 88 | logger.warning( 89 | f"用户 {self.user_data['userName']} 时间同步失败 " 90 | f"(尝试 {attempt + 1}/{max_retries}): {e}" 91 | ) 92 | except Exception as e: 93 | logger.error( 94 | f"用户 {self.user_data['userName']} 时间同步异常 " 95 | f"(尝试 {attempt + 1}/{max_retries}): {e}" 96 | ) 97 | 98 | if attempt < max_retries - 1: 99 | time.sleep(1) # 重试前等待1秒 100 | 101 | # 所有重试都失败 102 | logger.error(f"用户 {self.user_data['userName']} 时间同步完全失败,使用默认偏差0") 103 | self.server_time_offset = 0.0 104 | 105 | def _get_corrected_now(self) -> datetime: 106 | """获取校正后的当前时间""" 107 | return datetime.now() + timedelta(seconds=self.server_time_offset) 108 | 109 | def _refresh_token(self) -> bool: 110 | """ 111 | 刷新 token,最多重试 5 次 112 | :return: True 表示获取成功,False 表示失败 113 | """ 114 | for attempt in range(5): 115 | try: 116 | from utils.tools import get_token 117 | self.cur_token = get_token(self.user_data) 118 | if self.cur_token: 119 | logger.info(f"用户 {self.user_data['userName']} Token 刷新成功") 120 | return True 121 | logger.warning(f"用户 {self.user_data['userName']} 第 {attempt + 1} 次 Token 获取失败") 122 | except Exception as e: 123 | logger.error(f"用户 {self.user_data['userName']} Token 获取异常: {str(e)}") 124 | time.sleep(1) 125 | 126 | logger.error(f"用户 {self.user_data['userName']} Token 获取失败,已达最大重试次数") 127 | return False 128 | 129 | def _get_headers(self) -> Dict: 130 | """ 131 | 构造请求头 132 | :return: dict 格式的请求头 133 | """ 134 | headers = HEADERS_ACTIVITY.copy() 135 | headers["Authorization"] = f"Bearer {self.cur_token}:{self.user_data.get('sid')}" 136 | return headers 137 | 138 | def get_join_start_time(self, activity_id: str) -> Optional[datetime]: 139 | """ 140 | 获取活动的报名开始时间(改进版本) 141 | :param activity_id: 活动 ID 142 | :return: datetime 对象,如果失败返回 None 143 | """ 144 | if self.debug: 145 | return self.debug_time 146 | 147 | for retry in range(3): 148 | try: 149 | headers = self._get_headers() 150 | payload = {"id": activity_id} 151 | 152 | logger.info(f"用户 {self.user_data['userName']} 获取活动 {activity_id} 开始时间 (尝试 {retry + 1}/3)") 153 | response = requests.post(self.info_url, headers=headers, json=payload, timeout=8) 154 | 155 | if response.status_code == 401: 156 | logger.warning(f"用户 {self.user_data['userName']} Token 失效,尝试刷新 (重试 {retry + 1}/3)") 157 | if self._refresh_token(): 158 | continue 159 | else: 160 | break 161 | 162 | response.raise_for_status() 163 | 164 | data = response.json() 165 | join_start_time_str = data.get("data", {}).get("baseInfo", {}).get("joinStartTime") 166 | 167 | if join_start_time_str: 168 | start_time = datetime.strptime(join_start_time_str, '%Y-%m-%d %H:%M:%S') 169 | logger.info(f"用户 {self.user_data['userName']} 活动 {activity_id} 开始时间: {start_time}") 170 | return start_time 171 | 172 | except Exception as e: 173 | logger.warning(f"用户 {self.user_data['userName']} 获取活动信息失败 (重试 {retry + 1}/3): {e}") 174 | if retry < 2: # 不是最后一次重试 175 | time.sleep(2 ** retry) # 指数退避 176 | 177 | logger.error(f"用户 {self.user_data['userName']} 获取活动 {activity_id} 信息最终失败") 178 | return None 179 | 180 | def _monitor_start_time(self, 181 | activity_id: str, 182 | start_time: Optional[datetime], 183 | min_minutes: int = 15, 184 | max_minutes: int = 60, 185 | buffer_seconds: int = 600) -> Optional[datetime]: 186 | """ 187 | 定时查询活动开始时间,发现变化则更新 188 | :param activity_id: 活动id 189 | :param start_time: 开始时间 190 | :param min_minutes: 最小等待时间 191 | :param max_minutes: 最大等待时间 192 | :param buffer_seconds: 小于该时间就返回 193 | :return: 开始时间 194 | """ 195 | if not start_time: 196 | return None 197 | 198 | while True: 199 | now = self._get_corrected_now() 200 | time_to_start = (start_time - now).total_seconds() 201 | 202 | # 如果已经很接近开始(<= buffer_seconds),停止低频监控,返回当前 start_time 203 | if time_to_start <= float(buffer_seconds): 204 | logger.info(f"用户 {self.user_data['userName']} 活动 {activity_id} 进入最终等待阶段") 205 | return start_time 206 | 207 | # 计算允许的最大睡眠分钟(留出 buffer_seconds 缓冲) 208 | max_allowed_minutes = max(1, int((time_to_start - buffer_seconds) / 60)) 209 | 210 | # 决定随机区间 211 | lower = min(min_minutes, max_allowed_minutes) 212 | upper = min(max_minutes, max_allowed_minutes) 213 | 214 | if upper < 1: 215 | return start_time 216 | 217 | sleep_minutes = random.randint(max(1, lower), upper) 218 | 219 | logger.info( 220 | f"用户 {self.user_data['userName']} 等待 {sleep_minutes} 分钟后再次确认开始时间 " 221 | f"(距离开始 {time_to_start / 60:.1f} 分钟)") 222 | 223 | time.sleep(sleep_minutes * 60) 224 | 225 | # 重新获取活动时间 226 | new_start = self.get_join_start_time(activity_id) 227 | if new_start and new_start != start_time: 228 | logger.warning( 229 | f"用户 {self.user_data['userName']} 活动 {activity_id} 开始时间变更: {start_time} -> {new_start}") 230 | start_time = new_start 231 | 232 | def _precise_wait_until(self, target_time: datetime, advance_ms: int = 50): 233 | """ 234 | 精确等待到目标时间前advance_ms毫秒 235 | :param target_time: 目标时间 236 | :param advance_ms: 提前毫秒数 237 | """ 238 | while True: 239 | current_time = self._get_corrected_now() 240 | remaining = (target_time - current_time).total_seconds() 241 | 242 | if remaining <= advance_ms / 1000.0: 243 | break 244 | 245 | # 如果剩余时间>1秒,粗略等待 246 | if remaining > 1: 247 | time.sleep(remaining - 0.5) 248 | elif remaining > 0.1: 249 | # 中等精度等待 250 | time.sleep(0.05) 251 | else: 252 | # 高精度等待 253 | time.sleep(0.001) 254 | 255 | def _parse_signup_response(self, response_text: str) -> Tuple[bool, str]: 256 | """ 257 | 解析报名响应,返回(是否成功, 状态描述) 258 | :param response_text: 响应文本 259 | :return: (是否成功, 状态描述) 260 | """ 261 | try: 262 | data = json.loads(response_text) 263 | code = data.get('code') 264 | message = data.get('message', '') 265 | 266 | # 根据具体的响应码判断 267 | if code == 0 and ("成功" in message or "报名成功" in str(data)): 268 | return True, "报名成功" 269 | elif code == 9405 or "您已报名" in response_text: 270 | return True, "已报名" 271 | else: 272 | return False, f"报名失败: {message} (code: {code})" 273 | 274 | except json.JSONDecodeError: 275 | # 备用字符串匹配 276 | if "报名成功" in response_text: 277 | return True, "报名成功" 278 | elif "您已报名" in response_text: 279 | return True, "已报名" 280 | else: 281 | return False, f"未知响应: {response_text[:100]}" 282 | 283 | def _send_signup_request(self, activity_id: str) -> bool: 284 | """ 285 | 发送报名请求(改进版本) 286 | :param activity_id: 活动 ID 287 | :return: True 表示报名成功/已报名,False 表示失败 288 | """ 289 | if self.signup_flags.get(activity_id): 290 | return True 291 | 292 | try: 293 | data = {"activityId": activity_id} 294 | headers = self._get_headers() 295 | echo = generate_random_echo() 296 | timestamp = current_timestamp_str() 297 | xSign=generate_x_sign(echo=echo, timestamp=timestamp, client='web') 298 | headers["X-Sign"] = xSign 299 | 300 | # 使用更短的超时时间提高响应速度 301 | response = requests.post(self.activity_url, headers=headers, json=data, timeout=5) 302 | 303 | print(response.text) 304 | 305 | if response.status_code != 200: 306 | logger.warning(f"用户 {self.user_data['userName']} 报名请求失败: HTTP {response.status_code}") 307 | return False 308 | 309 | # 使用改进的响应解析 310 | success, status_msg = self._parse_signup_response(response.text) 311 | 312 | if success: 313 | with self._lock: 314 | if not self.signup_flags.get(activity_id): # 双重检查 315 | self.signup_flags[activity_id] = True 316 | logger.success(f"用户 {self.user_data['userName']} 活动 {activity_id} {status_msg}!") 317 | 318 | if "报名成功" in status_msg: 319 | self._send_email_notification(activity_id) 320 | return True 321 | else: 322 | logger.debug(f"用户 {self.user_data['userName']} 报名响应: {status_msg}") 323 | return False 324 | 325 | except requests.exceptions.Timeout: 326 | logger.warning(f"用户 {self.user_data['userName']} 报名请求超时") 327 | return False 328 | except Exception as e: 329 | logger.error(f"用户 {self.user_data['userName']} 报名请求异常: {str(e)}") 330 | return False 331 | 332 | def _send_email_notification(self, activity_id: str): 333 | """ 334 | 报名成功后发送邮件通知 335 | :param activity_id: 活动 ID 336 | """ 337 | try: 338 | from config import ENABLE_EMAIL_NOTIFICATION 339 | if not (ENABLE_EMAIL_NOTIFICATION and self.email and self.email.strip()): 340 | return 341 | 342 | from utils.tools import make_success_email, send_email 343 | 344 | logger.info(f"用户 {self.user_data['userName']} 发送报名成功邮件通知...") 345 | email_content = make_success_email(activity_id, self.user_data) 346 | 347 | if email_content and send_email(email_content, self.email): 348 | logger.success(f"用户 {self.user_data['userName']} 邮件发送成功!") 349 | else: 350 | logger.error(f"用户 {self.user_data['userName']} 邮件发送失败") 351 | 352 | except Exception as e: 353 | logger.error(f"用户 {self.user_data['userName']} 邮件发送异常: {str(e)}") 354 | 355 | def _send_fail_email_notification(self, activity_id: str): 356 | """ 357 | 报名失败后发送邮件通知 358 | :param activity_id: 活动 ID 359 | """ 360 | try: 361 | from config import ENABLE_EMAIL_NOTIFICATION 362 | if not (ENABLE_EMAIL_NOTIFICATION and self.email and self.email.strip()): 363 | return 364 | 365 | from utils.tools import make_fail_email, send_email 366 | 367 | logger.info(f"用户 {self.user_data['userName']} 发送报名失败邮件通知...") 368 | email_content = make_fail_email(activity_id, self.user_data) 369 | 370 | if email_content and send_email(email_content, self.email): 371 | logger.success(f"用户 {self.user_data['userName']} 报名失败邮件发送成功!") 372 | else: 373 | logger.error(f"用户 {self.user_data['userName']} 报名失败邮件发送失败") 374 | 375 | except Exception as e: 376 | logger.error(f"用户 {self.user_data['userName']} 报名失败邮件异常: {str(e)}") 377 | 378 | def signup(self, activity_id: str): 379 | """ 380 | 报名活动入口,自动轮询获取报名时间 381 | :param activity_id: 活动id 382 | """ 383 | logger.info(f"用户 {self.user_data['userName']} 开始报名活动 {activity_id}") 384 | 385 | # 确保 token 有效 386 | if not self.cur_token and not self._refresh_token(): 387 | logger.error(f"用户 {self.user_data['userName']} 无法获取有效 Token,报名中止") 388 | return 389 | 390 | # 初次获取活动开始时间 391 | start_time = self.get_join_start_time(activity_id) 392 | if not start_time: 393 | logger.error(f"用户 {self.user_data['userName']} 无法获取活动 {activity_id} 开始时间") 394 | return 395 | 396 | # 启动定时监控,确保时间更新 397 | monitored_start_time = self._monitor_start_time(activity_id, start_time) 398 | if not monitored_start_time: 399 | logger.error(f"用户 {self.user_data['userName']} 监控活动时间失败,报名中止") 400 | return 401 | 402 | # 计算距离开始的秒数 403 | current_time = self._get_corrected_now() 404 | time_to_start = (monitored_start_time - current_time).total_seconds() 405 | logger.info(f"用户 {self.user_data['userName']} 活动 {activity_id} 距离开始: {time_to_start:.1f} 秒") 406 | 407 | # 等待到活动开始前 60 秒 408 | if time_to_start > 60: 409 | sleep_time = time_to_start - 60 410 | logger.info(f"用户 {self.user_data['userName']} 等待 {sleep_time:.1f} 秒到活动开始前 60 秒") 411 | time.sleep(max(0.0, sleep_time)) 412 | 413 | # 在靠近报名时刷新 token 414 | logger.info(f"用户 {self.user_data['userName']} 刷新 Token 准备报名") 415 | self._refresh_token() 416 | 417 | # 精确等待到报名开始时间 418 | logger.info(f"用户 {self.user_data['userName']} 进入精确等待阶段") 419 | self._precise_wait_until(monitored_start_time, advance_ms=30) 420 | 421 | # 开始多线程抢报名 422 | self._start_signup_threads(activity_id) 423 | 424 | def _start_signup_threads(self, activity_id: str): 425 | """ 426 | 启动多线程报名(优化版本) 427 | :param activity_id: 活动 ID 428 | """ 429 | logger.info(f"用户 {self.user_data['userName']} 开始多线程报名活动 {activity_id}") 430 | 431 | # 使用更多初始线程,提高成功率 432 | max_workers = 8 433 | with ThreadPoolExecutor(max_workers=max_workers) as executor: 434 | futures = [] 435 | 436 | # 第一轮:立即发起5个快速请求 437 | logger.info("启动第一轮快速报名...") 438 | futures.extend([executor.submit(self._signup_worker, activity_id) for _ in range(5)]) 439 | 440 | # 第二轮:每0.5秒一次,持续15次 441 | logger.info("启动第二轮密集报名...") 442 | for i in range(15): 443 | if self.signup_flags.get(activity_id): 444 | logger.success("报名成功,停止后续请求") 445 | break 446 | futures.append(executor.submit(self._signup_worker, activity_id)) 447 | time.sleep(0.4) 448 | 449 | # 第三轮:每秒一次,持续45次 450 | logger.info("启动第三轮持续报名...") 451 | for i in range(45): 452 | if self.signup_flags.get(activity_id): 453 | logger.success("报名成功,停止后续请求") 454 | break 455 | futures.append(executor.submit(self._signup_worker, activity_id)) 456 | time.sleep(0.8) 457 | 458 | # 等待所有任务完成 459 | completed_count = 0 460 | for future in futures: 461 | try: 462 | result = future.result(timeout=2) 463 | if result: 464 | completed_count += 1 465 | except Exception as e: 466 | logger.debug(f"报名线程异常: {e}") 467 | 468 | # 最终状态检查 469 | if self.signup_flags.get(activity_id, False): 470 | logger.success(f"用户 {self.user_data['userName']} 活动 {activity_id} 报名成功!") 471 | else: 472 | logger.error(f"用户 {self.user_data['userName']} 活动 {activity_id} 报名失败,发送失败邮件通知") 473 | self._send_fail_email_notification(activity_id) 474 | 475 | def _signup_worker(self, activity_id: str) -> bool: 476 | """ 477 | 报名工作线程(优化版本) 478 | :param activity_id: 活动 ID 479 | :return: True 表示报名成功,False 表示失败 480 | """ 481 | max_attempts = 5 # 每个线程最多尝试 5 次 482 | 483 | for attempt in range(max_attempts): 484 | if self.signup_flags.get(activity_id): 485 | return True 486 | 487 | try: 488 | if self._send_signup_request(activity_id): 489 | return True 490 | 491 | time.sleep(0.01) 492 | 493 | except Exception as e: 494 | logger.error(f"用户 {self.user_data['userName']} 报名线程异常: {str(e)}") 495 | time.sleep(0.1) 496 | 497 | return False 498 | --------------------------------------------------------------------------------