gzst.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. // 广州松田职业技术学院(gzst.edu.cn)拾光课程表适配脚本
  2. // 非该大学开发者适配,开发者无法及时发现问题
  3. // 出现问题请提联系开发者或者提交pr更改,这更加快速
  4. // 预设节次时间
  5. const TimeSlots = [
  6. { "number": 1, "startTime": "08:20", "endTime": "09:00" },
  7. { "number": 2, "startTime": "09:05", "endTime": "09:45" },
  8. { "number": 3, "startTime": "09:55", "endTime": "10:35" },
  9. { "number": 4, "startTime": "10:45", "endTime": "11:25" },
  10. { "number": 5, "startTime": "11:30", "endTime": "12:10" },
  11. { "number": 6, "startTime": "14:20", "endTime": "15:00" },
  12. { "number": 7, "startTime": "15:05", "endTime": "15:45" },
  13. { "number": 8, "startTime": "15:55", "endTime": "16:35" },
  14. { "number": 9, "startTime": "16:45", "endTime": "17:25" },
  15. { "number": 10, "startTime": "17:30", "endTime": "18:10" },
  16. { "number": 11, "startTime": "19:30", "endTime": "20:35" },
  17. { "number": 12, "startTime": "20:35", "endTime": "21:40" }
  18. ];
  19. // 课表配置
  20. const CourseConfig = {
  21. "semesterTotalWeeks": 20
  22. };
  23. /**
  24. * 验证周次字符串并转换为数字数组
  25. * 同时移除周次字符串中的节次信息,因为它可能会干扰周次解析。
  26. * @param {string} weeksStr 课表中的周次字符串,如 "5-15,17(周)[02-03节]"
  27. * @returns {number[]} 周数数组
  28. */
  29. function parseWeeks(weeksStr) {
  30. const weeks = [];
  31. if (!weeksStr) return weeks;
  32. // 移除括号内的内容(如 (周))、[节次] 和 HTML 标签
  33. const cleanedStr = weeksStr.replace(/\(周\)|\[.*?节\]|<\/?[a-z]+[^>]*>/ig, '').trim();
  34. if (cleanedStr === '') return weeks;
  35. cleanedStr.split(',').forEach(part => {
  36. const rangeMatch = part.match(/^(\d+)-(\d+)$/);
  37. if (rangeMatch) {
  38. const start = parseInt(rangeMatch[1]);
  39. const end = parseInt(rangeMatch[2]);
  40. for (let i = start; i <= end; i++) {
  41. weeks.push(i);
  42. }
  43. } else {
  44. const singleWeek = parseInt(part);
  45. if (!isNaN(singleWeek)) {
  46. weeks.push(singleWeek);
  47. }
  48. }
  49. });
  50. return [...new Set(weeks)].sort((a, b) => a - b);
  51. }
  52. /**
  53. * 从周次/节次字符串中提取节次范围
  54. * 修正了多节连排(如 [07-08-09-10节])只识别到中间节次的问题。
  55. * @param {string} weeksSectionStr 包含节次信息的字符串
  56. * @returns {{start: number, end: number} | null} 节次范围对象
  57. */
  58. function parseSectionsFromStr(weeksSectionStr) {
  59. // 匹配 [XX-XX节] 或 [XX节] 或 [XX-XX-XX节] 的完整内容
  60. const fullContentMatch = weeksSectionStr.match(/\[(\d+(?:-\d+)*)节\]/i);
  61. if (fullContentMatch) {
  62. const numberString = fullContentMatch[1]; // 例如: "07-08-09-10" 或 "09-10" 或 "10"
  63. // 分割所有数字
  64. const numbers = numberString.split('-').map(n => parseInt(n));
  65. // 确保数字是有效的
  66. if (numbers.length > 0 && !isNaN(numbers[0])) {
  67. const start = numbers[0];
  68. // 结束节次是数组中的最后一个有效数字
  69. const end = numbers[numbers.length - 1];
  70. if (start > 0 && end > 0) {
  71. // 确保 end >= start
  72. return {
  73. start: Math.min(start, end),
  74. end: Math.max(start, end)
  75. };
  76. }
  77. }
  78. }
  79. return null;
  80. }
  81. // 核心解析函数 (parseCourseTable)
  82. function parseCourseTable(htmlContent) {
  83. const parser = new DOMParser();
  84. const doc = parser.parseFromString(htmlContent, "text/html");
  85. const courseList = [];
  86. const table = doc.getElementById('kbtable');
  87. if (!table) {
  88. AndroidBridge.showToast("错误:未找到课表表格 (id=kbtable)。");
  89. return [];
  90. }
  91. const rows = table.querySelectorAll('tr');
  92. for (let i = 1; i < rows.length; i++) {
  93. const row = rows[i];
  94. const cells = row.querySelectorAll('td');
  95. for (let j = 0; j < cells.length; j++) {
  96. const cell = cells[j];
  97. const dayOfWeek = j + 1;
  98. const detailDiv = cell.querySelector('div[class*="kbcontent"][style*="display: none"]');
  99. if (!detailDiv) continue;
  100. const rawContent = detailDiv.innerHTML.trim();
  101. // 过滤空内容
  102. if (rawContent === '' || rawContent.replace(/&nbsp;|<[^>]*>/ig, '').trim() === '') continue;
  103. // 多个课程块以分隔符处理
  104. const courseBlocks = rawContent.split('---------------------<br>');
  105. courseBlocks.forEach(blockHtml => {
  106. if (blockHtml.trim() === '') return;
  107. const cleanedBlock = blockHtml.replace(/<br\/?>/gi, '\n').trim();
  108. // 提取课程名 (第一行)
  109. const nameMatch = cleanedBlock.match(/^(.*?)(?:\<span.*?\/span\>)?\n/i);
  110. let name = (nameMatch && nameMatch[1].trim()) || "未知课程";
  111. // 移除课程名中的 span 或其他标签
  112. name = name.replace(/<span[^>]*>.*?<\/span>|<\/?[a-z]+[^>]*>/ig, '').trim();
  113. // 提取教师
  114. const teacherMatch = cleanedBlock.match(/<font title="老师">([^<]+?)<\/font>/i);
  115. const teacher = (teacherMatch && teacherMatch[1].trim()) || "暂无教师";
  116. // 提取地点
  117. const positionMatch = cleanedBlock.match(/<font title="教室">([^<]+?)<\/font>/i);
  118. const position = (positionMatch && positionMatch[1].trim()) || "暂无教室";
  119. // 提取周次和节次字符串
  120. const weeksSectionMatch = cleanedBlock.match(/<font title="周次\(节次\)">([^<]+?)<\/font>/i);
  121. const weeksSectionStr = (weeksSectionMatch && weeksSectionMatch[1].trim()) || "";
  122. const sections = parseSectionsFromStr(weeksSectionStr);
  123. if (!sections) {
  124. return;
  125. }
  126. // 解析周次数组
  127. const weeksArray = parseWeeks(weeksSectionStr);
  128. if (weeksArray.length === 0) {
  129. return; // 周次为空,跳过
  130. }
  131. const course = {
  132. name: name,
  133. teacher: teacher,
  134. position: position,
  135. day: dayOfWeek, // 周几 (1-7)
  136. startSection: sections.start, // 准确的开始节次
  137. endSection: sections.end, // 准确的结束节次
  138. weeks: weeksArray // 周次数组
  139. };
  140. courseList.push(course);
  141. });
  142. }
  143. }
  144. return courseList;
  145. }
  146. /**
  147. * 修正后的合并逻辑:先进行精确去重,再合并连续的课程节次。
  148. * @param {Array} courses 课程列表
  149. * @returns {Array} 去重并合并后的课程列表
  150. */
  151. function mergeCourses(courses) {
  152. if (!courses || courses.length === 0) {
  153. return [];
  154. }
  155. // 1. 排序:确保同一天、同一周次、完全相同的课程和连续的课程都排在一起
  156. courses.sort((a, b) => {
  157. if (a.day !== b.day) return a.day - b.day;
  158. const weekA = JSON.stringify(a.weeks);
  159. const weekB = JSON.stringify(b.weeks);
  160. if (weekA !== weekB) return weekA.localeCompare(weekB);
  161. return a.startSection - b.startSection;
  162. });
  163. // 2. 精确去重
  164. const uniqueCourses = [];
  165. const courseSet = new Set();
  166. for (const course of courses) {
  167. // 创建一个包含所有关键属性的唯一 Key
  168. const key = `${course.name}|${course.teacher}|${course.position}|${course.day}|${course.startSection}|${course.endSection}|${JSON.stringify(course.weeks)}`;
  169. if (!courseSet.has(key)) {
  170. courseSet.add(key);
  171. uniqueCourses.push(course);
  172. }
  173. }
  174. if (uniqueCourses.length <= 1) {
  175. return uniqueCourses;
  176. }
  177. // 3. 连续课程合并逻辑
  178. const mergedCourses = [];
  179. let currentMergedCourse = { ...uniqueCourses[0] };
  180. for (let i = 1; i < uniqueCourses.length; i++) {
  181. const nextCourse = uniqueCourses[i];
  182. const isSameDay = nextCourse.day === currentMergedCourse.day;
  183. const isSameWeeks = JSON.stringify(nextCourse.weeks) === JSON.stringify(currentMergedCourse.weeks);
  184. const isSameName = nextCourse.name === currentMergedCourse.name;
  185. const isSameTeacher = nextCourse.teacher === currentMergedCourse.teacher;
  186. const isSamePosition = nextCourse.position === currentMergedCourse.position;
  187. // 检查是否连续 (下一节的开始 = 当前节的结束 + 1)
  188. const isConsecutive = nextCourse.startSection === currentMergedCourse.endSection + 1;
  189. const canMerge = isSameDay && isSameWeeks && isSameName && isSameTeacher && isSamePosition && isConsecutive;
  190. if (canMerge) {
  191. // 合并:更新结束节次
  192. currentMergedCourse.endSection = nextCourse.endSection;
  193. } else {
  194. // 无法合并:推入当前合并结果,并开始新的合并
  195. mergedCourses.push(currentMergedCourse);
  196. currentMergedCourse = { ...nextCourse };
  197. }
  198. }
  199. // 推入最后一次合并的结果
  200. mergedCourses.push(currentMergedCourse);
  201. return mergedCourses;
  202. }
  203. // 网络请求函数
  204. async function fetchCourseHtml() {
  205. AndroidBridge.showToast("正在获取课表数据...");
  206. const URL = "https://jw.educationgroup.cn/gzstzyxy_jsxsd/xskb/xskb_list.do";
  207. try {
  208. const response = await fetch(URL, {
  209. "method": "GET",
  210. "credentials": "include"
  211. });
  212. if (!response.ok) {
  213. throw new Error(`网络请求失败,状态码: ${response.status}`);
  214. }
  215. const text = await response.text();
  216. AndroidBridge.showToast("课表数据获取成功,开始解析...");
  217. return text;
  218. } catch (error) {
  219. AndroidBridge.showToast(`网络请求异常: ${error.message}`);
  220. return null;
  221. }
  222. }
  223. async function importPresetTimeSlots() {
  224. try {
  225. await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(TimeSlots));
  226. AndroidBridge.showToast("预设时间段导入成功!");
  227. return true;
  228. } catch (error) {
  229. AndroidBridge.showToast("导入时间段失败: " + error.message);
  230. return false;
  231. }
  232. }
  233. async function saveConfig() {
  234. try {
  235. await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify(CourseConfig));
  236. AndroidBridge.showToast("课表配置更新成功!");
  237. return true;
  238. } catch (error) {
  239. AndroidBridge.showToast("保存配置失败: " + error.message);
  240. return false;
  241. }
  242. }
  243. async function saveCourses(parsedCourses, originalCount, mergedCount) {
  244. if (parsedCourses.length === 0) {
  245. AndroidBridge.showToast("未解析到任何课程数据,跳过保存。");
  246. return true;
  247. }
  248. try {
  249. await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(parsedCourses));
  250. if (originalCount !== undefined && mergedCount !== undefined) {
  251. AndroidBridge.showToast(`课程导入成功!原始 ${originalCount} 条,去重合并 ${mergedCount} 条,最终导入 ${parsedCourses.length} 条。`);
  252. } else {
  253. AndroidBridge.showToast(`成功导入 ${parsedCourses.length} 条课程!`);
  254. }
  255. return true;
  256. } catch (error) {
  257. AndroidBridge.showToast(`保存失败: ${error.message}`);
  258. return false;
  259. }
  260. }
  261. async function runImportFlow() {
  262. const alertConfirmed = await window.AndroidBridgePromise.showAlert(
  263. "开始导入",
  264. "请确保您已登录教务系统,即将获取课表数据并进行课程去重和合并。",
  265. "确定"
  266. );
  267. if (!alertConfirmed) {
  268. AndroidBridge.showToast("用户取消了导入。");
  269. return;
  270. }
  271. // 获取 HTML
  272. const htmlContent = await fetchCourseHtml();
  273. if (htmlContent === null) {
  274. AndroidBridge.showToast("导入终止。");
  275. return;
  276. }
  277. // 解析课程数据
  278. let parsedCourses = parseCourseTable(htmlContent);
  279. if (parsedCourses.length === 0) {
  280. AndroidBridge.showToast("解析失败或未发现有效课程。导入终止。");
  281. return;
  282. }
  283. const originalCourseCount = parsedCourses.length;
  284. // 课程去重和合并
  285. parsedCourses = mergeCourses(parsedCourses);
  286. const mergedCount = originalCourseCount - parsedCourses.length;
  287. // 导入时间段数据
  288. await importPresetTimeSlots();
  289. // 导入课表配置
  290. if (!await saveConfig()) return;
  291. // 课程数据保存,并传入合并信息
  292. if (!await saveCourses(parsedCourses, originalCourseCount, mergedCount)) return;
  293. // 流程成功
  294. AndroidBridge.showToast("所有任务已完成!课表已导入成功!");
  295. AndroidBridge.notifyTaskCompletion();
  296. }
  297. // 启动导入流程
  298. runImportFlow();