From 059e1de98494626dae6c80e9c7c9f784fd4e31f2 Mon Sep 17 00:00:00 2001 From: ChoChoX Date: Sat, 27 Jun 2026 01:58:31 +0800 Subject: [PATCH] =?UTF-8?q?=E8=8E=9E=E5=B7=A5=E6=95=99=E5=8A=A1=E9=80=89?= =?UTF-8?q?=E8=AF=BE=E7=B3=BB=E7=BB=9F=20CLI=20+=20GUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过 browser-act 浏览器会话直接调用教务系统 API, 支持命令行和图形界面操作选课/退课,自适应不同选课轮次。 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 17 +++ README.md | 100 +++++++++++++ dgut-gui.sh | 1 + dgut-gui/README.md | 100 +++++++++++++ dgut-gui/dgut_core.py | 208 ++++++++++++++++++++++++++ dgut-gui/dgut_gui.py | 339 ++++++++++++++++++++++++++++++++++++++++++ dgut-gui/start.sh | 4 + dgut.py | 290 ++++++++++++++++++++++++++++++++++++ 8 files changed, 1059 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 120000 dgut-gui.sh create mode 100644 dgut-gui/README.md create mode 100644 dgut-gui/dgut_core.py create mode 100644 dgut-gui/dgut_gui.py create mode 100755 dgut-gui/start.sh create mode 100755 dgut.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..422acef --- /dev/null +++ b/.gitignore @@ -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__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..44a38a1 --- /dev/null +++ b/README.md @@ -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)。 diff --git a/dgut-gui.sh b/dgut-gui.sh new file mode 120000 index 0000000..6a7d063 --- /dev/null +++ b/dgut-gui.sh @@ -0,0 +1 @@ +/home/cho/dgut-gui/start.sh \ No newline at end of file diff --git a/dgut-gui/README.md b/dgut-gui/README.md new file mode 100644 index 0000000..44a38a1 --- /dev/null +++ b/dgut-gui/README.md @@ -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)。 diff --git a/dgut-gui/dgut_core.py b/dgut-gui/dgut_core.py new file mode 100644 index 0000000..1aff82b --- /dev/null +++ b/dgut-gui/dgut_core.py @@ -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: 从
  • 选修选课
  • 或 'xxxksfkf' 提取 + tabs: list[dict] = [] + seen_ids: set[str] = set() + for m in re.finditer( + r"""]*?onclick="jpbxxk\(['']{1}(.+?)['']{1}\)"[^>]*?>([^<]+)""", + 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 diff --git a/dgut-gui/dgut_gui.py b/dgut-gui/dgut_gui.py new file mode 100644 index 0000000..bcc1d24 --- /dev/null +++ b/dgut-gui/dgut_gui.py @@ -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() diff --git a/dgut-gui/start.sh b/dgut-gui/start.sh new file mode 100755 index 0000000..551b278 --- /dev/null +++ b/dgut-gui/start.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# 莞工教务选课系统 GUI 启动脚本 +DIR="$(cd "$(dirname "$0")" && pwd)" +exec "$DIR/venv/bin/python3" "$DIR/dgut_gui.py" "$@" diff --git a/dgut.py b/dgut.py new file mode 100755 index 0000000..86c0791 --- /dev/null +++ b/dgut.py @@ -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 # 选课 + python3 dgut.py drop # 退课 + 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()