Compare commits
10 Commits
04e4fcaff7
...
6e9704a803
Author | SHA1 | Date | |
---|---|---|---|
6e9704a803 | |||
7f512b145c | |||
21f2f2dbec | |||
38a9240149 | |||
d7c62fbc32 | |||
68f1d9e24f | |||
879d131f1c | |||
287c99c88e | |||
5e18dadad5 | |||
cc7fdca070 |
8
.env_demo
Normal file
8
.env_demo
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
uname=20240000000
|
||||||
|
password=123456
|
||||||
|
recap_username=this_is_recap_unam
|
||||||
|
recap_password=123456
|
||||||
|
SC_KEY=this_is_sc_key
|
||||||
|
FILE = "lessons.xlsx"
|
||||||
|
INTERVAL_1=2
|
||||||
|
INTERVAL_2=10
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
logs
|
||||||
|
lessons*
|
144
README.md
Normal file
144
README.md
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
# 自动选课脚本
|
||||||
|
|
||||||
|
一个用于某URP系统的自动监控课程余量并进行选课的工具。当目标课程有余量时,程序会自动尝试选课,并推送选课结果。
|
||||||
|
|
||||||
|
## 功能特点
|
||||||
|
|
||||||
|
- 🔄 自动监控课程余量
|
||||||
|
- 📱 微信消息推送通知
|
||||||
|
- 🔁 失败自动重试
|
||||||
|
- 📊 支持多种格式的课程文件
|
||||||
|
- 📝 详细的日志记录
|
||||||
|
|
||||||
|
## 使用前准备
|
||||||
|
|
||||||
|
### 1. 准备课程文件
|
||||||
|
|
||||||
|
创建一个Excel文件(建议命名 `lessons.xlsx`),**至少**包含以下三列:
|
||||||
|
|
||||||
|
| 课程号 | 课序号 | 课程名 |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| 080302001 | 01 | 高等数学A |
|
||||||
|
| 080302002 | 02 | 线性代数 |
|
||||||
|
|
||||||
|
**说明:**
|
||||||
|
- **课程名**:课程的完整名称,不允许增加空格等,建议直接复制选课手册行。
|
||||||
|
- 允许出现除上述内容以外的项,所以如果是辽大的可以直接复制选课手册对应行到文件中。
|
||||||
|
- 允许存在**同一课程多个课序号**,将按照自上而下的顺序选,任何一个选课成功就认为该课程选课成功。
|
||||||
|
- 具体格式参考`demo.xlsx`和`demo.csv`
|
||||||
|
- 支持的文件格式:`.xlsx`、`.xls`、`.csv`、`.json`
|
||||||
|
|
||||||
|
### 2. 配置环境变量
|
||||||
|
|
||||||
|
创建 `.env` 文件,包含以下配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 教务系统登录信息
|
||||||
|
uname=你的学号
|
||||||
|
password=你的密码
|
||||||
|
|
||||||
|
# 验证码识别服务账号(必需)
|
||||||
|
recap_username=验证码识别服务用户名
|
||||||
|
recap_password=验证码识别服务密码
|
||||||
|
|
||||||
|
# 课程文件路径
|
||||||
|
# 改成自己起的名字,注意拓展名
|
||||||
|
FILE=lessons.xlsx
|
||||||
|
|
||||||
|
# 微信推送服务(可选)
|
||||||
|
SC_KEY=你的ServerChan密钥
|
||||||
|
|
||||||
|
# 教务系统地址(可选,默认为辽宁大学)
|
||||||
|
base=http://jwstudent.lnu.edu.cn
|
||||||
|
|
||||||
|
# 查询间隔
|
||||||
|
# 每次选课请求间隔
|
||||||
|
INTERVAL_1=2
|
||||||
|
# 完成一轮选课后的间隔
|
||||||
|
INTERVAL_2=10
|
||||||
|
```
|
||||||
|
|
||||||
|
你可以查看`.env_demo`查看样例。
|
||||||
|
|
||||||
|
### 3. 获取验证码识别服务
|
||||||
|
|
||||||
|
目前使用 http://www.fdyscloud.com.cn/ 提供的验证码识别,0.003 元/张。
|
||||||
|
这是一个付费平台,与作者无关。
|
||||||
|
|
||||||
|
操作方法:
|
||||||
|
1. 打开并注册账号(密码建议随机,因为要明文保存)
|
||||||
|
2. 充值合适余额(1元估计就够用到大学毕业了)
|
||||||
|
3. 在.env文件中`recap_username`和`recap_password`输入你的账号密码。
|
||||||
|
|
||||||
|
如果您不希望付费,可以修改代码中的`Lessons.recapture`自行实现本地验证码OCR。
|
||||||
|
|
||||||
|
|
||||||
|
### 4. 设置推送(可选)
|
||||||
|
|
||||||
|
如需接收选课结果的微信推送:
|
||||||
|
1. 访问 [Server酱](https://sct.ftqq.com/)或者[Server$^3$酱](https://sct.ftqq.com/)
|
||||||
|
2. 注册并获取SendKey
|
||||||
|
3. 将密钥填入 `.env` 文件的 `SC_KEY` 字段
|
||||||
|
|
||||||
|
**注意,Server$^3$酱会产生费用**
|
||||||
|
|
||||||
|
### 5. 安装依赖
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
1. 确保已完成上述准备工作
|
||||||
|
2. 将课程文件和 `.env` 文件放在脚本同目录下
|
||||||
|
3. 运行脚本:
|
||||||
|
```
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行流程
|
||||||
|
|
||||||
|
1. **登录验证**: 自动登录教务系统
|
||||||
|
2. **读取课程**: 从课程文件中读取要选择的课程信息
|
||||||
|
3. **监控余量**: 循环检查各课程的剩余名额
|
||||||
|
4. **自动选课**: 发现有余量时立即尝试选课
|
||||||
|
5. **结果通知**: 通过推送选课结果
|
||||||
|
6. **持续监控**: 未选上的课程继续监控
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
⚠️ **重要提醒**:
|
||||||
|
- 请在选课开放时间内使用,在未开放时会发生未知错误。
|
||||||
|
- 确保网络连接稳定
|
||||||
|
- 验证码识别服会产生费用
|
||||||
|
- 建议在选课高峰期前测试配置是否正确
|
||||||
|
- 程序会自动处理网络异常和重新登录
|
||||||
|
|
||||||
|
## 日志文件
|
||||||
|
|
||||||
|
程序运行时会在 `logs` 目录下生成详细的日志文件,文件名格式为 `lessons_YYYYMMDD_HHMMSS.log`,可用于排查问题。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
项目目录/
|
||||||
|
├── main.py # 主程序
|
||||||
|
├── .env # 环境变量配置文件
|
||||||
|
├── lessons.xlsx # 课程信息文件
|
||||||
|
├── logs/ # 日志文件目录
|
||||||
|
└── README.md # 说明文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
**Q: 程序提示"请在环境变量中设置xxx"**
|
||||||
|
A: 检查 `.env` 文件是否存在且包含所有必需的配置项
|
||||||
|
|
||||||
|
**Q: 一直提示登录失败**
|
||||||
|
A: 检查用户名密码是否正确,以及验证码识别服务是否可用
|
||||||
|
|
||||||
|
**Q: 找不到课程信息**
|
||||||
|
A: 检查课程文件中的课程号和课序号是否正确
|
||||||
|
|
||||||
|
**Q: 没有收到推送**
|
||||||
|
A: 检查 ServerChan 配置是否正确,或查看日志中的错误信息
|
3
demo.csv
Normal file
3
demo.csv
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
课程号, 课序号,课程名
|
||||||
|
2310031,01,体育(二)
|
||||||
|
2310031,02,体育(二)
|
|
181
main.py
181
main.py
@ -13,7 +13,6 @@ from typing import Callable, List, Optional
|
|||||||
import dotenv
|
import dotenv
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from serverchan_sdk import sc_send as _sc_send
|
from serverchan_sdk import sc_send as _sc_send
|
||||||
|
|
||||||
# 配置日志
|
# 配置日志
|
||||||
@ -32,7 +31,9 @@ logger.addHandler(file) # 日志输出到文件
|
|||||||
|
|
||||||
class LessonsException(Exception):
|
class LessonsException(Exception):
|
||||||
"""自定义异常类"""
|
"""自定义异常类"""
|
||||||
|
pass
|
||||||
|
class ReloginException(LessonsException):
|
||||||
|
"""用于处理需要重新登录的异常"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@ -64,13 +65,14 @@ class Lessons:
|
|||||||
"recap_username",
|
"recap_username",
|
||||||
"recap_password",
|
"recap_password",
|
||||||
"FILE",
|
"FILE",
|
||||||
"SC_KEY",
|
|
||||||
]
|
]
|
||||||
for key in required_keys:
|
for key in required_keys:
|
||||||
if not environ.get(key):
|
if not environ.get(key):
|
||||||
raise LessonsException(f"请在环境变量中设置{key}")
|
raise LessonsException(f"请在环境变量中设置{key}")
|
||||||
|
|
||||||
self.base = environ.get("base", "http://jwstudent.lnu.edu.cn")
|
self.base = environ.get("base", "http://jwstudent.lnu.edu.cn")
|
||||||
|
self.interval_1 = int(environ.get("INTERVAL_1", 2)) # 请求间隔,默认为2秒
|
||||||
|
self.interval_2 = int(environ.get("INTERVAL_2", 10)) # 请求间隔,默认为10秒
|
||||||
|
|
||||||
def _retry_request(
|
def _retry_request(
|
||||||
self, func, max_retries: int = 10, error_msg: str = "请求失败"
|
self, func, max_retries: int = 10, error_msg: str = "请求失败"
|
||||||
@ -190,7 +192,7 @@ class Lessons:
|
|||||||
def judge_logout(self, response: requests.Response):
|
def judge_logout(self, response: requests.Response):
|
||||||
"""检查账号是否在其他地方被登录"""
|
"""检查账号是否在其他地方被登录"""
|
||||||
if response.url == f"{self.base}/login?errorCode=concurrentSessionExpired":
|
if response.url == f"{self.base}/login?errorCode=concurrentSessionExpired":
|
||||||
raise LessonsException("有人登录了您的账号!")
|
raise ReloginException("有人登录了您的账号!")
|
||||||
|
|
||||||
def get_base_info(self):
|
def get_base_info(self):
|
||||||
res = self.session.get(f"{self.base}/student/courseSelect/gotoSelect/index")
|
res = self.session.get(f"{self.base}/student/courseSelect/gotoSelect/index")
|
||||||
@ -198,7 +200,7 @@ class Lessons:
|
|||||||
html = res.text
|
html = res.text
|
||||||
match = re.search(r"fajhh=(\d+)", html)
|
match = re.search(r"fajhh=(\d+)", html)
|
||||||
if not match:
|
if not match:
|
||||||
print(html)
|
# print(html)
|
||||||
raise LessonsException("未找到培养方案编号")
|
raise LessonsException("未找到培养方案编号")
|
||||||
self.fajhh = match.group(1)
|
self.fajhh = match.group(1)
|
||||||
|
|
||||||
@ -207,13 +209,25 @@ class Lessons:
|
|||||||
)
|
)
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
html = res.text
|
html = res.text
|
||||||
bs = BeautifulSoup(html, "html.parser")
|
# 使用正则表达式替代 BeautifulSoup 来查找选中的学期选项
|
||||||
match = bs.select_one("select#jhxn option[selected]")
|
# 由于 HTML 结构特殊,selected 在单独行上,需要向前查找对应的 option
|
||||||
if not match:
|
lines = html.split('\n')
|
||||||
raise LessonsException("未找到学期信息")
|
for i, line in enumerate(lines):
|
||||||
self.term = str(match.get("value"))
|
if 'selected' in line.strip():
|
||||||
|
# 向前查找包含 option value 的行
|
||||||
|
for j in range(i-1, max(0, i-10), -1):
|
||||||
|
if 'option value=' in lines[j]:
|
||||||
|
value_match = re.search(r'value="([^"]*)"', lines[j])
|
||||||
|
if value_match:
|
||||||
|
self.term = str(value_match.group(1))
|
||||||
|
break
|
||||||
|
if self.term:
|
||||||
|
break
|
||||||
|
|
||||||
print(self.fajhh, self.term)
|
if not self.term:
|
||||||
|
raise LessonsException("未找到学期信息")
|
||||||
|
|
||||||
|
# print(self.fajhh, self.term)
|
||||||
|
|
||||||
def read_lessons(self) -> List[tuple[str, str, str]]:
|
def read_lessons(self) -> List[tuple[str, str, str]]:
|
||||||
classes = []
|
classes = []
|
||||||
@ -260,20 +274,42 @@ class Lessons:
|
|||||||
"jc": 0,
|
"jc": 0,
|
||||||
}
|
}
|
||||||
response = self._retry_request(lambda: self.session.post(url, data=params))
|
response = self._retry_request(lambda: self.session.post(url, data=params))
|
||||||
data = response.json()["kylMap"]
|
with open("response.json", "w", encoding="utf-8") as f:
|
||||||
if len(data) == 0:
|
f.write(response.text)
|
||||||
logger.error(f"课程 {cl[2]} 的余量信息为空: {data}")
|
data:dict = response.json()
|
||||||
return
|
|
||||||
|
|
||||||
|
cls:list[dict] = data.get("rwfalist", [])
|
||||||
|
if not cls:
|
||||||
|
logger.error(f"课程 {cl[2]} 的课程信息为空: {data}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
for item in cls:
|
||||||
|
if item["classNum"] in cl[1]:
|
||||||
|
# print(item["classNum"],type(item["classNum"]))
|
||||||
|
if item["kcm"] != cl[2]:
|
||||||
|
logger.critical(
|
||||||
|
f"课程 {cl[2]} 的课程名与查询信息不匹配: {item['kcm']} != {cl[2]}"
|
||||||
|
)
|
||||||
|
sc_send(
|
||||||
|
"选课异常",
|
||||||
|
desp=f"课程 {cl[2]} 的课程名与查询信息不匹配: {item['kcm']} != {cl[2]}",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
kyl:dict[str,str] = data["kylMap"]
|
||||||
|
if len(kyl) == 0:
|
||||||
|
logger.error(f"课程 {cl[2]} 的余量信息为空: {kyl}")
|
||||||
|
return
|
||||||
ret = []
|
ret = []
|
||||||
for kxh in cl[1]:
|
for kxh in cl[1]:
|
||||||
key = f"{self.term}_{cl[0]}_{kxh}"
|
key = f"{self.term}_{cl[0]}_{kxh}"
|
||||||
left = data.get(key, None)
|
left = kyl.get(key, None)
|
||||||
if left is None:
|
if left is None:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"课程 {cl[2]} 的余量信息不存在: {key} not in {data.keys()}"
|
f"课程 {cl[2]} 的余量信息不存在: {key} not in {kyl.keys()}"
|
||||||
)
|
)
|
||||||
ret.append(-1)
|
ret.append(-1)
|
||||||
|
continue
|
||||||
ret.append(int(left))
|
ret.append(int(left))
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@ -319,7 +355,7 @@ class Lessons:
|
|||||||
html = response.text
|
html = response.text
|
||||||
redisKey = re.search(r'var redisKey = "(.+)";', html)
|
redisKey = re.search(r'var redisKey = "(.+)";', html)
|
||||||
if not redisKey:
|
if not redisKey:
|
||||||
print(html)
|
# print(html)
|
||||||
logger.error(f"选课 {cl[2]} 时未找到 redisKey")
|
logger.error(f"选课 {cl[2]} 时未找到 redisKey")
|
||||||
return False
|
return False
|
||||||
redisKey = redisKey.group(1)
|
redisKey = redisKey.group(1)
|
||||||
@ -345,7 +381,7 @@ class Lessons:
|
|||||||
else:
|
else:
|
||||||
logger.info(f"选课成功: {text}")
|
logger.info(f"选课成功: {text}")
|
||||||
else:
|
else:
|
||||||
print(f"第{cnt}次查询中...")
|
logger.info(f"第{cnt}次查询中...")
|
||||||
cnt += 1
|
cnt += 1
|
||||||
sleep(1)
|
sleep(1)
|
||||||
|
|
||||||
@ -379,6 +415,10 @@ class Lessons:
|
|||||||
if classes.get(id) is None:
|
if classes.get(id) is None:
|
||||||
classes[id] = (id, [kxh], name)
|
classes[id] = (id, [kxh], name)
|
||||||
else:
|
else:
|
||||||
|
if classes[id][2] != name:
|
||||||
|
raise LessonsException(
|
||||||
|
f"课程 {name}_{kxh} 的名称不一致: {classes[id][2]} != {name}"
|
||||||
|
)
|
||||||
classes[id][1].append(kxh)
|
classes[id][1].append(kxh)
|
||||||
|
|
||||||
logger.info(f"读取课程信息,共有 {len(classes)} 门课程")
|
logger.info(f"读取课程信息,共有 {len(classes)} 门课程")
|
||||||
@ -396,53 +436,74 @@ class Lessons:
|
|||||||
sc_send("选课异常", desp="最近5次获取课程余量异常,尝试重新登录")
|
sc_send("选课异常", desp="最近5次获取课程余量异常,尝试重新登录")
|
||||||
self.login()
|
self.login()
|
||||||
master_err += 1
|
master_err += 1
|
||||||
|
errs.clear()
|
||||||
if master_err >= 3:
|
if master_err >= 3:
|
||||||
logger.error("反复发生重要异常,退出程序")
|
logger.error("反复发生重要异常,退出程序")
|
||||||
sc_send("选课异常", desp="反复发生重要异常,退出程序")
|
sc_send("选课异常", desp="反复发生重要异常,退出程序")
|
||||||
return
|
return
|
||||||
for lcl in classes.copy().values():
|
try:
|
||||||
logger.info(f"检查课程 {lcl[2]} 余量")
|
for lcl in classes.copy().values():
|
||||||
try:
|
logger.info(f"检查课程 {lcl[2]} 余量")
|
||||||
lefts = self.get_left(lcl)
|
try:
|
||||||
if lefts is None:
|
lefts = self.get_left(lcl)
|
||||||
errs.appendleft(time())
|
if lefts is None:
|
||||||
logger.error(f"获取课程 {lcl[2]} 余量时返回异常")
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
errs.appendleft(time())
|
|
||||||
logger.error(f"获取课程 {lcl[2]} 余量时发生错误: {e}")
|
|
||||||
continue
|
|
||||||
for i, left in enumerate(lefts):
|
|
||||||
cl = (lcl[0], lcl[1][i], lcl[2])
|
|
||||||
if left > 0:
|
|
||||||
logger.info(
|
|
||||||
f"课程 {cl[2]}_{cl[1]} 有余量: {left},开始选课"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
ret = self.select(cl)
|
|
||||||
if ret:
|
|
||||||
suc.append(cl)
|
|
||||||
classes.pop(cl[0])
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
errs.appendleft(time())
|
errs.appendleft(time())
|
||||||
logger.error(f"选课 {cl[2]}_{cl[1]} 时发生错误: {e}")
|
logger.error(f"获取课程 {lcl[2]} 余量时返回异常")
|
||||||
finally:
|
continue
|
||||||
sleep(2) # 避免请求过快导致服务器拒绝
|
except ReloginException as e:
|
||||||
elif left == -1:
|
raise e
|
||||||
logger.error(f"课程 {cl[2]}_{cl[1]} 余量信息异常")
|
except Exception as e:
|
||||||
else:
|
errs.appendleft(time())
|
||||||
logger.info(f"课程 {cl[2]}_{cl[1]} 无余量")
|
logger.error(f"获取课程 {lcl[2]} 余量时发生错误: {e}")
|
||||||
sleep(2)
|
continue
|
||||||
logger.info(
|
for i, left in enumerate(lefts):
|
||||||
f"当前还有{len(classes)}门课程未选上,分别为{','.join(mp[i] for i in classes.keys())}。等待10秒后继续检查"
|
cl = (lcl[0], lcl[1][i], lcl[2])
|
||||||
)
|
if left > 0:
|
||||||
if suc:
|
logger.info(
|
||||||
sc_send(
|
f"课程 {cl[2]}_{cl[1]} 有余量: {left},开始选课"
|
||||||
"选课成功",
|
)
|
||||||
desp=f"已成功选上课程: {', '.join(f'{i[2]}_{i[1]}' for i in suc)}",
|
try:
|
||||||
|
ret = self.select(cl)
|
||||||
|
if ret:
|
||||||
|
suc.append(cl)
|
||||||
|
classes.pop(cl[0])
|
||||||
|
break
|
||||||
|
except ReloginException as e:
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
errs.appendleft(time())
|
||||||
|
logger.error(f"选课 {cl[2]}_{cl[1]} 时发生错误: {e}")
|
||||||
|
finally:
|
||||||
|
sleep(self.interval_1) # 避免请求过快导致服务器拒绝
|
||||||
|
elif left == -1:
|
||||||
|
logger.error(f"课程 {cl[2]}_{cl[1]} 余量信息异常")
|
||||||
|
else:
|
||||||
|
logger.info(f"课程 {cl[2]}_{cl[1]} 无余量")
|
||||||
|
sleep(self.interval_1)
|
||||||
|
logger.info(
|
||||||
|
f"当前还有{len(classes)}门课程未选上,分别为{','.join(mp[i] for i in classes.keys())}。等待{self.interval_2}秒后继续检查"
|
||||||
)
|
)
|
||||||
sleep(10) # 等待10秒后继续检查
|
if suc:
|
||||||
|
sc_send(
|
||||||
|
"选课成功",
|
||||||
|
desp=f"已成功选上课程: {', '.join(f'{i[2]}_{i[1]}' for i in suc)}",
|
||||||
|
)
|
||||||
|
except ReloginException as e:
|
||||||
|
logger.error(f"需要重新登录: {e}")
|
||||||
|
sc_send("选课异常", desp=f"需要重新登录: {e}")
|
||||||
|
self.login()
|
||||||
|
continue
|
||||||
|
except LessonsException as e:
|
||||||
|
logger.error(f"选课过程中发生错误: {e}")
|
||||||
|
sc_send("选课异常", desp=f"选课过程中发生错误: {e}")
|
||||||
|
errs.appendleft(time())
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"意外错误: {e}")
|
||||||
|
sc_send("选课异常", desp=f"选课过程中发生意外错误: {e}")
|
||||||
|
errs.appendleft(time())
|
||||||
|
continue
|
||||||
|
sleep(self.interval_2) # 等待10秒后继续检查
|
||||||
|
|
||||||
logger.info("自动选课完成")
|
logger.info("自动选课完成")
|
||||||
|
|
||||||
|
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
python-dotenv
|
||||||
|
pandas
|
||||||
|
requests
|
||||||
|
serverchan-sdk
|
||||||
|
openpyxl
|
Reference in New Issue
Block a user