莞工教务选课系统 CLI + GUI
通过 browser-act 浏览器会话直接调用教务系统 API, 支持命令行和图形界面操作选课/退课,自适应不同选课轮次。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Ignore everything by default
|
||||||
|
/*
|
||||||
|
|
||||||
|
# Except our project files
|
||||||
|
!README.md
|
||||||
|
!dgut.py
|
||||||
|
!dgut-gui.sh
|
||||||
|
!.gitignore
|
||||||
|
|
||||||
|
!dgut-gui/
|
||||||
|
dgut-gui/*
|
||||||
|
!dgut-gui/README.md
|
||||||
|
!dgut-gui/dgut_core.py
|
||||||
|
!dgut-gui/dgut_gui.py
|
||||||
|
!dgut-gui/start.sh
|
||||||
|
dgut-gui/venv/
|
||||||
|
dgut-gui/__pycache__/
|
||||||
100
README.md
Normal file
100
README.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# 🎓 莞工教务选课系统 CLI + GUI
|
||||||
|
|
||||||
|
通过命令行或图形界面直接操作莞工教务系统选课,无需打开浏览器手动操作。
|
||||||
|
|
||||||
|
## 原理
|
||||||
|
|
||||||
|
利用已有的 `browser-act` 浏览器会话,通过 JavaScript `fetch()` 在浏览器上下文中直接调用教务系统的 API,自动携带登录态,不需要额外认证。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- ✅ 查看可选课轮次
|
||||||
|
- ✅ 动态探测选课中心开放了哪些标签页(选修/必修/公选/跨专业等)
|
||||||
|
- ✅ 列出课程列表(编号、名称、学分、容量、已选状态)
|
||||||
|
- ✅ 一键选课
|
||||||
|
- ✅ 一键退课
|
||||||
|
- ✅ 搜索课程(支持名称和编号)
|
||||||
|
- ✅ 图形界面(CustomTkinter)
|
||||||
|
- ✅ 自适应不同选课轮次,未来新增轮次无需改代码
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── dgut.py # CLI 入口
|
||||||
|
├── dgut-gui.sh # GUI 快捷启动
|
||||||
|
└── dgut-gui/
|
||||||
|
├── dgut_core.py # 核心 API 模块 (CLI 和 GUI 共用)
|
||||||
|
├── dgut_gui.py # CustomTkinter 图形界面
|
||||||
|
├── start.sh # GUI 启动脚本
|
||||||
|
└── venv/ # Python 虚拟环境 (含 customtkinter)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用前提
|
||||||
|
|
||||||
|
1. 已安装 `browser-act` CLI 工具
|
||||||
|
2. 已通过 `browser-act` 打开浏览器并登录了莞工教务系统
|
||||||
|
3. 浏览器会话处于活跃状态
|
||||||
|
|
||||||
|
## CLI 使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看可选课轮次
|
||||||
|
python3 dgut.py list-cycles
|
||||||
|
|
||||||
|
# 探测选课中心开放了哪些标签页
|
||||||
|
python3 dgut.py list-tabs
|
||||||
|
|
||||||
|
# 列出课程(默认加载第1个有数据的标签)
|
||||||
|
python3 dgut.py list-courses
|
||||||
|
|
||||||
|
# 加载指定标签的课程
|
||||||
|
python3 dgut.py list-courses 2
|
||||||
|
|
||||||
|
# 按课程编号选课
|
||||||
|
python3 dgut.py select 0410578
|
||||||
|
|
||||||
|
# 按课程名称选课
|
||||||
|
python3 dgut.py select "微信小程序"
|
||||||
|
|
||||||
|
# 退课
|
||||||
|
python3 dgut.py drop 0410531
|
||||||
|
|
||||||
|
# 搜索课程
|
||||||
|
python3 dgut.py search 鸿蒙
|
||||||
|
```
|
||||||
|
|
||||||
|
## GUI 使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dgut-gui.sh
|
||||||
|
# 或
|
||||||
|
./dgut-gui/start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
GUI 界面提供:
|
||||||
|
- 课程类型下拉切换(自动探测所有可用标签页)
|
||||||
|
- 实时搜索过滤
|
||||||
|
- 一键选课/退课按钮
|
||||||
|
- 操作日志显示
|
||||||
|
- 已选学分/门数统计
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
- Python 3.x
|
||||||
|
- [browser-act-cli](https://github.com/your-org/browser-act) — 浏览器自动化
|
||||||
|
- customtkinter — GUI 界面(已安装在虚拟环境中)
|
||||||
|
|
||||||
|
## 工作方式
|
||||||
|
|
||||||
|
```
|
||||||
|
你的操作 → dgut.py/dgut_gui.py → 构造 fetch() JavaScript
|
||||||
|
→ browser-act eval → 在浏览器中执行 fetch()
|
||||||
|
→ 教务系统 API → 返回 JSON 数据
|
||||||
|
→ 解析并展示结果
|
||||||
|
```
|
||||||
|
|
||||||
|
所有 API 请求的认证由浏览器会话的 Cookie 自动处理,应用本身不存储任何凭证。
|
||||||
|
|
||||||
|
## API 参考
|
||||||
|
|
||||||
|
详细 API 信息参见 [记忆文档](https://github.com/your-org/dgut-course-cli/wiki)。
|
||||||
1
dgut-gui.sh
Symbolic link
1
dgut-gui.sh
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/cho/dgut-gui/start.sh
|
||||||
100
dgut-gui/README.md
Normal file
100
dgut-gui/README.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# 🎓 莞工教务选课系统 CLI + GUI
|
||||||
|
|
||||||
|
通过命令行或图形界面直接操作莞工教务系统选课,无需打开浏览器手动操作。
|
||||||
|
|
||||||
|
## 原理
|
||||||
|
|
||||||
|
利用已有的 `browser-act` 浏览器会话,通过 JavaScript `fetch()` 在浏览器上下文中直接调用教务系统的 API,自动携带登录态,不需要额外认证。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- ✅ 查看可选课轮次
|
||||||
|
- ✅ 动态探测选课中心开放了哪些标签页(选修/必修/公选/跨专业等)
|
||||||
|
- ✅ 列出课程列表(编号、名称、学分、容量、已选状态)
|
||||||
|
- ✅ 一键选课
|
||||||
|
- ✅ 一键退课
|
||||||
|
- ✅ 搜索课程(支持名称和编号)
|
||||||
|
- ✅ 图形界面(CustomTkinter)
|
||||||
|
- ✅ 自适应不同选课轮次,未来新增轮次无需改代码
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── dgut.py # CLI 入口
|
||||||
|
├── dgut-gui.sh # GUI 快捷启动
|
||||||
|
└── dgut-gui/
|
||||||
|
├── dgut_core.py # 核心 API 模块 (CLI 和 GUI 共用)
|
||||||
|
├── dgut_gui.py # CustomTkinter 图形界面
|
||||||
|
├── start.sh # GUI 启动脚本
|
||||||
|
└── venv/ # Python 虚拟环境 (含 customtkinter)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用前提
|
||||||
|
|
||||||
|
1. 已安装 `browser-act` CLI 工具
|
||||||
|
2. 已通过 `browser-act` 打开浏览器并登录了莞工教务系统
|
||||||
|
3. 浏览器会话处于活跃状态
|
||||||
|
|
||||||
|
## CLI 使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看可选课轮次
|
||||||
|
python3 dgut.py list-cycles
|
||||||
|
|
||||||
|
# 探测选课中心开放了哪些标签页
|
||||||
|
python3 dgut.py list-tabs
|
||||||
|
|
||||||
|
# 列出课程(默认加载第1个有数据的标签)
|
||||||
|
python3 dgut.py list-courses
|
||||||
|
|
||||||
|
# 加载指定标签的课程
|
||||||
|
python3 dgut.py list-courses 2
|
||||||
|
|
||||||
|
# 按课程编号选课
|
||||||
|
python3 dgut.py select 0410578
|
||||||
|
|
||||||
|
# 按课程名称选课
|
||||||
|
python3 dgut.py select "微信小程序"
|
||||||
|
|
||||||
|
# 退课
|
||||||
|
python3 dgut.py drop 0410531
|
||||||
|
|
||||||
|
# 搜索课程
|
||||||
|
python3 dgut.py search 鸿蒙
|
||||||
|
```
|
||||||
|
|
||||||
|
## GUI 使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dgut-gui.sh
|
||||||
|
# 或
|
||||||
|
./dgut-gui/start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
GUI 界面提供:
|
||||||
|
- 课程类型下拉切换(自动探测所有可用标签页)
|
||||||
|
- 实时搜索过滤
|
||||||
|
- 一键选课/退课按钮
|
||||||
|
- 操作日志显示
|
||||||
|
- 已选学分/门数统计
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
- Python 3.x
|
||||||
|
- [browser-act-cli](https://github.com/your-org/browser-act) — 浏览器自动化
|
||||||
|
- customtkinter — GUI 界面(已安装在虚拟环境中)
|
||||||
|
|
||||||
|
## 工作方式
|
||||||
|
|
||||||
|
```
|
||||||
|
你的操作 → dgut.py/dgut_gui.py → 构造 fetch() JavaScript
|
||||||
|
→ browser-act eval → 在浏览器中执行 fetch()
|
||||||
|
→ 教务系统 API → 返回 JSON 数据
|
||||||
|
→ 解析并展示结果
|
||||||
|
```
|
||||||
|
|
||||||
|
所有 API 请求的认证由浏览器会话的 Cookie 自动处理,应用本身不存储任何凭证。
|
||||||
|
|
||||||
|
## API 参考
|
||||||
|
|
||||||
|
详细 API 信息参见 [记忆文档](https://github.com/your-org/dgut-course-cli/wiki)。
|
||||||
208
dgut-gui/dgut_core.py
Normal file
208
dgut-gui/dgut_core.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""
|
||||||
|
莞工教务系统 - 核心API模块
|
||||||
|
============================
|
||||||
|
被 CLI (dgut.py) 和 GUI (dgut-gui/dgut_gui.py) 共用。
|
||||||
|
自适应探测选课类型标签页,不依赖硬编码的课程类型。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
ORIGIN = "https://jwx.dgut.edu.cn"
|
||||||
|
BROWSER_ACT = "browser-act"
|
||||||
|
SESSION_NAME = "jwx-s1"
|
||||||
|
|
||||||
|
# 已知的标签页ID → iframe页面 → API 映射
|
||||||
|
# 新增选课类型时只需在此添加
|
||||||
|
TAB_REGISTRY: dict[str, dict[str, Optional[str]]] = {
|
||||||
|
"选课学分情况": {"id": "xkzx", "iframe": "/xsxk/xsxk_tzsm", "api": None},
|
||||||
|
"必修选课": {"id": "bxxksfkf", "iframe": "/xsxkkc/getBxxkxx", "api": "/xsxkkc/xsxkXxxk"},
|
||||||
|
"选修选课": {"id": "xxxksfkf", "iframe": "/xsxkkc/getXxxk", "api": "/xsxkkc/xsxkXxxk"},
|
||||||
|
"本学期计划选课": {"id": "bxqxksfkf", "iframe": "/xsxkkc/getBxqjxk", "api": "/xsxkkc/xsxkXxxk"},
|
||||||
|
"公共选修": {"id": "ggksfkf", "iframe": "/xsxkkc/getGgxxk", "api": "/xsxkkc/xsxkXxxk"},
|
||||||
|
"跨年级选课": {"id": "knjxksfkf", "iframe": "/xsxkkc/getKnjxk", "api": "/xsxkkc/xsxkXxxk"},
|
||||||
|
"跨专业选课": {"id": "kzyxksfkf", "iframe": "/xsxkkc/getKzyxk", "api": "/xsxkkc/xsxkXxxk"},
|
||||||
|
"辅修选课": {"id": "fxzyxksfkf", "iframe": "/xsxkkc/getFxzy", "api": "/xsxkkc/xsxkXxxk"},
|
||||||
|
"重修选课": {"id": "cxxkkf", "iframe": "/xsxkkc/comeInCxxk", "api": None},
|
||||||
|
"体育选课": {"id": "tyxksfkf", "iframe": "/xsxkkc/newcomeInTyxk", "api": None},
|
||||||
|
"选课组选课": {"id": "xkzxksfkf", "iframe": "/xsxkkc/getXkzxk", "api": None},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 底层通信 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _browser_eval(js_code: str) -> str:
|
||||||
|
escaped = js_code.replace("'", "'\\''")
|
||||||
|
cmd = f"{BROWSER_ACT} --session {SESSION_NAME} eval '{escaped}'"
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(result.stderr.strip() or result.stdout.strip())
|
||||||
|
return result.stdout.strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise RuntimeError("browser-act not found")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_api(method: str, path: str, body: Optional[str] = None,
|
||||||
|
referer: Optional[str] = None) -> Any:
|
||||||
|
"""在浏览器上下文中发 fetch 请求(自动携带 cookie)。"""
|
||||||
|
url = f"{ORIGIN}{path}"
|
||||||
|
headers: dict[str, str] = {"X-Requested-With": "XMLHttpRequest"}
|
||||||
|
if referer:
|
||||||
|
headers["Referer"] = referer
|
||||||
|
opts = {"method": method, "credentials": "include", "headers": headers}
|
||||||
|
if body:
|
||||||
|
opts["body"] = body
|
||||||
|
opts["headers"]["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8"
|
||||||
|
|
||||||
|
opts_json = json.dumps(opts)
|
||||||
|
js = (
|
||||||
|
f"(async()=>{{"
|
||||||
|
f"try{{const r=await fetch('{url}',{opts_json});const t=await r.text();"
|
||||||
|
f"try{{return JSON.stringify({{ok:!0,data:JSON.parse(t)}})}}"
|
||||||
|
f"catch(e){{return JSON.stringify({{ok:!0,raw:t}})}}}}"
|
||||||
|
f"catch(e){{return JSON.stringify({{ok:!1,error:e.message}})}}}})()"
|
||||||
|
)
|
||||||
|
output = _browser_eval(js)
|
||||||
|
result = json.loads(output)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise RuntimeError(result.get("error", "unknown error"))
|
||||||
|
return result.get("data") or result.get("raw", {})
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_html(path: str) -> str:
|
||||||
|
return fetch_api("GET", path)
|
||||||
|
|
||||||
|
|
||||||
|
def _browser_click_enter_button() -> bool:
|
||||||
|
"""在浏览器中点击"进入选课"按钮。"""
|
||||||
|
js = """
|
||||||
|
(function(){
|
||||||
|
var f = document.querySelector('iframe[id=NEW_XSD_PYGL_XKGL_NXSXKZX]');
|
||||||
|
if(!f) return 'no iframe';
|
||||||
|
var d = f.contentDocument;
|
||||||
|
var btns = d.querySelectorAll('button');
|
||||||
|
for(var b of btns) {
|
||||||
|
if(b.textContent.includes('进入选课')) { b.click(); return 'ok'; }
|
||||||
|
}
|
||||||
|
return 'btn not found';
|
||||||
|
})()
|
||||||
|
"""
|
||||||
|
result = _browser_eval(js)
|
||||||
|
time.sleep(3)
|
||||||
|
return "ok" in result
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_in_course_selection() -> bool:
|
||||||
|
"""
|
||||||
|
确保浏览器已在选课界面内。若在列表页则自动点击"进入选课"。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
f"{BROWSER_ACT} --session {SESSION_NAME} state",
|
||||||
|
shell=True, capture_output=True, text=True, timeout=15,
|
||||||
|
)
|
||||||
|
out = r.stdout
|
||||||
|
if "selectBottom" in out and "进入选课" not in out:
|
||||||
|
return True
|
||||||
|
if "进入选课" in out:
|
||||||
|
return _browser_click_enter_button()
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 业务 API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_cycles() -> list[dict]:
|
||||||
|
"""获取可选课的选课轮次列表。"""
|
||||||
|
return fetch_api("GET", "/xsxk/xklc_list_data?xkmc=").get("data", [])
|
||||||
|
|
||||||
|
|
||||||
|
def discover_tabs(jx0502zbid: str) -> list[dict]:
|
||||||
|
"""
|
||||||
|
动态探测选课界面开放了哪些标签页。
|
||||||
|
通过分析 selectBottom 页面的 HTML 和 JavaScript 实现。
|
||||||
|
返回: [{"id": str, "label": str, "iframe": str, "api": str|None}, ...]
|
||||||
|
"""
|
||||||
|
html = fetch_html(f"/xsxk/selectBottom?jx0502zbid={jx0502zbid}&sfylxkstr=")
|
||||||
|
|
||||||
|
# 方式1: 从 <li onclick="jpbxxk('xxxksfkf')">选修选课</li> 或 'xxxksfkf' 提取
|
||||||
|
tabs: list[dict] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
for m in re.finditer(
|
||||||
|
r"""<li[^>]*?onclick="jpbxxk\(['']{1}(.+?)['']{1}\)"[^>]*?>([^<]+)</li>""",
|
||||||
|
html,
|
||||||
|
):
|
||||||
|
tid, label = m.group(1), m.group(2).strip()
|
||||||
|
if tid in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(tid)
|
||||||
|
known = TAB_REGISTRY.get(label, {})
|
||||||
|
tabs.append({
|
||||||
|
"id": tid,
|
||||||
|
"label": label,
|
||||||
|
"iframe": known.get("iframe"),
|
||||||
|
"api": known.get("api"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 方式2: 从 jpbxxk 函数解析 iframe 映射
|
||||||
|
iframe_map: dict[str, str] = {}
|
||||||
|
for m in re.finditer(
|
||||||
|
r"case\s+'([^']+)':\s*[^#]*?#selectTable\)\.attr\('src',\s*\"([^\"]+)\"",
|
||||||
|
html,
|
||||||
|
):
|
||||||
|
iframe_map[m.group(1)] = m.group(2)
|
||||||
|
for tab in tabs:
|
||||||
|
if not tab["iframe"]:
|
||||||
|
tab["iframe"] = iframe_map.get(tab["id"])
|
||||||
|
|
||||||
|
# 方式3: 对新标签尝试自动推断 API
|
||||||
|
for tab in tabs:
|
||||||
|
if tab["api"] is None and tab.get("iframe"):
|
||||||
|
# 如果 iframe 路径包含 getXxx 模式,推测对应 xsxkXxx API
|
||||||
|
iframe = tab["iframe"]
|
||||||
|
m = re.match(r"/xsxkkc/get(\w+)", iframe)
|
||||||
|
if m:
|
||||||
|
suffix = m.group(1)
|
||||||
|
tab["api"] = f"/xsxkkc/xsxk{suffix}"
|
||||||
|
|
||||||
|
return tabs
|
||||||
|
|
||||||
|
|
||||||
|
def get_courses(api_path: str, referer: str) -> list[dict]:
|
||||||
|
"""获取某选课类型的课程列表。"""
|
||||||
|
post_data = (
|
||||||
|
"sEcho=1&iColumns=14&sColumns=&iDisplayStart=0&iDisplayLength=50"
|
||||||
|
"&mDataProp_0=kch&mDataProp_1=kcmc&mDataProp_2=skfsmc"
|
||||||
|
"&mDataProp_3=fzmc&mDataProp_4=ktmc&mDataProp_5=xf"
|
||||||
|
"&mDataProp_6=skls&mDataProp_7=sksj&mDataProp_8=skdd"
|
||||||
|
"&mDataProp_9=xqmc&mDataProp_10=syrs&mDataProp_11=skfsmc"
|
||||||
|
"&mDataProp_12=ctsm&mDataProp_13=czOper"
|
||||||
|
)
|
||||||
|
query = "?1=1&kcxx=&skls=&skfs=" if "?" not in api_path else ""
|
||||||
|
data = fetch_api("POST", f"{api_path}{query}", body=post_data, referer=referer)
|
||||||
|
return data.get("aaData", [])
|
||||||
|
|
||||||
|
|
||||||
|
def select_course(kcid: str, jx0404id: str, referer: str) -> dict:
|
||||||
|
"""选课。"""
|
||||||
|
params = f"kcid={kcid}&cfbs=null&jx0404id={jx0404id}&xkzy=&trjf=&sfsyjc=&sfkvtj="
|
||||||
|
return fetch_api("GET", f"/xsxkkc/xxxkOper?{params}", referer=referer)
|
||||||
|
|
||||||
|
|
||||||
|
def drop_course(jx0404id: str, referer: str) -> dict:
|
||||||
|
"""退课。"""
|
||||||
|
return fetch_api("GET", f"/xsxkjg/xstkOper?jx0404id={jx0404id}", referer=referer)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_session() -> bool:
|
||||||
|
"""检查 browser-act session 是否存在。"""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(f"{BROWSER_ACT} session list", shell=True,
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
return SESSION_NAME in r.stdout
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
339
dgut-gui/dgut_gui.py
Normal file
339
dgut-gui/dgut_gui.py
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
莞工教务系统 GUI - 图形化选课工具
|
||||||
|
===================================
|
||||||
|
自适应探测选课类型标签页,支持选修/必修/公选/跨专业等。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
# 引入同目录下的核心模块
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from dgut_core import (
|
||||||
|
ORIGIN,
|
||||||
|
discover_tabs,
|
||||||
|
drop_course,
|
||||||
|
ensure_in_course_selection,
|
||||||
|
ensure_session,
|
||||||
|
get_courses,
|
||||||
|
get_cycles,
|
||||||
|
select_course,
|
||||||
|
)
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
ctk.set_appearance_mode("System")
|
||||||
|
ctk.set_default_color_theme("green")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── GUI 组件 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CourseRow(ctk.CTkFrame):
|
||||||
|
"""单行课程。"""
|
||||||
|
def __init__(self, parent, course: dict, on_action, **kwargs):
|
||||||
|
super().__init__(parent, height=40, corner_radius=6, **kwargs)
|
||||||
|
self.course = course
|
||||||
|
self.on_action = on_action
|
||||||
|
self.pack(fill="x", padx=4, pady=2)
|
||||||
|
|
||||||
|
is_sel = course.get("xkzt") == "9"
|
||||||
|
kcmc = course.get("kcmc", "-")
|
||||||
|
kch = course.get("kch", "-")
|
||||||
|
xf = course.get("xf", "-")
|
||||||
|
syrs = int(course.get("syrs", 0) or 0)
|
||||||
|
pkrs = int(course.get("pkrs", 0) or 0)
|
||||||
|
xqmc = course.get("xqmc", "-")
|
||||||
|
|
||||||
|
ctk.CTkLabel(self, text="✅" if is_sel else "⬜",
|
||||||
|
width=30, anchor="center", font=("", 14)).pack(side="left", padx=(8, 2))
|
||||||
|
|
||||||
|
txt = kcmc if len(kcmc) <= 18 else kcmc[:16] + ".."
|
||||||
|
ctk.CTkLabel(self, text=txt, width=160, anchor="w",
|
||||||
|
font=ctk.CTkFont(size=13, weight="bold")).pack(side="left", padx=4)
|
||||||
|
|
||||||
|
ctk.CTkLabel(self, text=kch, width=80, anchor="center",
|
||||||
|
font=("Consolas", 12)).pack(side="left", padx=4)
|
||||||
|
ctk.CTkLabel(self, text=str(xf), width=40, anchor="center",
|
||||||
|
font=("", 13)).pack(side="left", padx=2)
|
||||||
|
|
||||||
|
cap_c = "#e74c3c" if syrs <= 5 else "#2ecc71" if syrs > 20 else "#f39c12"
|
||||||
|
ctk.CTkLabel(self, text=f"{syrs}/{pkrs}", width=60, anchor="center",
|
||||||
|
text_color=cap_c, font=("Consolas", 12)).pack(side="left", padx=4)
|
||||||
|
|
||||||
|
short = xqmc[:4] if len(xqmc) > 4 else xqmc
|
||||||
|
ctk.CTkLabel(self, text=short, width=60, anchor="center",
|
||||||
|
font=("", 12)).pack(side="left", padx=2)
|
||||||
|
|
||||||
|
if is_sel:
|
||||||
|
btn = ctk.CTkButton(self, text="退课", width=60, height=28,
|
||||||
|
fg_color="#e74c3c", hover_color="#c0392b",
|
||||||
|
font=ctk.CTkFont(size=12), command=self._drop)
|
||||||
|
else:
|
||||||
|
btn = ctk.CTkButton(self, text="选课", width=60, height=28,
|
||||||
|
fg_color="#2ecc71", hover_color="#27ae60",
|
||||||
|
font=ctk.CTkFont(size=12), command=self._select)
|
||||||
|
btn.pack(side="right", padx=(4, 8))
|
||||||
|
|
||||||
|
def _select(self):
|
||||||
|
self.on_action("select", self.course)
|
||||||
|
|
||||||
|
def _drop(self):
|
||||||
|
self.on_action("drop", self.course)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 主窗口 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class DgutGUI(ctk.CTk):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.title("🎓 莞工教务选课系统")
|
||||||
|
self.geometry("920x720")
|
||||||
|
self.minsize(800, 600)
|
||||||
|
|
||||||
|
self.is_loading = False
|
||||||
|
self.courses: list[dict] = []
|
||||||
|
self.filtered: list[dict] = []
|
||||||
|
self.tabs: list[dict] = []
|
||||||
|
self.current_tab: dict = {}
|
||||||
|
self.cycle_id: str = ""
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
self.after(500, self._init_load)
|
||||||
|
|
||||||
|
# ─── 界面 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
self.grid_rowconfigure(2, weight=1)
|
||||||
|
|
||||||
|
# 标题栏
|
||||||
|
tf = ctk.CTkFrame(self, height=48, corner_radius=0)
|
||||||
|
tf.grid(row=0, column=0, sticky="ew")
|
||||||
|
tf.grid_columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
ctk.CTkLabel(tf, text="🎓 莞工教务选课系统",
|
||||||
|
font=ctk.CTkFont(size=20, weight="bold")).grid(row=0, column=0, padx=20, pady=8)
|
||||||
|
|
||||||
|
self.cycle_lbl = ctk.CTkLabel(tf, text="选课轮次: 加载中...", font=ctk.CTkFont(size=13))
|
||||||
|
self.cycle_lbl.grid(row=0, column=1, padx=10, sticky="w")
|
||||||
|
|
||||||
|
self.stat_lbl = ctk.CTkLabel(tf, text="📊 -", font=ctk.CTkFont(size=13))
|
||||||
|
self.stat_lbl.grid(row=0, column=2, padx=20, sticky="e")
|
||||||
|
|
||||||
|
# 工具栏
|
||||||
|
tb = ctk.CTkFrame(self, height=40, corner_radius=0)
|
||||||
|
tb.grid(row=1, column=0, sticky="ew", pady=2)
|
||||||
|
tb.grid_columnconfigure(3, weight=1)
|
||||||
|
|
||||||
|
self.refresh_btn = ctk.CTkButton(tb, text="🔄 刷新",
|
||||||
|
font=ctk.CTkFont(size=13), command=self._refresh)
|
||||||
|
self.refresh_btn.grid(row=0, column=0, padx=(10, 4), pady=4)
|
||||||
|
|
||||||
|
ctk.CTkLabel(tb, text="课程类型:", font=ctk.CTkFont(size=13)).grid(row=0, column=1, padx=(10, 2))
|
||||||
|
self.tab_var = ctk.StringVar(value="加载中...")
|
||||||
|
self.tab_menu = ctk.CTkOptionMenu(tb, variable=self.tab_var, values=["加载中..."],
|
||||||
|
command=self._on_tab_switch,
|
||||||
|
width=210, font=ctk.CTkFont(size=13))
|
||||||
|
self.tab_menu.grid(row=0, column=2, padx=4)
|
||||||
|
|
||||||
|
self.sv = ctk.StringVar()
|
||||||
|
self.sv.trace_add("write", lambda *_: self._filter())
|
||||||
|
ctk.CTkEntry(tb, textvariable=self.sv, placeholder_text="🔍 搜索...",
|
||||||
|
width=200).grid(row=0, column=3, padx=(20, 4), pady=4, sticky="e")
|
||||||
|
ctk.CTkButton(tb, text="✕", width=36, font=ctk.CTkFont(size=12),
|
||||||
|
command=lambda: self.sv.set("")).grid(row=0, column=4)
|
||||||
|
|
||||||
|
# 课程列表
|
||||||
|
lc = ctk.CTkFrame(self)
|
||||||
|
lc.grid(row=2, column=0, sticky="nsew", padx=10, pady=(5, 0))
|
||||||
|
lc.grid_columnconfigure(0, weight=1)
|
||||||
|
lc.grid_rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
hdr = ctk.CTkFrame(lc, height=32, corner_radius=6, fg_color=("gray85", "gray20"))
|
||||||
|
hdr.grid(row=0, column=0, sticky="ew", padx=4, pady=(4, 2))
|
||||||
|
for ci, (t, w) in enumerate([("状态", 50), ("课程名称", 1), ("编号", 85),
|
||||||
|
("学分", 48), ("容量", 68), ("校区", 68), ("操作", 80)]):
|
||||||
|
kw = {"text": t, "font": ctk.CTkFont(size=12, weight="bold")}
|
||||||
|
if w == 1:
|
||||||
|
kw["anchor"] = "w"
|
||||||
|
lbl = ctk.CTkLabel(hdr, **kw)
|
||||||
|
lbl.grid(row=0, column=ci, padx=2, sticky="ew")
|
||||||
|
hdr.grid_columnconfigure(ci, weight=1)
|
||||||
|
else:
|
||||||
|
kw["width"] = w
|
||||||
|
kw["anchor"] = "center"
|
||||||
|
ctk.CTkLabel(hdr, **kw).grid(row=0, column=ci, padx=2)
|
||||||
|
|
||||||
|
self.sf = ctk.CTkScrollableFrame(lc, corner_radius=6)
|
||||||
|
self.sf.grid(row=1, column=0, sticky="nsew", padx=4, pady=(0, 4))
|
||||||
|
self.sf.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
lf = ctk.CTkFrame(self, height=110, corner_radius=0)
|
||||||
|
lf.grid(row=3, column=0, sticky="ew", pady=(5, 0))
|
||||||
|
lf.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
ctk.CTkLabel(lf, text="📝 操作日志",
|
||||||
|
font=ctk.CTkFont(size=12, weight="bold")).grid(row=0, column=0, padx=15, pady=(5, 0), sticky="w")
|
||||||
|
|
||||||
|
self.log = ctk.CTkTextbox(lf, height=60, corner_radius=6,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=11))
|
||||||
|
self.log.grid(row=1, column=0, sticky="ew", padx=10, pady=(2, 8))
|
||||||
|
self.log.configure(state="disabled")
|
||||||
|
|
||||||
|
def _log(self, msg):
|
||||||
|
def _do():
|
||||||
|
self.log.configure(state="normal")
|
||||||
|
self.log.insert("end", f"[{time.strftime('%H:%M:%S')}] {msg}\n")
|
||||||
|
self.log.see("end")
|
||||||
|
self.log.configure(state="disabled")
|
||||||
|
self.after(0, _do)
|
||||||
|
|
||||||
|
def _loading(self, on: bool):
|
||||||
|
self.is_loading = on
|
||||||
|
st = "disabled" if on else "normal"
|
||||||
|
txt = "⏳ ..." if on else "🔄 刷新"
|
||||||
|
self.after(0, lambda: self.refresh_btn.configure(state=st, text=txt))
|
||||||
|
|
||||||
|
# ─── 数据 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _init_load(self):
|
||||||
|
self._log("🔄 连接教务系统...")
|
||||||
|
threading.Thread(target=self._load_cycles, daemon=True).start()
|
||||||
|
|
||||||
|
def _refresh(self):
|
||||||
|
if not self.is_loading:
|
||||||
|
self._log("🔄 刷新...")
|
||||||
|
threading.Thread(target=self._load_cycles, daemon=True).start()
|
||||||
|
|
||||||
|
def _load_cycles(self):
|
||||||
|
self._loading(True)
|
||||||
|
try:
|
||||||
|
cycles = get_cycles()
|
||||||
|
if not cycles:
|
||||||
|
self._log("⚠️ 无可用选课轮次")
|
||||||
|
self.after(0, lambda: self.cycle_lbl.configure(text="选课轮次: 暂无"))
|
||||||
|
return
|
||||||
|
|
||||||
|
cyc = next((c for c in cycles if c.get("xkzt") == "1"), cycles[0])
|
||||||
|
self.cycle_id = cyc.get("jx0502zbid", "")
|
||||||
|
info = f"{cyc.get('xnxq01id', '')} {cyc.get('xklc_mc', '')}"
|
||||||
|
self.after(0, lambda: self.cycle_lbl.configure(text=f"选课轮次: {info}"))
|
||||||
|
self._log(f"✅ {info}")
|
||||||
|
|
||||||
|
# 探测标签
|
||||||
|
if self.cycle_id:
|
||||||
|
self._log("🔍 探测选课类型标签页...")
|
||||||
|
self.tabs = discover_tabs(self.cycle_id)
|
||||||
|
if self.tabs:
|
||||||
|
labels = [f"{t['label']} {'✅' if t.get('api') else '📄'}" for t in self.tabs]
|
||||||
|
has_api = [t for t in self.tabs if t.get("api")]
|
||||||
|
|
||||||
|
def _update():
|
||||||
|
self.tab_menu.configure(values=labels)
|
||||||
|
if has_api:
|
||||||
|
idx = self.tabs.index(has_api[0])
|
||||||
|
self.tab_var.set(labels[idx])
|
||||||
|
self._on_tab_switch(labels[idx])
|
||||||
|
else:
|
||||||
|
self.tab_var.set(labels[0])
|
||||||
|
self.after(0, _update)
|
||||||
|
self._log(f"📑 {len(self.tabs)} 个标签页: {', '.join(t['label'] for t in self.tabs)}")
|
||||||
|
else:
|
||||||
|
self._log("⚠️ 未探测到标签页")
|
||||||
|
except RuntimeError as e:
|
||||||
|
self._log(f"❌ {e}")
|
||||||
|
finally:
|
||||||
|
self._loading(False)
|
||||||
|
|
||||||
|
def _on_tab_switch(self, selected: str):
|
||||||
|
label = selected.split(" ✅")[0].split(" 📄")[0]
|
||||||
|
tab = next((t for t in self.tabs if t["label"] == label), None)
|
||||||
|
if not tab:
|
||||||
|
return
|
||||||
|
self.current_tab = tab
|
||||||
|
if not tab.get("api"):
|
||||||
|
self._log(f"📄 {label} 为信息页")
|
||||||
|
self.courses = []
|
||||||
|
self.after(0, self._render)
|
||||||
|
return
|
||||||
|
self._log(f"📋 加载 {label}...")
|
||||||
|
threading.Thread(target=self._load_courses, args=(tab,), daemon=True).start()
|
||||||
|
|
||||||
|
def _load_courses(self, tab: dict):
|
||||||
|
self._loading(True)
|
||||||
|
try:
|
||||||
|
ref = f"{ORIGIN}{tab['iframe']}"
|
||||||
|
courses = get_courses(tab["api"], ref)
|
||||||
|
self.courses = courses
|
||||||
|
self.sv.set("")
|
||||||
|
|
||||||
|
sel = [c for c in courses if c.get("xkzt") == "9"]
|
||||||
|
sc = sum(float(c.get("xf", 0)) for c in sel)
|
||||||
|
self.after(0, lambda: self.stat_lbl.configure(
|
||||||
|
text=f"📊 已选 {sc:.1f}学分/{len(sel)}门 | 共{len(courses)}门"))
|
||||||
|
self._log(f"📊 {len(courses)}门,已选{len(sel)}门({sc:.1f}学分)")
|
||||||
|
self.after(0, self._render)
|
||||||
|
except RuntimeError as e:
|
||||||
|
self._log(f"❌ {e}")
|
||||||
|
finally:
|
||||||
|
self._loading(False)
|
||||||
|
|
||||||
|
def _filter(self):
|
||||||
|
kw = self.sv.get().strip().lower()
|
||||||
|
if not kw:
|
||||||
|
self.filtered = self.courses
|
||||||
|
else:
|
||||||
|
self.filtered = [c for c in self.courses
|
||||||
|
if kw in c.get("kcmc", "").lower() or kw in c.get("kch", "").lower()]
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _render(self):
|
||||||
|
for w in self.sf.winfo_children():
|
||||||
|
w.destroy()
|
||||||
|
disp = self.filtered if self.sv.get().strip() else self.courses
|
||||||
|
if not self.courses:
|
||||||
|
ctk.CTkLabel(self.sf, text="📭 暂无课程数据",
|
||||||
|
font=ctk.CTkFont(size=14)).pack(pady=60)
|
||||||
|
elif not disp:
|
||||||
|
ctk.CTkLabel(self.sf, text="📭 无匹配课程",
|
||||||
|
font=ctk.CTkFont(size=14)).pack(pady=60)
|
||||||
|
else:
|
||||||
|
for c in disp:
|
||||||
|
CourseRow(self.sf, c, self._on_action)
|
||||||
|
|
||||||
|
# ─── 操作 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_action(self, action: str, course: dict):
|
||||||
|
if self.is_loading or not self.current_tab:
|
||||||
|
return
|
||||||
|
ref = f"{ORIGIN}{self.current_tab['iframe']}"
|
||||||
|
kcid, kcmc, jxid = course["kch"], course["kcmc"], course["jx0404id"]
|
||||||
|
self._log(f"📤 {'选课' if action == 'select' else '退课'}: {kcmc}")
|
||||||
|
t = threading.Thread(target=self._do, args=(action, kcid, jxid, kcmc, ref),
|
||||||
|
daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
def _do(self, action: str, kcid: str, jxid: str, kcmc: str, ref: str):
|
||||||
|
self._loading(True)
|
||||||
|
try:
|
||||||
|
fn = select_course if action == "select" else drop_course
|
||||||
|
args = (kcid, jxid, ref) if action == "select" else (jxid, ref)
|
||||||
|
result = fn(*args)
|
||||||
|
if result.get("success"):
|
||||||
|
self._log(f"✅ {kcmc}: {'选课成功' if action == 'select' else '退课成功'}")
|
||||||
|
time.sleep(0.5)
|
||||||
|
if self.current_tab:
|
||||||
|
self.after(0, lambda: self._on_tab_switch(self.tab_var.get()))
|
||||||
|
else:
|
||||||
|
self._log(f"❌ {kcmc}: {result.get('message', '失败')}")
|
||||||
|
except RuntimeError as e:
|
||||||
|
self._log(f"❌ 出错: {e}")
|
||||||
|
finally:
|
||||||
|
self._loading(False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
DgutGUI().mainloop()
|
||||||
4
dgut-gui/start.sh
Executable file
4
dgut-gui/start.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 莞工教务选课系统 GUI 启动脚本
|
||||||
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
exec "$DIR/venv/bin/python3" "$DIR/dgut_gui.py" "$@"
|
||||||
290
dgut.py
Executable file
290
dgut.py
Executable file
@@ -0,0 +1,290 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
莞工教务系统 CLI - 直接通过API操作选课
|
||||||
|
======================================
|
||||||
|
依赖: browser-act (已安装), Python 3.x
|
||||||
|
使用前需要已在 browser-act 中登录教务系统并保持会话活跃。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 dgut.py list-cycles # 列出可选课的轮次
|
||||||
|
python3 dgut.py list-tabs # 探测选课界面开放了哪些标签页
|
||||||
|
python3 dgut.py list-courses [标签序号] # 列出课程 (默认第1个有API的标签)
|
||||||
|
python3 dgut.py select <kcid/名称> # 选课
|
||||||
|
python3 dgut.py drop <kcid/名称> # 退课
|
||||||
|
python3 dgut.py search <关键词> # 搜索课程
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# 将 dgut-gui 目录加入 path 以导入 dgut_core
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "dgut-gui"))
|
||||||
|
|
||||||
|
from dgut_core import (
|
||||||
|
ORIGIN,
|
||||||
|
discover_tabs,
|
||||||
|
drop_course,
|
||||||
|
ensure_in_course_selection,
|
||||||
|
ensure_session,
|
||||||
|
fetch_api,
|
||||||
|
get_courses,
|
||||||
|
get_cycles,
|
||||||
|
select_course,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 全局状态 ────────────────────────────────────────────────────────────
|
||||||
|
_current_tab: Optional[dict] = None
|
||||||
|
_tabs: list[dict] = []
|
||||||
|
_course_cache: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 命令实现 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def cmd_list_cycles():
|
||||||
|
cycles = get_cycles()
|
||||||
|
if not cycles:
|
||||||
|
print("📭 当前没有可选的选课轮次。")
|
||||||
|
return
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" 📚 可选选课轮次 ({len(cycles)} 个)")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
for i, cyc in enumerate(cycles, 1):
|
||||||
|
print(f"\n [{i}] {cyc.get('xklc_mc', '未知')}")
|
||||||
|
print(f" 学期: {cyc.get('xnxq01id', '-')}")
|
||||||
|
print(f" 时间: {cyc.get('xksj', '-')}")
|
||||||
|
print(f" 状态: {'进行中 ✅' if cyc.get('xkzt') == '1' else '未开始 ⏳'}")
|
||||||
|
print(f" 选退: {cyc.get('txkzmc', '-')}")
|
||||||
|
print(f" jx0502zbid: {cyc.get('jx0502zbid', '-')}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list_tabs():
|
||||||
|
global _tabs
|
||||||
|
|
||||||
|
print("🔄 进入选课界面...")
|
||||||
|
if not ensure_in_course_selection():
|
||||||
|
print("⚠️ 无法进入选课界面,请检查浏览器状态。")
|
||||||
|
return
|
||||||
|
|
||||||
|
cycles = get_cycles()
|
||||||
|
if not cycles:
|
||||||
|
print("📭 没有选课轮次,无法探测标签页。")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 用第一个进行中的轮次
|
||||||
|
cyc = next((c for c in cycles if c.get("xkzt") == "1"), cycles[0])
|
||||||
|
jx0502zbid = cyc.get("jx0502zbid", "")
|
||||||
|
if not jx0502zbid:
|
||||||
|
print("❌ 无法获取轮次ID。")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n📋 选课轮次: {cyc.get('xklc_mc', '-')}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
_tabs = discover_tabs(jx0502zbid)
|
||||||
|
if not _tabs:
|
||||||
|
print("⚠️ 未探测到任何标签页。")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n 共发现 {len(_tabs)} 个标签页:\n")
|
||||||
|
for i, t in enumerate(_tabs, 1):
|
||||||
|
api_tag = " ✅ 有课程数据" if t.get("api") else " 📄 信息页"
|
||||||
|
print(f" [{i}] {t['label']}{api_tag}")
|
||||||
|
print(f" ID: {t['id']} | API: {t.get('api', '无')}")
|
||||||
|
|
||||||
|
return _tabs
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list_courses(tab_index: Optional[int] = None):
|
||||||
|
global _tabs, _course_cache, _current_tab
|
||||||
|
|
||||||
|
if not _tabs:
|
||||||
|
print("🔄 首次使用,先探测标签页...")
|
||||||
|
result = cmd_list_tabs()
|
||||||
|
if not result:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 找有API的标签
|
||||||
|
course_tabs = [(i, t) for i, t in enumerate(_tabs) if t.get("api")]
|
||||||
|
if not course_tabs:
|
||||||
|
print("⚠️ 没有可加载课程数据的标签页。")
|
||||||
|
return
|
||||||
|
|
||||||
|
if tab_index is not None:
|
||||||
|
idx = tab_index - 1
|
||||||
|
if idx < 0 or idx >= len(_tabs) or not _tabs[idx].get("api"):
|
||||||
|
print(f"❌ 标签 [{tab_index}] 无效或无课程数据。")
|
||||||
|
return
|
||||||
|
chosen = _tabs[idx]
|
||||||
|
else:
|
||||||
|
chosen = course_tabs[0][1]
|
||||||
|
|
||||||
|
_current_tab = chosen
|
||||||
|
print(f"\n📋 加载: {chosen['label']}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
referer = f"{ORIGIN}{chosen['iframe']}"
|
||||||
|
courses = get_courses(chosen["api"], referer)
|
||||||
|
_course_cache = courses
|
||||||
|
|
||||||
|
if not courses:
|
||||||
|
print("📭 无课程数据。")
|
||||||
|
return
|
||||||
|
|
||||||
|
selected = [c for c in courses if c.get("xkzt") == "9"]
|
||||||
|
sel_credits = sum(float(c.get("xf", 0)) for c in selected)
|
||||||
|
print(f"\n 共 {len(courses)} 门课程,已选 {len(selected)} 门 ({sel_credits:.1f}学分)\n")
|
||||||
|
|
||||||
|
for i, c in enumerate(courses, 1):
|
||||||
|
xkzt = c.get("xkzt", "0")
|
||||||
|
status = "✅ 已选" if xkzt == "9" else "⬜ 可选"
|
||||||
|
action = "[退课]" if xkzt == "9" else "[选课]"
|
||||||
|
print(f" [{i:2d}] {c.get('kcmc', '-'):20s} {c.get('kch', '-'):8s} "
|
||||||
|
f"{str(c.get('xf', '-')):4s}学分 余{c.get('syrs', '?'):>3s}座 {status} {action}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_select(kcid_or_name: str):
|
||||||
|
global _course_cache, _current_tab
|
||||||
|
|
||||||
|
if not _course_cache:
|
||||||
|
print("🔄 先加载课程列表...")
|
||||||
|
cmd_list_courses()
|
||||||
|
|
||||||
|
if not _current_tab:
|
||||||
|
print("❌ 未选择课程类型。先执行 list-courses。")
|
||||||
|
return
|
||||||
|
|
||||||
|
course = _find_course(kcid_or_name)
|
||||||
|
if not course:
|
||||||
|
print(f"❌ 未找到 '{kcid_or_name}'")
|
||||||
|
return
|
||||||
|
if course.get("xkzt") == "9":
|
||||||
|
print(f"⚠️ '{course['kcmc']}' 已选,无需重复选课。")
|
||||||
|
return
|
||||||
|
|
||||||
|
referer = f"{ORIGIN}{_current_tab['iframe']}"
|
||||||
|
result = select_course(course["kch"], course["jx0404id"], referer)
|
||||||
|
if result.get("success"):
|
||||||
|
print(f"✅ 选课成功: {course['kcmc']} ({result.get('message', '')})")
|
||||||
|
else:
|
||||||
|
print(f"❌ 选课失败: {result.get('message', '未知错误')}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_drop(kcid_or_name: str):
|
||||||
|
global _course_cache, _current_tab
|
||||||
|
|
||||||
|
if not _course_cache:
|
||||||
|
print("🔄 先加载课程列表...")
|
||||||
|
cmd_list_courses()
|
||||||
|
|
||||||
|
if not _current_tab:
|
||||||
|
print("❌ 未选择课程类型。先执行 list-courses。")
|
||||||
|
return
|
||||||
|
|
||||||
|
course = _find_course(kcid_or_name)
|
||||||
|
if not course:
|
||||||
|
print(f"❌ 未找到 '{kcid_or_name}'")
|
||||||
|
return
|
||||||
|
if course.get("xkzt") != "9":
|
||||||
|
print(f"⚠️ '{course['kcmc']}' 未选,无需退课。")
|
||||||
|
return
|
||||||
|
|
||||||
|
referer = f"{ORIGIN}{_current_tab['iframe']}"
|
||||||
|
result = drop_course(course["jx0404id"], referer)
|
||||||
|
if result.get("success"):
|
||||||
|
print(f"✅ 退课成功: {course['kcmc']}")
|
||||||
|
else:
|
||||||
|
print(f"❌ 退课失败: {result.get('message', '未知错误')}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_search(keyword: str):
|
||||||
|
global _course_cache
|
||||||
|
if not _course_cache:
|
||||||
|
print("🔄 先加载课程列表...")
|
||||||
|
cmd_list_courses()
|
||||||
|
|
||||||
|
kw = keyword.lower()
|
||||||
|
results = [c for c in _course_cache
|
||||||
|
if kw in c.get("kcmc", "").lower() or kw in c.get("kch", "").lower()]
|
||||||
|
if not results:
|
||||||
|
print(f"📭 未找到包含 '{keyword}' 的课程。")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n 🔍 找到 {len(results)} 门课程:\n")
|
||||||
|
for c in results:
|
||||||
|
status = "✅ 已选" if c.get("xkzt") == "9" else "⬜ 可选"
|
||||||
|
print(f" {c.get('kcmc', '-'):20s} {c.get('kch', '-'):8s} "
|
||||||
|
f"{str(c.get('xf', '-')):4s}学分 {status}")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_course(kcid_or_name: str) -> Optional[dict]:
|
||||||
|
for c in _course_cache:
|
||||||
|
if c.get("kch") == kcid_or_name or c.get("kcmc") == kcid_or_name:
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CLI ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="莞工教务系统 CLI - 选课工具(自适应选课类型)",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=textwrap.dedent("""
|
||||||
|
示例:
|
||||||
|
python3 dgut.py list-cycles # 查看可选课轮次
|
||||||
|
python3 dgut.py list-tabs # 探测选课类型标签
|
||||||
|
python3 dgut.py list-courses # 默认加载第1个有数据的标签
|
||||||
|
python3 dgut.py list-courses 2 # 加载第2个标签
|
||||||
|
python3 dgut.py select 0410578 # 按编号选课
|
||||||
|
python3 dgut.py select "微信小程序" # 按名称选课
|
||||||
|
python3 dgut.py drop 0410531 # 退课
|
||||||
|
python3 dgut.py search 鸿蒙 # 搜索课程
|
||||||
|
"""),
|
||||||
|
)
|
||||||
|
parser.add_argument("command", nargs="?",
|
||||||
|
choices=["list-cycles", "list-tabs", "list-courses",
|
||||||
|
"select", "drop", "search"],
|
||||||
|
help="操作命令")
|
||||||
|
parser.add_argument("arg", nargs="?", default="", help="命令参数")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.command:
|
||||||
|
parser.print_help()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not ensure_session():
|
||||||
|
print(f"⚠️ 会话 '{'jwx-s1'}' 未找到。请先通过 browser-act 登录教务系统。")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.command == "list-cycles":
|
||||||
|
cmd_list_cycles()
|
||||||
|
elif args.command == "list-tabs":
|
||||||
|
cmd_list_tabs()
|
||||||
|
elif args.command == "list-courses":
|
||||||
|
idx = int(args.arg) if args.arg and args.arg.isdigit() else None
|
||||||
|
cmd_list_courses(idx)
|
||||||
|
elif args.command == "select":
|
||||||
|
if not args.arg:
|
||||||
|
print("❌ 请指定课程编号或名称")
|
||||||
|
return
|
||||||
|
cmd_select(args.arg)
|
||||||
|
elif args.command == "drop":
|
||||||
|
if not args.arg:
|
||||||
|
print("❌ 请指定课程编号或名称")
|
||||||
|
return
|
||||||
|
cmd_drop(args.arg)
|
||||||
|
elif args.command == "search":
|
||||||
|
if not args.arg:
|
||||||
|
print("❌ 请指定搜索关键词")
|
||||||
|
return
|
||||||
|
cmd_search(args.arg)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"\n❌ 错误: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user