HNVCC_01.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. /**
  2. * 湖南商务职业技术学院教务系统(hnvcc.edu.cn) 拾光课程表适配脚本
  3. // 非该大学开发者适配,开发者无法及时发现问题
  4. // 出现问题请提联系开发者或者提交pr更改,这更加快速
  5. /**
  6. * 定义一个全局的学年验证函数。
  7. */
  8. window.validateYearInput = function(input) {
  9. // 检查输入是否为四位数字
  10. if (/^[0-9]{4}$/.test(input)) {
  11. return false;
  12. } else {
  13. return "请输入四位数字的学年!";
  14. }
  15. }
  16. /**
  17. * 验证周次字符串并转换为数字数组
  18. * @param {string} weekStr 课表中的周次字符串
  19. * @returns {number[]} 周数数组
  20. */
  21. function parseWeeks(weekStr) {
  22. const weeks = [];
  23. // 匹配 "第...周" 或 "第...(...",提取中间的范围部分
  24. const match = weekStr.match(/第(.*?)(周|\()/);
  25. if (!match) return weeks;
  26. const ranges = match[1].split(',');
  27. for (const range of ranges) {
  28. // 兼容处理 1-10 或 10 这样的数字
  29. const parts = range.split('-');
  30. if (parts.length === 2) {
  31. let start = parseInt(parts[0].trim());
  32. let end = parseInt(parts[1].trim());
  33. if (start > end) [start, end] = [end, start];
  34. for (let i = start; i <= end; i++) {
  35. if (!weeks.includes(i)) {
  36. weeks.push(i);
  37. }
  38. }
  39. } else if (parts.length === 1) {
  40. const week = parseInt(parts[0].trim());
  41. if (!isNaN(week) && !weeks.includes(week)) {
  42. weeks.push(week);
  43. }
  44. }
  45. }
  46. return weeks.sort((a, b) => a - b);
  47. }
  48. /**
  49. * 解析节次字符串为开始和结束节次
  50. * 允许字符串包含其他文本(如 "第一大节\n第1-2节"),只要包含数字范围即可。
  51. * @param {string} sectionStr 课表的节次行标题(包含文字和数字)
  52. * @returns {{start: number, end: number} | null}
  53. */
  54. function parseSections(sectionStr) {
  55. // 1. 尝试匹配范围格式:匹配 (\d+)-(\d+)
  56. let match = sectionStr.match(/(\d+)\s*-\s*(\d+)/);
  57. if (match) {
  58. return {
  59. start: parseInt(match[1]),
  60. end: parseInt(match[2])
  61. };
  62. }
  63. // 2. 尝试匹配单个节次格式:匹配单个 (\d+)
  64. match = sectionStr.match(/(\d+)/);
  65. if (match) {
  66. const section = parseInt(match[1]);
  67. // 忽略小于 1 的数字,避免解析到其他无关的数字
  68. if (section > 0) {
  69. return {
  70. start: section,
  71. end: section
  72. };
  73. }
  74. }
  75. // 3. 都没有匹配到,返回 null
  76. return null;
  77. }
  78. /**
  79. * 异步获取教务系统课表 HTML 内容,并用 DOMParser 转换成 Document 对象。
  80. * @param {string} xnxqid 学年学期ID
  81. * @returns {Document | null} 解析后的 Document 对象或 null
  82. */
  83. async function fetchTimetable(xnxqid) {
  84. AndroidBridge.showToast("正在请求课表数据...");
  85. // 完整的 URL 路径
  86. const url = `http://jwxt.hnvcc.edu.cn/jsxsd/framework/mainV_index_loadkb.htmlx?rq=all&xnxqid=${xnxqid}&xswk=false`;
  87. try {
  88. const response = await fetch(url, {
  89. "body": null,
  90. "mode": "cors",
  91. "credentials": "include" // 确保携带了教务系统的登录Session
  92. });
  93. if (!response.ok) {
  94. AndroidBridge.showToast(`网络响应错误,状态码: ${response.status}。请检查登录状态。`);
  95. return null;
  96. }
  97. const htmlData = await response.text();
  98. AndroidBridge.showToast("数据获取成功,开始解析 HTML...");
  99. const parser = new DOMParser();
  100. const doc = parser.parseFromString(htmlData, "text/html");
  101. return doc;
  102. } catch (error) {
  103. AndroidBridge.showToast(`请求过程中发生错误: ${error.message}`);
  104. return null;
  105. }
  106. }
  107. /**
  108. * 主要解析逻辑:从 Document 对象中提取课程数据
  109. * @param {Document} doc - 包含课表数据的 Document 对象
  110. * @returns {{courses: object[], config: object} | null}
  111. */
  112. function parseTimetable(doc) {
  113. const courses = [];
  114. let parsedRowCount = 0;
  115. const timetable = doc.getElementById('timetable');
  116. if (!timetable) {
  117. AndroidBridge.showToast("HTML 中未找到课表表格 #timetable。");
  118. return null;
  119. }
  120. // 尝试从 li_showWeek 元素中提取总周数,默认 20
  121. const totalWeeksElement = doc.getElementById('li_showWeek');
  122. const totalWeeksMatch = (totalWeeksElement ? totalWeeksElement.innerHTML : '').match(/\/(\d+)周/);
  123. const semesterTotalWeeks = totalWeeksMatch ? parseInt(totalWeeksMatch[1]) : 20;
  124. const rows = timetable.querySelectorAll('tbody > tr');
  125. rows.forEach((row, rowIndex) => {
  126. const allCells = row.querySelectorAll('td');
  127. // 1. 过滤掉不符合格式的行(如备注行或空行)
  128. if (allCells.length < 2 || row.querySelector('td[colspan="7"]')) {
  129. return;
  130. }
  131. const sectionCell = allCells[0];
  132. // 提取节次单元格的纯文本
  133. const sectionText = sectionCell.innerText.trim();
  134. const sections = parseSections(sectionText);
  135. if (!sections) {
  136. return;
  137. }
  138. parsedRowCount++;
  139. // 遍历周一到周日
  140. for (let day = 1; day <= 7; day++) {
  141. const cellIndex = day;
  142. const dayCell = allCells[cellIndex];
  143. if (!dayCell) continue;
  144. // 获取详细课程信息的容器
  145. const itemBoxes = dayCell.querySelectorAll('.item-box');
  146. itemBoxes.forEach(box => {
  147. // 优化:只选择 .item-box 的直接子元素 <p>,避免任何可能的嵌套干扰
  148. const courseNamePs = box.querySelectorAll(':scope > p');
  149. courseNamePs.forEach(nameP => {
  150. try {
  151. const name = nameP.innerText.trim();
  152. if (!name) return; // 课程名为空,跳过
  153. // 查找紧随 P 标签的 .tch-name 元素(包含教师和学分)
  154. let tchNameDiv = nameP.nextElementSibling;
  155. while (tchNameDiv && (tchNameDiv.nodeType !== 1 || !tchNameDiv.classList.contains('tch-name'))) {
  156. tchNameDiv = tchNameDiv.nextElementSibling;
  157. }
  158. if (!tchNameDiv || !tchNameDiv.classList.contains('tch-name')) {
  159. // 未找到教师信息,跳过
  160. return;
  161. }
  162. // 提取教师
  163. const teacherSpan = tchNameDiv.querySelector('span:nth-child(1)');
  164. const teacher = teacherSpan ? teacherSpan.innerText.replace('教师:', '').trim() : '';
  165. // 2. 查找地点/周次 Div
  166. let infoDiv = null;
  167. let currentElement = tchNameDiv.nextElementSibling;
  168. while (currentElement) {
  169. // 目标 Location/Week DIV: 必须是 DIV 且包含位置图标
  170. if (currentElement.tagName === 'DIV' && currentElement.querySelector('img[src*="item1.png"]')) {
  171. infoDiv = currentElement;
  172. break;
  173. }
  174. // 遇到下一个课程名 P 标签,停止搜索
  175. if (currentElement.tagName === 'P') {
  176. break;
  177. }
  178. currentElement = currentElement.nextElementSibling;
  179. }
  180. if (!infoDiv) {
  181. // 未找到地点/周次信息,跳过
  182. return;
  183. }
  184. // 提取地点和周次
  185. let position = '';
  186. let weekText = '';
  187. const infoSpans = infoDiv.querySelectorAll('span');
  188. if (infoSpans.length >= 1) {
  189. position = infoSpans[0].innerText.trim();
  190. }
  191. if (infoSpans.length >= 2) {
  192. weekText = infoSpans[1].innerText.trim();
  193. }
  194. // 3. 构造课程对象
  195. const weeksArray = parseWeeks(weekText);
  196. if (weeksArray.length > 0) {
  197. const newCourse = {
  198. name: name,
  199. teacher: teacher,
  200. position: position,
  201. day: day, // 1=周一, 7=周日
  202. startSection: sections.start,
  203. endSection: sections.end,
  204. weeks: weeksArray
  205. };
  206. courses.push(newCourse);
  207. }
  208. } catch (e) {
  209. // 保留 error 级别的日志以防关键错误被忽略
  210. console.error(`解析课程时发生未预期的错误: ${e.message}`, e);
  211. }
  212. });
  213. });
  214. }
  215. });
  216. // 构造配置对象
  217. const config = {
  218. semesterTotalWeeks: semesterTotalWeeks,
  219. firstDayOfWeek: 1 // 一周的第一天是周一
  220. };
  221. return { courses, config };
  222. }
  223. /**
  224. * 合并连续的课程节次
  225. * 合并条件:同一天、同一周次、同一课程名、同一教师、同一地点,且节次连续。
  226. * @param {object[]} courses 待合并的课程列表
  227. * @returns {object[]} 合并后的课程列表
  228. */
  229. function mergeCourses(courses) {
  230. if (!courses || courses.length === 0) {
  231. return [];
  232. }
  233. // 1. 排序:确保同一天、同一周次的课程按节次顺序排列
  234. courses.sort((a, b) => {
  235. if (a.day !== b.day) return a.day - b.day;
  236. const weekA = JSON.stringify(a.weeks);
  237. const weekB = JSON.stringify(b.weeks);
  238. if (weekA !== weekB) return weekA.localeCompare(weekB);
  239. return a.startSection - b.startSection;
  240. });
  241. const mergedCourses = [];
  242. // 初始化第一个课程为当前的合并起点
  243. let currentMergedCourse = { ...courses[0] };
  244. for (let i = 1; i < courses.length; i++) {
  245. const nextCourse = courses[i];
  246. const isSameDay = nextCourse.day === currentMergedCourse.day;
  247. const isSameWeeks = JSON.stringify(nextCourse.weeks) === JSON.stringify(currentMergedCourse.weeks);
  248. const isSameName = nextCourse.name === currentMergedCourse.name;
  249. const isSameTeacher = nextCourse.teacher === currentMergedCourse.teacher;
  250. const isSamePosition = nextCourse.position === currentMergedCourse.position;
  251. const isConsecutive = nextCourse.startSection === currentMergedCourse.endSection + 1;
  252. // 检查合并条件
  253. const canMerge = isSameDay && isSameWeeks && isSameName && isSameTeacher && isSamePosition && isConsecutive;
  254. if (canMerge) {
  255. currentMergedCourse.endSection = nextCourse.endSection;
  256. } else {
  257. mergedCourses.push(currentMergedCourse);
  258. currentMergedCourse = { ...nextCourse };
  259. }
  260. }
  261. mergedCourses.push(currentMergedCourse);
  262. return mergedCourses;
  263. }
  264. // 生成夏季作息时间段
  265. function generateSummerTimeSlots() {
  266. return [
  267. { "number": 1, "startTime": "08:20", "endTime": "09:05" },
  268. { "number": 2, "startTime": "09:15", "endTime": "10:00" },
  269. { "number": 3, "startTime": "10:20", "endTime": "11:05" },
  270. { "number": 4, "startTime": "11:15", "endTime": "12:00" },
  271. { "number": 5, "startTime": "14:30", "endTime": "15:15" },
  272. { "number": 6, "startTime": "15:25", "endTime": "16:10" },
  273. { "number": 7, "startTime": "16:25", "endTime": "17:10" },
  274. { "number": 8, "startTime": "17:20", "endTime": "18:05" },
  275. { "number": 9, "startTime": "19:00", "endTime": "19:45" },
  276. { "number": 10, "startTime": "19:55", "endTime": "20:40" },
  277. { "number": 11, "startTime": "20:45", "endTime": "21:30" },
  278. { "number": 12, "startTime": "21:35", "endTime": "22:20" }
  279. ];
  280. }
  281. // 生成冬季作息时间段
  282. function generateWinterTimeSlots() {
  283. return [
  284. { "number": 1, "startTime": "08:20", "endTime": "09:05" },
  285. { "number": 2, "startTime": "09:15", "endTime": "10:00" },
  286. { "number": 3, "startTime": "10:20", "endTime": "11:05" },
  287. { "number": 4, "startTime": "11:15", "endTime": "12:00" },
  288. { "number": 5, "startTime": "14:00", "endTime": "14:45" },
  289. { "number": 6, "startTime": "14:55", "endTime": "15:40" },
  290. { "number": 7, "startTime": "15:55", "endTime": "16:40" },
  291. { "number": 8, "startTime": "16:50", "endTime": "17:35" },
  292. { "number": 9, "startTime": "19:00", "endTime": "19:45" },
  293. { "number": 10, "startTime": "19:55", "endTime": "20:40" },
  294. { "number": 11, "startTime": "20:45", "endTime": "21:30" },
  295. { "number": 12, "startTime": "21:35", "endTime": "22:20" }
  296. ];
  297. }
  298. async function runImportFlow() {
  299. const currentTitle = document.title || '';
  300. if (currentTitle.includes('登录') || currentTitle.includes('Login')) {
  301. AndroidBridge.showToast("请先登录教务系统");
  302. return;
  303. }
  304. // 获取用户输入:学年
  305. let currentYear = new Date().getFullYear();
  306. const academicYear = await window.AndroidBridgePromise.showPrompt(
  307. "选择学年",
  308. "请输入要导入课程的学年(如 " + currentYear + "):",
  309. String(currentYear),
  310. "validateYearInput"
  311. );
  312. if (academicYear === null) {
  313. AndroidBridge.showToast("导入已取消。");
  314. return;
  315. }
  316. // 获取用户输入:学期
  317. const semesters = ["1(第一学期)", "2(第二学期)"];
  318. const semesterIndex = await window.AndroidBridgePromise.showSingleSelection(
  319. "选择学期",
  320. JSON.stringify(semesters),
  321. -1
  322. );
  323. if (semesterIndex === null) {
  324. AndroidBridge.showToast("导入已取消。");
  325. return;
  326. }
  327. const semesterNumber = semesterIndex + 1;
  328. // 构造学年学期 ID (xnxqid)
  329. const nextYear = parseInt(academicYear) + 1;
  330. const xnxqid = `${academicYear}-${nextYear}-${semesterNumber}`;
  331. AndroidBridge.showToast(`准备获取 ${academicYear} 学年第 ${semesterNumber} 学期数据...`);
  332. // 获取用户输入:作息季节
  333. const seasons = ["夏季作息", "冬季作息"];
  334. const seasonIndex = await window.AndroidBridgePromise.showSingleSelection(
  335. "选择作息季节",
  336. JSON.stringify(seasons),
  337. -1
  338. );
  339. if (seasonIndex === null) {
  340. AndroidBridge.showToast("导入已取消。");
  341. return;
  342. }
  343. const selectedSeason = seasonIndex === 0 ? 'summer' : 'winter';
  344. const seasonText = seasonIndex === 0 ? '夏季作息' : '冬季作息';
  345. AndroidBridge.showToast(`已选择:${seasonText}。`);
  346. // 异步获取和解析 HTML 数据
  347. const doc = await fetchTimetable(xnxqid);
  348. if (doc === null) {
  349. return;
  350. }
  351. const parsedData = parseTimetable(doc);
  352. if (!parsedData || parsedData.courses.length === 0) {
  353. AndroidBridge.showToast("课表解析失败或未解析到课程。请检查登录状态、学期选择或课表数据是否为空。");
  354. return;
  355. }
  356. let { courses, config } = parsedData;
  357. // 执行课程合并逻辑
  358. const originalCourseCount = courses.length;
  359. courses = mergeCourses(courses);
  360. // 提交课程数据
  361. try {
  362. await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
  363. const mergedCount = originalCourseCount - courses.length;
  364. AndroidBridge.showToast(`课程导入成功!原始 ${originalCourseCount} 门,合并 ${mergedCount} 门,最终导入 ${courses.length} 门。`);
  365. } catch (error) {
  366. AndroidBridge.showToast(`课程数据保存失败: ${error.message}`);
  367. return;
  368. }
  369. // 提交课表配置数据
  370. try {
  371. await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify(config));
  372. AndroidBridge.showToast(`课表配置更新成功!总周数:${config.semesterTotalWeeks}周。`);
  373. } catch (error) {
  374. AndroidBridge.showToast(`课表配置保存失败: ${error.message}`);
  375. }
  376. // 提交预设时间段数据
  377. try {
  378. let timeSlots;
  379. if (selectedSeason === 'summer') {
  380. timeSlots = generateSummerTimeSlots();
  381. } else {
  382. timeSlots = generateWinterTimeSlots();
  383. }
  384. await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  385. AndroidBridge.showToast(`预设时间段导入成功!已使用${seasonText}。请在设置中校对具体时间。`);
  386. } catch (error) {
  387. AndroidBridge.showToast(`导入时间段失败: ${error.message}`);
  388. }
  389. AndroidBridge.showToast("所有任务已完成!");
  390. AndroidBridge.notifyTaskCompletion();
  391. }
  392. // 启动导入流程
  393. runImportFlow();