ynufe.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. // 云南财经大学(xjwis.ynufe.edu.cn) 拾光课程表适配脚本
  2. // 整体包一层 IIFE:App 每次点「执行导入」都会在同一页面重新 evaluate 本文件而不刷新,
  3. // 顶层直接写 const 会与上一次执行冲突(Identifier '...' has already been declared)
  4. (function () {
  5. const URL_SEMESTER = "/jsxsd/jxzl/jxzl_query"; // 教学周历:学期列表 / 开学日期 / 最大周次
  6. const URL_COURSE = "/jsxsd/xskb/xskb_list.do"; // 课表查询
  7. const clean = (s = "") => s.replace(/[\s\u00a0]+/g, " ").trim();
  8. const parseDoc = html => new DOMParser().parseFromString(html, "text/html");
  9. // ========== 解析 ==========
  10. /** 周次:"1-16(周)[01-02节]" / "1-6,7-13周(单)" → [1..16];单双周按整串判断 */
  11. function parseWeeks(text = "") {
  12. const weeks = new Set();
  13. const fallback = /单/.test(text) ? 1 : /双/.test(text) ? 0 : -1; // -1 = 不区分单双
  14. for (const seg of text.split("[")[0].match(/\d+(?:\s*-\s*\d+)?[^,\d]*/g) || []) {
  15. const [start, end = start] = (seg.match(/\d+/g) || []).map(Number);
  16. if (!start) continue;
  17. const parity = /单/.test(seg) ? 1 : /双/.test(seg) ? 0 : fallback;
  18. for (let w = start; w <= end; w++) if (parity < 0 || w % 2 === parity) weeks.add(w);
  19. }
  20. return [...weeks].sort((a, b) => a - b);
  21. }
  22. /** 节次:"...[06-07节]" → { startSection: 6, endSection: 7 } */
  23. function parseSections(text = "") {
  24. const nums = ((text.match(/\[([^\]]*)\]/) || [])[1] || "").match(/\d+/g)?.map(Number);
  25. return nums ? { startSection: Math.min(...nums), endSection: Math.max(...nums) } : null;
  26. }
  27. /** 单个课程块;字段不全返回 null */
  28. function parseBlock(html, day) {
  29. const box = document.createElement("div");
  30. box.innerHTML = html;
  31. // 强智把节次/周次提示藏在 display:none 的 font 里,先剔除再取文本
  32. const hidden = box.querySelectorAll('[style*="display:none"],[style*="display: none"]');
  33. hidden.forEach(node => node.remove());
  34. const text = selector => clean(box.querySelector(selector)?.textContent);
  35. // 课程名 = 首个非空文本行(去掉调课标记 span)
  36. const lines = html.split(/<br\s*\/?>/i);
  37. const name = lines.map(line => clean(line.replace(/<span[^>]*>[\s\S]*?<\/span>/gi, "").replace(/<[^>]*>/g, ""))).find(Boolean);
  38. const weekText = text('font[title="周次(节次)"]');
  39. const sections = parseSections(weekText);
  40. const weeks = parseWeeks(weekText);
  41. if (!name || !sections || weeks.length === 0) return null;
  42. // 少数页面没有 title="教室",教室名和隐藏的节次提示挤在同一行
  43. const room = hidden.length === 0 ? "" : lines
  44. .filter(line => /display:\s*none/.test(line))
  45. .map(line => clean(line.replace(/<font[^>]*display:\s*none[^>]*>[\s\S]*?<\/font>/gi, "").replace(/<[^>]*>/g, "")))
  46. .find(item => item && item !== name);
  47. return {
  48. name,
  49. teacher: text('font[title="老师"],font[title="教师"]').replace(/^任课教师[::]/, "") || "未知教师",
  50. position: text('font[title="教室"]').replace(/^[\d\-\s]+/, "") || room || "未知地点",
  51. day,
  52. startSection: sections.startSection,
  53. endSection: sections.endSection,
  54. weeks
  55. };
  56. }
  57. /** 同课、同师、同地、同星期、同节次 → 合并周次 */
  58. function mergeCourses(list) {
  59. const map = new Map();
  60. for (const course of list) {
  61. const key = [course.name, course.teacher, course.position, course.day, course.startSection, course.endSection].join("|");
  62. const exist = map.get(key);
  63. if (exist) exist.weeks = [...new Set([...exist.weeks, ...course.weeks])].sort((a, b) => a - b);
  64. else map.set(key, course);
  65. }
  66. return [...map.values()];
  67. }
  68. /** 课表 HTML → 课程数组;td[0] 是周一,div.kbcontent 的 name 属性(hash-星期-序)优先校正星期 */
  69. function parseTimetable(doc) {
  70. const table = doc.getElementById("kbtable");
  71. if (!table) return [];
  72. const courses = [];
  73. for (const row of table.querySelectorAll("tr")) {
  74. [...row.querySelectorAll("td")].forEach((cell, index) =>
  75. [...cell.querySelectorAll("div.kbcontent")].forEach(div => {
  76. const fromName = Number((div.getAttribute("name") || "").split("-")[1]);
  77. const day = fromName >= 1 && fromName <= 7 ? fromName : index + 1;
  78. // 一格多门课,用 ----- 分隔
  79. div.innerHTML.split(/-{5,}/).forEach(block => courses.push(parseBlock(block, day)));
  80. }));
  81. }
  82. return mergeCourses(courses.filter(Boolean));
  83. }
  84. // ========== 网络 ==========
  85. /** 同源请求:有 body 走 POST,否则 GET,统一返回 HTML 文本 */
  86. const request = (url, body) => fetch(url, {
  87. method: body === undefined ? "GET" : "POST",
  88. headers: body === undefined ? undefined : { "Content-Type": "application/x-www-form-urlencoded" },
  89. body,
  90. credentials: "include"
  91. }).then(response => response.text());
  92. /** 学期列表;只保留最近 5 个学年 */
  93. async function fetchSemesterList() {
  94. const html = await request(URL_COURSE);
  95. const all = [...parseDoc(html).querySelectorAll("#xnxq01id option")].map(opt => ({
  96. value: opt.value, label: clean(opt.textContent), current: opt.hasAttribute("selected") || opt.selected
  97. }));
  98. if (all.length === 0) return { list: [], defaultIndex: -1, html };
  99. const startYear = opt => Number(String(opt.value).slice(0, 4));
  100. const years = all.map(startYear).filter(year => year > 2000);
  101. const list = (years.length ? all.filter(opt => startYear(opt) >= Math.max(...years) - 4) : all)
  102. .map(opt => ({ ...opt, label: opt.current ? `${opt.label}(当前学期)` : opt.label }));
  103. return { list, defaultIndex: Math.max(0, list.findIndex(opt => opt.current)), html };
  104. }
  105. /** 开学日期 + 最大周次 */
  106. async function fetchSemesterInfo(semesterId) {
  107. const doc = parseDoc(await request(URL_SEMESTER, `xnxq01id=${semesterId}`));
  108. const rows = [...doc.querySelectorAll("#kbtable tr")].filter(row => row.querySelector("td"));
  109. const match = (rows[0]?.querySelector("td[title]")?.title || "").match(/^(\d{4})年(\d{1,2})月(\d{1,2})日?$/);
  110. const totalWeeks = rows.reduce((max, row) => Math.max(max, parseInt(row.querySelector("td")?.textContent, 10) || 0), 0) || null;
  111. return {
  112. startDate: match ? `${match[1]}-${match[2].padStart(2, "0")}-${match[3].padStart(2, "0")}` : null,
  113. totalWeeks
  114. };
  115. }
  116. /** 按“时间模式”(作息/校区)取下拉框 value */
  117. function getTimeModeValue(html, matcher) {
  118. const option = [...parseDoc(html).querySelectorAll("#kbjcmsid option")].find(opt => matcher(clean(opt.textContent)));
  119. return option ? option.value : "";
  120. }
  121. /** 课表页面;mode 未指定时用“全部”模式 */
  122. async function fetchSchedule(semesterId, pageHtml, mode) {
  123. const params = { jx0404id: "", cj0701id: "", zc: "", demo: "", xnxq01id: semesterId };
  124. params.kbjcmsid = mode === undefined ? getTimeModeValue(pageHtml, text => text === "全部") : mode;
  125. return await request(URL_COURSE, new URLSearchParams(params).toString());
  126. }
  127. // ========== 作息 ==========
  128. // 只写早/中/晚的参数,节次时间由 buildTimeSlots 推出(参考 resources/ZHKU)
  129. // start/end:该段首节上课与末节下课;count:节数;longBreak/longBreakAfter:第几节后放大课间(0 = 无)
  130. // empty:教务系统里有节次编号但不排课的时段(如晚饭),只占位,时间仅供参考
  131. const CLASS_MINUTES = 40;
  132. const SHORT_BREAK_MINUTES = 10;
  133. const TIME_CONFIG = {
  134. longquan: {
  135. morning: { start: "08:00", end: "12:20", count: 5, longBreak: 30, longBreakAfter: 2 },
  136. afternoon: { start: "14:30", end: "18:00", count: 4, longBreak: 30, longBreakAfter: 2 },
  137. evening: { start: "19:00", end: "22:20", count: 4, longBreak: 20, longBreakAfter: 2 },
  138. empty: [{ number: 10, start: "18:10", end: "18:30" }] // 第 10 节:晚饭时间
  139. },
  140. anning: {
  141. morning: { start: "08:20", end: "12:30", count: 5, longBreak: 20, longBreakAfter: 2 },
  142. afternoon: { start: "14:00", end: "18:10", count: 5, longBreak: 20, longBreakAfter: 3 },
  143. evening: { start: "19:00", end: "21:20", count: 3, longBreak: 0, longBreakAfter: 0 }
  144. }
  145. };
  146. const toMinutes = hhmm => hhmm.split(":").reduce((total, part) => total * 60 + Number(part), 0);
  147. const toClock = minutes => [Math.floor(minutes / 60), minutes % 60].map(n => String(n).padStart(2, "0")).join(":");
  148. /** 生成节次表;withSegment 时附带早/中/晚标记(跨大节判定用) */
  149. function buildTimeSlots(campus, withSegment = false) {
  150. const config = TIME_CONFIG[campus];
  151. const slots = [];
  152. for (const segment of ["morning", "afternoon", "evening"]) {
  153. const rule = config[segment];
  154. let cursor = toMinutes(rule.start);
  155. for (let i = 1; i <= rule.count; i++) {
  156. const end = cursor + CLASS_MINUTES;
  157. slots.push({ startTime: toClock(cursor), endTime: toClock(end), segment });
  158. cursor = end + (i < rule.count ? (rule.longBreakAfter === i ? rule.longBreak : SHORT_BREAK_MINUTES) : 0);
  159. }
  160. }
  161. // 空节按声明位置插入,保证节次编号与教务系统一致(后面的节次顺延)
  162. for (const slot of config.empty ?? []) slots.splice(slot.number - 1, 0, { startTime: slot.start, endTime: slot.end, segment: "empty" });
  163. return slots.map((slot, index) => {
  164. const item = { number: index + 1, startTime: slot.startTime, endTime: slot.endTime };
  165. return withSegment ? { ...item, segment: slot.segment } : item;
  166. });
  167. }
  168. /**
  169. * 跨大节连堂课:大课间其实不休息,仍按「40 分钟 + 10 分钟课间」连着上,所以下课早于公共时间段。
  170. * 只处理同一早/中/晚时段内的连堂,跨午休/晚饭(含空节)的保持公共时间段。
  171. */
  172. function applyCrossBlockTimes(courses, campus) {
  173. const slots = buildTimeSlots(campus, true);
  174. for (const course of courses) {
  175. const span = slots.slice(course.startSection - 1, course.endSection);
  176. if (!span.length || span[0].segment === "empty") continue;
  177. if (!span.every(slot => slot.segment === span[0].segment)) continue;
  178. const customEnd = toClock(toMinutes(span[0].startTime) + span.length * CLASS_MINUTES + (span.length - 1) * SHORT_BREAK_MINUTES);
  179. if (customEnd !== span[span.length - 1].endTime) {
  180. Object.assign(course, { isCustomTime: true, customStartTime: span[0].startTime, customEndTime: customEnd });
  181. }
  182. }
  183. return courses;
  184. }
  185. // ========== 主流程 ==========
  186. async function runImportFlow() {
  187. if (!window.shiguangBridge || !window.shiguangBridgePromise) return false;
  188. const bridge = window.shiguangBridge;
  189. const fail = message => {
  190. bridge.showToast(message);
  191. return false;
  192. };
  193. if (window.location.href.includes("login") || document.querySelector('input[type="password"]')) return fail("请先登录教务系统!");
  194. try {
  195. bridge.showToast("正在获取学期列表...");
  196. const { list, defaultIndex, html } = await fetchSemesterList();
  197. if (list.length === 0) return fail("未获取到学期列表,请检查登录状态。");
  198. const selected = await window.shiguangBridgePromise.showSingleSelection(
  199. "选择学期", JSON.stringify(list.map(item => item.label)), defaultIndex
  200. );
  201. if (selected === null || selected === -1 || !list[selected]) return fail("已取消导入");
  202. const semesterId = list[selected].value;
  203. const { startDate, totalWeeks } = await fetchSemesterInfo(semesterId);
  204. const page = await fetchSchedule(semesterId, html);
  205. // 课表页同时带两校区作息模式,安宁模式有课说明该学期在安宁校区
  206. const anningMode = getTimeModeValue(page, text => text.includes("安宁校区"));
  207. const onAnning = Boolean(anningMode)
  208. && parseTimetable(parseDoc(await fetchSchedule(semesterId, page, anningMode))).length > 0;
  209. const campusKey = onAnning ? "anning" : "longquan";
  210. const courses = applyCrossBlockTimes(parseTimetable(parseDoc(page)), campusKey);
  211. if (courses.length === 0) return fail("所选学期课表为空,请检查学期或登录状态。");
  212. // 学期配置:总周数 + 开学日期(取不到开学日期就不写,避免覆盖已有值)
  213. const config = { semesterTotalWeeks: totalWeeks || 20, firstDayOfWeek: 1, defaultClassDuration: CLASS_MINUTES };
  214. if (startDate) config.semesterStartDate = startDate;
  215. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  216. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(buildTimeSlots(campusKey)));
  217. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  218. bridge.showToast(`成功导入 ${courses.length} 门课程!(${onAnning ? "安宁校区" : "龙泉校区"})`);
  219. return true;
  220. } catch (error) {
  221. return fail("导入失败: " + error.message);
  222. }
  223. }
  224. runImportFlow()
  225. .then(ok => ok && window.shiguangBridge.notifyTaskCompletion())
  226. .catch(error => window.shiguangBridge.showToast("导入失败: " + error.message));
  227. })();