HUSE.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. /**
  2. * 将周次字符串(如 "1-4,6,8-10")解析为有序的周次数字数组。
  3. * 支持连续区间(如 "1-4")和单个周次(如 "6")的混合格式。
  4. * @param {string} weekStr - 原始周次字符串
  5. * @returns {number[]} 去重并升序排列的周次数组
  6. */
  7. function parseWeeks(weekStr) {
  8. const weeks = [];
  9. const parts = weekStr.split(',');
  10. for (const part of parts) {
  11. if (part.includes('-')) {
  12. const [start, end] = part.split('-');
  13. for (let w = parseInt(start); w <= parseInt(end); w++) {
  14. if (!weeks.includes(w)) weeks.push(w);
  15. }
  16. } else {
  17. const w = parseInt(part);
  18. if (!isNaN(w) && !weeks.includes(w)) weeks.push(w);
  19. }
  20. }
  21. return weeks.sort((a, b) => a - b);
  22. }
  23. /**
  24. * 从教务系统返回的 HTML 文档中解析所有课程信息。
  25. * 遍历课表格 #timetable 中每个单元格,提取课程名、教师、教室及周次节次。
  26. * @param {Document} doc - 已解析的 HTML 文档对象
  27. * @returns {object[]} 课程对象数组,每项包含 day/name/teacher/position/weeks/startSection/endSection
  28. */
  29. function extractCoursesFromDoc(doc) {
  30. const courses = [];
  31. const table = doc.getElementById('timetable');
  32. if (!table) throw new Error("未找到课表格元素(#timetable),请确认教务系统页面已正常加载。");
  33. const rows = table.getElementsByTagName('tr');
  34. // 跳过首行(表头)和末行(通常为空白行)
  35. for (let rowIdx = 1; rowIdx < rows.length - 1; rowIdx++) {
  36. const cells = rows[rowIdx].getElementsByTagName('td');
  37. for (let colIdx = 0; colIdx < cells.length; colIdx++) {
  38. const dayOfWeek = colIdx + 1; // 列索引对应星期几(1=周一)
  39. const cell = cells[colIdx];
  40. const contentDivs = cell.querySelectorAll('div.kbcontent');
  41. if (contentDivs.length === 0) continue;
  42. contentDivs.forEach(div => {
  43. const rawHtml = div.innerHTML;
  44. // 跳过空单元格
  45. if (!rawHtml.trim() || rawHtml === '&nbsp;') return;
  46. // 同一格内多门课以 10 个以上连字符分隔
  47. const blocks = rawHtml.split(/-{10,}\s*<br\s*\/?>/i);
  48. blocks.forEach(block => {
  49. if (!block.trim()) return;
  50. const tmp = document.createElement('div');
  51. tmp.innerHTML = block;
  52. const course = {
  53. day: dayOfWeek,
  54. isCustomTime: false
  55. };
  56. // 课程名称取第一个文本节点,fallback 到 innerText 首行
  57. const firstNode = tmp.childNodes[0];
  58. if (firstNode && firstNode.nodeType === Node.TEXT_NODE) {
  59. course.name = firstNode.nodeValue.trim();
  60. } else {
  61. course.name = tmp.innerText.split('\n')[0].trim();
  62. }
  63. // 教师姓名
  64. const teacherEl = tmp.querySelector('font[title="教师"]');
  65. course.teacher = teacherEl ? teacherEl.innerText.trim() : "未知";
  66. // 上课教室
  67. const roomEl = tmp.querySelector('font[title="教室"]');
  68. course.position = roomEl ? roomEl.innerText.trim() : "待定";
  69. // 周次与节次:格式为 "X-Y(周)[A-B节]" 或仅 "X-Y(周)"
  70. const timeEl = tmp.querySelector('font[title="周次(节次)"]');
  71. if (!timeEl) return;
  72. const timeText = timeEl.innerText.trim();
  73. const fullMatch = timeText.match(/(.+?)\(周\)\[(\d+)-(\d+)节\]/);
  74. if (fullMatch) {
  75. course.weeks = parseWeeks(fullMatch[1]);
  76. course.startSection = parseInt(fullMatch[2]);
  77. course.endSection = parseInt(fullMatch[3]);
  78. } else {
  79. const weekOnlyMatch = timeText.match(/(.+?)\(周\)/);
  80. if (weekOnlyMatch) {
  81. // 节次信息缺失时,根据行索引推算默认节次
  82. course.weeks = parseWeeks(weekOnlyMatch[1]);
  83. course.startSection = rowIdx * 2 - 1;
  84. course.endSection = rowIdx * 2;
  85. } else {
  86. return; // 无法识别的时间格式,跳过
  87. }
  88. }
  89. if (course.name && course.weeks && course.weeks.length > 0) {
  90. courses.push(course);
  91. }
  92. });
  93. });
  94. }
  95. }
  96. return courses;
  97. }
  98. /**
  99. * 根据当前日期返回对应学期的作息时间表。
  100. * 5月1日至9月30日期间使用夏季作息,其余时间使用春秋冬季作息。
  101. * @returns {object[]} 节次时间数组,每项包含 number/startTime/endTime
  102. */
  103. function getPresetTimeSlots() {
  104. const now = new Date();
  105. const month = now.getMonth() + 1;
  106. const day = now.getDate();
  107. // 判断是否处于夏季作息期间(5月1日 ~ 9月30日)
  108. const isSummer = (month > 5) || (month === 5 && day >= 1) || (month < 10) && (month > 4);
  109. if (isSummer) {
  110. // 夏季作息
  111. return [
  112. { number: 1, startTime: "08:10", endTime: "08:55" },
  113. { number: 2, startTime: "09:05", endTime: "09:50" },
  114. { number: 3, startTime: "10:10", endTime: "10:55" },
  115. { number: 4, startTime: "11:05", endTime: "11:50" },
  116. { number: 5, startTime: "14:45", endTime: "15:30" },
  117. { number: 6, startTime: "15:40", endTime: "16:25" },
  118. { number: 7, startTime: "16:40", endTime: "17:25" },
  119. { number: 8, startTime: "17:35", endTime: "18:20" },
  120. { number: 9, startTime: "19:30", endTime: "20:15" },
  121. { number: 10, startTime: "20:25", endTime: "21:10" },
  122. { number: 11, startTime: "21:20", endTime: "22:05" }
  123. ];
  124. } else {
  125. // 春秋冬季作息
  126. return [
  127. { number: 1, startTime: "08:20", endTime: "09:05" },
  128. { number: 2, startTime: "09:05", endTime: "10:00" },
  129. { number: 3, startTime: "10:20", endTime: "11:05" },
  130. { number: 4, startTime: "11:15", endTime: "12:00" },
  131. { number: 5, startTime: "14:30", endTime: "15:15" },
  132. { number: 6, startTime: "15:25", endTime: "16:10" },
  133. { number: 7, startTime: "16:25", endTime: "17:10" },
  134. { number: 8, startTime: "17:20", endTime: "18:05" },
  135. { number: 9, startTime: "19:10", endTime: "19:55" },
  136. { number: 10, startTime: "20:05", endTime: "20:50" },
  137. { number: 11, startTime: "21:00", endTime: "21:45" }
  138. ];
  139. }
  140. }
  141. /**
  142. * 返回全局课表基础配置(单节课时长与课间休息时长)。
  143. * @returns {{ defaultClassDuration: number, defaultBreakDuration: number }}
  144. */
  145. function getCourseConfig() {
  146. return {
  147. defaultClassDuration: 45,
  148. defaultBreakDuration: 10
  149. };
  150. }
  151. /**
  152. * 课表导入主流程。
  153. * 依次完成:发起请求 → 解析 HTML → 提取课程 → 保存配置/作息/课程 → 通知完成。
  154. * 在浏览器调试环境中仅打印结果,不调用 AndroidBridge。
  155. */
  156. async function runImportFlow() {
  157. const isApp = typeof window.AndroidBridgePromise !== 'undefined';
  158. const hasToast = typeof window.AndroidBridge !== 'undefined';
  159. try {
  160. if (hasToast) {
  161. AndroidBridge.showToast("正在拉取课表,请稍候...");
  162. } else {
  163. console.log("[HUSE] 开始请求课表页面...");
  164. }
  165. const response = await fetch('/jsxsd/xskb/xskb_list.do', { method: 'GET' });
  166. const htmlText = await response.text();
  167. const doc = new DOMParser().parseFromString(htmlText, 'text/html');
  168. // 读取学期列表(当前仅用于记录,实际取最新学期)
  169. const selectEl = doc.getElementById('xnxq01id');
  170. const semesters = [];
  171. const semesterValues = [];
  172. let defaultIndex = 0;
  173. if (selectEl) {
  174. selectEl.querySelectorAll('option').forEach((opt, idx) => {
  175. semesters.push(opt.innerText.trim());
  176. semesterValues.push(opt.value);
  177. if (opt.hasAttribute('selected')) defaultIndex = idx;
  178. });
  179. }
  180. // 始终选取列表末尾的最新学期
  181. defaultIndex = semesters.length - 1;
  182. console.log(`[HUSE] 共找到 ${semesters.length} 个学期,当前使用:${semesters[defaultIndex] || '未知'}`);
  183. const courses = extractCoursesFromDoc(doc);
  184. if (courses.length === 0) {
  185. const msg = "未解析到任何课程,当前学期可能暂无排课。";
  186. console.warn("[HUSE] " + msg);
  187. if (isApp) {
  188. await window.AndroidBridgePromise.showAlert("提示", msg, "好的");
  189. } else {
  190. alert(msg);
  191. }
  192. return;
  193. }
  194. console.log(`[HUSE] 成功解析 ${courses.length} 门课程。`);
  195. const config = getCourseConfig();
  196. const timeSlots = getPresetTimeSlots();
  197. // 浏览器调试环境:输出结果后退出,不执行 APP 存储逻辑
  198. if (!isApp) {
  199. console.log("[HUSE] 课表基础配置:", config);
  200. console.log("[HUSE] 作息时间表:", timeSlots);
  201. console.log("[HUSE] 课程列表:", courses);
  202. alert(`解析完成!共获取 ${courses.length} 门课程及作息时间,详情见控制台(F12)。`);
  203. return;
  204. }
  205. // APP 环境:保存课表配置与作息时间
  206. const configSaved = await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify(config));
  207. const slotsSaved = await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  208. if (!configSaved || !slotsSaved) {
  209. // 时间配置保存失败不强制中断,继续尝试导入课程
  210. console.warn("[HUSE] 课表时间配置保存失败,将继续尝试导入课程。");
  211. AndroidBridge.showToast("时间配置保存失败,继续导入课程...");
  212. }
  213. // APP 环境:保存课程数据
  214. const courseSaved = await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
  215. if (!courseSaved) {
  216. console.error("[HUSE] 课程数据保存失败。");
  217. AndroidBridge.showToast("课程保存失败,请重试!");
  218. return;
  219. }
  220. console.log(`[HUSE] 导入完成,共写入 ${courses.length} 门课程。`);
  221. AndroidBridge.showToast(`成功导入 ${courses.length} 门课程及作息时间!`);
  222. AndroidBridge.notifyTaskCompletion();
  223. } catch (err) {
  224. console.error("[HUSE] 导入流程发生异常:", err);
  225. if (hasToast) {
  226. AndroidBridge.showToast("导入失败:" + err.message);
  227. } else {
  228. alert("导入失败:" + err.message);
  229. }
  230. }
  231. }
  232. runImportFlow();