gzhmu_01.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. /**
  2. * 广州医科大学强智教务,适配作者:小漫君。
  3. * 请求和课表字段解析参考拾光仓库 GDCP/gdcp_01.js,周历参考
  4. * ZHKU/zhku_lc6464.js(lc6464);保留原作者贡献及仓库 MIT License。
  5. * GDCP 所记载的 HNIU、HHTC、NJFU、QFNU(Yumu-banxia)贡献一并保留。
  6. * Copyright (c) 2025 星河欲转;上述文件的贡献历史保留在仓库中。
  7. * 作息:https://jwc.gzhmu.edu.cn/info/1118/5748.htm(2018-2019-2 起执行)。
  8. * 不保存账号密码;所有响应先校验,再通过官方 bridge 提交。
  9. */
  10. function gzhmuText(value) {
  11. return String(value == null ? '' : value).replace(/\u00a0/g, ' ').trim();
  12. }
  13. function gzhmuNormalize(value) {
  14. return gzhmuText(value).replace(/\s+/g, '').replace(/[(]/g, '(')
  15. .replace(/[)]/g, ')').replace(/[[【]/g, '[').replace(/[]】]/g, ']')
  16. .replace(/[,、]/g, ',').replace(/[-–—~~]/g, '-');
  17. }
  18. function gzhmuTimeSlots() {
  19. return [
  20. { number: 1, startTime: '08:30', endTime: '09:10' },
  21. { number: 2, startTime: '09:15', endTime: '09:55' },
  22. { number: 3, startTime: '10:05', endTime: '10:45' },
  23. { number: 4, startTime: '10:50', endTime: '11:30' },
  24. { number: 5, startTime: '11:35', endTime: '12:15' },
  25. { number: 6, startTime: '12:20', endTime: '13:00' },
  26. { number: 7, startTime: '13:00', endTime: '13:40' },
  27. { number: 8, startTime: '14:00', endTime: '14:40' },
  28. { number: 9, startTime: '14:45', endTime: '15:25' },
  29. { number: 10, startTime: '15:35', endTime: '16:15' },
  30. { number: 11, startTime: '16:20', endTime: '17:00' },
  31. { number: 12, startTime: '17:05', endTime: '17:45' },
  32. { number: 13, startTime: '17:45', endTime: '18:25' },
  33. { number: 14, startTime: '18:30', endTime: '19:10' },
  34. { number: 15, startTime: '19:15', endTime: '19:55' },
  35. { number: 16, startTime: '20:00', endTime: '20:40' }
  36. ];
  37. }
  38. function gzhmuBaseUrl() {
  39. const host = window.location.hostname;
  40. const path = window.location.pathname.match(/^(.*?\/jsxsd)(?:\/|$)/);
  41. if (!path || !(host === 'jwgl.gzhmu.edu.cn' || host === 'webvpn.gzhmu.edu.cn'
  42. || host.endsWith('.webvpn.gzhmu.edu.cn'))) {
  43. throw new Error('请先登录广州医科大学教务。校外请在学校 WebVPN 内进入教务系统,再点击导入。');
  44. }
  45. // 保留 WebVPN 的 /https/<代理标识>/ 前缀,不能跳回校内域名。
  46. return window.location.origin + path[1] + '/';
  47. }
  48. function gzhmuCheckLogin(doc) {
  49. if (doc.querySelector('input[type="password"]') || /登录|统一身份认证|Login/i.test(doc.title)) {
  50. throw new Error('教务登录已失效,请通过学校 WebVPN 重新进入教务系统后再导入。');
  51. }
  52. }
  53. async function gzhmuFetchDoc(url, options) {
  54. const response = await fetch(url, { credentials: 'same-origin', ...options });
  55. if (!response.ok) throw new Error('教务请求失败:HTTP ' + response.status);
  56. const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
  57. gzhmuCheckLogin(doc);
  58. return doc;
  59. }
  60. function gzhmuTerms(doc) {
  61. const select = doc.querySelector('#xnxq01id');
  62. if (!select) throw new Error('未找到学期列表,请确认已进入教务系统。');
  63. const terms = [];
  64. const ids = new Set();
  65. let selectedIndex = -1;
  66. for (const option of select.options) {
  67. const id = gzhmuText(option.value);
  68. if (!id || option.disabled) continue;
  69. if (ids.has(id)) throw new Error('教务返回了重复的学期标识,已停止导入。');
  70. ids.add(id);
  71. if (option.selected) selectedIndex = terms.length;
  72. terms.push({ id, name: gzhmuText(option.textContent) || id });
  73. }
  74. if (!terms.length) throw new Error('教务没有返回可选学期。');
  75. return { terms, selectedIndex };
  76. }
  77. function gzhmuCheckTerm(doc, termId) {
  78. const select = doc.querySelector('#xnxq01id');
  79. if (!select || gzhmuText(select.value) !== termId) {
  80. throw new Error('教务返回的学期与所选学期不一致,已停止导入。');
  81. }
  82. }
  83. function gzhmuParseWeeks(value, max) {
  84. const label = '课程周次';
  85. const text = gzhmuNormalize(value).replace(/第/g, '').replace(/\(周\)/g, '')
  86. .replace(/\((单|双)周\)/g, '($1)').replace(/周/g, '');
  87. const numbers = new Set();
  88. for (const part of text.split(',')) {
  89. const match = part.match(/^(\d+)(?:-(\d+))?(?:\((单|双)\))?$/);
  90. if (!match) throw new Error(label + '格式无法识别,已停止导入。');
  91. const start = Number(match[1]);
  92. const end = Number(match[2] || match[1]);
  93. if (start < 1 || end < start || end > max) throw new Error(label + '超出范围,已停止导入。');
  94. for (let number = start; number <= end; number++) {
  95. if (!match[3] || number % 2 === (match[3] === '单' ? 1 : 0)) numbers.add(number);
  96. }
  97. }
  98. if (!numbers.size) throw new Error(label + '为空,已停止导入。');
  99. return Array.from(numbers).sort((a, b) => a - b);
  100. }
  101. function gzhmuSectionRanges(numbers) {
  102. const ranges = [];
  103. for (const number of numbers) {
  104. const last = ranges[ranges.length - 1];
  105. if (last && number === last.endSection + 1) last.endSection = number;
  106. else ranges.push({ startSection: number, endSection: number });
  107. }
  108. return ranges;
  109. }
  110. function gzhmuCourses(doc, totalWeeks) {
  111. const table = doc.querySelector('#timetable');
  112. if (!table || !table.rows.length) throw new Error('未找到学期课表 #timetable。');
  113. const headers = Array.from(table.rows[0].cells).slice(1).map(cell => gzhmuText(cell.textContent));
  114. if (headers.join(',') !== '星期一,星期二,星期三,星期四,星期五,星期六,星期日') {
  115. throw new Error('课表星期列顺序无法识别,已停止导入。');
  116. }
  117. const grouped = new Map();
  118. for (const row of table.rows) {
  119. const cells = row.querySelectorAll(':scope > td');
  120. if (cells.length !== 7) continue; // 表头与页脚不是课程行。
  121. for (let column = 0; column < cells.length; column++) {
  122. for (const original of cells[column].querySelectorAll('.kbcontent')) {
  123. const content = original.cloneNode(true);
  124. content.querySelectorAll('.item-box').forEach(item => item.remove());
  125. for (const html of content.innerHTML.split(/-{10,}\s*<br\s*\/?>/i)) {
  126. const block = new DOMParser().parseFromString(html, 'text/html');
  127. if (!gzhmuText(block.body.textContent)) continue;
  128. const name = gzhmuText(block.querySelector('font:not([title])')?.textContent);
  129. const teacher = block.querySelector('font[title="教师"]');
  130. const position = block.querySelector('font[title="教室"]');
  131. const time = block.querySelector('font[title="周次(节次)"]');
  132. const match = time && gzhmuNormalize(time.textContent).match(/^(.*?)\[(\d+(?:-\d+)*)节\]$/);
  133. if (!name || !teacher || !position || !match) throw new Error('课程字段不完整,已停止导入。');
  134. const weeks = gzhmuParseWeeks(match[1], totalWeeks);
  135. // 接口逐项列举节次,例如 01-02-03-04;不能将 01-03 擅自补为三节。
  136. const sections = match[2].split('-').map(Number);
  137. if (sections.some(section => section < 1 || section > 16)) throw new Error('课程节次超出作息范围。');
  138. const day = column + 1;
  139. const fields = { day, name, teacher: gzhmuText(teacher.textContent), position: gzhmuText(position.textContent) };
  140. const key = JSON.stringify(fields);
  141. if (!grouped.has(key)) grouped.set(key, { ...fields, occurrences: new Map() });
  142. const occurrences = grouped.get(key).occurrences;
  143. for (const week of weeks) {
  144. if (!occurrences.has(week)) occurrences.set(week, new Set());
  145. for (const section of sections) occurrences.get(week).add(section);
  146. }
  147. }
  148. }
  149. }
  150. }
  151. const courses = [];
  152. for (const course of grouped.values()) {
  153. const ranges = new Map();
  154. for (const [week, sections] of course.occurrences) {
  155. for (const range of gzhmuSectionRanges(Array.from(sections).sort((a, b) => a - b))) {
  156. const key = JSON.stringify(range);
  157. if (!ranges.has(key)) ranges.set(key, { name: course.name, teacher: course.teacher, position: course.position,
  158. day: course.day, weeks: [], isCustomTime: false, ...range });
  159. ranges.get(key).weeks.push(week);
  160. }
  161. }
  162. for (const range of ranges.values()) courses.push({ ...range, weeks: range.weeks.sort((a, b) => a - b) });
  163. }
  164. if (!courses.length) throw new Error('所选学期没有可识别的固定课程,未保存任何数据。');
  165. return courses;
  166. }
  167. function gzhmuDate(value) {
  168. const match = gzhmuText(value).match(/(\d{4})(?:年|-)(\d{1,2})(?:月|-)(\d{1,2})(?:日)?/);
  169. if (!match) throw new Error('周历缺少完整日期,已停止导入。');
  170. const year = Number(match[1]), month = Number(match[2]), day = Number(match[3]);
  171. const date = new Date(Date.UTC(year, month - 1, day));
  172. if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
  173. throw new Error('周历日期无效,已停止导入。');
  174. }
  175. return date;
  176. }
  177. function gzhmuApplyTimeDifferences(doc, courses, slots) {
  178. for (const row of doc.querySelector('#timetable').rows) {
  179. if (row.querySelectorAll(':scope > td').length !== 7) continue;
  180. const header = gzhmuNormalize(row.querySelector(':scope > th')?.textContent);
  181. const match = header.match(/\((\d+(?:,\d+)*)小节\)(\d{2}:\d{2})-(\d{2}:\d{2})$/);
  182. if (!match) throw new Error('课表缺少节次时钟,无法核对作息。');
  183. const sections = match[1].split(',').map(Number);
  184. const first = sections[0], last = sections[sections.length - 1];
  185. if (!slots[first - 1] || !slots[last - 1]) throw new Error('课表时段超出公开作息范围。');
  186. const changedStart = match[2] !== slots[first - 1].startTime;
  187. const changedEnd = match[3] !== slots[last - 1].endTime;
  188. if (!changedStart && !changedEnd) continue;
  189. // 实测历史学期第 6–7 节为 12:20–13:45;保留教务明确给出的整段时钟。
  190. // 若课程只占发生差异的部分,不能从整段时长反推单节时间。
  191. for (const course of courses) {
  192. const touches = section => course.startSection <= section && course.endSection >= section;
  193. if (!(changedStart && touches(first)) && !(changedEnd && touches(last))) continue;
  194. if (course.startSection > first || course.endSection < last) {
  195. throw new Error('教务时段 ' + first + '–' + last + ' 节与公开作息不同,无法确定其中单节时间。');
  196. }
  197. course.customStartTime = course.startSection === first ? match[2]
  198. : course.customStartTime || slots[course.startSection - 1].startTime;
  199. course.customEndTime = course.endSection === last ? match[3]
  200. : course.customEndTime || slots[course.endSection - 1].endTime;
  201. course.isCustomTime = true;
  202. }
  203. }
  204. }
  205. function gzhmuCalendar(doc, termId) {
  206. gzhmuCheckTerm(doc, termId);
  207. const table = doc.querySelector('#kbtable');
  208. if (!table) throw new Error('未找到教学周历 #kbtable,无法确定所选学期的开学日期。');
  209. const weeks = new Map();
  210. for (const row of table.rows) {
  211. const numberText = gzhmuText(row.cells[0]?.textContent);
  212. if (!/^\d+$/.test(numberText)) continue;
  213. const week = Number(numberText);
  214. if (weeks.has(week)) throw new Error('周历出现重复周次,已停止导入。');
  215. const date = gzhmuDate(row.cells[1]?.getAttribute('title'));
  216. if (date.getUTCDay() !== 1) throw new Error('周历周一日期不正确,已停止导入。');
  217. weeks.set(week, date);
  218. }
  219. if (!weeks.has(1)) throw new Error('周历没有第一教学周的周一日期。');
  220. const start = weeks.get(1);
  221. let totalWeeks = 0;
  222. for (const [week, monday] of weeks) {
  223. if (monday.getTime() !== start.getTime() + (week - 1) * 7 * 86400000) {
  224. throw new Error('周历日期与周次不一致,已停止导入。');
  225. }
  226. totalWeeks = Math.max(totalWeeks, week);
  227. }
  228. if (weeks.size !== totalWeeks) throw new Error('周历存在缺失周次,已停止导入。');
  229. const term = termId.match(/^(\d{4})-(\d{4})-([12])$/);
  230. if (term && (Number(term[2]) !== Number(term[1]) + 1
  231. || start.getUTCFullYear() !== Number(term[term[3] === '1' ? 1 : 2]))) {
  232. throw new Error('周历年份与所选学期不一致,已停止导入。');
  233. }
  234. return { semesterStartDate: start.toISOString().slice(0, 10), semesterTotalWeeks: totalWeeks,
  235. defaultClassDuration: 40, defaultBreakDuration: 5, firstDayOfWeek: 1 };
  236. }
  237. async function gzhmuRunImport() {
  238. const bridge = window.shiguangBridgePromise;
  239. try {
  240. if (!bridge || !window.shiguangBridge) throw new Error('请在支持拾光教务桥接的应用或官方测试器中运行。');
  241. const base = gzhmuBaseUrl();
  242. window.shiguangBridge.showToast('正在获取广医学期列表…');
  243. const endpoint = base + 'xskb/xskb_list.do';
  244. const entry = await gzhmuFetchDoc(endpoint, { method: 'GET' });
  245. const { terms, selectedIndex } = gzhmuTerms(entry);
  246. const mode = entry.querySelector('#kbjcmsid')?.value;
  247. if (!mode) throw new Error('未获取到教务节次模式,已停止导入。');
  248. const choice = await bridge.showSingleSelection('选择导入学期', JSON.stringify(terms.map(term => term.name)), selectedIndex);
  249. if (choice == null) return;
  250. const index = Number(choice);
  251. if ((typeof choice !== 'number' && !/^\d+$/.test(choice))
  252. || !Number.isInteger(index) || index < 0 || index >= terms.length) throw new Error('未选择有效学期。');
  253. const term = terms[index];
  254. // 即使选中当前学期,也显式请求整学期,避免复用入口的周筛选结果。
  255. const schedule = await gzhmuFetchDoc(endpoint, { method: 'POST',
  256. headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  257. body: new URLSearchParams({ cj0701id: '', zc: '', demo: '', xnxq01id: term.id, sfFD: '1', kbjcmsid: mode }).toString() });
  258. gzhmuCheckTerm(schedule, term.id);
  259. const calendarDoc = await gzhmuFetchDoc(base + 'jxzl/jxzl_query', { method: 'POST',
  260. headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  261. body: new URLSearchParams({ xnxq01id: term.id }).toString() });
  262. const config = gzhmuCalendar(calendarDoc, term.id);
  263. const courses = gzhmuCourses(schedule, config.semesterTotalWeeks);
  264. const slots = gzhmuTimeSlots();
  265. gzhmuApplyTimeDifferences(schedule, courses, slots);
  266. for (const [method, data] of [['saveCourseConfig', config], ['savePresetTimeSlots', slots], ['saveImportedCourses', courses]]) {
  267. const saved = await bridge[method](JSON.stringify(data));
  268. if (saved !== true && saved !== 'true') throw new Error('保存失败(' + method + '),导入未完成。');
  269. }
  270. window.shiguangBridge.showToast('已提交 ' + courses.length + ' 条课程,请在预览中核对后确认。');
  271. window.shiguangBridge.notifyTaskCompletion();
  272. } catch (error) {
  273. const message = error instanceof Error ? error.message : '教务导入异常。';
  274. if (bridge) await bridge.showAlert('导入未完成', message, '知道了');
  275. else if (window.shiguangBridge) window.shiguangBridge.showToast(message);
  276. else console.error(message);
  277. }
  278. }
  279. gzhmuRunImport();