imnc_01.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. /**
  2. * 呼和浩特民族学院 (IMNC) 课表解析脚本-通过 WebVPN 登录
  3. * 目标页面:教务系统【本学期课表】("本学期课程安排"页面)
  4. * 从课程主表读取课程号、课程序号、学分、选课属性,写入课程备注 remark
  5. * 桥接 API 使用 v2:window.shiguangBridge(同步) / window.shiguangBridgePromise(异步)
  6. * 导入时一并写入默认时间表(10 节)与默认学期总周数(16 周)
  7. */
  8. // 清理 <wbr> 标签
  9. function cleanWbr(str) {
  10. return str ? str.replace(/<wbr\s*\/?>/gi, '') : str;
  11. }
  12. // 默认时间表(TimeSlotJsonModel:number 从 1 递增,HH:mm 格式)
  13. const TIME_SLOTS = [
  14. { number: 1, startTime: "08:30", endTime: "09:15" },
  15. { number: 2, startTime: "09:25", endTime: "10:10" },
  16. { number: 3, startTime: "10:30", endTime: "11:15" },
  17. { number: 4, startTime: "11:25", endTime: "12:10" },
  18. { number: 5, startTime: "14:30", endTime: "15:15" },
  19. { number: 6, startTime: "15:25", endTime: "16:10" },
  20. { number: 7, startTime: "16:30", endTime: "17:15" },
  21. { number: 8, startTime: "17:25", endTime: "18:10" },
  22. { number: 9, startTime: "19:30", endTime: "20:15" },
  23. { number: 10, startTime: "20:25", endTime: "21:10" }
  24. ];
  25. // 默认本学期总周数(CourseConfigJsonModel.semesterTotalWeeks,未传入的字段使用应用默认值)
  26. const SEMESTER_TOTAL_WEEKS = 16;
  27. // 周次解析函数
  28. function parseWeeks(weekStr) {
  29. let weeks = [];
  30. if (!weekStr) return weeks;
  31. let isSingle = weekStr.includes('单');
  32. let isDouble = weekStr.includes('双');
  33. // 匹配 "1-16", "第1-9周", "全周(1-16)"
  34. let match = weekStr.match(/(\d+)-(\d+)/);
  35. if (match) {
  36. let start = parseInt(match[1]);
  37. let end = parseInt(match[2]);
  38. for (let i = start; i <= end; i++) {
  39. if (isSingle && i % 2 === 0) continue;
  40. if (isDouble && i % 2 !== 0) continue;
  41. weeks.push(i);
  42. }
  43. } else {
  44. // 匹配 "第13周"
  45. let singleMatch = weekStr.match(/(\d+)/);
  46. if (singleMatch) {
  47. weeks.push(parseInt(singleMatch[1]));
  48. }
  49. }
  50. return weeks;
  51. }
  52. // 星期映射(星期一~星期日 -> 1~7)
  53. const DAY_NAME_MAP = {
  54. '星期一': 1, '星期二': 2, '星期三': 3, '星期四': 4,
  55. '星期五': 5, '星期六': 6, '星期日': 7, '星期天': 7
  56. };
  57. function parseDay(dayStr) {
  58. if (!dayStr) return 0;
  59. let normalized = dayStr.replace(/\s+/g, '');
  60. if (DAY_NAME_MAP[normalized]) return DAY_NAME_MAP[normalized];
  61. let match = normalized.match(/星期([一二三四五六日天])/);
  62. if (!match) match = normalized.match(/周([一二三四五六日天])/);
  63. if (match) {
  64. let index = '一二三四五六日天'.indexOf(match[1]);
  65. return index >= 0 ? index + 1 : 0;
  66. }
  67. return 0;
  68. }
  69. // 节次解析:"第1-2节" -> {start:1, end:2};"第3节" -> {start:3, end:3}
  70. function parseSections(sectionStr) {
  71. if (!sectionStr) return null;
  72. let rangeMatch = sectionStr.match(/第\s*(\d+)\s*[-—~至]\s*(\d+)\s*节/);
  73. if (rangeMatch) {
  74. return { start: parseInt(rangeMatch[1]), end: parseInt(rangeMatch[2]) };
  75. }
  76. let singleMatch = sectionStr.match(/第\s*(\d+)\s*节/);
  77. if (singleMatch) {
  78. let section = parseInt(singleMatch[1]);
  79. return { start: section, end: section };
  80. }
  81. return null;
  82. }
  83. function cleanCellText(cell) {
  84. return cell.textContent.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
  85. }
  86. // 教师单元格解析:任课教师以链接列表展示,用"、"拼接
  87. function parseTeacherCell(cell) {
  88. let links = cell.querySelectorAll('a');
  89. if (links.length > 0) {
  90. let names = [];
  91. for (let i = 0; i < links.length; i++) {
  92. let name = cleanWbr(links[i].textContent).replace(/\s+/g, ' ').trim();
  93. if (name) names.push(name);
  94. }
  95. if (names.length > 0) return names.join('、');
  96. }
  97. return cleanCellText(cell);
  98. }
  99. // 解析"上课时间、地点"单元格内的嵌套表格,每行:周次 / 星期 / 节次 / 地点
  100. function parseScheduleCell(cell) {
  101. let slots = [];
  102. let rows = cell.querySelectorAll('table tr');
  103. for (let i = 0; i < rows.length; i++) {
  104. let tds = rows[i].querySelectorAll('td');
  105. if (tds.length < 3) continue;
  106. let weekStr = cleanCellText(tds[0]);
  107. let dayStr = cleanCellText(tds[1]);
  108. let sectionStr = cleanCellText(tds[2]);
  109. let position = tds.length > 3 ? cleanCellText(tds[3]) : '';
  110. // 无星期或节次的子行(仅有周次的课程安排)跳过
  111. let day = parseDay(dayStr);
  112. let section = parseSections(sectionStr);
  113. if (!day || !section) continue;
  114. slots.push({
  115. weeks: parseWeeks(weekStr),
  116. weeksText: weekStr,
  117. day: day,
  118. startSection: section.start,
  119. endSection: section.end,
  120. position: cleanWbr(position)
  121. });
  122. }
  123. return slots;
  124. }
  125. // 在单个文档中定位课程主表:table.infolist_tab 且表头含"课程号"(排除"上课大节"对照表)
  126. function findCourseTableInDoc(doc) {
  127. let tables = doc.querySelectorAll('table.infolist_tab');
  128. for (let i = 0; i < tables.length; i++) {
  129. let headerCells = tables[i].rows[0] ? tables[i].rows[0].cells : [];
  130. for (let j = 0; j < headerCells.length; j++) {
  131. if (cleanCellText(headerCells[j]).replace(/\s+/g, '') === '课程号') {
  132. return tables[i];
  133. }
  134. }
  135. }
  136. return null;
  137. }
  138. // 收集文档内所有可访问的同源框架文档(教务系统为 frameset 结构,课表位于 mainFrame 内)
  139. function collectFrameDocs(doc, docs, depth) {
  140. if (depth > 3) return;
  141. let frames = doc.querySelectorAll('frame, iframe');
  142. for (let i = 0; i < frames.length; i++) {
  143. try {
  144. let frameDoc = frames[i].contentDocument ||
  145. (frames[i].contentWindow ? frames[i].contentWindow.document : null);
  146. if (frameDoc && docs.indexOf(frameDoc) < 0) {
  147. docs.push(frameDoc);
  148. collectFrameDocs(frameDoc, docs, depth + 1);
  149. }
  150. } catch (e) { /* 跨域框架无法访问,忽略 */ }
  151. }
  152. return docs;
  153. }
  154. function findCourseTable() {
  155. // 优先在当前文档查找
  156. let table = findCourseTableInDoc(document);
  157. if (table) return table;
  158. // 当前文档没有(如处于 index_frame 顶层框架),遍历同源框架查找
  159. let frameDocs = collectFrameDocs(document, [], 0);
  160. for (let i = 0; i < frameDocs.length; i++) {
  161. let tableInFrame = findCourseTableInDoc(frameDocs[i]);
  162. if (tableInFrame) return tableInFrame;
  163. }
  164. return null;
  165. }
  166. // 组装课程备注(官方 ImportCourseJsonModel 字段 remark,字符数限制 300)
  167. function buildRemark(courseId, sequence, credit, electiveAttr) {
  168. let lines = [];
  169. if (courseId) lines.push('课程号:' + courseId);
  170. if (sequence) lines.push('课程序号:' + sequence);
  171. if (credit) lines.push('学分:' + credit);
  172. if (electiveAttr) lines.push('选课属性:' + electiveAttr);
  173. let remark = lines.join('\n');
  174. if (remark.length > 300) remark = remark.substring(0, 300);
  175. return remark;
  176. }
  177. // 核心解析函数:在"本学期课程安排"页面按表头定位列并逐行解析
  178. function fetchCurrentSemesterCourses() {
  179. let table = findCourseTable();
  180. if (!table) return null;
  181. // 按表头文本建立列索引(表头文本去除空白,兼容"课程<br>序号"、"学 分"等换行写法)
  182. let indexMap = {};
  183. let headerCells = table.rows[0] ? table.rows[0].cells : [];
  184. for (let i = 0; i < headerCells.length; i++) {
  185. indexMap[cleanCellText(headerCells[i]).replace(/\s+/g, '')] = i;
  186. }
  187. function columnIndex(keys) {
  188. for (let i = 0; i < keys.length; i++) {
  189. if (indexMap[keys[i]] !== undefined) return indexMap[keys[i]];
  190. }
  191. return -1;
  192. }
  193. let courseIdIdx = columnIndex(['课程号']);
  194. let sequenceIdx = columnIndex(['课程序号']);
  195. let nameIdx = columnIndex(['课程名称']);
  196. let teacherIdx = columnIndex(['任课教师']);
  197. let creditIdx = columnIndex(['学分']);
  198. let electiveAttrIdx = columnIndex(['选课属性']);
  199. let scheduleIdx = columnIndex(['上课时间、地点', '上课时间地点']);
  200. if (nameIdx < 0 || scheduleIdx < 0) return null;
  201. let courses = [];
  202. let skipped = [];
  203. // 第 0 行是表头,从第 1 行开始遍历课程
  204. for (let i = 1; i < table.rows.length; i++) {
  205. let cells = table.rows[i].cells;
  206. function cellAt(index) {
  207. return index >= 0 && index < cells.length ? cells[index] : null;
  208. }
  209. let nameCell = cellAt(nameIdx);
  210. let name = nameCell ? cleanWbr(cleanCellText(nameCell)) : '';
  211. if (!name) continue;
  212. let courseIdCell = cellAt(courseIdIdx);
  213. let sequenceCell = cellAt(sequenceIdx);
  214. let creditCell = cellAt(creditIdx);
  215. let electiveAttrCell = cellAt(electiveAttrIdx);
  216. let teacherCell = cellAt(teacherIdx);
  217. let courseId = courseIdCell ? cleanCellText(courseIdCell) : '';
  218. let sequence = sequenceCell ? cleanCellText(sequenceCell) : '';
  219. let credit = creditCell ? cleanCellText(creditCell) : '';
  220. let electiveAttr = electiveAttrCell ? cleanCellText(electiveAttrCell) : '';
  221. let teacher = teacherCell ? parseTeacherCell(teacherCell) : '';
  222. let remark = buildRemark(courseId, sequence, credit, electiveAttr);
  223. let scheduleCell = cellAt(scheduleIdx);
  224. let slots = scheduleCell ? parseScheduleCell(scheduleCell) : [];
  225. if (slots.length === 0) {
  226. // 无任何有效排课位的课程(如仅有周次的"形势与政策(三)"、无时间安排的"体能测试Ⅱ")
  227. let weeksText = '未填写周次';
  228. if (scheduleCell) {
  229. let firstTd = scheduleCell.querySelector('table td');
  230. if (firstTd) {
  231. let text = cleanCellText(firstTd);
  232. if (text) weeksText = text;
  233. }
  234. }
  235. skipped.push({
  236. name: name,
  237. teacher: teacher || '未填写教师',
  238. weeks: weeksText
  239. });
  240. continue;
  241. }
  242. for (let k = 0; k < slots.length; k++) {
  243. let slot = slots[k];
  244. let courseBlock = {
  245. name: name,
  246. teacher: teacher,
  247. position: slot.position,
  248. day: slot.day,
  249. startSection: slot.startSection,
  250. endSection: slot.endSection,
  251. weeks: slot.weeks,
  252. remark: remark
  253. };
  254. // 查找同一天、同名、同老师、同地点、同周次、同备注,且正好是上一节的课程(合并连上的课)
  255. let existingCourse = courses.find(c =>
  256. c.name === courseBlock.name &&
  257. c.day === courseBlock.day &&
  258. c.teacher === courseBlock.teacher &&
  259. c.position === courseBlock.position &&
  260. JSON.stringify(c.weeks) === JSON.stringify(courseBlock.weeks) &&
  261. c.remark === courseBlock.remark &&
  262. c.endSection === courseBlock.startSection - 1
  263. );
  264. if (existingCourse) {
  265. existingCourse.endSection = courseBlock.endSection;
  266. } else {
  267. courses.push(courseBlock);
  268. }
  269. }
  270. }
  271. return { courses: courses, skipped: skipped };
  272. }
  273. function formatNoArrangementMessage(courses) {
  274. let lines = courses.map((course, index) => {
  275. let teacher = course.teacher || '未填写教师';
  276. let weeks = course.weeks || '未填写周次';
  277. return `${index + 1}. ${course.name} / ${teacher} / ${weeks}`;
  278. });
  279. return `检测到 ${courses.length} 门课程没有具体上课时间或地点,无法自动放入课表:\n\n${lines.join('\n')}\n\n请在确认课程时间后重新导入,点击【继续】将导入已知课程。`;
  280. }
  281. // 保存课表配置(默认学期总周数;失败不阻断课程导入)
  282. async function saveCourseConfigData() {
  283. try {
  284. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
  285. semesterTotalWeeks: SEMESTER_TOTAL_WEEKS
  286. }));
  287. window.shiguangBridge.showToast(`已设置默认学期总周数:${SEMESTER_TOTAL_WEEKS} 周`);
  288. return true;
  289. } catch (error) {
  290. window.shiguangBridge.showToast("保存课表配置失败: " + error.message);
  291. return false;
  292. }
  293. }
  294. // 导入预设时间段(默认时间表;失败不阻断课程导入)
  295. async function importPresetTimeSlots() {
  296. try {
  297. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(TIME_SLOTS));
  298. window.shiguangBridge.showToast(`预设时间段导入成功(${TIME_SLOTS.length} 节)!`);
  299. return true;
  300. } catch (error) {
  301. window.shiguangBridge.showToast("导入时间段失败: " + error.message);
  302. return false;
  303. }
  304. }
  305. // 调度流程
  306. async function runImportFlow() {
  307. try {
  308. window.shiguangBridge.showToast("开始解析课表...");
  309. const alertConfirmed = await window.shiguangBridgePromise.showAlert(
  310. "导入确认",
  311. "请确保您目前处于教务系统的“本学期课表”显示页面(页面标题为“本学期课程安排”)。\n是否立即提取并导入课表?",
  312. "开始提取"
  313. );
  314. if (!alertConfirmed) {
  315. window.shiguangBridge.showToast("导入已取消");
  316. return;
  317. }
  318. const result = fetchCurrentSemesterCourses();
  319. if (!result || result.courses.length === 0) {
  320. await window.shiguangBridgePromise.showAlert("错误", "未在当前页面找到可导入的课程数据,请确认是否处于“本学期课表”页面,或联系适配开发者。", "好的");
  321. return;
  322. }
  323. if (result.skipped.length > 0) {
  324. await window.shiguangBridgePromise.showAlert(
  325. "存在未安排课程",
  326. formatNoArrangementMessage(result.skipped),
  327. "继续"
  328. );
  329. }
  330. // 课表配置(学期总周数),失败不阻断
  331. await saveCourseConfigData();
  332. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(result.courses));
  333. window.shiguangBridge.showToast(`成功导入 ${result.courses.length} 门课程块!`);
  334. // 默认时间表,失败不阻断
  335. await importPresetTimeSlots();
  336. window.shiguangBridge.notifyTaskCompletion();
  337. } catch (error) {
  338. window.shiguangBridge.showToast("导入发生错误: " + error.message);
  339. }
  340. }
  341. // 启动执行
  342. runImportFlow();