mku.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. // 闽南科技学院(mku.edu.cn) 轻屿课表适配脚本
  2. // 教务平台:强智教务系统(新版 layui UI)
  3. // 数据来源:个人课表 /jsxsd/xskb/xskb_list.do(默认渲染当前学期,可用 xnxq01id 切换学期)
  4. // 解析逻辑参考本仓库 HYNU / ZHKU 两个强智适配的实现,按 MKU 新版 DOM 重写
  5. // 非该校在校开发者适配,出现问题请提交 issue 或 PR
  6. // ===== 基础常量 =====
  7. const MKU_BASE_URL = "https://jwgl.mku.edu.cn";
  8. const MKU_TIMETABLE_URL = MKU_BASE_URL + "/jsxsd/xskb/xskb_list.do";
  9. const MKU_WEEK_AJAX_URL = MKU_BASE_URL + "/jsxsd/xskb/jxzlzc_xnxq_ajax";
  10. const MKU_HOME_URL = MKU_BASE_URL + "/jsxsd/framework/xsMainV.htmlx";
  11. // 默认总周数;实际值优先取自教务周次接口 jxzlzc_xnxq_ajax
  12. const MKU_DEFAULT_TOTAL_WEEKS = 20;
  13. // 每小节课 45 分钟、课间 10 分钟(与课表页大节时间 08:00~09:40 等标注一致)
  14. const MKU_SECTION_MINUTES = 45;
  15. const MKU_BREAK_MINUTES = 10;
  16. // 兜底作息(按 2026-2027-1 学期课表页标注的大节时间推导)
  17. const MKU_FALLBACK_TIME_SLOTS = [
  18. { number: 1, startTime: "08:00", endTime: "08:45" },
  19. { number: 2, startTime: "08:55", endTime: "09:40" },
  20. { number: 3, startTime: "10:00", endTime: "10:45" },
  21. { number: 4, startTime: "10:55", endTime: "11:40" },
  22. { number: 5, startTime: "14:00", endTime: "14:45" },
  23. { number: 6, startTime: "14:55", endTime: "15:40" },
  24. { number: 7, startTime: "15:50", endTime: "16:35" },
  25. { number: 8, startTime: "16:45", endTime: "17:30" },
  26. { number: 9, startTime: "18:30", endTime: "19:15" },
  27. { number: 10, startTime: "19:25", endTime: "20:10" },
  28. { number: 11, startTime: "20:20", endTime: "21:05" },
  29. { number: 12, startTime: "21:15", endTime: "22:00" }
  30. ];
  31. // ===== 通用工具 =====
  32. // HH:mm 转当天分钟数
  33. function mkuTimeToMinutes(hhmm) {
  34. const parts = hhmm.split(":");
  35. return Number(parts[0]) * 60 + Number(parts[1]);
  36. }
  37. // 当天分钟数转 HH:mm
  38. function mkuMinutesToTime(total) {
  39. const h = Math.floor(total / 60);
  40. const m = total % 60;
  41. return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0");
  42. }
  43. // Date 转 YYYY-MM-DD
  44. function mkuFormatDate(date) {
  45. const y = date.getFullYear();
  46. const m = String(date.getMonth() + 1).padStart(2, "0");
  47. const d = String(date.getDate()).padStart(2, "0");
  48. return y + "-" + m + "-" + d;
  49. }
  50. // ===== 周次与节次解析 =====
  51. // 将周次片段("1,3,5" / "1-16" / "1-8,10-16")展开为去重排序的数字数组
  52. function mkuExpandWeeks(weekPart) {
  53. const weeks = [];
  54. (weekPart.match(/\d+(?:\s*[-~]\s*\d+)?/g) || []).forEach((seg) => {
  55. const m = seg.match(/(\d+)\s*(?:[-~]\s*(\d+))?/);
  56. const start = Number(m[1]);
  57. const end = m[2] ? Number(m[2]) : start;
  58. for (let w = start; w <= end; w++) weeks.push(w);
  59. });
  60. return [...new Set(weeks)].sort((a, b) => a - b);
  61. }
  62. // 解析"时间"字段,如 "1,3,5,7,9,11,13,15周[1-2节]"
  63. function mkuParseTimeText(timeText) {
  64. if (!timeText) return null;
  65. const weekMatch = timeText.match(/^([^周]+)周/);
  66. const secMatch = timeText.match(/\[\s*(\d+)\s*(?:[-~]\s*(\d+))?\s*节\s*\]/);
  67. if (!weekMatch && !secMatch) return null;
  68. let weeks = weekMatch ? mkuExpandWeeks(weekMatch[1]) : null;
  69. if (weeks) {
  70. // 兼容"单周/双周"标注
  71. if (/单\s*周/.test(timeText)) weeks = weeks.filter((w) => w % 2 === 1);
  72. if (/双\s*周/.test(timeText)) weeks = weeks.filter((w) => w % 2 === 0);
  73. }
  74. return {
  75. weeks,
  76. startSection: secMatch ? Number(secMatch[1]) : null,
  77. endSection: secMatch ? (secMatch[2] ? Number(secMatch[2]) : Number(secMatch[1])) : null
  78. };
  79. }
  80. // ===== 课表解析 =====
  81. // 将表格展开为 grid[row][col],正确处理 rowspan / colspan
  82. function mkuBuildGrid(table) {
  83. const grid = [];
  84. Array.from(table.rows).forEach((tr, r) => {
  85. if (!grid[r]) grid[r] = [];
  86. let col = 0;
  87. Array.from(tr.cells).forEach((td) => {
  88. while (grid[r][col]) col++;
  89. const rowSpan = td.rowSpan || 1;
  90. const colSpan = td.colSpan || 1;
  91. for (let dr = 0; dr < rowSpan; dr++) {
  92. for (let dc = 0; dc < colSpan; dc++) {
  93. if (!grid[r + dr]) grid[r + dr] = [];
  94. grid[r + dr][col + dc] = td;
  95. }
  96. }
  97. col += colSpan;
  98. });
  99. });
  100. return grid;
  101. }
  102. // 解析行标签(第 0 列):大节范围与时间,如 "1-2" + "08:00~09:40"
  103. function mkuParseRowInfos(table) {
  104. const rowInfos = new Map();
  105. Array.from(table.rows).forEach((tr, r) => {
  106. const labelCell = tr.cells[0];
  107. if (!labelCell) return;
  108. const titleText = labelCell.querySelector(".index-title")
  109. ? labelCell.querySelector(".index-title").textContent.trim()
  110. : "";
  111. const range = titleText.match(/^(\d+)(?:\s*[-~]\s*(\d+))?$/);
  112. if (!range) return;
  113. const timeMatch = labelCell.textContent.match(/(\d{1,2}:\d{2})\s*~\s*(\d{1,2}:\d{2})/);
  114. rowInfos.set(r, {
  115. startSection: Number(range[1]),
  116. endSection: range[2] ? Number(range[2]) : Number(range[1]),
  117. blockStart: timeMatch ? mkuTimeToMinutes(timeMatch[1]) : null,
  118. blockEnd: timeMatch ? mkuTimeToMinutes(timeMatch[2]) : null
  119. });
  120. });
  121. return rowInfos;
  122. }
  123. // 从"老师:xx;时间:..周[..节];地点:yy"详情串中按标签取字段
  124. function mkuParseInfoField(text, label) {
  125. const m = text.match(new RegExp(label + "[::]\\s*([^;;]*)"));
  126. return m ? m[1].trim() : "";
  127. }
  128. // 解析课表文档中的全部课程(同一单元格可能含多条课程,逐 li 解析)
  129. function mkuParseCourses(doc) {
  130. const sampleTd = doc.querySelector('td[name="kbDataTd"]');
  131. if (!sampleTd) return { courses: [], rowInfos: new Map() };
  132. const table = sampleTd.closest("table");
  133. const grid = mkuBuildGrid(table);
  134. const rowInfos = mkuParseRowInfos(table);
  135. const courses = [];
  136. const seenTds = new Set();
  137. for (const [row, rowInfo] of rowInfos) {
  138. for (let day = 1; day <= 7; day++) {
  139. const td = grid[row] && grid[row][day];
  140. // rowspan 跨行的大格只在首行解析一次
  141. if (!td || seenTds.has(td)) continue;
  142. seenTds.add(td);
  143. Array.from(td.querySelectorAll("li")).forEach((li) => {
  144. const nameEl = li.querySelector(".qz-hasCourse-title");
  145. const infoEl = li.querySelector(".qz-hasCourse-abbrinfo");
  146. if (!nameEl || !infoEl) return;
  147. const name = nameEl.textContent.trim();
  148. const infoText = infoEl.textContent.trim();
  149. if (!name || !infoText) return;
  150. const teacher = mkuParseInfoField(infoText, "老师") || "未知教师";
  151. const position = mkuParseInfoField(infoText, "地点") || "未知地点";
  152. const timeInfo = mkuParseTimeText(mkuParseInfoField(infoText, "时间"));
  153. // 节次以"时间"字段为准,缺失时回退到大节行标签
  154. const startSection = timeInfo && timeInfo.startSection
  155. ? timeInfo.startSection
  156. : rowInfo.startSection;
  157. const endSection = timeInfo && timeInfo.endSection
  158. ? timeInfo.endSection
  159. : rowInfo.endSection;
  160. const weeks = timeInfo && timeInfo.weeks && timeInfo.weeks.length
  161. ? timeInfo.weeks
  162. : null;
  163. // 无周次信息则跳过,避免导入错误数据
  164. if (!weeks) return;
  165. courses.push({
  166. name,
  167. teacher,
  168. position,
  169. day,
  170. startSection,
  171. endSection,
  172. weeks
  173. });
  174. });
  175. }
  176. }
  177. // 完全重复的条目去重(同名/同师/同地/同天/同节次/同周次)
  178. const deduped = [];
  179. const seenKeys = new Set();
  180. for (const course of courses) {
  181. const key = [
  182. course.name, course.teacher, course.position, course.day,
  183. course.startSection, course.endSection, course.weeks.join(",")
  184. ].join("|");
  185. if (seenKeys.has(key)) continue;
  186. seenKeys.add(key);
  187. deduped.push(course);
  188. }
  189. return { courses: deduped, rowInfos };
  190. }
  191. // 由课表页大节时间推导小节作息;推导失败时回退兜底配置
  192. function mkuBuildTimeSlots(rowInfos) {
  193. const slots = [];
  194. const rows = [...rowInfos.values()].sort((a, b) => a.startSection - b.startSection);
  195. for (const info of rows) {
  196. if (info.blockStart == null || info.blockEnd == null) return MKU_FALLBACK_TIME_SLOTS;
  197. const count = info.endSection - info.startSection + 1;
  198. const span = info.blockEnd - info.blockStart;
  199. if (span !== MKU_SECTION_MINUTES * count + MKU_BREAK_MINUTES * (count - 1)) {
  200. return MKU_FALLBACK_TIME_SLOTS;
  201. }
  202. for (let i = 0; i < count; i++) {
  203. const start = info.blockStart + i * (MKU_SECTION_MINUTES + MKU_BREAK_MINUTES);
  204. slots.push({
  205. number: info.startSection + i,
  206. startTime: mkuMinutesToTime(start),
  207. endTime: mkuMinutesToTime(start + MKU_SECTION_MINUTES)
  208. });
  209. }
  210. }
  211. return slots.length ? slots : MKU_FALLBACK_TIME_SLOTS;
  212. }
  213. // ===== 教务数据获取 =====
  214. // 拉取个人课表页面(不传学期则渲染教务当前学期)
  215. async function mkuFetchTimetableDoc(semesterId) {
  216. let url = MKU_TIMETABLE_URL + "?viweType=0&zc=&cj0701id=";
  217. if (semesterId) url += "&xnxq01id=" + encodeURIComponent(semesterId);
  218. const resp = await fetch(url, { credentials: "include" });
  219. if (!resp.ok) throw new Error("获取课表页面失败:HTTP " + resp.status);
  220. const html = await resp.text();
  221. const doc = new DOMParser().parseFromString(html, "text/html");
  222. if (!doc.querySelector("#xnxq01id") && !doc.querySelector('td[name="kbDataTd"]')) {
  223. throw new Error("未获取到课表数据,请先在浏览器登录教务系统");
  224. }
  225. return doc;
  226. }
  227. // 学期下拉选项(value 与显示文本一致,如 2026-2027-1)
  228. function mkuGetSemesterOptions(doc) {
  229. const select = doc.querySelector("#xnxq01id");
  230. if (!select) return [];
  231. return Array.from(select.options)
  232. .filter((o) => o.value)
  233. .map((o) => ({ value: o.value, text: o.text.trim(), selected: o.selected }));
  234. }
  235. // 学期总周数(接口返回如 [{"qszc":1,"jszc":20}])
  236. async function mkuFetchTotalWeeks(semesterId) {
  237. try {
  238. const resp = await fetch(MKU_WEEK_AJAX_URL + "?xnxq01id=" + encodeURIComponent(semesterId), {
  239. credentials: "include"
  240. });
  241. const data = await resp.json();
  242. if (Array.isArray(data) && data.length > 0 && Number(data[0].jszc) > 0) {
  243. return Number(data[0].jszc);
  244. }
  245. } catch (error) {
  246. console.warn("获取学期总周数失败,使用默认值:", error);
  247. }
  248. return MKU_DEFAULT_TOTAL_WEEKS;
  249. }
  250. // 学期第一周周一日期:仅当所选学期是教务"当前学期"时可信
  251. // 首页 #headerShowTime 形如 "2026-2027-1 第1周",结合本地今天日期反推开学日
  252. async function mkuFetchSemesterStartDate(semesterId) {
  253. try {
  254. const resp = await fetch(MKU_HOME_URL, { credentials: "include" });
  255. if (!resp.ok) return null;
  256. const html = await resp.text();
  257. const m = html.match(/id="headerShowTime"[^>]*>\s*([^<]+?)\s*</);
  258. if (!m) return null;
  259. const currentSemester = m[1].split(/\s+/)[0];
  260. const weekMatch = m[1].match(/第\s*(\d+)\s*周/);
  261. if (currentSemester !== semesterId || !weekMatch) return null;
  262. const currentWeek = Number(weekMatch[1]);
  263. if (!(currentWeek >= 1)) return null;
  264. const now = new Date();
  265. const mondayOffset = (now.getDay() + 6) % 7; // 周一记 0
  266. const start = new Date(
  267. now.getFullYear(),
  268. now.getMonth(),
  269. now.getDate() - mondayOffset - (currentWeek - 1) * 7
  270. );
  271. return mkuFormatDate(start);
  272. } catch (error) {
  273. console.warn("获取学期开始日期失败:", error);
  274. return null;
  275. }
  276. }
  277. // ===== 主流程 =====
  278. async function mkuRunImportFlow() {
  279. const bridge = window.shiguangBridge;
  280. const bridgePromise = window.shiguangBridgePromise;
  281. const confirmed = await bridgePromise.showAlert(
  282. "导入说明",
  283. "将读取闽南科技学院教务系统\"个人课表\"数据并导入轻屿课表。\n请确保已在当前浏览器登录教务系统(jwgl.mku.edu.cn)。\n是否继续?",
  284. "确认已登录"
  285. );
  286. if (!confirmed) {
  287. bridge.showToast("导入已取消");
  288. return;
  289. }
  290. bridge.showToast("正在读取课表页面...");
  291. const firstDoc = await mkuFetchTimetableDoc();
  292. // 让用户选择学期(默认为教务当前学期)
  293. const options = mkuGetSemesterOptions(firstDoc);
  294. const defaultOption = options.find((o) => o.selected) || null;
  295. let semesterId = defaultOption ? defaultOption.value : null;
  296. if (options.length > 0) {
  297. const defaultIndex = Math.max(0, options.findIndex((o) => o.selected));
  298. const labels = options.map((o) => o.text + (o.selected ? "(当前学期)" : ""));
  299. const index = await bridgePromise.showSingleSelection(
  300. "选择要导入的学期",
  301. JSON.stringify(labels),
  302. defaultIndex
  303. );
  304. if (index == null || index < 0 || index >= options.length) {
  305. bridge.showToast("导入已取消");
  306. return;
  307. }
  308. semesterId = options[index].value;
  309. }
  310. // 所选学期与默认渲染学期一致时直接复用首次页面
  311. const doc = defaultOption && semesterId === defaultOption.value
  312. ? firstDoc
  313. : await mkuFetchTimetableDoc(semesterId);
  314. const { courses, rowInfos } = mkuParseCourses(doc);
  315. if (courses.length === 0) {
  316. bridge.showToast("未解析到课程,请确认所选学期有课且已登录");
  317. return;
  318. }
  319. bridge.showToast("正在获取学期配置...");
  320. const totalWeeks = await mkuFetchTotalWeeks(semesterId);
  321. const startDate = await mkuFetchSemesterStartDate(semesterId);
  322. const timeSlots = mkuBuildTimeSlots(rowInfos);
  323. const config = {
  324. defaultClassDuration: MKU_SECTION_MINUTES,
  325. defaultBreakDuration: MKU_BREAK_MINUTES,
  326. semesterTotalWeeks: totalWeeks,
  327. firstDayOfWeek: 1
  328. };
  329. // 仅当前学期能可靠推算开学日期,历史学期留给 App 端处理
  330. if (startDate) config.semesterStartDate = startDate;
  331. await bridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  332. await bridgePromise.saveCourseConfig(JSON.stringify(config));
  333. await bridgePromise.saveImportedCourses(JSON.stringify(courses));
  334. bridge.showToast("导入成功:共 " + courses.length + " 条课程");
  335. bridge.notifyTaskCompletion();
  336. }
  337. // 启动导入流程
  338. (async () => {
  339. try {
  340. await mkuRunImportFlow();
  341. } catch (error) {
  342. console.error("课表导入失败:", error);
  343. try {
  344. window.shiguangBridge.showToast(
  345. "导入失败:" + (error && error.message ? error.message : error)
  346. );
  347. } catch (e) {
  348. /* 桥接不可用时仅保留控制台日志 */
  349. }
  350. }
  351. })();