Przeglądaj źródła

feat(JLU): 重构调课逻辑并新增 WebVPN 入口 (#718)

Lei_meng 3 dni temu
rodzic
commit
ca9632c176
2 zmienionych plików z 78 dodań i 69 usunięć
  1. 69 68
      resources/JLU/JLU_01.js
  2. 9 1
      resources/JLU/adapters.yaml

+ 69 - 68
resources/JLU/JLU_01.js

@@ -1,20 +1,22 @@
 // 文件: JLU_01.js (JLU 吉林大学教务适配)
-// 经 vpn.jlu.edu.cn 网页 VPN 访问"(新)教务管理系统"(金智旧版接口)抓取课表
-// 流程:VPN 登录 -> 进入(新)教务"我的课表"页 -> 触发本脚本
-
-// ========== 运行时提取 VPN 代理基址 ==========
-// Sangfor webvpn 把目标主机加密成 hex 段放进 URL,该 hex 是【会话相关】的,
-// 不能硬编码。脚本在课表页运行时,从当前页 URL 提取基址,保证会话无关。
+// 经 iedu.jlu.edu.cn 直连"(新)教务管理系统"(金智旧版接口)抓取课表
+// 流程:统一身份认证登录 -> 进入(新)教务"我的课表"页 -> 触发本脚本
+// 兼容:经 Sangfor 网页 VPN 代理访问时基址可自动适配,无需改动脚本
+
+// ========== 运行时提取教务站点基址 ==========
+// 公网直连时基址为 https://iedu.jlu.edu.cn;若经 Sangfor webvpn 代理,则为
+// https://vpn.jlu.edu.cn/https/<会话hex>(该 hex 是【会话相关】的,不能硬编码)。
+// 两种情况都在课表页运行时从当前页 URL 提取基址,保证会话无关。
 const PAGE_URL = window.location.href;
 const _idx = PAGE_URL.indexOf("/jwapp/sys/");
 if (_idx < 0) {
     window.shiguangBridge.showToast("请先进入(新)教务的「我的课表」页面再导入");
-    throw new Error("未在课表页运行,无法定位 VPN 基址");
+    throw new Error("未在课表页运行,无法定位教务站点基址");
 }
-const VPN_BASE = PAGE_URL.substring(0, _idx);            // https://vpn.jlu.edu.cn/https/<会话hex>
+const SITE_BASE = PAGE_URL.substring(0, _idx);            // https://iedu.jlu.edu.cn
 const REFERER = PAGE_URL;                                 // 当前课表页 URL 作为 Referer
 
-// 七个金智接口(均 POST,credentials:include 带 VPN 会话票 cookie)
+// 七个金智接口(均 POST,credentials:include 携带教务站点会话 cookie)
 const EP_CURRENT_SEM   = "/jwapp/sys/wdkb/modules/jshkcb/dqxnxq.do";    // 当前学期(空 body)
 const EP_SEMESTER_LIST = "/jwapp/sys/wdkb/modules/jshkcb/xnxqcx.do";   // 学期列表(*order=-DM 倒序,手动选择用)
 const EP_SEM_CAL       = "/jwapp/sys/wdkb/modules/xskcb/cxxljc.do";     // 学期校历(XN+XQ -> 起始日/总周次)
@@ -34,7 +36,7 @@ async function api(path, body) {
     if (hasBody) {
         headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8";
     }
-    const res = await fetch(VPN_BASE + path, {
+    const res = await fetch(SITE_BASE + path, {
         method: "POST",
         headers: headers,
         body: hasBody ? body : "",
@@ -54,10 +56,16 @@ function parseWeeksFromSkzc(skzc) {
     return weeks;
 }
 
+// ========== 解析:教师字段 ==========
+// 教务格式为 "姓名/工号",多教师之间用 "," 分隔(工号不能当成第二位教师)
+function cleanTeacher(raw) {
+    return (raw || "").split(",").map(t => t.split("/")[0].trim()).filter(Boolean).join(",");
+}
+
 // ========== 解析:单条金智课程 -> 拾光模型 ==========
 function parseSingleCourse(raw) {
     const name = raw.KCM;
-    const teacher = raw.SKJS ? raw.SKJS.replace(/\//g, ",").trim() : "未知教师";
+    const teacher = cleanTeacher(raw.SKJS) || "未知教师";
     const position = raw.JASMC || "待定";          // JASMC 可能为 null
     const day = parseInt(raw.SKXQ, 10);            // 1=周一...7=周日
     const startSection = parseInt(raw.KSJC, 10);
@@ -68,10 +76,7 @@ function parseSingleCourse(raw) {
     }
     return {
         name, teacher, position, day, startSection, endSection, weeks,
-        _kbId: raw.KBID,          // 内部字段,供调课匹配用,保存前清除
-        _day: day,
-        _startSection: startSection,
-        _endSection: endSection
+        _kbId: raw.KBID     // 内部字段,调课按 KBID 匹配用,保存前清除
     };
 }
 
@@ -100,7 +105,7 @@ function parseSemesterCalendar(rows) {
 async function promptUserToStart() {
     const confirmed = await window.shiguangBridgePromise.showAlert(
         "吉林大学课表导入",
-        "请确保您已登录吉大 VPN(vpn.jlu.edu.cn),并已进入(新)教务管理系统的「我的课表」页面。\n未停留在课表页可能导致会话失效,无法获取数据。",
+        "请确保您已登录(新)教务管理系统,并已进入「我的课表」页面。\n未停留在课表页可能导致会话失效,无法获取数据。",
         "开始导入"
     );
     if (!confirmed) {
@@ -203,7 +208,7 @@ async function fetchCourses(xnxqdm) {
     return rows.map(parseSingleCourse).filter(c => c !== null);
 }
 
-// ========== 调课/停课(镜像 CTGU,字段同 legacy 金智)==========
+// ========== 调课/停课(xsdkkc.do,字段已按真实抓包核对)==========
 async function fetchCourseChanges(xnxqdm) {
     const res = await api(EP_CHANGES, `XNXQDM=${xnxqdm}&*order=-SQSJ`);
     return res?.datas?.xsdkkc?.rows || [];
@@ -216,8 +221,8 @@ async function fetchUndeterminedCourses(xnxqdm) {
 
 /**
  * 将调课/停课变更应用到已解析课程列表(就地修改)
- * - 停课:按 KBID+星期+节次 匹配原始课,删除受影响周次(change.SKZC)
- * - 调时间/地点(TKLXDM 01/03):用新时间/周次/师/地 新建一条课程
+ * - 停课:按 KBID 匹配原始课,删除受影响周次(change.SKZC)
+ * - 调课:新时间字段齐全时,按新时间/周次/师/地落位;同一 KBID 已存在该时段则并入周次
  * - weeks 清空的课程(全周停课)被过滤掉
  * @returns {{courses: Array, appliedCount: number}}
  */
@@ -225,45 +230,52 @@ function applyCourseChanges(parsedCourses, rawChanges) {
     let appliedCount = 0;
     for (const change of rawChanges) {
         const kbID = change.KBID;
-        const originalTeacher = change.YSKJS ? change.YSKJS.replace(/\//g, ",").trim() : "未知教师";
-        const weeksToRemove = parseWeeksFromSkzc(change.SKZC);
-        let changeApplied = false;
-
-        const affected = parsedCourses.filter(c =>
-            c._kbId === kbID &&
-            c._day === parseInt(change.SKXQ, 10) &&
-            c._startSection === parseInt(change.KSJC, 10) &&
-            c._endSection === parseInt(change.JSJC, 10)
-        );
-        if (affected.length === 0) continue;
-
-        if (weeksToRemove.length > 0) {
-            affected.forEach(course => {
-                const before = course.weeks.length;
-                course.weeks = course.weeks.filter(w => !weeksToRemove.includes(w));
-                if (course.weeks.length < before) changeApplied = true;
-            });
+        // 课表接口同一学期内 KBID 唯一(实测 11/11),按 KBID 即可精确定位
+        const affected = parsedCourses.filter(c => c._kbId === kbID);
+        if (affected.length === 0) {
+            console.warn("调课未匹配到课程, KBID:", kbID);
+            continue;
         }
+        let changeApplied = false;
 
-        const isTimeLocationChange = (change.TKLXDM === "01" || change.TKLXDM === "03");
-        if (isTimeLocationChange && change.XSKZC && change.XSKXQ && change.XKSJC && change.XJSJC) {
-            const newWeeks = parseWeeksFromSkzc(change.XSKZC);
-            if (newWeeks.length > 0) {
+        // 1. 从原课剔除被调走的周次(课表给整学期周次,调课记录只给被调走的那几周)
+        const weeksToRemove = parseWeeksFromSkzc(change.SKZC);
+        affected.forEach(course => {
+            const before = course.weeks.length;
+            course.weeks = course.weeks.filter(w => !weeksToRemove.includes(w));
+            if (course.weeks.length < before) changeApplied = true;
+        });
+
+        // 2. 落位到新时间/地点(新时间字段齐全即为调课,缺字段的即为停课)
+        const newWeeks = parseWeeksFromSkzc(change.XSKZC);
+        const newDay = parseInt(change.XSKXQ, 10);
+        const newStart = parseInt(change.XKSJC, 10);
+        const newEnd = parseInt(change.XJSJC, 10);
+        if (newWeeks.length > 0 && newDay && newStart && newEnd) {
+            const newPosition = change.XJASMC || change.JASMC || "待定";
+            // 同一 KBID 的同一课位(含教室)已存在则并入周次,避免产生重复条目
+            const existing = parsedCourses.find(c =>
+                c._kbId === kbID &&
+                c.day === newDay &&
+                c.startSection === newStart &&
+                c.endSection === newEnd &&
+                c.position === newPosition
+            );
+            if (existing) {
+                existing.weeks = [...new Set([...existing.weeks, ...newWeeks])].sort((a, b) => a - b);
+            } else {
                 parsedCourses.push({
                     name: change.KCM,
-                    teacher: change.XSKJS ? change.XSKJS.replace(/\//g, ",").trim() : originalTeacher,
-                    position: change.XJASMC || change.JASMC || "待定",
-                    day: parseInt(change.XSKXQ, 10),
-                    startSection: parseInt(change.XKSJC, 10),
-                    endSection: parseInt(change.XJSJC, 10),
+                    teacher: cleanTeacher(change.XSKJS) || cleanTeacher(change.YSKJS) || "未知教师",
+                    position: newPosition,
+                    day: newDay,
+                    startSection: newStart,
+                    endSection: newEnd,
                     weeks: newWeeks,
-                    _kbId: kbID,
-                    _day: parseInt(change.XSKXQ, 10),
-                    _startSection: parseInt(change.XKSJC, 10),
-                    _endSection: parseInt(change.XJSJC, 10)
+                    _kbId: kbID
                 });
-                changeApplied = true;
             }
+            changeApplied = true;
         }
         if (changeApplied) appliedCount++;
     }
@@ -273,7 +285,7 @@ function applyCourseChanges(parsedCourses, rawChanges) {
 /** 保存前清除内部匹配字段 */
 function stripInternalFields(courses) {
     return courses.map(c => {
-        delete c._kbId; delete c._day; delete c._startSection; delete c._endSection;
+        delete c._kbId;
         return c;
     });
 }
@@ -332,26 +344,15 @@ async function runImportFlow() {
             return;
         }
 
-        // 4.5 调课/停课(防御性:失败或无数据则跳过,不影响已验证的主流程)
+        // 4.5 调课/停课(查询异常不阻塞主流程)
         let finalCourses = courses;
         let changeNote = "";
         try {
             const changes = await fetchCourseChanges(sem.xnxqdm);
-            if (changes.length === 0) {
-                changeNote = "无调课信息";
-            } else {
-                const result = applyCourseChanges(courses, changes);
-                finalCourses = result.courses;
-                if (result.appliedCount > 0) {
-                    changeNote = `已应用${result.appliedCount}条调课`;
-                } else {
-                    // 调课存在但未能自动应用:阻塞提醒用户手动核对
-                    await window.shiguangBridgePromise.showAlert(
-                        "调课提示",
-                        `检测到 ${changes.length} 条调课记录但未能自动应用。已导入原始课表,请对照教务页"调课信息"手动核对,如有出入请联系开发者。`,
-                        "知道了"
-                    );
-                }
+            const result = applyCourseChanges(courses, changes);
+            finalCourses = result.courses;
+            if (result.appliedCount > 0) {
+                changeNote = `已应用${result.appliedCount}条调课`;
             }
         } catch (e) {
             console.warn("调课处理跳过:", e);

+ 9 - 1
resources/JLU/adapters.yaml

@@ -3,6 +3,14 @@ adapters: #固定字段
     adapter_name: "吉林大学(新)教务管理系统"
     category: "BACHELOR_AND_ASSOCIATE"
     asset_js_path: "JLU_01.js" #相对路径
+    import_url: "https://iedu.jlu.edu.cn/"
+    maintainer: "Leimeng52mc"
+    description: "直接打开 iedu.jlu.edu.cn 登录(统一身份认证),进入(新)教务管理系统后定位到我的课表页面,点击导出按钮即可导出课表数据。\n校内、校外均可直接访问,推荐优先使用。若该地址在你的网络下打不开,请改用本页的 WebVPN 入口。"
+
+  - adapter_id: "JLU_02"
+    adapter_name: "吉林大学(新)教务管理系统 (WebVPN)"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "JLU_01.js"
     import_url: "https://vpn.jlu.edu.cn/login"
     maintainer: "Leimeng52mc"
-    description: "在网页版VPN系统登录后,打开(新)教务管理系统,定位到我的课表页面,点击导出按钮即可导出课表数据。"
+    description: "网页版 VPN(WebVPN)入口,与直连入口共用同一份适配脚本。登录 WebVPN 后打开(新)教务管理系统,定位到我的课表页面,点击导出按钮即可导出课表数据。\n该入口通常仅在校外网络可用(校内访问异常),校内请使用直连入口。"