sysu_01.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. // 中山大学研究生教务系统拾光课表适配脚本
  2. // 适配系统:cms.sysu.edu.cn /mk/student-web/(研究生课表查询)
  3. // 直接调用课表查询接口 selectStudentClassTable 抓取课表(同源 fetch,自动携带登录会话),不解析页面 DOM
  4. (function () {
  5. "use strict";
  6. // ---------- 通用工具 ----------
  7. function toast(message) {
  8. if (window.shiguangBridge && window.shiguangBridge.showToast) {
  9. window.shiguangBridge.showToast(message);
  10. } else {
  11. console.log("[SYSU]", message);
  12. }
  13. }
  14. async function alertUser(title, message) {
  15. if (window.shiguangBridgePromise && window.shiguangBridgePromise.showAlert) {
  16. return await window.shiguangBridgePromise.showAlert(title, message, "确定");
  17. }
  18. alert(title + "\n" + message);
  19. return true;
  20. }
  21. // ---------- 作息时间(中山大学研究生, 11 节) ----------
  22. const TIME_SLOTS = [
  23. { number: 1, startTime: "08:00", endTime: "08:45" },
  24. { number: 2, startTime: "08:55", endTime: "09:40" },
  25. { number: 3, startTime: "10:10", endTime: "10:55" },
  26. { number: 4, startTime: "11:05", endTime: "11:50" },
  27. { number: 5, startTime: "14:20", endTime: "15:05" },
  28. { number: 6, startTime: "15:15", endTime: "16:00" },
  29. { number: 7, startTime: "16:30", endTime: "17:15" },
  30. { number: 8, startTime: "17:25", endTime: "18:10" },
  31. { number: 9, startTime: "19:00", endTime: "19:45" },
  32. { number: 10, startTime: "19:55", endTime: "20:40" },
  33. { number: 11, startTime: "20:50", endTime: "21:35" }
  34. ];
  35. // ================================================================
  36. // 主路径: 接口直取
  37. // ================================================================
  38. const SCHEDULE_API = "/start-class/classTableInfo/selectStudentClassTable";
  39. const CALENDAR_API = "/base-info/school-calender"; // 校历
  40. const MENU_CODE = "byytxsd_xskbcx_week_query"; // 学生课表查询菜单 code
  41. // 响应 JSON 里的星期字段名 -> 拾光 day(1=周一..7=周日)
  42. const DAY_KEYS = {
  43. monday: 1, tuesday: 2, wednesday: 3, thursday: 4,
  44. friday: 5, saturday: 6, sunday: 7
  45. };
  46. /** 从当前页面自动探测学年学期, 如 "2026-1"; 找不到返回 null */
  47. function detectAcademicYear() {
  48. // 1) URL 参数
  49. const urlMatch = location.href.match(/academicYear=([^&]+)/);
  50. if (urlMatch) return decodeURIComponent(urlMatch[1]);
  51. // 2) 页面标题, 如 <p class="title">2026-1学期课程</p>
  52. const text = document.body ? document.body.innerText : "";
  53. const titleMatch = text.match(/(\d{4}-\d)\s*学期/);
  54. if (titleMatch) return titleMatch[1];
  55. // 3) 页面上的学年学期下拉框(已选中项)
  56. const selects = document.querySelectorAll("select");
  57. for (let i = 0; i < selects.length; i++) {
  58. const opt = selects[i].selectedOptions && selects[i].selectedOptions[0];
  59. if (opt) {
  60. const m = opt.value.match(/^\d{4}-\d$/);
  61. if (m) return opt.value;
  62. }
  63. }
  64. // 4) 页面任意文本中形如 2026-1 / 2026-2027-1 的学期串
  65. const anyMatch = text.match(/(20\d{2}-\d)(?![\d-])/);
  66. if (anyMatch) return anyMatch[1];
  67. return null;
  68. }
  69. /** 从当前日期推算学年候选列表(登录后系统默认停在上学期, 假期导课表时要用新学期) */
  70. function buildSemesterCandidates(detected) {
  71. let baseYear = null;
  72. const dm = String(detected || "").match(/^(\d{4})-(\d)$/);
  73. if (dm) {
  74. baseYear = parseInt(dm[1], 10);
  75. } else {
  76. const now = new Date();
  77. const year = now.getFullYear();
  78. const month = now.getMonth() + 1; // 1..12
  79. // 学年 Y 覆盖 当年9月 ~ 次年8月; 9月(含)前属于上学年
  80. baseYear = month >= 9 ? year : year - 1;
  81. }
  82. const out = [];
  83. for (let y = baseYear - 1; y <= baseYear + 1; y++) {
  84. out.push(y + "-1");
  85. out.push(y + "-2");
  86. }
  87. return out;
  88. }
  89. /** 弹出学期选择, 返回选中的学年学期; 取消返回 null */
  90. async function selectSemester(detected) {
  91. const candidates = buildSemesterCandidates(detected);
  92. let defaultIndex = 0;
  93. if (detected) {
  94. const idx = candidates.indexOf(detected);
  95. if (idx >= 0) defaultIndex = idx;
  96. else candidates.unshift(detected);
  97. }
  98. const selected = await window.shiguangBridgePromise.showSingleSelection(
  99. "选择学年学期",
  100. JSON.stringify(candidates),
  101. defaultIndex
  102. );
  103. if (selected === null || selected === undefined) return null;
  104. return candidates[parseInt(selected, 10)];
  105. }
  106. async function fetchSchedule(academicYear) {
  107. const url = SCHEDULE_API +
  108. "?code=" + MENU_CODE +
  109. "&academicYear=" + encodeURIComponent(academicYear) +
  110. "&weekly=0&_t=" + Date.now();
  111. const resp = await fetch(url, {
  112. method: "GET",
  113. credentials: "include",
  114. headers: { "X-Requested-With": "XMLHttpRequest" }
  115. });
  116. if (!resp.ok) throw new Error("接口请求失败: HTTP " + resp.status);
  117. const json = await resp.json();
  118. if (json.code !== 200) {
  119. throw new Error("接口返回异常: code=" + json.code + (json.message ? " " + json.message : ""));
  120. }
  121. if (!Array.isArray(json.data)) {
  122. throw new Error("接口返回的 data 不是数组");
  123. }
  124. return json.data;
  125. }
  126. /** 拉第 1 周周一作为开学日 */
  127. async function fetchSemesterStartDate(academicYear) {
  128. const url = CALENDAR_API +
  129. "?academicYear=" + encodeURIComponent(academicYear) +
  130. "&weekly=1&_t=" + Date.now();
  131. const resp = await fetch(url, {
  132. method: "GET",
  133. credentials: "include",
  134. headers: { "X-Requested-With": "XMLHttpRequest" }
  135. });
  136. if (!resp.ok) throw new Error("HTTP " + resp.status);
  137. const json = await resp.json();
  138. const start = json && json.data && json.data.startTime;
  139. if (json.code !== 200 || !start || !/^\d{4}-\d{2}-\d{2}$/.test(String(start))) {
  140. throw new Error("校历未返回有效 startTime");
  141. }
  142. return String(start);
  143. }
  144. /**
  145. * 解析接口返回的字段串。
  146. * 接口每个星期字段(如 tuesday)的值是若干 "键:值" 对用 ";;" 连接的字符串,
  147. * 例如: "kcmc:课程名;;rkjs:教师;;skdd:地点;;zs:1-12周;;js:周二 2-4节;;..."。
  148. * 本函数按 ";;" 切分后提取各键值, 返回 { name, teacher, position, weeks, startSection, endSection }。
  149. */
  150. function parseCourseField(fieldText) {
  151. const fields = {};
  152. String(fieldText).split(";;").forEach(function (part) {
  153. const idx = part.indexOf(":");
  154. if (idx <= 0) return;
  155. fields[part.slice(0, idx).trim()] = part.slice(idx + 1).trim();
  156. });
  157. const name = fields.kcmc;
  158. if (!name) return null;
  159. // 周次范围: 取 zs(如 "1-12周"); 个别学期 zs 缺失时回退到 sksj(如 "1-12每周(2-4节)")
  160. // 中同样的 "x-y周" 片段。
  161. const zsText = String(fields.zs || fields.sksj || "");
  162. const zsMatch = zsText.match(/(\d+)\s*-\s*(\d+)\s*每?周/) || zsText.match(/(\d+)\s*每?周/);
  163. if (!zsMatch) {
  164. console.warn("[SYSU] 无法解析周次:", zsText || "(空)", "字段:", fieldText);
  165. return null;
  166. }
  167. const weekStart = parseInt(zsMatch[1], 10);
  168. const weekEnd = zsMatch[2] ? parseInt(zsMatch[2], 10) : weekStart;
  169. const weeks = [];
  170. for (let w = weekStart; w <= weekEnd; w++) weeks.push(w);
  171. if (!weeks.length) {
  172. console.warn("[SYSU] 周次区间为空:", zsText);
  173. return null;
  174. }
  175. // 节次: js 形如 "周二 2-4节" 或 "周二 3节"; 星期由外层 JSON 键决定。
  176. const jsText = String(fields.js || "");
  177. const jsMatch = jsText.match(/(\d+)\s*-\s*(\d+)\s*节/) || jsText.match(/(\d+)\s*节/);
  178. if (!jsMatch) {
  179. console.warn("[SYSU] 无法解析节次:", jsText || "(空)", "字段:", fieldText);
  180. return null;
  181. }
  182. const startSection = parseInt(jsMatch[1], 10);
  183. const endSection = jsMatch[2] ? parseInt(jsMatch[2], 10) : startSection;
  184. if (!startSection || !endSection || endSection < startSection) {
  185. console.warn("[SYSU] 节次区间无效:", jsText);
  186. return null;
  187. }
  188. return {
  189. name: name,
  190. teacher: fields.rkjs || "未知教师",
  191. position: fields.skdd || "未知地点",
  192. weeks: weeks,
  193. startSection: startSection,
  194. endSection: endSection
  195. };
  196. }
  197. /** 遍历响应数组(每项 = 某周某节次的当日课程), 提取全部上课并去重 */
  198. function buildCoursesFromApi(data) {
  199. const meetings = [];
  200. let maxWeek = 0;
  201. data.forEach(function (entry) {
  202. if (!entry) return;
  203. if (entry.weekly > maxWeek) maxWeek = entry.weekly;
  204. Object.keys(DAY_KEYS).forEach(function (dayKey) {
  205. const field = entry[dayKey];
  206. if (!field || typeof field !== "string" || field.length < 4) return;
  207. const course = parseCourseField(field);
  208. if (!course) return;
  209. course.weeks.forEach(function (w) {
  210. if (w > maxWeek) maxWeek = w;
  211. });
  212. meetings.push({
  213. name: course.name,
  214. teacher: course.teacher,
  215. position: course.position,
  216. day: DAY_KEYS[dayKey],
  217. startSection: course.startSection,
  218. endSection: course.endSection,
  219. weeks: course.weeks
  220. });
  221. });
  222. });
  223. return { meetings: dedupe(meetings), maxWeek: maxWeek };
  224. }
  225. function dedupe(meetings) {
  226. const seen = {};
  227. const out = [];
  228. meetings.forEach(function (m) {
  229. const key = [m.day, m.startSection, m.endSection, m.name, m.weeks.join(","), m.teacher, m.position].join("|");
  230. if (seen[key]) return;
  231. seen[key] = true;
  232. out.push(m);
  233. });
  234. return out.sort(function (a, b) {
  235. return a.day - b.day || a.startSection - b.startSection;
  236. });
  237. }
  238. // ---------- 保存 ----------
  239. async function saveToApp(courses, timeSlots, maxWeek, startDate) {
  240. const config = {
  241. semesterTotalWeeks: maxWeek > 0 ? maxWeek : 17,
  242. firstDayOfWeek: 1,
  243. defaultClassDuration: 45,
  244. defaultBreakDuration: 10
  245. };
  246. if (startDate) config.semesterStartDate = startDate;
  247. if (window.shiguangBridgePromise && window.shiguangBridgePromise.saveCourseConfig) {
  248. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  249. }
  250. if (timeSlots.length && window.shiguangBridgePromise && window.shiguangBridgePromise.savePresetTimeSlots) {
  251. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  252. }
  253. if (window.shiguangBridgePromise && window.shiguangBridgePromise.saveImportedCourses) {
  254. return await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  255. }
  256. console.log("[SYSU] parsed courses:", JSON.stringify(courses, null, 2));
  257. return true;
  258. }
  259. // ---------- 主流程 ----------
  260. async function runImportFlow() {
  261. try {
  262. // 1. 确定学年学期: 自动探测 + 弹窗让用户确认/修改
  263. // (假期登录时系统默认是上学期, 必须允许用户选到目标学期)
  264. let academicYear = detectAcademicYear();
  265. if (window.shiguangBridgePromise && window.shiguangBridgePromise.showSingleSelection) {
  266. const picked = await selectSemester(academicYear);
  267. if (picked === null) return; // 用户取消
  268. academicYear = picked;
  269. } else if (!academicYear) {
  270. // 无 bridge 且页面也识别不到学期: 无法继续
  271. await alertUser("未识别到学年学期", "请先进入目标学期的「课表查询」页面再执行导入。");
  272. return;
  273. }
  274. // 2. 接口直取(主路径)
  275. let courses = null, maxWeek = 0;
  276. try {
  277. toast("正在从教务接口获取 " + academicYear + " 课表...");
  278. const data = await fetchSchedule(academicYear);
  279. const result = buildCoursesFromApi(data);
  280. if (result.meetings.length) {
  281. courses = result.meetings;
  282. maxWeek = result.maxWeek;
  283. toast("接口获取成功, 一周共 " + courses.length + " 次上课");
  284. } else {
  285. console.warn("[SYSU] 接口返回空数据: academicYear=" + academicYear);
  286. }
  287. } catch (e) {
  288. console.warn("[SYSU] 接口获取失败:", e);
  289. }
  290. if (!courses || !courses.length) {
  291. await alertUser(
  292. "未解析到课程",
  293. "所选学期 " + academicYear + " 没有返回课程。\n\n可能原因:\n" +
  294. "1. 该学期课程尚未排课/选课(假期导入新学期时常见, 可等开学前选课后重试);\n" +
  295. "2. 学期代码不对(请重新执行导入, 在选择学期时核对);\n" +
  296. "3. 登录状态失效或接口异常(请重新进入课表页面登录后再试; 若仍失败, 请向适配维护者反馈)。"
  297. );
  298. return;
  299. }
  300. // 3. 校历
  301. let startDate = null;
  302. try {
  303. startDate = await fetchSemesterStartDate(academicYear);
  304. toast("已获取开学日 " + startDate);
  305. } catch (e) {
  306. console.warn("[SYSU] 校历获取失败, 跳过开学日:", e);
  307. }
  308. // 4. 保存
  309. const saved = await saveToApp(courses, TIME_SLOTS, maxWeek, startDate);
  310. if (!saved) { toast("课程保存失败, 请重试"); return; }
  311. toast("导入成功: " + courses.length + " 个课程时段" +
  312. (startDate ? ", 开学日 " + startDate : "") +
  313. ", 已同步作息时间");
  314. if (window.shiguangBridge && window.shiguangBridge.notifyTaskCompletion) {
  315. window.shiguangBridge.notifyTaskCompletion();
  316. }
  317. } catch (error) {
  318. console.error("[SYSU] import failed:", error);
  319. await alertUser("导入失败", error && error.message ? error.message : String(error));
  320. }
  321. }
  322. runImportFlow();
  323. })();