scnu.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. // 华南师范大学本科教务系统。登录后,在教务系统内任意页面执行;学年学期在弹窗中确认,默认为当前学期。
  2. // 主接口包含部分实验课;独立实验预约系统的安排不在本脚本的导入范围内。
  3. (async function () {
  4. const bridge = window.shiguangBridgePromise;
  5. const native = window.shiguangBridge;
  6. if (!bridge || !native) throw new Error('请通过拾光课程表或适配测试器运行此脚本。');
  7. if (window.__scnuImportRunning) {
  8. native.showToast('华师课表正在导入,请勿重复点击。');
  9. return;
  10. }
  11. window.__scnuImportRunning = true;
  12. let saving = false;
  13. // 华师教务页面(zftal-ui 的 jquery.extends.contact)改写了部分原型方法:
  14. // Array.prototype.filter/some 的回调实参变成 (下标, 元素, 数组),every 恒为 true,
  15. // String.prototype.trim 会删除所有空白而非仅首尾。本脚本只使用原生安全的写法:
  16. // 用 replace 实现 trim,不用 filter/some/every 做数组过滤。
  17. const text = value => String(value == null ? '' : value).replace(/^\s+|\s+$/g, '');
  18. // 整段匹配,遇到未知格式就停止,避免部分解析造成漏课或多排课。
  19. function parseNumbers(value, unit, limit, allowParity) {
  20. const normalized = text(value).replace(/\s/g, '').replace(/,|、/g, ',')
  21. .replace(/(/g, '(').replace(/)/g, ')');
  22. const numbers = new Set();
  23. for (const part of normalized.split(',')) {
  24. const match = part.match(/^(\d+)(?:-(\d+))?(?:周|节)?(?:\(([单双])(?:周)?\))?$/);
  25. if (!match || (match[3] && !allowParity) || (unit === '周' ? part.includes('节') : part.includes('周'))) {
  26. throw new Error(`无法识别${unit}次:${value}`);
  27. }
  28. const start = Number(match[1]);
  29. const end = Number(match[2] || match[1]);
  30. if (start < 1 || end < start || end > limit) throw new Error(`${unit}次范围无效:${value}`);
  31. for (let n = start; n <= end; n++) {
  32. if (match[3] === '单' && n % 2 === 0) continue;
  33. if (match[3] === '双' && n % 2 !== 0) continue;
  34. numbers.add(n);
  35. }
  36. }
  37. if (!numbers.size) throw new Error(`${unit}次为空:${value}`);
  38. return [...numbers].sort((a, b) => a - b);
  39. }
  40. function parseCourses(rows) {
  41. const entries = [];
  42. for (const raw of rows) {
  43. const name = text(raw.kcmc);
  44. try {
  45. const day = Number(raw.xqj);
  46. if (!name || !Number.isInteger(day) || day < 1 || day > 7) throw new Error('缺少课程名称或有效星期');
  47. const campusId = text(raw.xqh_id);
  48. if (!campusId) throw new Error('缺少校区编号,无法确定作息时间');
  49. const weeks = parseNumbers(raw.zcd, '周', 100, true);
  50. const sections = parseNumbers(raw.jcs, '节', 30, false);
  51. const type = { '*': '理论', '&': '实验', '#': '实践' }[text(raw.xslxbj)];
  52. // 当前 App 的此导入路径不保存 remark,课程类型直接写入名称。
  53. const courseName = type && type !== '理论' && !name.includes(type) ? `${name}(${type})` : name;
  54. for (let i = 0; i < sections.length; i++) {
  55. const startSection = sections[i];
  56. while (i + 1 < sections.length && sections[i + 1] === sections[i] + 1) i++;
  57. entries.push({
  58. campusId,
  59. campusName: text(raw.xqmc) || campusId,
  60. course: {
  61. name: courseName,
  62. teacher: text(raw.xm),
  63. // 页面的 Array.prototype.filter 已被污染,拼接后统一去空白。
  64. position: text(`${text(raw.xqmc)} ${text(raw.cdmc)}`),
  65. day, startSection, endSection: sections[i], weeks
  66. }
  67. });
  68. }
  69. } catch (error) {
  70. throw new Error(`${name || '未命名课程'}:${error.message}`);
  71. }
  72. }
  73. return entries;
  74. }
  75. function parseTimeSlots(rows) {
  76. if (!Array.isArray(rows) || !rows.length) throw new Error('没有取得校区作息时间');
  77. const slots = rows.map(row => ({
  78. number: Number(row.jcmc), startTime: text(row.qssj), endTime: text(row.jssj)
  79. })).sort((a, b) => a.number - b.number);
  80. let previousEnd = '';
  81. slots.forEach((slot, index) => {
  82. const validTime = value => /^([01]\d|2[0-3]):[0-5]\d$/.test(value);
  83. if (slot.number !== index + 1 || !validTime(slot.startTime) || !validTime(slot.endTime)
  84. || slot.startTime >= slot.endTime || slot.startTime < previousEnd) {
  85. throw new Error('校区作息时间格式无效、节次不连续或时间重叠');
  86. }
  87. previousEnd = slot.endTime;
  88. });
  89. return slots;
  90. }
  91. // 周次校历接口(xskbcxZccx_cxZcByXnxq)返回每周的起止日期,可推导开学日期、总周数与每周起始日。
  92. function parseWeekCalendar(rows) {
  93. if (!Array.isArray(rows) || !rows.length) throw new Error('没有取得学期周次校历');
  94. const weeks = rows.map(row => {
  95. const number = Number(row.zs);
  96. const start = text(row.rq).split('/')[0];
  97. if (!Number.isInteger(number) || number < 1 || !/^\d{4}-\d{2}-\d{2}$/.test(start)) {
  98. throw new Error('学期周次校历格式无效');
  99. }
  100. const date = new Date(`${start}T00:00:00Z`);
  101. if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== start) {
  102. throw new Error(`学期周次校历包含无效日期:${start}`);
  103. }
  104. return { number, start };
  105. }).sort((a, b) => a.number - b.number);
  106. weeks.forEach((week, index) => {
  107. if (week.number !== index + 1) throw new Error('学期周次校历的周次不连续');
  108. });
  109. // App 的 firstDayOfWeek 约定为 ISO 星期(1=周一 … 7=周日),由开学日当天推算。
  110. const firstDayOfWeek = (new Date(`${weeks[0].start}T00:00:00Z`).getUTCDay() + 6) % 7 + 1;
  111. return { startDate: weeks[0].start, totalWeeks: weeks.length, firstDayOfWeek };
  112. }
  113. function toMinutes(value) {
  114. const [hours, minutes] = value.split(':').map(Number);
  115. return hours * 60 + minutes;
  116. }
  117. // 同一课程可能被教务拆成多条记录(如 1-8周 与 9-16周、1-2节 与 3-4节),参考 QQHRIT 适配做两阶段合并:
  118. // 先并相接/重复的节次,再并同节次的周次,减少课表里的重复卡片。
  119. // 判等键包含自定义时间,作息不同的记录不会被错误合并。
  120. function mergeCourses(courses) {
  121. if (courses.length <= 1) return courses;
  122. const keyOf = c => [c.name, c.teacher, c.position, c.day,
  123. c.isCustomTime ? `${c.customStartTime}-${c.customEndTime}` : ''].join('\u0001');
  124. const compareKey = (a, b) => {
  125. const ka = keyOf(a), kb = keyOf(b);
  126. return ka < kb ? -1 : ka > kb ? 1 : 0;
  127. };
  128. const compareText = (x, y) => x < y ? -1 : x > y ? 1 : 0;
  129. const byStart = [...courses].sort((a, b) => compareKey(a, b)
  130. || compareText(a.weeks.join(','), b.weeks.join(','))
  131. || a.startSection - b.startSection);
  132. const step1 = [];
  133. for (const c of byStart) {
  134. const prev = step1[step1.length - 1];
  135. if (prev && compareKey(prev, c) === 0 && prev.weeks.join(',') === c.weeks.join(',')
  136. && c.startSection <= prev.endSection + 1) {
  137. if (c.endSection > prev.endSection) prev.endSection = c.endSection;
  138. continue;
  139. }
  140. step1.push({ ...c, weeks: [...c.weeks] });
  141. }
  142. step1.sort((a, b) => compareKey(a, b) || a.startSection - b.startSection || a.endSection - b.endSection);
  143. const step2 = [];
  144. for (const c of step1) {
  145. const prev = step2[step2.length - 1];
  146. if (prev && compareKey(prev, c) === 0
  147. && prev.startSection === c.startSection && prev.endSection === c.endSection) {
  148. prev.weeks = [...new Set([...prev.weeks, ...c.weeks])].sort((a, b) => a - b);
  149. continue;
  150. }
  151. step2.push(c);
  152. }
  153. return step2;
  154. }
  155. // 参考 QQHRIT 适配:抓取课表查询页 HTML,读出学年学期下拉框的选项与默认选中项,
  156. // 让用户在弹窗里确认或修改,无需事先停留在课表页面。
  157. async function fetchAcademicOptions() {
  158. // 课表查询页地址必须带其菜单功能代码,否则服务端直接报错;N253508 为个人课表查询页面的菜单代码。
  159. const response = await fetch('/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N253508&layout=default', { credentials: 'same-origin' });
  160. if (!response.ok) throw new Error(`读取学年学期选项失败(HTTP ${response.status})`);
  161. const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
  162. const readOptions = selector => {
  163. const select = doc.querySelector(selector);
  164. if (!select) return [];
  165. const options = [];
  166. for (const option of select.querySelectorAll('option')) {
  167. if (!option.value) continue;
  168. options.push({ value: option.value, label: text(option.textContent), selected: option.selected || option.hasAttribute('selected') });
  169. }
  170. return options;
  171. };
  172. const years = readOptions('#xnm');
  173. const semesters = readOptions('#xqm');
  174. if (!years.length || !semesters.length) throw new Error('读取学年学期选项失败,请确认登录未过期');
  175. return { years, semesters };
  176. }
  177. async function selectYearSemester() {
  178. const { years, semesters } = await fetchAcademicOptions();
  179. const selectedYear = years.findIndex(o => o.selected);
  180. // 学年选项跨度通常很大,以当前学年为中心截取前后各两年,默认选中原学年。
  181. const start = Math.max(0, (selectedYear === -1 ? 0 : selectedYear) - 2);
  182. const yearChoices = years.slice(start, start + 5);
  183. const pickedYear = await bridge.showSingleSelection('选择学年', JSON.stringify(yearChoices.map(o => o.label)), selectedYear === -1 ? 0 : selectedYear - start);
  184. if (pickedYear === null || pickedYear === -1) return null;
  185. if (!Number.isInteger(pickedYear) || !yearChoices[pickedYear]) throw new Error('学年选择无效');
  186. const selectedSemester = semesters.findIndex(o => o.selected);
  187. const pickedSemester = await bridge.showSingleSelection('选择学期', JSON.stringify(semesters.map(o => `第 ${o.label} 学期`)), selectedSemester === -1 ? 0 : selectedSemester);
  188. if (pickedSemester === null || pickedSemester === -1) return null;
  189. if (!Number.isInteger(pickedSemester) || !semesters[pickedSemester]) throw new Error('学期选择无效');
  190. return { xnm: yearChoices[pickedYear].value, xnmText: yearChoices[pickedYear].label, xqm: semesters[pickedSemester].value, xqmText: semesters[pickedSemester].label };
  191. }
  192. async function requestJson(path, params) {
  193. const controller = new AbortController();
  194. const timer = setTimeout(() => controller.abort(), 20000);
  195. try {
  196. const response = await fetch(path, {
  197. method: 'POST', credentials: 'same-origin',
  198. headers: {
  199. 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
  200. 'X-Requested-With': 'XMLHttpRequest'
  201. },
  202. body: new URLSearchParams(params).toString(), signal: controller.signal
  203. });
  204. if (!response.ok) throw new Error(`教务请求失败(HTTP ${response.status})`);
  205. let data;
  206. try {
  207. data = await response.json();
  208. } catch (_) {
  209. throw new Error('教务系统没有返回 JSON,请确认登录未过期后重试');
  210. }
  211. if (!data || typeof data !== 'object') throw new Error('教务系统未返回有效数据,请检查登录状态及查询权限');
  212. return data;
  213. } catch (error) {
  214. if (error.name === 'AbortError') throw new Error('教务请求超时,请稍后重试');
  215. throw error;
  216. } finally {
  217. clearTimeout(timer);
  218. }
  219. }
  220. async function save(method, value) {
  221. const result = await bridge[method](JSON.stringify(value));
  222. if (result !== true) throw new Error(`${method} 未确认保存成功`);
  223. }
  224. try {
  225. if (window.location.hostname !== 'jwxt.scnu.edu.cn') {
  226. throw new Error('请先登录华师教务系统,停留在教务系统内任意页面后再导入');
  227. }
  228. const selection = await selectYearSemester();
  229. if (!selection) return;
  230. const { xnm, xnmText, xqm, xqmText } = selection;
  231. if (!/^\d{4}$/.test(xnm) || !xqm) throw new Error('学年学期选项无效');
  232. native.showToast('正在获取华师课表、校区作息与学期校历…');
  233. const data = await requestJson('/kbcx/xskbcx_cxXsgrkb.html', {
  234. xnm, xqm, kzlx: 'ck', xsdm: '', kclbdm: '', kclxdm: ''
  235. });
  236. if (!Array.isArray(data.kbList)) throw new Error('响应缺少课表数据,请确认登录未过期');
  237. if (data.sjkList != null && !Array.isArray(data.sjkList)) throw new Error('未排课数据格式异常');
  238. const other = (data.sjkList || []).map(c => text(`${text(c.kcmc) || '未命名课程'} ${text(c.qsjsz)}`));
  239. if (!data.kbList.length) {
  240. await bridge.showAlert('没有已排课程', ['所选学期没有可导入的已排课程,原课表未改动。', ...other].join('\n'), '确定');
  241. return;
  242. }
  243. const entries = parseCourses(data.kbList);
  244. const campuses = [...new Map(entries.map(e => [e.campusId, e.campusName])).entries()];
  245. // 校区作息与学期周次校历互不依赖,并行获取。
  246. const schedules = new Map();
  247. const fetchSchedule = async (id, name) => {
  248. try {
  249. schedules.set(id, parseTimeSlots(await requestJson('/kbcx/xskbcx_cxRjc.html', { xnm, xqm, xqh_id: id })));
  250. } catch (error) {
  251. throw new Error(`${name}校区:${error.message}`);
  252. }
  253. };
  254. const fetchCalendar = async () => {
  255. try {
  256. return parseWeekCalendar(await requestJson('/kbcx/xskbcxZccx_cxZcByXnxq.html', { xnm, xqm }));
  257. } catch (error) {
  258. throw new Error(`学期周次校历:${error.message}`);
  259. }
  260. };
  261. const [calendar] = await Promise.all([fetchCalendar(), ...campuses.map(([id, name]) => fetchSchedule(id, name))]);
  262. let presetIndex = 0;
  263. if (campuses.length > 1) {
  264. presetIndex = await bridge.showSingleSelection('默认作息校区', JSON.stringify(campuses.map(c => c[1])), 0);
  265. if (presetIndex === null || presetIndex === -1) return;
  266. if (!Number.isInteger(presetIndex) || !campuses[presetIndex]) throw new Error('校区选择无效');
  267. }
  268. const [presetCampus, presetName] = campuses[presetIndex];
  269. const timeSlots = schedules.get(presetCampus);
  270. const courses = entries.map(({ course, campusId }) => {
  271. const slots = schedules.get(campusId);
  272. const first = slots[course.startSection - 1];
  273. const last = slots[course.endSection - 1];
  274. if (!first || !last) throw new Error(`${course.name} 的节次超出校区作息范围`);
  275. if (campusId !== presetCampus && JSON.stringify(slots) !== JSON.stringify(timeSlots)) {
  276. return { ...course, isCustomTime: true, customStartTime: first.startTime, customEndTime: last.endTime };
  277. }
  278. return course;
  279. });
  280. const merged = mergeCourses(courses);
  281. const config = {
  282. semesterStartDate: calendar.startDate,
  283. semesterTotalWeeks: calendar.totalWeeks,
  284. defaultClassDuration: toMinutes(timeSlots[0].endTime) - toMinutes(timeSlots[0].startTime),
  285. defaultBreakDuration: timeSlots.length > 1 ? toMinutes(timeSlots[1].startTime) - toMinutes(timeSlots[0].endTime) : 10,
  286. firstDayOfWeek: calendar.firstDayOfWeek
  287. };
  288. const summary = [
  289. `${xnmText} 学年第 ${xqmText} 学期`,
  290. `${merged.length} 条排课记录;默认作息:${presetName}。`,
  291. `开学日期:${calendar.startDate},共 ${calendar.totalWeeks} 周。`,
  292. '将覆盖所选目标课表的课程、作息与学期配置',
  293. '本次仅导入个人课表主接口中的安排;独立实验预约安排请另行核对。'
  294. ];
  295. if (other.length) summary.push(`以下 ${other.length} 门课程未提供星期和节次,不会导入,请按实际安排手动添加:`, ...other);
  296. if (!await bridge.showAlert('确认导入华师课表', summary.join('\n\n'), '确认导入')) return;
  297. // 三个 Bridge 保存操作彼此独立,失败后不能宣称已全部完成或已回滚。
  298. saving = true;
  299. await save('saveImportedCourses', merged);
  300. await save('savePresetTimeSlots', timeSlots);
  301. await save('saveCourseConfig', config);
  302. await bridge.showAlert('华师课表导入完成', [
  303. `已导入 ${merged.length} 条排课记录及校区作息。`,
  304. `开学日期 ${calendar.startDate},共 ${calendar.totalWeeks} 周,已写入课表配置。`,
  305. '课程周次已按教务系统返回的数据导入。'
  306. ].join('\n\n'), '确定');
  307. native.notifyTaskCompletion();
  308. } catch (error) {
  309. const suffix = saving ? '\n保存已经开始,目标课表可能已部分更新。请检查后重新导入。' : '\n尚未写入目标课表。';
  310. await bridge.showAlert('华师课表导入失败', `${error.message}${suffix}`, '确定');
  311. } finally {
  312. delete window.__scnuImportRunning;
  313. }
  314. })();