JLU_01.js 16 KB

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