csuft.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. // 中南林业科技大学(csuft.edu.cn) 拾光课程表适配脚本
  2. // 强智教务系统(新版 qz- 周历表界面),通过 WebVPN 访问
  3. //
  4. // 2026-09 页面改版同步:
  5. // 1. 教务域名由 jwgl 变更为 jwxt:http-jwgl-... -> http-jwxt-...
  6. // 2. 课表表格由 #kbtable 改为 table.qz-weeklyTable(layui 周历表)
  7. // 课程结构:td[name=kbDataTd] > ul.courselists > li.courselists-item
  8. // 课程名 .qz-hasCourse-title,其余信息在 .qz-hasCourse-abbrinfo
  9. // (形如 "老师:张三;时间:1-8周[1-2节];地点:树人楼(树人楼北502)")
  10. // 3. 请求方式由 POST 改为 GET,新增 时间模式(kbjcmsid) 等参数
  11. // 4. 跨大节的课会用 rowspan 合并单元格,必须还原网格才能定位星期
  12. // 5. 2026-09 按官方《适配脚本开发指南 v2》迁移桥接 API:
  13. // window.shiguangBridgePromise(异步)+ window.shiguangBridge(同步)
  14. // notifyTaskCompletion 仅在整条流程成功后调用
  15. var JWXT_BASE = "https://http-jwxt-csuft-edu-cn-80.webvpn.csuft.edu.cn/jsxsd";
  16. var KB_URL = JWXT_BASE + "/xskb/xskb_list.do";
  17. var WEEK_DAY_MAP = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7, '天': 7 };
  18. var TIME_SLOTS = [
  19. { number: 1, startTime: "08:00", endTime: "08:45" },
  20. { number: 2, startTime: "08:55", endTime: "09:40" },
  21. { number: 3, startTime: "10:00", endTime: "10:45" },
  22. { number: 4, startTime: "10:55", endTime: "11:40" },
  23. { number: 5, startTime: "14:00", endTime: "14:45" },
  24. { number: 6, startTime: "14:55", endTime: "15:40" },
  25. { number: 7, startTime: "16:00", endTime: "16:45" },
  26. { number: 8, startTime: "16:55", endTime: "17:40" },
  27. { number: 9, startTime: "19:00", endTime: "19:45" },
  28. { number: 10, startTime: "19:55", endTime: "20:40" }
  29. ];
  30. function trimText(s) {
  31. return (s || '').replace(/^[\s\u00a0]+|[\s\u00a0]+$/g, '');
  32. }
  33. function uniqueSort(arr) {
  34. var seen = {}, out = [];
  35. for (var i = 0; i < arr.length; i++) {
  36. if (!seen[arr[i]]) { seen[arr[i]] = 1; out.push(arr[i]); }
  37. }
  38. out.sort(function (a, b) { return a - b; });
  39. return out;
  40. }
  41. // 解析周次:支持 "1-8周"、"2,4,6,8周"、"14周"、"1-8周(单)"、"1-8周(双)"
  42. function parseWeeks(timeStr) {
  43. var weeks = [];
  44. if (!timeStr) return weeks;
  45. var s = String(timeStr).split('[')[0];
  46. var odd = null;
  47. if (/[((]\s*单\s*[))]/.test(s)) odd = true;
  48. else if (/[((]\s*双\s*[))]/.test(s)) odd = false;
  49. s = s.replace(/[((][^(())]*[))]/g, '').replace(/周/g, '').replace(/\s/g, '');
  50. var segs = s.split(/[,,]/);
  51. for (var i = 0; i < segs.length; i++) {
  52. var m = segs[i].match(/^(\d+)(?:\s*[-~]\s*(\d+))?$/);
  53. if (!m) continue;
  54. var a = parseInt(m[1], 10);
  55. var b = m[2] ? parseInt(m[2], 10) : a;
  56. if (isNaN(a) || isNaN(b) || b < a) continue;
  57. for (var w = a; w <= b; w++) {
  58. if (odd === true && w % 2 === 0) continue;
  59. if (odd === false && w % 2 === 1) continue;
  60. weeks.push(w);
  61. }
  62. }
  63. return uniqueSort(weeks);
  64. }
  65. // 把带 rowspan/colspan 的表格还原成二维网格
  66. function buildGrid(table) {
  67. var grid = [];
  68. var trs = table.getElementsByTagName('tr');
  69. for (var r = 0; r < trs.length; r++) {
  70. if (!grid[r]) grid[r] = [];
  71. var cells = trs[r].children;
  72. var c = 0;
  73. for (var i = 0; i < cells.length; i++) {
  74. var cell = cells[i];
  75. while (grid[r][c]) c++;
  76. var rs = parseInt(cell.getAttribute('rowspan'), 10);
  77. var cs = parseInt(cell.getAttribute('colspan'), 10);
  78. if (!rs || rs < 1) rs = 1;
  79. if (!cs || cs < 1) cs = 1;
  80. for (var dr = 0; dr < rs; dr++) {
  81. if (!grid[r + dr]) grid[r + dr] = [];
  82. for (var dc = 0; dc < cs; dc++) grid[r + dr][c + dc] = cell;
  83. }
  84. c += cs;
  85. }
  86. }
  87. return grid;
  88. }
  89. // 去掉地点中重复的楼栋名,如 "树人楼(树人楼北508)" -> "树人楼(北508)"
  90. function dedupePosition(pos) {
  91. if (!pos) return pos;
  92. var m = pos.match(/^(.+?)\s*[((]\s*\1\s*(.+?)[))]\s*$/);
  93. return m ? m[1] + '(' + m[2] + ')' : pos;
  94. }
  95. function textOf(el, selector) {
  96. var t = el.querySelector(selector);
  97. return t ? trimText(t.textContent) : '';
  98. }
  99. function parseSchedule(doc) {
  100. var table = doc.querySelector('table.qz-weeklyTable');
  101. if (!table) return [];
  102. var grid = buildGrid(table);
  103. // 1. 表头行:列号 -> 星期
  104. var colDay = {};
  105. var headerRow = -1;
  106. for (var r = 0; r < grid.length; r++) {
  107. var row = grid[r] || [];
  108. var hit = false;
  109. for (var c = 1; c < row.length; c++) {
  110. var txt = trimText(row[c] ? row[c].textContent : '').replace(/\s/g, '');
  111. var m = txt.match(/^星期([一二三四五六日天])/);
  112. if (m) { colDay[c] = WEEK_DAY_MAP[m[1]]; hit = true; }
  113. }
  114. if (hit) { headerRow = r; break; }
  115. }
  116. if (headerRow < 0) return [];
  117. // 2. 定位每个单元格的星期 与 行对应的节次(rowspan 合并的行会被正确填充)
  118. for (var r2 = headerRow + 1; r2 < grid.length; r2++) {
  119. var row2 = grid[r2] || [];
  120. var label = row2[0];
  121. var lm = label ? trimText(label.textContent).match(/第\s*(\d+)\s*[,,、]\s*(\d+)\s*节/) : null;
  122. var sec = lm ? [parseInt(lm[1], 10), parseInt(lm[2], 10)] : null;
  123. for (var c2 = 1; c2 < row2.length; c2++) {
  124. var cell = row2[c2];
  125. if (!cell || !colDay[c2]) continue;
  126. if (cell.__sgDay === undefined) cell.__sgDay = colDay[c2];
  127. if (cell.__sgSec === undefined) cell.__sgSec = sec;
  128. }
  129. }
  130. // 3. 逐单元格解析课程
  131. var raw = [];
  132. var cells = table.querySelectorAll('td[name="kbDataTd"]');
  133. for (var i = 0; i < cells.length; i++) {
  134. var td = cells[i];
  135. var day = td.__sgDay;
  136. if (!day) continue;
  137. var items = td.querySelectorAll('li.courselists-item') || [];
  138. for (var j = 0; j < items.length; j++) {
  139. var li = items[j];
  140. var name = textOf(li, '.qz-hasCourse-title');
  141. if (!name) continue;
  142. var abbr = textOf(li, '.qz-hasCourse-abbrinfo');
  143. var teacher = (abbr.match(/老师[::]\s*([^;;]*)/) || [])[1] || '';
  144. var position = (abbr.match(/地点[::]\s*([^;;]*)/) || [])[1] || '';
  145. if (teacher) teacher = trimText(teacher);
  146. if (position) position = trimText(dedupePosition(position));
  147. var weeks = [], start = 0, end = 0;
  148. var times = abbr.match(/时间[::]\s*([^;;]*)/g) || [];
  149. for (var k = 0; k < times.length; k++) {
  150. var v = trimText(times[k].replace(/^时间[::]/, ''));
  151. weeks = weeks.concat(parseWeeks(v));
  152. var sm = v.match(/[\[[]\s*(\d+)\s*[-~]\s*(\d+)\s*节\s*[\]]]/);
  153. if (sm) {
  154. var s1 = parseInt(sm[1], 10), e1 = parseInt(sm[2], 10);
  155. start = start ? Math.min(start, s1) : s1;
  156. end = Math.max(end, e1);
  157. }
  158. }
  159. weeks = uniqueSort(weeks);
  160. // 时间字段缺失时,退回使用行首的节次标签
  161. if (!start && td.__sgSec) { start = td.__sgSec[0]; end = td.__sgSec[1]; }
  162. if (!start) continue;
  163. raw.push({
  164. name: name,
  165. teacher: teacher,
  166. weeks: weeks,
  167. position: position,
  168. day: day,
  169. startSection: start,
  170. endSection: end || start
  171. });
  172. }
  173. }
  174. return mergeCourses(raw);
  175. }
  176. function mergeCourses(courses) {
  177. if (courses.length <= 1) return courses;
  178. courses.sort(function (a, b) {
  179. return a.name.localeCompare(b.name) || a.day - b.day || a.startSection - b.startSection;
  180. });
  181. var merged = [];
  182. var cur = courses[0];
  183. for (var i = 1; i < courses.length; i++) {
  184. var n = courses[i];
  185. var sameBase = cur.name === n.name && cur.teacher === n.teacher &&
  186. cur.position === n.position && cur.day === n.day;
  187. if (sameBase && cur.endSection + 1 === n.startSection) {
  188. // 同一天连续节次的同一门课,合并成一条
  189. cur.endSection = n.endSection;
  190. cur.weeks = uniqueSort(cur.weeks.concat(n.weeks));
  191. } else if (sameBase && cur.startSection === n.startSection && cur.endSection === n.endSection) {
  192. // 同一节次被拆成多段周次(如 14 周、15 周各一条),合并周次
  193. cur.weeks = uniqueSort(cur.weeks.concat(n.weeks));
  194. } else {
  195. merged.push(cur);
  196. cur = n;
  197. }
  198. }
  199. merged.push(cur);
  200. return merged;
  201. }
  202. function buildQuery(params) {
  203. var parts = [];
  204. for (var k in params) {
  205. if (params.hasOwnProperty(k)) {
  206. parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(params[k]));
  207. }
  208. }
  209. return parts.join('&');
  210. }
  211. function baseParams(semId) {
  212. var p = {
  213. viweType: "0",
  214. showallprint: "0",
  215. showkchprint: "0",
  216. showkink: "0",
  217. showfzmprint: "0",
  218. baseUrl: "/jsxsd",
  219. xsflMapListJsonStr: "讲课学时,实践学时,讲座学时,实验学时,上机学时,课内实践学时,课程实践学时,",
  220. zc: ""
  221. };
  222. if (semId) p.xnxq01id = semId;
  223. return p;
  224. }
  225. async function fetchPage(params) {
  226. var resp = await fetch(KB_URL + "?" + buildQuery(params), { credentials: "include" });
  227. return new DOMParser().parseFromString(await resp.text(), "text/html");
  228. }
  229. // "时间模式"参数,部分账号不带上就不会渲染课表
  230. function getKbjcmsid(doc) {
  231. var opt = doc.querySelector('select#kbjcmsid option');
  232. return opt ? trimText(opt.getAttribute('value')) : '';
  233. }
  234. // 从页面表单里读取可选的学年学期列表(如 2025-2026-2),无需手工输入
  235. async function loadTermList() {
  236. var doc = await fetchPage(baseParams(""));
  237. var sel = doc.querySelector('select#xnxq01id');
  238. var options = [];
  239. var selected = 0;
  240. if (sel) {
  241. var opts = sel.getElementsByTagName('option');
  242. for (var i = 0; i < opts.length; i++) {
  243. var v = trimText(opts[i].getAttribute('value')) || trimText(opts[i].textContent);
  244. if (!v) continue;
  245. if (opts[i].getAttribute('selected') !== null) selected = options.length;
  246. options.push(v);
  247. }
  248. }
  249. return { options: options, selected: selected, kbjcmsid: getKbjcmsid(doc) };
  250. }
  251. async function fetchScheduleDoc(semId, kbjcmsid) {
  252. var p = baseParams(semId);
  253. if (kbjcmsid) p.kbjcmsid = kbjcmsid;
  254. return await fetchPage(p);
  255. }
  256. // showPrompt 的输入校验:v2 约定为全局函数名,返回 false=通过,返回字符串=错误提示
  257. function validateYearInput(input) {
  258. return /^[0-9]{4}$/.test(input) ? false : "请输入四位数字的起始学年!";
  259. }
  260. async function promptUserToStart() {
  261. return window.shiguangBridgePromise.showAlert(
  262. "提示", "导入前请确保已成功登录教务系统。", "开始导入");
  263. }
  264. // 优先从教务页面读取学年学期列表直接选择;取不到再退回"手输学年 + 选学期"
  265. async function chooseTerm() {
  266. try {
  267. window.shiguangBridge.showToast("正在获取学年学期列表...");
  268. const info = await loadTermList();
  269. if (info.options.length) {
  270. const idx = await window.shiguangBridgePromise.showSingleSelection(
  271. "选择学年学期", JSON.stringify(info.options), info.selected);
  272. if (idx === null) return null;
  273. return { semId: info.options[idx], kbjcmsid: info.kbjcmsid };
  274. }
  275. } catch (e) { /* 列表获取失败,走手工输入兜底 */ }
  276. const year = await window.shiguangBridgePromise.showPrompt(
  277. "选择学年", "请输入要导入的起始学年(例如 2025-2026 应输入 2025):", "", "validateYearInput");
  278. if (year === null) return null;
  279. const semesterIdx = await window.shiguangBridgePromise.showSingleSelection(
  280. "选择学期", JSON.stringify(["第一学期", "第二学期"]), -1);
  281. if (semesterIdx === null) return null;
  282. return {
  283. semId: year + "-" + (parseInt(year, 10) + 1) + "-" + (semesterIdx + 1),
  284. kbjcmsid: ""
  285. };
  286. }
  287. async function fetchAndParseCourses(term) {
  288. window.shiguangBridge.showToast("正在获取课表数据...");
  289. const doc = await fetchScheduleDoc(term.semId, term.kbjcmsid);
  290. const courses = parseSchedule(doc);
  291. if (courses.length === 0) {
  292. // 区分"页面没拿到"和"页面拿到了但该学期确实没课",后者不能覆盖已有课程
  293. window.shiguangBridge.showToast(!doc.querySelector('table.qz-weeklyTable')
  294. ? "未获取到课表页面,请检查登录状态或学年学期是否正确。"
  295. : "该学年学期暂无课表数据,已保留原有课程。");
  296. return null;
  297. }
  298. return courses;
  299. }
  300. async function saveAll(courses) {
  301. try {
  302. await window.shiguangBridgePromise.saveCourseConfig(
  303. JSON.stringify({ semesterTotalWeeks: 20, firstDayOfWeek: 1 }));
  304. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(TIME_SLOTS));
  305. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  306. return true;
  307. } catch (error) {
  308. window.shiguangBridge.showToast("保存失败: " + error.message);
  309. return false;
  310. }
  311. }
  312. async function runImportFlow() {
  313. const confirmed = await promptUserToStart();
  314. if (!confirmed) {
  315. window.shiguangBridge.showToast("导入已取消。");
  316. return;
  317. }
  318. const term = await chooseTerm();
  319. if (term === null) {
  320. window.shiguangBridge.showToast("导入已取消。");
  321. return;
  322. }
  323. const courses = await fetchAndParseCourses(term);
  324. if (courses === null) return; // 失败原因已在 fetchAndParseCourses 中提示
  325. if (!(await saveAll(courses))) return;
  326. // 整条流程成功后才发送结束信号
  327. window.shiguangBridge.showToast("成功导入 " + courses.length + " 门课程");
  328. window.shiguangBridge.notifyTaskCompletion();
  329. }
  330. runImportFlow();