HUSE.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  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. * 从教学周历页面推断学期开学日期(YYYY-MM-DD)。
  143. * 解析策略:
  144. * 1) 优先读取第 1 周对应的周一日期;
  145. * 2) 若未命中,则在周历表中收集全部日期并取最小值兜底。
  146. * @returns {Promise<string|null>} 开学日期字符串;无法解析时返回 null
  147. */
  148. async function getStartDate() {
  149. // 请求教学周历页面(包含每周日期映射)
  150. const response = await fetch('/jsxsd/jxzl/jxzl_query', { method: 'GET' });
  151. const htmlText = await response.text();
  152. const doc = new DOMParser().parseFromString(htmlText, 'text/html');
  153. // 周历主表格,日期信息存放在单元格 title 属性中
  154. const table = doc.querySelector("#kbtable");
  155. if (!table) {
  156. console.log("未找到周历表格 #kbtable");
  157. return null;
  158. }
  159. // 将“YYYY年M月D日”转为“YYYY-MM-DD”
  160. const parseCNDate = (text) => {
  161. const m = String(text).match(/(\d{4})年(\d{1,2})月(\d{1,2})/);
  162. if (!m) return null;
  163. const [, y, mo, d] = m;
  164. return `${y}-${mo.padStart(2, "0")}-${d.padStart(2, "0")}`;
  165. };
  166. // 优先取“第1周 + 星期一”
  167. const week1Row = Array.from(table.querySelectorAll("tr")).find((tr) => {
  168. const first = tr.cells?.[0]?.textContent?.trim();
  169. return first === "1";
  170. });
  171. let start = null;
  172. if (week1Row && week1Row.cells[1]) {
  173. start = parseCNDate(week1Row.cells[1].getAttribute("title"));
  174. }
  175. // 兜底:从所有日期里取最小值
  176. if (!start) {
  177. const allDates = Array.from(table.querySelectorAll("td[title]"))
  178. .map((td) => parseCNDate(td.getAttribute("title")))
  179. .filter(Boolean)
  180. .sort();
  181. start = allDates[0] || null;
  182. }
  183. console.log("开学日期:", start || "未找到");
  184. return start;
  185. }
  186. /**
  187. * 返回全局课表基础配置(单节课时长与课间休息时长)。
  188. * @returns {Promise<{ semesterStartDate: string|null, defaultClassDuration: number, defaultBreakDuration: number }>}
  189. */
  190. async function getCourseConfig() {
  191. const semesterStartDate = await getStartDate();
  192. return {
  193. semesterStartDate: semesterStartDate,
  194. defaultClassDuration: 45,
  195. defaultBreakDuration: 10
  196. };
  197. }
  198. /**
  199. * 课表导入主流程。
  200. * 依次完成:发起请求 → 解析 HTML → 提取课程 → 保存配置/作息/课程 → 通知完成。
  201. * 在浏览器调试环境中仅打印结果,不调用 AndroidBridge。
  202. */
  203. async function runImportFlow() {
  204. const isApp = typeof window.AndroidBridgePromise !== 'undefined';
  205. const hasToast = typeof window.AndroidBridge !== 'undefined';
  206. try {
  207. if (hasToast) {
  208. AndroidBridge.showToast("正在拉取课表,请稍候...");
  209. } else {
  210. console.log("[HUSE] 开始请求课表页面...");
  211. }
  212. const response = await fetch('/jsxsd/xskb/xskb_list.do', { method: 'GET' });
  213. const htmlText = await response.text();
  214. const doc = new DOMParser().parseFromString(htmlText, 'text/html');
  215. // 读取学期列表(当前仅用于记录,实际取最新学期)
  216. const selectEl = doc.getElementById('xnxq01id');
  217. const semesters = [];
  218. const semesterValues = [];
  219. let defaultIndex = 0;
  220. if (selectEl) {
  221. selectEl.querySelectorAll('option').forEach((opt, idx) => {
  222. semesters.push(opt.innerText.trim());
  223. semesterValues.push(opt.value);
  224. if (opt.hasAttribute('selected')) defaultIndex = idx;
  225. });
  226. }
  227. // 始终选取列表末尾的最新学期
  228. defaultIndex = semesters.length - 1;
  229. console.log(`[HUSE] 共找到 ${semesters.length} 个学期,当前使用:${semesters[defaultIndex] || '未知'}`);
  230. const courses = extractCoursesFromDoc(doc);
  231. if (courses.length === 0) {
  232. const msg = "未解析到任何课程,当前学期可能暂无排课。";
  233. console.warn("[HUSE] " + msg);
  234. if (isApp) {
  235. await window.AndroidBridgePromise.showAlert("提示", msg, "好的");
  236. } else {
  237. alert(msg);
  238. }
  239. return;
  240. }
  241. console.log(`[HUSE] 成功解析 ${courses.length} 门课程。`);
  242. const config = await getCourseConfig();
  243. const timeSlots = getPresetTimeSlots();
  244. // 浏览器调试环境:输出结果后退出,不执行 APP 存储逻辑
  245. if (!isApp) {
  246. console.log("[HUSE] 课表基础配置:", config);
  247. console.log("[HUSE] 作息时间表:", timeSlots);
  248. console.log("[HUSE] 课程列表:", courses);
  249. alert(`解析完成!共获取 ${courses.length} 门课程及作息时间,详情见控制台(F12)。`);
  250. return;
  251. }
  252. // APP 环境:保存课表配置与作息时间
  253. const configSaved = await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify(config));
  254. const slotsSaved = await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  255. if (!configSaved || !slotsSaved) {
  256. // 时间配置保存失败不强制中断,继续尝试导入课程
  257. console.warn("[HUSE] 课表时间配置保存失败,将继续尝试导入课程。");
  258. AndroidBridge.showToast("时间配置保存失败,继续导入课程...");
  259. }
  260. // APP 环境:保存课程数据
  261. const courseSaved = await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
  262. if (!courseSaved) {
  263. console.error("[HUSE] 课程数据保存失败。");
  264. AndroidBridge.showToast("课程保存失败,请重试!");
  265. return;
  266. }
  267. console.log(`[HUSE] 导入完成,共写入 ${courses.length} 门课程。`);
  268. AndroidBridge.showToast(`成功导入 ${courses.length} 门课程及作息时间!`);
  269. AndroidBridge.notifyTaskCompletion();
  270. } catch (err) {
  271. console.error("[HUSE] 导入流程发生异常:", err);
  272. if (hasToast) {
  273. AndroidBridge.showToast("导入失败:" + err.message);
  274. } else {
  275. alert("导入失败:" + err.message);
  276. }
  277. }
  278. }
  279. runImportFlow();