hzu.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. // 菏泽学院强智教务:新版学期理论课表(viweType=0)。
  2. // 参考仓库 CSUFT/MKU 的新版强智 DOM 结构,按本站实测重写。
  3. // 不保存登录凭据;所有请求沿用当前 WebView 的会话与协议。
  4. const HZU_SUMMER_TIME_SLOTS = [
  5. { number: 1, startTime: "08:00", endTime: "08:50" },
  6. { number: 2, startTime: "09:00", endTime: "09:50" },
  7. { number: 3, startTime: "10:10", endTime: "11:00" },
  8. { number: 4, startTime: "11:10", endTime: "12:00" },
  9. { number: 5, startTime: "14:30", endTime: "15:20" },
  10. { number: 6, startTime: "15:30", endTime: "16:20" },
  11. { number: 7, startTime: "16:40", endTime: "17:30" },
  12. { number: 8, startTime: "17:40", endTime: "18:30" },
  13. { number: 9, startTime: "19:30", endTime: "20:20" },
  14. { number: 10, startTime: "20:30", endTime: "21:20" }
  15. ];
  16. // 冬季仅下午第5至8节提前半小时,上午和晚课保持不变。
  17. const HZU_WINTER_TIME_SLOTS = HZU_SUMMER_TIME_SLOTS.map(slot => {
  18. if (slot.number < 5 || slot.number > 8) return { ...slot };
  19. const earlier = time => {
  20. const [hour, minute] = time.split(":").map(Number);
  21. const minutes = hour * 60 + minute - 30;
  22. return String(Math.floor(minutes / 60)).padStart(2, "0") + ":" +
  23. String(minutes % 60).padStart(2, "0");
  24. };
  25. return { ...slot, startTime: earlier(slot.startTime), endTime: earlier(slot.endTime) };
  26. });
  27. function hzuComboSchedule(semesterId) {
  28. const term = semesterId.match(/^(\d{4})-(\d{4})-([12])$/);
  29. if (!term || Number(term[2]) !== Number(term[1]) + 1) {
  30. throw new Error("无法识别作息所属学年。");
  31. }
  32. const firstYear = Number(term[1]);
  33. const lastYear = Number(term[2]);
  34. const summerYear = term[3] === "1" ? firstYear : lastYear;
  35. const publicSchedules = [
  36. {
  37. name: "菏泽学院夏季作息",
  38. startDate: summerYear + "-05-01",
  39. endDate: summerYear + "-10-04",
  40. defaultClassDuration: 50,
  41. defaultBreakDuration: 10,
  42. timeSlots: HZU_SUMMER_TIME_SLOTS.filter(slot => slot.number >= 5 && slot.number <= 8)
  43. }
  44. ];
  45. // App 在夏季规则之外回退到冬季基础作息,边界日期均为闭区间。
  46. return { name: "菏泽学院冬夏季作息", publicSchedules };
  47. }
  48. function hzuUrl(path) {
  49. const pathname = window.location.pathname;
  50. const index = pathname.indexOf("/jsxsd/");
  51. if (index < 0 && !pathname.endsWith("/jsxsd")) {
  52. throw new Error("请先登录菏泽学院教务系统,再开始导入。");
  53. }
  54. const prefix = index >= 0 ? pathname.slice(0, index) : pathname.slice(0, -6);
  55. return window.location.origin + prefix + "/jsxsd" + path;
  56. }
  57. async function hzuFetchDocument(path) {
  58. const controller = new AbortController();
  59. const timer = setTimeout(() => controller.abort(), 20000);
  60. try {
  61. const response = await fetch(hzuUrl(path), {
  62. credentials: "include",
  63. signal: controller.signal
  64. });
  65. if (!response.ok) throw new Error("教务请求失败:HTTP " + response.status);
  66. return new DOMParser().parseFromString(await response.text(), "text/html");
  67. } finally {
  68. clearTimeout(timer);
  69. }
  70. }
  71. function hzuSemesters(doc) {
  72. const select = doc.querySelector("#xnxq01id");
  73. if (!select) throw new Error("登录已失效或未获取到课表页面,请重新登录。");
  74. return Array.from(select.options).filter(option => option.value).map(option => ({
  75. value: option.value,
  76. text: option.textContent.trim(),
  77. selected: option.selected
  78. }));
  79. }
  80. function hzuCheckSemester(doc, semesterId) {
  81. const value = doc.querySelector("#xnxq")?.value || doc.querySelector("#xnxq01id")?.value;
  82. if (value !== semesterId) throw new Error("教务返回的学期与所选学期不一致,请重新查询。");
  83. }
  84. function hzuNumbers(text, limit) {
  85. const numbers = [];
  86. const parts = text.replace(/\s/g, "").split(/[,,、]/);
  87. for (const part of parts) {
  88. const match = part.match(/^(\d+)(?:[-~~—至](\d+))?$/);
  89. if (!match) throw new Error("无法识别周次或节次:" + text);
  90. const start = Number(match[1]);
  91. const end = Number(match[2] || match[1]);
  92. if (start < 1 || end < start || end > limit) throw new Error("周次或节次超出范围:" + text);
  93. for (let number = start; number <= end; number++) numbers.push(number);
  94. }
  95. return Array.from(new Set(numbers)).sort((a, b) => a - b);
  96. }
  97. function hzuParseTime(text) {
  98. const sectionMatch = text.match(/[\[[]([^[\][]]+)节[\]]]/);
  99. if (!sectionMatch || !text.slice(0, sectionMatch.index).includes("周")) {
  100. throw new Error("课程缺少有效的周次或节次:" + text);
  101. }
  102. const weekText = text.slice(0, sectionMatch.index).replace(/第|周|\s/g, "");
  103. const weeks = [];
  104. for (const part of weekText.split(/[,,、]/)) {
  105. const parity = part.match(/[((]?(单|双)[))]?$/);
  106. const range = parity ? part.slice(0, parity.index) : part;
  107. weeks.push(...hzuNumbers(range, 100).filter(week =>
  108. !parity || week % 2 === (parity[1] === "单" ? 1 : 0)));
  109. }
  110. const sections = hzuNumbers(sectionMatch[1], 30);
  111. // 不连续的节次拆开,不能把未上课的小节填进连续区间。
  112. const ranges = [];
  113. for (const section of sections) {
  114. const last = ranges[ranges.length - 1];
  115. if (last && last.endSection + 1 === section) last.endSection = section;
  116. else ranges.push({ startSection: section, endSection: section });
  117. }
  118. const sortedWeeks = Array.from(new Set(weeks)).sort((a, b) => a - b);
  119. if (!sortedWeeks.length) throw new Error("课程周次为空:" + text);
  120. return { weeks: sortedWeeks, ranges };
  121. }
  122. function hzuGrid(table) {
  123. const grid = [];
  124. Array.from(table.rows).forEach((row, r) => {
  125. if (!grid[r]) grid[r] = [];
  126. let column = 0;
  127. Array.from(row.cells).forEach(cell => {
  128. while (grid[r][column]) column++;
  129. for (let dr = 0; dr < (cell.rowSpan || 1); dr++) {
  130. if (!grid[r + dr]) grid[r + dr] = [];
  131. for (let dc = 0; dc < (cell.colSpan || 1); dc++) {
  132. grid[r + dr][column + dc] = cell;
  133. }
  134. }
  135. column += cell.colSpan || 1;
  136. });
  137. });
  138. return grid;
  139. }
  140. function hzuField(text, label) {
  141. return (text.match(new RegExp(label + "[::]\\s*([^;;]*)")) || [])[1]?.trim() || "";
  142. }
  143. function hzuParseCourses(doc) {
  144. const table = doc.querySelector('td[name="kbDataTd"]')?.closest("table");
  145. if (!table) {
  146. if (doc.querySelector("#xnxq01id")) return [];
  147. throw new Error("未获取到课表,请重新登录教务系统。");
  148. }
  149. const grid = hzuGrid(table);
  150. const days = new Map();
  151. const dayNames = "一二三四五六日";
  152. for (const row of grid) {
  153. row.forEach((cell, column) => {
  154. if (cell.tagName !== "TH") return;
  155. const match = cell.textContent.trim().match(/^(?:星期|周)([一二三四五六日天])/);
  156. if (match) days.set(column, match[1] === "天" ? 7 : dayNames.indexOf(match[1]) + 1);
  157. });
  158. }
  159. if (days.size !== 7) throw new Error("无法识别课表的星期表头,已停止导入。");
  160. const seen = new Set();
  161. const merged = new Map();
  162. for (const row of grid) {
  163. for (const [column, day] of days) {
  164. const cell = row[column];
  165. if (!cell || seen.has(cell) || cell.getAttribute("name") !== "kbDataTd") continue;
  166. seen.add(cell);
  167. // 只读简表课程,跳过同一格内 tooltip 的重复详情。
  168. for (const item of cell.querySelectorAll("ul.courselists > li.courselists-item")) {
  169. const name = item.querySelector(".qz-hasCourse-title")?.textContent.trim();
  170. const info = item.querySelector(".qz-hasCourse-abbrinfo")?.textContent.trim() || "";
  171. if (!name) throw new Error("课表中有无法识别的课程名称,已停止导入。");
  172. const time = hzuParseTime(hzuField(info, "时间"));
  173. const teacher = hzuField(info, "老师");
  174. const location = hzuField(info, "地点");
  175. const position = /^[((]\s*[))]$/.test(location) ? "" : location;
  176. for (const range of time.ranges) {
  177. const key = JSON.stringify([name, teacher, position, day, range.startSection, range.endSection]);
  178. if (merged.has(key)) {
  179. const course = merged.get(key);
  180. course.weeks = Array.from(new Set(course.weeks.concat(time.weeks))).sort((a, b) => a - b);
  181. } else {
  182. merged.set(key, { name, teacher, position, day, ...range, weeks: time.weeks.slice() });
  183. }
  184. }
  185. }
  186. }
  187. }
  188. return Array.from(merged.values());
  189. }
  190. function hzuCalendar(doc, semesterId) {
  191. hzuCheckSemester(doc, semesterId);
  192. const table = doc.querySelector("#dataTable");
  193. if (!table) throw new Error("未找到教学周历。");
  194. const anchors = [];
  195. let totalWeeks = 0;
  196. for (const row of table.rows) {
  197. const match = row.cells[0]?.textContent.match(/第\s*(\d+)\s*周/);
  198. if (!match) continue;
  199. const week = Number(match[1]);
  200. if (week < 1 || week > 100) throw new Error("教学周历周次异常。");
  201. totalWeeks = Math.max(totalWeeks, week);
  202. for (let day = 0; day < 7; day++) {
  203. const cell = row.cells[day + 1];
  204. const date = cell?.textContent.match(/(\d{1,2})月(\d{1,2})日/);
  205. if (date) anchors.push({ offset: (week - 1) * 7 + day, month: Number(date[1]), day: Number(date[2]) });
  206. }
  207. }
  208. if (!anchors.length || !totalWeeks) throw new Error("教学周历缺少日期。");
  209. anchors.sort((a, b) => a.offset - b.offset);
  210. const term = semesterId.match(/^(\d{4})-\d{4}-([12])$/);
  211. if (!term) throw new Error("无法识别校历学期。");
  212. const first = anchors[0];
  213. const year = Number(term[1]) + (term[2] === "2" || first.month < 7 ? 1 : 0);
  214. const dayMs = 86400000;
  215. const startMs = Date.UTC(year, first.month - 1, first.day) - first.offset * dayMs;
  216. if (new Date(startMs).getUTCDay() !== 1 || anchors.some(anchor => {
  217. const date = new Date(startMs + anchor.offset * dayMs);
  218. return date.getUTCMonth() + 1 !== anchor.month || date.getUTCDate() !== anchor.day;
  219. })) throw new Error("教学周历日期不一致,无法确定开学日期。");
  220. return { semesterStartDate: new Date(startMs).toISOString().slice(0, 10), semesterTotalWeeks: totalWeeks };
  221. }
  222. async function hzuRunImportFlow() {
  223. const bridge = window.shiguangBridge;
  224. const api = window.shiguangBridgePromise;
  225. if (!bridge || !api) throw new Error("未检测到拾光桥接接口,请在软件或测试插件中运行。");
  226. const requiredMethods = ["showAlert", "showSingleSelection", "saveCourseConfig",
  227. "savePresetTimeSlots", "saveComboSchedule", "saveImportedCourses"];
  228. if (requiredMethods.some(method => typeof api[method] !== "function") ||
  229. typeof bridge.showToast !== "function" || typeof bridge.notifyTaskCompletion !== "function") {
  230. throw new Error("桥接接口不完整,请使用时光课程表 2.1.0 或新版测试插件后重试。");
  231. }
  232. if (!await api.showAlert("菏泽学院课表导入", "请确认已登录教务系统。将读取所选学期的全部周课程。", "开始导入")) return;
  233. bridge.showToast("正在读取学期列表...");
  234. const firstDoc = await hzuFetchDocument("/xskb/xskb_list.do?viweType=0&zc=");
  235. const semesters = hzuSemesters(firstDoc);
  236. if (!semesters.length) throw new Error("教务未提供可导入的学期。");
  237. const index = await api.showSingleSelection("选择学期",
  238. JSON.stringify(semesters.map(semester => semester.text)),
  239. Math.max(0, semesters.findIndex(semester => semester.selected)));
  240. if (index === null) return;
  241. if (!Number.isInteger(index) || !semesters[index]) throw new Error("学期选择无效。");
  242. const semesterId = semesters[index].value;
  243. const params = new URLSearchParams({ viweType: "0", zc: "", xnxq01id: semesterId });
  244. const modeSelect = firstDoc.querySelector("#kbjcmsid");
  245. const mode = modeSelect && "value" in modeSelect ? modeSelect.value : null;
  246. if (typeof mode === "string" && mode) params.set("kbjcmsid", mode);
  247. const doc = await hzuFetchDocument("/xskb/xskb_list.do?" + params);
  248. hzuCheckSemester(doc, semesterId);
  249. const courses = hzuParseCourses(doc);
  250. if (!courses.length) {
  251. bridge.showToast("该学期暂无已排课课程,未修改原有课表。");
  252. return;
  253. }
  254. let config = null;
  255. try {
  256. config = hzuCalendar(await hzuFetchDocument("/jxzl/jxzl_query?xnxq01id=" +
  257. encodeURIComponent(semesterId)), semesterId);
  258. if (courses.some(course => course.weeks.some(week => week > config.semesterTotalWeeks))) {
  259. throw new Error("课程周次超出教学周历。");
  260. }
  261. } catch (_) {
  262. config = null;
  263. if (!await api.showAlert("教学周历不可用",
  264. "无法确定所选学期的开学日期与总周数。继续导入后,请在课表设置中核对学期配置。", "继续导入")) return;
  265. }
  266. const comboSchedule = hzuComboSchedule(semesterId);
  267. // 只在全部读取和确认完成后写入;保存失败不能发出完成信号。
  268. if (config) {
  269. config.firstDayOfWeek = 1;
  270. config.defaultClassDuration = 50;
  271. config.defaultBreakDuration = 10;
  272. if (await api.saveCourseConfig(JSON.stringify(config)) !== true) throw new Error("学期配置保存失败。");
  273. }
  274. if (await api.savePresetTimeSlots(JSON.stringify(HZU_WINTER_TIME_SLOTS)) !== true) {
  275. throw new Error("作息时间保存失败。");
  276. }
  277. // App 要求先保存基础节次,成功后再绑定组合作息,之后不能重存基础节次。
  278. if (await api.saveComboSchedule(JSON.stringify(comboSchedule)) !== true) {
  279. throw new Error("冬夏季作息保存失败。");
  280. }
  281. if (await api.saveImportedCourses(JSON.stringify(courses)) !== true) throw new Error("课程保存失败。");
  282. bridge.showToast("成功导入 " + courses.length + " 条上课记录");
  283. bridge.notifyTaskCompletion();
  284. }
  285. // Start import.
  286. hzuRunImportFlow().catch(error => {
  287. console.error("HZU import failed:", error);
  288. if (typeof window.shiguangBridge?.showToast === "function") {
  289. window.shiguangBridge.showToast("导入失败:" + (error?.message || String(error)));
  290. }
  291. });