JLU_01.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. // 文件: JLU_01.js (JLU 吉林大学教务适配)
  2. // 经 vpn.jlu.edu.cn 网页 VPN 访问"(新)教务管理系统"(金智旧版接口)抓取课表
  3. // 流程:VPN 登录 -> 进入(新)教务"我的课表"页 -> 触发本脚本
  4. // ========== 运行时提取 VPN 代理基址 ==========
  5. // Sangfor webvpn 把目标主机加密成 hex 段放进 URL,该 hex 是【会话相关】的,
  6. // 不能硬编码。脚本在课表页运行时,从当前页 URL 提取基址,保证会话无关。
  7. const PAGE_URL = window.location.href;
  8. const _idx = PAGE_URL.indexOf("/jwapp/sys/");
  9. if (_idx < 0) {
  10. AndroidBridge.showToast("请先进入(新)教务的「我的课表」页面再导入");
  11. throw new Error("未在课表页运行,无法定位 VPN 基址");
  12. }
  13. const VPN_BASE = PAGE_URL.substring(0, _idx); // https://vpn.jlu.edu.cn/https/<会话hex>
  14. const REFERER = PAGE_URL; // 当前课表页 URL 作为 Referer
  15. // 七个金智接口(均 POST,credentials:include 带 VPN 会话票 cookie)
  16. const EP_CURRENT_SEM = "/jwapp/sys/wdkb/modules/jshkcb/dqxnxq.do"; // 当前学期(空 body)
  17. const EP_SEMESTER_LIST = "/jwapp/sys/wdkb/modules/jshkcb/xnxqcx.do"; // 学期列表(*order=-DM 倒序,手动选择用)
  18. const EP_SEM_CAL = "/jwapp/sys/wdkb/modules/xskcb/cxxljc.do"; // 学期校历(XN+XQ -> 起始日/总周次)
  19. const EP_SECTIONS = "/jwapp/sys/wdkb/modules/jshkcb/jc.do"; // 节次时间(空 body)
  20. const EP_COURSES = "/jwapp/sys/wdkb/modules/xskcb/cxxszhxqkb.do"; // 课表(XNXQDM,不传 SKZC 取全部)
  21. const EP_CHANGES = "/jwapp/sys/wdkb/modules/xskcb/xsdkkc.do"; // 调课/停课(XNXQDM)
  22. const EP_UNDETERMINED = "/jwapp/sys/wdkb/modules/xskcb/xswpkc.do"; // 未定时间课程(XNXQDM,仅提醒不导入)
  23. // ========== 请求工具 ==========
  24. async function api(path, body) {
  25. const hasBody = body != null && body !== "";
  26. const headers = {
  27. "Accept": "application/json, text/javascript, */*; q=0.01",
  28. "X-Requested-With": "XMLHttpRequest",
  29. "Referer": REFERER
  30. };
  31. if (hasBody) {
  32. headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8";
  33. }
  34. const res = await fetch(VPN_BASE + path, {
  35. method: "POST",
  36. headers: headers,
  37. body: hasBody ? body : "",
  38. credentials: "include"
  39. });
  40. return res.json();
  41. }
  42. // ========== 解析:周次二进制串 "0000111111111111" -> [5,6,...,16] ==========
  43. // 长度可变(14/16...),按字符位遍历即可
  44. function parseWeeksFromSkzc(skzc) {
  45. const weeks = [];
  46. const s = skzc || "";
  47. for (let i = 0; i < s.length; i++) {
  48. if (s[i] === "1") weeks.push(i + 1);
  49. }
  50. return weeks;
  51. }
  52. // ========== 解析:单条金智课程 -> 拾光模型 ==========
  53. function parseSingleCourse(raw) {
  54. const name = raw.KCM;
  55. const teacher = raw.SKJS ? raw.SKJS.replace(/\//g, ",").trim() : "未知教师";
  56. const position = raw.JASMC || "待定"; // JASMC 可能为 null
  57. const day = parseInt(raw.SKXQ, 10); // 1=周一...7=周日
  58. const startSection = parseInt(raw.KSJC, 10);
  59. const endSection = parseInt(raw.JSJC, 10);
  60. const weeks = parseWeeksFromSkzc(raw.SKZC);
  61. if (!name || !day || !startSection || !endSection || weeks.length === 0) {
  62. return null;
  63. }
  64. return {
  65. name, teacher, position, day, startSection, endSection, weeks,
  66. _kbId: raw.KBID, // 内部字段,供调课匹配用,保存前清除
  67. _day: day,
  68. _startSection: startSection,
  69. _endSection: endSection
  70. };
  71. }
  72. // ========== 解析:节次时间行 -> timeSlots(只取 SFSY==1 启用的节次)==========
  73. function parseTimeSlots(rows) {
  74. return (rows || [])
  75. .filter(r => Number(r.SFSY) === 1)
  76. .map(r => ({
  77. number: r.DM,
  78. startTime: r.KSSJ,
  79. endTime: r.JSSJ
  80. }))
  81. .sort((a, b) => a.number - b.number);
  82. }
  83. // ========== 解析:学期校历 -> {startDate, totalWeeks} ==========
  84. function parseSemesterCalendar(rows) {
  85. const r = (rows || [])[0];
  86. if (!r) return null;
  87. const startDate = r.XQKSRQ ? String(r.XQKSRQ).split(" ")[0] : null; // "2026-03-09"
  88. const totalWeeks = Number(r.ZZC) || 20; // 18
  89. return { startDate, totalWeeks };
  90. }
  91. // ========== UI:开始提示 ==========
  92. async function promptUserToStart() {
  93. const confirmed = await window.AndroidBridgePromise.showAlert(
  94. "吉林大学课表导入",
  95. "请确保您已登录吉大 VPN(vpn.jlu.edu.cn),并已进入(新)教务管理系统的「我的课表」页面。\n未停留在课表页可能导致会话失效,无法获取数据。",
  96. "开始导入"
  97. );
  98. if (!confirmed) {
  99. AndroidBridge.showToast("已取消导入");
  100. return false;
  101. }
  102. return true;
  103. }
  104. // ========== 学期选择(默认当前学期,可选其他)==========
  105. // 假期教务系统可能仍把"当前学期"指向上学期,故保留手动选择其他学期能力
  106. async function selectSemester() {
  107. // 1. 尝试获取当前学期作为默认选项
  108. let current = null;
  109. try {
  110. current = await fetchCurrentSemester();
  111. } catch (e) {
  112. console.warn("当前学期获取失败,将直接显示学期列表:", e);
  113. }
  114. if (current) {
  115. // 2. 问用户:导入当前学期 / 选择其他
  116. const choice = await window.AndroidBridgePromise.showSingleSelection(
  117. "选择学期",
  118. JSON.stringify([
  119. `导入当前学期:${current.mc}`,
  120. "选择其他学期"
  121. ]),
  122. 0
  123. );
  124. if (choice === null) {
  125. AndroidBridge.showToast("已取消导入");
  126. return null;
  127. }
  128. if (choice === 0) return current;
  129. // choice === 1 -> 继续到学期列表
  130. }
  131. // 3. 拉取学期列表手动选择
  132. let rows = [];
  133. try {
  134. rows = await fetchSemesterList();
  135. } catch (e) {
  136. AndroidBridge.showToast("学期列表获取失败,请检查登录状态");
  137. console.error("学期列表获取失败:", e);
  138. return null;
  139. }
  140. if (rows.length === 0) {
  141. AndroidBridge.showToast("未获取到学期列表");
  142. return null;
  143. }
  144. const labels = rows.map(r => r.MC || r.DM);
  145. const idx = await window.AndroidBridgePromise.showSingleSelection(
  146. "选择其他学期",
  147. JSON.stringify(labels),
  148. 0
  149. );
  150. if (idx === null) {
  151. AndroidBridge.showToast("已取消导入");
  152. return null;
  153. }
  154. return parseSemesterRow(rows[idx]);
  155. }
  156. // ========== 数据获取 ==========
  157. // 解析学期行 -> {xnxqdm, xn, xq, mc}(当前学期与列表选择共用)
  158. function parseSemesterRow(r) {
  159. const xnxqdm = r.DM; // "2025-2026-2"
  160. const parts = xnxqdm.split("-"); // ["2025","2026","2"]
  161. const xn = parts[0] + "-" + parts[1]; // "2025-2026"
  162. const xq = parts[2]; // "2"
  163. return { xnxqdm, xn, xq, mc: r.MC };
  164. }
  165. async function fetchCurrentSemester() {
  166. const res = await api(EP_CURRENT_SEM, ""); // 空 body
  167. const r = res?.datas?.dqxnxq?.rows?.[0];
  168. if (!r) return null;
  169. return parseSemesterRow(r);
  170. }
  171. async function fetchSemesterList() {
  172. const res = await api(EP_SEMESTER_LIST, "*order=-DM"); // 倒序,最新在前
  173. return res?.datas?.xnxqcx?.rows || [];
  174. }
  175. async function fetchSemesterCalendar(xn, xq) {
  176. const res = await api(EP_SEM_CAL, `XN=${xn}&XQ=${xq}`);
  177. return parseSemesterCalendar(res?.datas?.cxxljc?.rows);
  178. }
  179. async function fetchTimeSlots() {
  180. const res = await api(EP_SECTIONS, ""); // 空 body
  181. return parseTimeSlots(res?.datas?.jc?.rows);
  182. }
  183. async function fetchCourses(xnxqdm) {
  184. const res = await api(EP_COURSES, `XNXQDM=${xnxqdm}`); // 不传 SKZC,取全部课程
  185. const rows = res?.datas?.cxxszhxqkb?.rows || [];
  186. return rows.map(parseSingleCourse).filter(c => c !== null);
  187. }
  188. // ========== 调课/停课(镜像 CTGU,字段同 legacy 金智)==========
  189. async function fetchCourseChanges(xnxqdm) {
  190. const res = await api(EP_CHANGES, `XNXQDM=${xnxqdm}&*order=-SQSJ`);
  191. return res?.datas?.xsdkkc?.rows || [];
  192. }
  193. async function fetchUndeterminedCourses(xnxqdm) {
  194. const res = await api(EP_UNDETERMINED, `XNXQDM=${xnxqdm}`);
  195. return res?.datas?.xswpkc?.rows || [];
  196. }
  197. /**
  198. * 将调课/停课变更应用到已解析课程列表(就地修改)
  199. * - 停课:按 KBID+星期+节次 匹配原始课,删除受影响周次(change.SKZC)
  200. * - 调时间/地点(TKLXDM 01/03):用新时间/周次/师/地 新建一条课程
  201. * - weeks 清空的课程(全周停课)被过滤掉
  202. * @returns {{courses: Array, appliedCount: number}}
  203. */
  204. function applyCourseChanges(parsedCourses, rawChanges) {
  205. let appliedCount = 0;
  206. for (const change of rawChanges) {
  207. const kbID = change.KBID;
  208. const originalTeacher = change.YSKJS ? change.YSKJS.replace(/\//g, ",").trim() : "未知教师";
  209. const weeksToRemove = parseWeeksFromSkzc(change.SKZC);
  210. let changeApplied = false;
  211. const affected = parsedCourses.filter(c =>
  212. c._kbId === kbID &&
  213. c._day === parseInt(change.SKXQ, 10) &&
  214. c._startSection === parseInt(change.KSJC, 10) &&
  215. c._endSection === parseInt(change.JSJC, 10)
  216. );
  217. if (affected.length === 0) continue;
  218. if (weeksToRemove.length > 0) {
  219. affected.forEach(course => {
  220. const before = course.weeks.length;
  221. course.weeks = course.weeks.filter(w => !weeksToRemove.includes(w));
  222. if (course.weeks.length < before) changeApplied = true;
  223. });
  224. }
  225. const isTimeLocationChange = (change.TKLXDM === "01" || change.TKLXDM === "03");
  226. if (isTimeLocationChange && change.XSKZC && change.XSKXQ && change.XKSJC && change.XJSJC) {
  227. const newWeeks = parseWeeksFromSkzc(change.XSKZC);
  228. if (newWeeks.length > 0) {
  229. parsedCourses.push({
  230. name: change.KCM,
  231. teacher: change.XSKJS ? change.XSKJS.replace(/\//g, ",").trim() : originalTeacher,
  232. position: change.XJASMC || change.JASMC || "待定",
  233. day: parseInt(change.XSKXQ, 10),
  234. startSection: parseInt(change.XKSJC, 10),
  235. endSection: parseInt(change.XJSJC, 10),
  236. weeks: newWeeks,
  237. _kbId: kbID,
  238. _day: parseInt(change.XSKXQ, 10),
  239. _startSection: parseInt(change.XKSJC, 10),
  240. _endSection: parseInt(change.XJSJC, 10)
  241. });
  242. changeApplied = true;
  243. }
  244. }
  245. if (changeApplied) appliedCount++;
  246. }
  247. return { courses: parsedCourses.filter(c => c.weeks.length > 0), appliedCount };
  248. }
  249. /** 保存前清除内部匹配字段 */
  250. function stripInternalFields(courses) {
  251. return courses.map(c => {
  252. delete c._kbId; delete c._day; delete c._startSection; delete c._endSection;
  253. return c;
  254. });
  255. }
  256. // ========== 保存 ==========
  257. async function saveAll(courses, timeSlots, startDate, totalWeeks) {
  258. // 1. 课表配置(起始日 + 总周次,来自学期校历接口)
  259. const config = {
  260. semesterStartDate: startDate, // "2026-03-09" 或 null
  261. semesterTotalWeeks: totalWeeks // 18
  262. };
  263. const cfgOk = await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify(config));
  264. if (!cfgOk) {
  265. AndroidBridge.showToast("课表配置保存失败");
  266. return false;
  267. }
  268. // 2. 节次时间
  269. const slotOk = await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  270. if (!slotOk) {
  271. AndroidBridge.showToast("节次时间保存失败");
  272. return false;
  273. }
  274. // 3. 课程
  275. const courseOk = await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
  276. if (!courseOk) {
  277. AndroidBridge.showToast("课程数据保存失败");
  278. return false;
  279. }
  280. return true;
  281. }
  282. // ========== 主流程 ==========
  283. async function runImportFlow() {
  284. AndroidBridge.showToast("吉林大学课表导入启动...");
  285. try {
  286. if (!(await promptUserToStart())) return;
  287. // 1. 选择学期(默认当前学期,可选其他;假期教务可能仍指上学期)
  288. const sem = await selectSemester();
  289. if (!sem) return;
  290. // 2. 学期校历(起始日 + 总周次)
  291. const cal = await fetchSemesterCalendar(sem.xn, sem.xq);
  292. const startDate = cal?.startDate || null;
  293. const totalWeeks = cal?.totalWeeks || 20;
  294. // 3. 节次时间
  295. const timeSlots = await fetchTimeSlots();
  296. // 4. 课程数据
  297. const courses = await fetchCourses(sem.xnxqdm);
  298. if (courses.length === 0) {
  299. AndroidBridge.showToast("该学期未查询到课程数据");
  300. return;
  301. }
  302. // 4.5 调课/停课(防御性:失败或无数据则跳过,不影响已验证的主流程)
  303. let finalCourses = courses;
  304. let changeNote = "";
  305. try {
  306. const changes = await fetchCourseChanges(sem.xnxqdm);
  307. if (changes.length === 0) {
  308. changeNote = "无调课信息";
  309. } else {
  310. const result = applyCourseChanges(courses, changes);
  311. finalCourses = result.courses;
  312. if (result.appliedCount > 0) {
  313. changeNote = `已应用${result.appliedCount}条调课`;
  314. } else {
  315. // 调课存在但未能自动应用:阻塞提醒用户手动核对
  316. await window.AndroidBridgePromise.showAlert(
  317. "调课提示",
  318. `检测到 ${changes.length} 条调课记录但未能自动应用。已导入原始课表,请对照教务页"调课信息"手动核对,如有出入请联系开发者。`,
  319. "知道了"
  320. );
  321. }
  322. }
  323. } catch (e) {
  324. console.warn("调课处理跳过:", e);
  325. changeNote = "调课查询异常";
  326. }
  327. finalCourses = stripInternalFields(finalCourses);
  328. // 4.6 未定时间课程(仅提醒不导入;查询失败静默跳过)
  329. let undetermined = [];
  330. try {
  331. undetermined = await fetchUndeterminedCourses(sem.xnxqdm);
  332. } catch (e) {
  333. console.warn("未定课程查询跳过:", e);
  334. }
  335. // 5. 保存
  336. AndroidBridge.showToast(`当前学期:${sem.mc},${finalCourses.length} 条课程${changeNote ? "(" + changeNote + ")" : ""},正在导入...`);
  337. const ok = await saveAll(finalCourses, timeSlots, startDate, totalWeeks);
  338. if (!ok) return;
  339. // 5.5 未定时间课程提醒(导入已成功;提醒失败不回滚,仍结束流程)
  340. if (undetermined.length > 0) {
  341. try {
  342. const names = [...new Set(undetermined.map(c => c.KCM).filter(Boolean))].join("、");
  343. await window.AndroidBridgePromise.showAlert(
  344. "导入完成",
  345. `已导入 ${finalCourses.length} 条课程。\n另有 ${undetermined.length} 门课程上课时间暂未确定,未导入:${names}。\n请关注教务通知,时间确定后重新导入。`,
  346. "知道了"
  347. );
  348. } catch (e) {
  349. console.warn("未定课程提醒失败:", e);
  350. AndroidBridge.showToast(`导入成功!共 ${finalCourses.length} 条课程。`);
  351. }
  352. } else {
  353. AndroidBridge.showToast(`导入成功!共 ${finalCourses.length} 条课程。`);
  354. }
  355. AndroidBridge.notifyTaskCompletion();
  356. } catch (error) {
  357. console.error("主流程异常:", error);
  358. AndroidBridge.showToast("意外错误: " + error.message);
  359. }
  360. }
  361. // 启动
  362. runImportFlow();