IMUT_01.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. // 内蒙古工业大学教务系统课程导入脚本
  2. // 根据教务处网站内容解析课程表数据
  3. // 2025.11.25
  4. // ============生成时间段配置==============
  5. /**
  6. * 默认时段配置,来源于学校官网(2025.11.23)
  7. */
  8. const defaultTimeSlots = [
  9. { "number": 1, "startTime": "08:00", "endTime": "08:45" },
  10. { "number": 2, "startTime": "08:55", "endTime": "09:35" },
  11. { "number": 3, "startTime": "10:05", "endTime": "10:50" },
  12. { "number": 4, "startTime": "11:00", "endTime": "11:40" },
  13. { "number": 5, "startTime": "13:30", "endTime": "14:15" },
  14. { "number": 6, "startTime": "14:25", "endTime": "15:05" },
  15. { "number": 7, "startTime": "15:35", "endTime": "16:20" },
  16. { "number": 8, "startTime": "16:30", "endTime": "17:10" },
  17. { "number": 9, "startTime": "18:00", "endTime": "18:45" },
  18. { "number": 10, "startTime": "18:45", "endTime": "19:35" },
  19. { "number": 11, "startTime": "19:45", "endTime": "20:30" },
  20. { "number": 12, "startTime": "20:30", "endTime": "21:20" }
  21. ];
  22. /**
  23. * 从HTML文本中解析时段信息
  24. * @param {string} doc - DOM 文档对象
  25. * @returns {Object} 时段信息对象
  26. */
  27. function parseTimeSlotsFromHTML(doc) {
  28. const timeSlots = {};
  29. const timetable = doc.querySelector('table#timetable');
  30. if (timetable) {
  31. const rows = timetable.querySelectorAll('tr');
  32. for (let i = 1; i < rows.length; i++) { // 跳过表头行
  33. const th = rows[i].querySelector('th');
  34. if (th) {
  35. const sectionText = th.textContent.trim();
  36. // 解析格式如:"第1节\n08:20\n┆\n09:05"
  37. const sectionMatch = sectionText.match(/第(\d+)节/);
  38. const timeMatch = sectionText.match(/(\d{2}:\d{2})/g);
  39. if (sectionMatch && timeMatch && timeMatch.length >= 2) {
  40. const section = parseInt(sectionMatch[1]);
  41. timeSlots[section] = {
  42. section: section,
  43. startTime: timeMatch[0],
  44. endTime: timeMatch[1]
  45. };
  46. }
  47. }
  48. }
  49. }
  50. if (Object.keys(timeSlots).length === 0) {
  51. throw new Error('未找到时段信息表格');
  52. }
  53. return timeSlots;
  54. }
  55. /**
  56. * 从指定网页地址异步获取HTML并解析时段信息,如果解析失败则返回默认时段
  57. * @param {string} url - 网页地址
  58. * @returns {Promise<Array<Object>>} 时段信息数组,按节次排序
  59. * @returns {number} .number 节次编号
  60. * @returns {string} .startTime 开始时间
  61. * @returns {string} .endTime 结束时间
  62. */
  63. async function getTimeSlotsArray(url) {
  64. try {
  65. const doc = await fetchAndParseHTML(url, 'gbk');
  66. // 解析时段信息
  67. const timeSlots = parseTimeSlotsFromHTML(doc);
  68. const hasValidData = Object.keys(timeSlots).length > 0 &&
  69. timeSlots[1] && timeSlots[1].startTime;
  70. if (hasValidData) {
  71. // 转换为目标格式
  72. return Object.values(timeSlots).map(slot => ({
  73. number: slot.section,
  74. startTime: slot.startTime,
  75. endTime: slot.endTime
  76. })).sort((a, b) => a.number - b.number);
  77. } else {
  78. throw new Error('解析到的时段数据不完整');
  79. }
  80. } catch (error) {
  81. console.error('从HTML解析时段信息失败,使用默认时段:', error.message);
  82. // 使用默认时段
  83. return defaultTimeSlots;
  84. }
  85. }
  86. // ============解析课程表数据==============
  87. /**
  88. * 解析周数字符串
  89. * @param {string} weeksText - 周数字符串,支持格式:"11周"、"1-13周"、"1-10周,11-18周"
  90. * @returns {number[]} 解析后的周数数组,按升序排列
  91. */
  92. function parseWeeks(weeksText) {
  93. if (!weeksText) return [];
  94. const weeks = [];
  95. const text = weeksText.replace('周', '').trim();
  96. // 处理单个周数 "11周" -> [11]
  97. if (/^\d+$/.test(text)) {
  98. return [parseInt(text)];
  99. }
  100. // 处理范围 "1-13周" -> [1,2,3,...,13]
  101. const rangeMatch = text.match(/^(\d+)-(\d+)$/);
  102. if (rangeMatch) {
  103. const start = parseInt(rangeMatch[1]);
  104. const end = parseInt(rangeMatch[2]);
  105. for (let i = start; i <= end; i++) {
  106. weeks.push(i);
  107. }
  108. return weeks;
  109. }
  110. // 处理多个范围 "1-10周,11-18周"
  111. const ranges = text.split(',');
  112. ranges.forEach(range => {
  113. const singleMatch = range.match(/^(\d+)$/);
  114. if (singleMatch) {
  115. weeks.push(parseInt(singleMatch[1]));
  116. } else {
  117. const rangeMatch = range.match(/(\d+)-(\d+)/);
  118. if (rangeMatch) {
  119. const start = parseInt(rangeMatch[1]);
  120. const end = parseInt(rangeMatch[2]);
  121. for (let i = start; i <= end; i++) {
  122. weeks.push(i);
  123. }
  124. }
  125. }
  126. });
  127. return weeks;
  128. }
  129. /**
  130. * 解析课程名称(去除<<>>)
  131. * @param {*} courseText - 原始课程名称文本
  132. * @returns {string} 解析后的课程名称
  133. */
  134. function parseCourseName(courseText) {
  135. let name = courseText
  136. .replace(/<</g, '') // 直接移除 <<
  137. .replace(/>>/g, '') // 直接移除 >>
  138. .split(';')[0];
  139. return name.trim();
  140. }
  141. function parseSingleCourse(lines, day, timeSlot) {
  142. const courseNameMatch = lines[0].match(/<<(.*?)>>/);
  143. if (!courseNameMatch) {
  144. return null;
  145. }
  146. let courseData = {
  147. name: parseCourseName(courseNameMatch[1]),
  148. position: lines[1] || '',
  149. day: day,
  150. startSection: timeSlot,
  151. endSection: timeSlot,
  152. weeks: []
  153. };
  154. // 单门课程示例
  155. // ['<<离散数学导论>>;1', '教C', '贾老师', '1-15周', '讲课']
  156. // 无教师名课程示例:
  157. // ['<<体育选项课(一)>>;11', '操 场', '2-18周', '讲课']
  158. if (lines.length > 4) {
  159. // 有教师名课程
  160. courseData.teacher = lines[2].replace(/,$/, '');
  161. courseData.weeks = parseWeeks(lines[3]);
  162. } else {
  163. // 无教师名课程
  164. courseData.teacher = '';
  165. courseData.weeks = parseWeeks(lines[2]);
  166. }
  167. return courseData;
  168. }
  169. /**
  170. * 解析包含多个课程的课程信息块。
  171. *
  172. * @param {Array<string>} lines - 包含课程信息的字符串数组,每个元素表示一行数据。
  173. * @param {string} day - 表示课程所在的星期几。
  174. * @param {string} timeSlot - 表示课程所在的时间段。
  175. * @returns {Array<Object>} 返回一个包含课程信息的数组,每个课程信息是一个对象。
  176. */
  177. function parseMultipleCourses(lines, day, timeSlot) {
  178. const courses = [];
  179. let currentCourseLines = [];
  180. // 示例:
  181. // ['<<工程训练C>>;11', '格物楼D', '刘老师', '1-10周', '讲课', '<<数据结构与算法>>;1', '教C', '秦老师', '11-18周', '讲课']
  182. for (let i = 0; i < lines.length; i++) {
  183. if (lines[i].includes('<<') && currentCourseLines.length > 0) {
  184. const courseData = parseSingleCourse(currentCourseLines, day, timeSlot);
  185. if (courseData) {
  186. courses.push(courseData);
  187. }
  188. currentCourseLines = [];
  189. }
  190. currentCourseLines.push(lines[i]);
  191. }
  192. if (currentCourseLines.length > 0) {
  193. const courseData = parseSingleCourse(currentCourseLines, day, timeSlot);
  194. if (courseData) {
  195. courses.push(courseData);
  196. }
  197. }
  198. return courses;
  199. }
  200. /**
  201. * 处理课程区块信息,解析出课程的详细信息。
  202. *
  203. * @param {string} block - 包含课程信息的HTML字符串,使用`<br>`分隔每行。
  204. * @param {string} day - 表示课程所在的星期几。
  205. * @param {string} timeSlot - 表示课程所在的时间段。
  206. * @returns {Array<Object>} 返回一个包含课程信息的数组,每个课程信息是一个对象。
  207. */
  208. function processCourseBlock(block, day, timeSlot) {
  209. const lines = block.split('<br>').map(line =>
  210. line.replace(/&lt;/g, '<').replace(/&gt;/g, '>').trim()
  211. ).filter(line => line);
  212. const courses = [];
  213. const courseCount = lines.filter(line => line.includes('<<')).length;
  214. if (courseCount > 1) {
  215. courses.push(...parseMultipleCourses(lines, day, timeSlot));
  216. } else if (lines.length >= 4) {
  217. const courseData = parseSingleCourse(lines, day, timeSlot);
  218. if (courseData) {
  219. courses.push(courseData);
  220. }
  221. }
  222. return courses;
  223. }
  224. /**
  225. * 将HTML课程表转换为标准格式的课程数据
  226. * @param {string} url - 网页地址
  227. * @returns {Promise<Array<Object>>} 课程表数据数组
  228. * @returns {string} .name 课程名称
  229. * @returns {string} .teacher 授课教师
  230. * @returns {string} .position 上课地点
  231. * @returns {number} .day 星期几 (1=周一, 7=周日)
  232. * @returns {number} .startSection 开始节次
  233. * @returns {number} .endSection 结束节次
  234. * @returns {number[]} .weeks 上课周次数组
  235. */
  236. async function convertToTargetFormat(url) {
  237. try {
  238. const doc = await fetchAndParseHTML(url, 'gbk');
  239. const timetable = [];
  240. const rows = doc.querySelectorAll('#timetable tr');
  241. // 跳过表头行
  242. for (let rowIndex = 1; rowIndex < rows.length; rowIndex++) {
  243. const row = rows[rowIndex];
  244. const timeSlot = rowIndex; // 第1-13节对应rowIndex 1-13
  245. const cells = row.querySelectorAll('td');
  246. for (let day = 1; day <= cells.length; day++) {
  247. const cell = cells[day - 1];
  248. const content = cell.innerHTML.trim();
  249. if (content && content !== '&nbsp;') {
  250. // 分割每个课程块(一个单元格可能有多个课程)
  251. const courseBlocks = content.split(/<br>\s*<br>/);
  252. courseBlocks.forEach(block => {
  253. if (block.trim()) {
  254. const courses = processCourseBlock(block, day, timeSlot);
  255. for (const course of courses) {
  256. timetable.push(course);
  257. }
  258. }
  259. });
  260. }
  261. }
  262. }
  263. return timetable;
  264. } catch (error) {
  265. return []; // 返回空数组作为错误回退
  266. }
  267. }
  268. /**
  269. * 合并连续的课程信息。
  270. * 合并条件:同一天、同一课程名称、同一位置、同一教师、同一周次且时间连续
  271. *
  272. * @param {Array<Object>} courses - 课程信息数组
  273. * @returns {Array<Object>} 返回合并后的课程信息数组
  274. */
  275. function mergeContinuousCourses(courses) {
  276. // 按所有关键属性进行分组
  277. const grouped = {};
  278. courses.forEach(course => {
  279. // 使用周次数组的字符串表示作为分组键的一部分
  280. const weeksKey = JSON.stringify(course.weeks.sort((a, b) => a - b));
  281. const key = `${course.day}-${course.name}-${course.position}-${course.teacher || '未知'}-${weeksKey}`;
  282. if (!grouped[key]) {
  283. grouped[key] = [];
  284. }
  285. grouped[key].push(course);
  286. });
  287. const result = [];
  288. // 处理每个分组
  289. Object.values(grouped).forEach(group => {
  290. // 按开始节次排序
  291. group.sort((a, b) => a.startSection - b.startSection);
  292. let currentCourse = null;
  293. group.forEach(course => {
  294. if (!currentCourse) {
  295. // 第一个课程
  296. currentCourse = { ...course };
  297. } else if (currentCourse.endSection + 1 === course.startSection) {
  298. // 时间连续,合并
  299. currentCourse.endSection = course.endSection;
  300. } else {
  301. // 时间不连续,将当前课程加入结果,开始新的课程
  302. result.push(currentCourse);
  303. currentCourse = { ...course };
  304. }
  305. });
  306. // 将最后一个课程加入结果
  307. if (currentCourse) {
  308. result.push(currentCourse);
  309. }
  310. });
  311. return result;
  312. }
  313. // ============配置获取==============
  314. /*
  315. * 异步获取学年学期信息
  316. * @returns {Promise<Object>} 包含 studentid, year, term的对象
  317. * studentid: 标识ID
  318. * year: 学年,例如 45 (2025-1980)
  319. * term: 学期,1=春季,2=夏季,3=秋季
  320. */
  321. async function getSemesterInfo(url) {
  322. try {
  323. const doc = await fetchAndParseHTML(url, 'gbk');
  324. // 查找 CTRT 元素
  325. const ctrtElement = doc.querySelector('eduaffair\\:CTRT');
  326. if (!ctrtElement) {
  327. throw new Error('未找到 CTRT 元素');
  328. }
  329. // 提取参数
  330. const params = {
  331. studentid: ctrtElement.getAttribute('studentid'),
  332. year: ctrtElement.getAttribute('year'),
  333. term: ctrtElement.getAttribute('term'),
  334. };
  335. return params;
  336. } catch (error) {
  337. console.error('提取参数时出错:', error);
  338. return null;
  339. }
  340. }
  341. /**
  342. * 获取指定学年和学期的最大周数值。
  343. *
  344. * @param {string} yearid - 学年的ID,例如 "2023"。
  345. * @param {string} termid - 学期的ID,例如 "1" 或 "2"。
  346. * @returns {Promise<number>} 返回一个Promise,解析为最大周数值。
  347. */
  348. async function getMaxWeekValue(yearid, termid) {
  349. const url = `http://jw.imut.edu.cn/academic/manager/coursearrange/studentWeeklyTimetable.do?yearid=${yearid}&termid=${termid}`;
  350. try {
  351. const doc = await fetchAndParseHTML(url, 'gbk');
  352. // 查找whichWeek选择框
  353. const weekSelect = doc.querySelector('select[name="whichWeek"]');
  354. if (!weekSelect) {
  355. throw new Error('未找到周次选择框');
  356. }
  357. // 获取所有option的value并转换为数字
  358. const weekOptions = Array.from(weekSelect.querySelectorAll('option'));
  359. const weekValues = weekOptions
  360. .map(option => parseInt(option.value))
  361. .filter(value => !isNaN(value) && value !== 0); // 过滤掉非数字和空值
  362. if (weekValues.length === 0) {
  363. throw new Error('未找到有效的周数值');
  364. }
  365. const maxWeek = Math.max(...weekValues);
  366. return maxWeek;
  367. } catch (error) {
  368. console.error('获取最大周数时出错:', error);
  369. throw error;
  370. }
  371. }
  372. /* * 异步获取第一个课程日期
  373. * @param {string} yearid - 学年ID
  374. * @param {string} termid - 学期ID
  375. * @returns {Promise<string>} 第一个课程日期字符串,格式如 "2025-09-01"
  376. **/
  377. async function getFirstCourseDate(yearid, termid) {
  378. const url = `http://jw.imut.edu.cn/academic/manager/coursearrange/studentWeeklyTimetable.do?yearid=${yearid}&termid=${termid}&whichWeek=1`;
  379. try {
  380. const doc = await fetchAndParseHTML(url, 'gbk');
  381. // 查找第一个课程日期
  382. const firstDateTd = doc.querySelector('td[name="td0"]');
  383. if (firstDateTd) {
  384. const firstCourseDate = firstDateTd.textContent.trim();
  385. return firstCourseDate;
  386. } else {
  387. return null;
  388. }
  389. } catch (error) {
  390. console.error('获取数据失败:', error);
  391. return null;
  392. }
  393. }
  394. // ====================== 辅助函数 ======================
  395. // 请求与解析HTML的通用函数
  396. async function fetchAndParseHTML(url, encoding = 'gbk') {
  397. const response = await fetch(url);
  398. if (!response.ok) {
  399. throw new Error(`HTTP错误! 状态码: ${response.status}`);
  400. }
  401. const buffer = await response.arrayBuffer();
  402. const decoder = new TextDecoder(encoding);
  403. const htmlText = decoder.decode(buffer);
  404. const parser = new DOMParser();
  405. return parser.parseFromString(htmlText, 'text/html');
  406. }
  407. // 日期格式验证函数
  408. function validateDateFormat(dateString) {
  409. const regex = /^\d{4}-\d{2}-\d{2}$/;
  410. if (regex.test(dateString)) {
  411. return false;
  412. } else {
  413. return "请输入正确的日期格式,示例:2025-09-01";
  414. }
  415. }
  416. // 弹出日期确认对话框
  417. async function setStartDate(suggestedDate) {
  418. const dateSelection = await window.AndroidBridgePromise.showPrompt(
  419. "请确认学期起始日期",
  420. `此日期来自您本学期第一节课日期,如有误,请修改(格式:YYYY-MM-DD):`,
  421. suggestedDate || "",
  422. "validateDateFormat"
  423. );
  424. return dateSelection;
  425. }
  426. // ====================== 导入课程主流程 ======================
  427. async function runImportFlow() {
  428. AndroidBridge.showToast("即将开始导入课表,请稍候...");
  429. // 获取学年学期信息
  430. const semesterInfo = await getSemesterInfo("http://jw.imut.edu.cn/academic/student/currcourse/currcourse.jsdo");
  431. if (!semesterInfo) {
  432. AndroidBridge.showToast("获取学生信息失败,请重试!");
  433. return;
  434. }
  435. currentYear = semesterInfo.year; // 当前年份 - 1980
  436. currentTerm = semesterInfo.term; // 当前学期
  437. // 构造课程表URL
  438. const timetableUrl = `http://jw.imut.edu.cn/academic/manager/coursearrange/showTimetable.do?id=${semesterInfo.studentid}&yearid=${semesterInfo.year}&termid=${semesterInfo.term}&timetableType=STUDENT&sectionType=BASE`;
  439. // 获取时段数据
  440. const timeSlots = await getTimeSlotsArray(timetableUrl);
  441. if (!timeSlots || timeSlots.length === 0) {
  442. AndroidBridge.showToast("获取时间段信息失败,使用默认时间段!");
  443. }
  444. // 获取并转换课程表数据
  445. let courses = await convertToTargetFormat(timetableUrl);
  446. if (courses.length === 0) {
  447. AndroidBridge.showToast("获取课程表数据失败,请重试!");
  448. return;
  449. }
  450. // 合并连续课程
  451. courses = mergeContinuousCourses(courses)
  452. // 获取第一个课程日期
  453. let firstCourseDate = null;
  454. try {
  455. firstCourseDate = await getFirstCourseDate(semesterInfo.year, semesterInfo.term);
  456. } catch (err) {
  457. console.warn("获取第一个课程日期失败:", err);
  458. }
  459. // 用户确认起始日期
  460. try {
  461. firstCourseDate = await setStartDate(firstCourseDate);
  462. } catch (err) {
  463. console.error("用户取消了日期输入:", err);
  464. AndroidBridge.showToast("未输入起始日期。");
  465. }
  466. // 获取最大周数
  467. let maxWeeks = 20; // 默认最大周数
  468. try {
  469. maxWeeks = await getMaxWeekValue(semesterInfo.year, semesterInfo.term);
  470. } catch (err) {
  471. console.warn("获取最大周数失败,使用默认值 20");
  472. }
  473. // 配置课表配置
  474. const coursesConfig = {
  475. semesterStartDate: firstCourseDate,
  476. semesterTotalWeeks: maxWeeks,
  477. };
  478. // 将数据传递给Android端
  479. // 提交课程数据
  480. try {
  481. await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
  482. const coursesCount = courses.length;
  483. AndroidBridge.showToast(`课程导入成功,共导入 ${coursesCount} 门课程!`);
  484. } catch (err) {
  485. console.error("课程导入失败:", err);
  486. AndroidBridge.showToast("课程导入失败:" + err.message);
  487. return;
  488. }
  489. // 提交时间段数据
  490. try {
  491. await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  492. AndroidBridge.showToast("时间段导入成功!");
  493. } catch (err) {
  494. console.error("时间段导入失败:", err);
  495. AndroidBridge.showToast("时间段导入失败:" + err.message);
  496. return;
  497. }
  498. // 提交课表配置
  499. try {
  500. await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify(coursesConfig));
  501. AndroidBridge.showToast("课表配置保存成功!");
  502. } catch (err) {
  503. console.error("课表配置保存失败:", err);
  504. AndroidBridge.showToast("课表配置保存失败:" + err.message);
  505. return;
  506. }
  507. // 通知任务完成
  508. console.log("JS:整个导入流程执行完毕并成功。");
  509. AndroidBridge.notifyTaskCompletion();
  510. }
  511. runImportFlow();