├── 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 |
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 |
345 | - 请务必留意活动签到时间,准时参加
346 | - 请携带相关证件按时到达活动地点
347 | - 如有疑问,请联系活动主办方
348 |
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 |
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 |
411 | - 名额有限,下次请尽早报名
412 | - 可关注主办方后续活动通知
413 | - 如有疑问,请联系活动主办方
414 |
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 |
--------------------------------------------------------------------------------