sdufe_01.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. /**
  2. * 山东财经大学 教务适配脚本(强智教务系统)
  3. * 数据来源:/jsxsd/xskb/xskb_list.do(学期理论课表)
  4. * /jsxsd/jxzl/jxzl_query(教学周历,用于获取任意学期的开学日期与总周数)
  5. */
  6. // 山东财经大学作息时间(来源:https://www.sdufe.edu.cn/xyfw/zxxl.htm 日常教学时刻表)。
  7. // 官网给出的是“大节”时间(如第一、二节 8:30-10:00),这里按小节拆分:
  8. // 每个大节拆成两个 45 分钟的小节,大节内部无课间;第十一节官网单独给出。
  9. const TIME_SLOTS = [
  10. { number: 1, startTime: "08:30", endTime: "09:15" },
  11. { number: 2, startTime: "09:15", endTime: "10:00" },
  12. { number: 3, startTime: "10:20", endTime: "11:05" },
  13. { number: 4, startTime: "11:05", endTime: "11:50" },
  14. { number: 5, startTime: "14:00", endTime: "14:45" },
  15. { number: 6, startTime: "14:45", endTime: "15:30" },
  16. { number: 7, startTime: "15:50", endTime: "16:35" },
  17. { number: 8, startTime: "16:35", endTime: "17:20" },
  18. { number: 9, startTime: "18:40", endTime: "19:25" },
  19. { number: 10, startTime: "19:25", endTime: "20:10" },
  20. { number: 11, startTime: "20:20", endTime: "21:05" },
  21. ];
  22. const TIMETABLE_URL = "/jsxsd/xskb/xskb_list.do";
  23. const SEMESTER_CALENDAR_URL = "/jsxsd/jxzl/jxzl_query"; // 教学周历
  24. const FORM_HEADERS = { "Content-Type": "application/x-www-form-urlencoded" };
  25. const TEACHER_TITLE_RE = /(副教授|教授|讲师(高校)|讲师|助教|副研究员|研究员|高级实验师|实验师)$/;
  26. // ---------- 工具函数 ----------
  27. function pad2(n) {
  28. return String(n).padStart(2, "0");
  29. }
  30. function formatDate(d) {
  31. return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate());
  32. }
  33. /** 中文数字(一~九十九)或阿拉伯数字 -> 数字 */
  34. function cnToNum(s) {
  35. if (/^\d+$/.test(s)) return parseInt(s, 10);
  36. const map = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
  37. if (s === "十") return 10;
  38. if (s.startsWith("十")) return 10 + map[s[1]];
  39. if (s.endsWith("十")) return map[s[0]] * 10;
  40. if (s.includes("十")) return map[s[0]] * 10 + map[s[2]];
  41. return map[s];
  42. }
  43. /** “第一、二节” / “第九~十一节” -> { start, end } */
  44. function parseSections(text) {
  45. const m = text.match(/第(.+?)节/);
  46. if (!m) return null;
  47. const nums = m[1]
  48. .split(/[、~\-—,,]/)
  49. .map((s) => s.trim())
  50. .filter(Boolean)
  51. .map(cnToNum);
  52. if (!nums.length || nums.some((n) => !Number.isInteger(n))) return null;
  53. return { start: nums[0], end: nums[nums.length - 1] };
  54. }
  55. /** “1-3,5-7,18(周)” / “1-18(单周)” / “1-18(双周)” -> [1,2,3,...] */
  56. function parseWeeks(text) {
  57. const m = text.match(/([\d,,\-~\s]+)[((]\s*(单周|双周|周)\s*[))]/);
  58. if (!m) return [];
  59. const mode = m[2];
  60. const set = new Set();
  61. m[1].split(/[,,]/).forEach((part) => {
  62. const p = part.trim();
  63. if (!p) return;
  64. const r = p.split(/[-~]/).map(Number);
  65. const a = r[0];
  66. const b = r.length > 1 ? r[1] : r[0];
  67. if (!Number.isInteger(a) || !Number.isInteger(b) || a > b) return;
  68. for (let w = a; w <= b; w++) {
  69. if (mode === "单周" && w % 2 === 0) continue;
  70. if (mode === "双周" && w % 2 === 1) continue;
  71. set.add(w);
  72. }
  73. });
  74. return Array.from(set).sort((x, y) => x - y);
  75. }
  76. /** “韩慧健教授” / “刘博,赵天龙” -> 去掉职称,保留姓名 */
  77. function cleanTeacher(raw) {
  78. return raw
  79. .split(/[,,]/)
  80. .map((s) => s.trim().replace(TEACHER_TITLE_RE, ""))
  81. .filter(Boolean)
  82. .join(",");
  83. }
  84. // ---------- 解析 ----------
  85. /** 解析单元格中的一段课程(一个格子可能有多门课,用横线分隔) */
  86. function parseSegment(doc, html) {
  87. const box = doc.createElement("div");
  88. box.innerHTML = html;
  89. let name = "";
  90. for (const node of box.childNodes) {
  91. if (node.nodeType === 3 && node.textContent.trim()) {
  92. name = node.textContent.trim();
  93. break;
  94. }
  95. }
  96. if (!name) return null; // 空格子(&nbsp;)
  97. const pick = (title) => {
  98. const el = box.querySelector('font[title="' + title + '"]');
  99. return el ? el.textContent.trim() : "";
  100. };
  101. return {
  102. name: name,
  103. teacher: cleanTeacher(pick("老师")),
  104. position: pick("教室"),
  105. weeksText: pick("周次(节次)"),
  106. };
  107. }
  108. /** 解析整张课表,返回课程数组;无法解析周次的课程记入 skipped */
  109. function parseCourses(doc) {
  110. const courses = [];
  111. const skipped = [];
  112. const table = doc.getElementById("kbtable");
  113. if (!table) return { courses: courses, skipped: skipped };
  114. table.querySelectorAll("tr").forEach((tr) => {
  115. const th = tr.querySelector("th");
  116. const cells = tr.querySelectorAll("td");
  117. if (!th || cells.length !== 7) return; // 表头行、备注行
  118. const sec = parseSections(th.textContent);
  119. if (!sec) return; // 行首不是“第X节”
  120. cells.forEach((td, i) => {
  121. // 带教师信息的详细版本是 class="kbcontent"(kbcontent1 是简略版)
  122. const box = td.querySelector("div.kbcontent");
  123. if (!box) return;
  124. box.innerHTML.split(/-{5,}/).forEach((seg) => {
  125. const info = parseSegment(doc, seg);
  126. if (!info) return;
  127. const weeks = parseWeeks(info.weeksText);
  128. if (!weeks.length) {
  129. skipped.push(info.name);
  130. return;
  131. }
  132. courses.push({
  133. name: info.name,
  134. teacher: info.teacher,
  135. position: info.position,
  136. day: i + 1,
  137. startSection: sec.start,
  138. endSection: sec.end,
  139. weeks: weeks,
  140. });
  141. });
  142. });
  143. });
  144. return { courses: courses, skipped: skipped };
  145. }
  146. /** 读取页面里的学年学期下拉框 */
  147. function parseSemesters(doc) {
  148. const sel = doc.getElementById("xnxq01id");
  149. if (!sel) return [];
  150. return Array.from(sel.options).map((o) => ({
  151. value: o.value,
  152. label: o.textContent.trim(),
  153. selected: o.selected || o.hasAttribute("selected"),
  154. }));
  155. }
  156. // ---------- 网络请求 ----------
  157. /** semester 为空则取教务系统默认(当前)学期 */
  158. async function fetchTimetable(semester) {
  159. const options = { credentials: "include" };
  160. if (semester) {
  161. options.method = "POST";
  162. options.headers = FORM_HEADERS;
  163. options.body = "zc=&xnxq01id=" + encodeURIComponent(semester);
  164. }
  165. const res = await fetch(TIMETABLE_URL, options);
  166. if (!res.ok) throw new Error("HTTP " + res.status);
  167. return new DOMParser().parseFromString(await res.text(), "text/html");
  168. }
  169. /** 解析教学周历页面,返回 { startDate, totalWeeks },取不到的字段为 null */
  170. function parseSemesterCalendar(doc) {
  171. const info = { startDate: null, totalWeeks: null };
  172. const table = doc.getElementById("kbtable");
  173. if (!table) return info;
  174. const rows = Array.from(table.querySelectorAll("tr")).filter((r) => r.querySelector("td"));
  175. if (!rows.length) return info;
  176. // 第一行第一个带日期的单元格 = 第一周的起始日期,title 形如 “2026年08月24”
  177. const dateCell = rows[0].querySelector("td[title]");
  178. const m = ((dateCell && dateCell.getAttribute("title")) || "").match(/(\d{4})年(\d{1,2})月(\d{1,2})/);
  179. if (m) info.startDate = m[1] + "-" + pad2(m[2]) + "-" + pad2(m[3]);
  180. // 总周数:每行第一个单元格是周次,取最大值
  181. rows.forEach((row) => {
  182. const n = parseInt(row.querySelector("td").textContent.trim(), 10);
  183. if (n > 0 && (info.totalWeeks === null || n > info.totalWeeks)) info.totalWeeks = n;
  184. });
  185. return info;
  186. }
  187. /** 获取指定学期(形如 2025-2026-2)的开学日期与总周数;失败返回全 null,不抛错 */
  188. async function fetchSemesterCalendar(semester) {
  189. const empty = { startDate: null, totalWeeks: null };
  190. if (!semester) return empty;
  191. try {
  192. const res = await fetch(SEMESTER_CALENDAR_URL, {
  193. method: "POST",
  194. headers: FORM_HEADERS,
  195. body: "xnxq01id=" + encodeURIComponent(semester),
  196. credentials: "include",
  197. });
  198. if (!res.ok) return empty;
  199. return parseSemesterCalendar(new DOMParser().parseFromString(await res.text(), "text/html"));
  200. } catch (e) {
  201. return empty;
  202. }
  203. }
  204. /** showPrompt 的校验函数(须在全局作用域):合法返回 false,否则返回错误信息 */
  205. function validateDateInput(input) {
  206. const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(input).trim());
  207. if (!m) return "请按 YYYY-MM-DD 格式输入,例如 2026-08-24";
  208. const y = +m[1], mo = +m[2], d = +m[3];
  209. const dt = new Date(y, mo - 1, d);
  210. if (dt.getFullYear() !== y || dt.getMonth() !== mo - 1 || dt.getDate() !== d) return "日期不存在,请重新输入";
  211. return false;
  212. }
  213. // ---------- 保存 ----------
  214. async function saveCourses(courses) {
  215. try {
  216. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  217. window.shiguangBridge.showToast("成功导入 " + courses.length + " 条课程!");
  218. return true;
  219. } catch (error) {
  220. window.shiguangBridge.showToast("保存课程失败: " + error.message);
  221. return false;
  222. }
  223. }
  224. async function saveConfig(config) {
  225. try {
  226. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  227. return true;
  228. } catch (error) {
  229. window.shiguangBridge.showToast("保存课表配置失败: " + error.message);
  230. return false;
  231. }
  232. }
  233. async function importPresetTimeSlots() {
  234. if (!TIME_SLOTS.length) {
  235. window.shiguangBridge.showToast("未配置作息时间,已跳过。");
  236. return false;
  237. }
  238. try {
  239. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(TIME_SLOTS));
  240. window.shiguangBridge.showToast("作息时间导入成功!");
  241. return true;
  242. } catch (error) {
  243. window.shiguangBridge.showToast("导入作息时间失败: " + error.message);
  244. return false;
  245. }
  246. }
  247. // ---------- 主流程 ----------
  248. async function runImportFlow() {
  249. try {
  250. window.shiguangBridge.showToast("课程导入流程即将开始...");
  251. // 1. 公告和前置检查
  252. const confirmed = await window.shiguangBridgePromise.showAlert(
  253. "导入前提示",
  254. "请先登录山东财经大学教务系统。\n导入只会读取“学期理论课表”,不会修改教务系统中的任何数据。",
  255. "开始导入"
  256. );
  257. if (!confirmed) return;
  258. // 2. 读取默认(当前)学期课表,同时拿到学期列表
  259. let doc = await fetchTimetable(null);
  260. if (!doc.getElementById("kbtable")) {
  261. await window.shiguangBridgePromise.showAlert(
  262. "无法读取课表",
  263. "未获取到课表页面,请确认已登录教务系统后重试。",
  264. "知道了"
  265. );
  266. return;
  267. }
  268. // 3. 选择学期
  269. const semesters = parseSemesters(doc);
  270. let semesterValue = "";
  271. if (semesters.length) {
  272. const defIdx = Math.max(0, semesters.findIndex((s) => s.selected));
  273. const idx = await window.shiguangBridgePromise.showSingleSelection(
  274. "选择学期",
  275. JSON.stringify(semesters.map((s) => s.label)),
  276. defIdx
  277. );
  278. if (idx === null) {
  279. window.shiguangBridge.showToast("导入已取消。");
  280. return;
  281. }
  282. semesterValue = semesters[idx].value;
  283. if (idx !== defIdx) {
  284. doc = await fetchTimetable(semesterValue);
  285. if (!doc.getElementById("kbtable")) {
  286. window.shiguangBridge.showToast("该学期没有课表数据。");
  287. return;
  288. }
  289. }
  290. }
  291. // 4. 解析课程
  292. const result = parseCourses(doc);
  293. if (!result.courses.length) {
  294. await window.shiguangBridgePromise.showAlert(
  295. "没有可导入的课程",
  296. "所选学期的课表为空。",
  297. "知道了"
  298. );
  299. return;
  300. }
  301. if (result.skipped.length) {
  302. window.shiguangBridge.showToast("有 " + result.skipped.length + " 门课的周次无法识别,已跳过。");
  303. }
  304. // 5. 保存课程
  305. if (!(await saveCourses(result.courses))) return;
  306. // 6. 开学日期与总周数:来自教学周历,任意学期都可获取;取不到时让用户手动输入开学日期
  307. const info = await fetchSemesterCalendar(semesterValue);
  308. if (!info.startDate) {
  309. const input = await window.shiguangBridgePromise.showPrompt(
  310. "无法自动获取开学日期",
  311. "请输入该学期第一周周一的日期(YYYY-MM-DD),取消则不设置:",
  312. "",
  313. "validateDateInput"
  314. );
  315. if (input) info.startDate = input.trim();
  316. }
  317. const config = {};
  318. if (info.startDate) config.semesterStartDate = info.startDate;
  319. if (info.totalWeeks) config.semesterTotalWeeks = info.totalWeeks;
  320. if (Object.keys(config).length) await saveConfig(config);
  321. // 7. [可选] 作息时间(失败不阻止流程完成)
  322. await importPresetTimeSlots();
  323. // 8. 全部完成
  324. window.shiguangBridge.showToast("所有任务已完成!");
  325. window.shiguangBridge.notifyTaskCompletion();
  326. } catch (error) {
  327. window.shiguangBridge.showToast("导入出错: " + error.message);
  328. }
  329. }
  330. runImportFlow();