shnu.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. // 上海师范大学(course.shnu.edu.cn) 时光课程表适配脚本
  2. // 适配对象: 树维EAMS经典版 (courseTableForStd)
  3. // 登录方式: 统一身份认证(cas.shnu.edu.cn), 登录成功回到教务系统页面后点击导入
  4. // 非该大学开发者适配,开发者无法及时发现问题
  5. // 出现问题请提issues或者提交pr更改,这更加快速
  6. const BASE_URL = "https://course.shnu.edu.cn/eams";
  7. // 上师大每天 14 节(EAMS unitCount 默认 14), 作息时间参考上师大教务处公开作息
  8. const DEFAULT_UNIT_COUNT = 14;
  9. // 节次 → 时间 (第 N 节), 与教务系统课表一致
  10. const SHNU_TIME_SLOTS = [
  11. { "number": 1, "startTime": "08:00", "endTime": "08:45" },
  12. { "number": 2, "startTime": "08:50", "endTime": "09:30" },
  13. { "number": 3, "startTime": "09:45", "endTime": "10:30" },
  14. { "number": 4, "startTime": "10:35", "endTime": "11:15" },
  15. { "number": 5, "startTime": "11:25", "endTime": "12:10" },
  16. { "number": 6, "startTime": "13:00", "endTime": "13:45" },
  17. { "number": 7, "startTime": "13:50", "endTime": "14:30" },
  18. { "number": 8, "startTime": "14:45", "endTime": "15:30" },
  19. { "number": 9, "startTime": "15:35", "endTime": "16:15" },
  20. { "number": 10, "startTime": "16:25", "endTime": "17:10" },
  21. { "number": 11, "startTime": "18:00", "endTime": "18:45" },
  22. { "number": 12, "startTime": "18:50", "endTime": "19:30" },
  23. { "number": 13, "startTime": "19:40", "endTime": "20:25" },
  24. { "number": 14, "startTime": "20:30", "endTime": "21:10" }
  25. ];
  26. /**
  27. * 统一网络请求(携带登录 Cookie)
  28. */
  29. async function request(url, options = {}) {
  30. const res = await fetch(url, { credentials: "include", ...options });
  31. if (!res.ok) throw new Error(`网络请求失败: ${res.status}`);
  32. return await res.text();
  33. }
  34. /**
  35. * 获取课表初始化页面, 优先走 AJAX 接口(无冗余布局), 失败则回退完整页面
  36. */
  37. async function fetchCourseTablePage() {
  38. try {
  39. return await request(`${BASE_URL}/courseTableForStd.action?sf_request_type=ajax`);
  40. } catch (e) {
  41. return await request(`${BASE_URL}/courseTableForStd.action`);
  42. }
  43. }
  44. /**
  45. * 从课表初始化页面提取关键参数
  46. * ids: 个人课表id(第一个匹配为 std 个人课表, 第二个为 class 班级课表)
  47. * tagId: 学期选择条 id, 用于 dataQuery 查询全部学期
  48. * currentSemesterId: 页面隐藏域中的当前学期 id
  49. */
  50. function detectParameters(html) {
  51. const idsMatch = html.match(/bg\.form\.addInput\(form,\s*"ids"\s*,\s*"(\d+)"\)/);
  52. if (!idsMatch) return null;
  53. const tagIdMatch = html.match(/id="(semesterBar\d+Semester)"/);
  54. const semMatch = html.match(/name="semester\.id"\s+value="(\d+)"/);
  55. return {
  56. ids: idsMatch[1],
  57. tagId: tagIdMatch ? tagIdMatch[1] : null,
  58. currentSemesterId: semMatch ? semMatch[1] : null
  59. };
  60. }
  61. /**
  62. * 通过 dataQuery 接口获取所有学期, 解析失败时返回 null
  63. */
  64. function parseSemesterCalendar(raw) {
  65. // 树维返回的是不带引号 key 的 JS 字面量, 用 Function 求值
  66. try {
  67. const data = Function("return (" + raw + ");")();
  68. if (data && data.semesters) return data.semesters;
  69. } catch (e) { /* 忽略, 走正则兜底 */ }
  70. const m = raw.match(/semesters\s*:\s*(\{[\s\S]*\})/);
  71. if (m) {
  72. try {
  73. const data = Function("return ({semesters: " + m[1] + "});")();
  74. if (data && data.semesters) return data.semesters;
  75. } catch (e) { /* 兜底也失败 */ }
  76. }
  77. return null;
  78. }
  79. /**
  80. * 获取学期列表并让用户选择
  81. * 默认选中当前学期(id 与页面隐藏域一致), 其次为 id 最大的(最新)学期
  82. */
  83. async function getSelectedSemester(tagId, currentSemesterId) {
  84. let list = [];
  85. if (tagId) {
  86. try {
  87. const raw = await request(`${BASE_URL}/dataQuery.action?sf_request_type=ajax`, {
  88. method: "POST",
  89. headers: { "Content-Type": "application/x-www-form-urlencoded" },
  90. body: `tagId=${encodeURIComponent(tagId)}&dataType=semesterCalendar&empty=false`
  91. });
  92. const semesters = parseSemesterCalendar(raw);
  93. if (semesters) {
  94. const seen = {};
  95. for (const key in semesters) {
  96. const arr = semesters[key];
  97. if (!Array.isArray(arr)) continue;
  98. arr.forEach(s => {
  99. if (!s || s.id == null || seen[s.id]) return;
  100. seen[s.id] = true;
  101. list.push({ id: String(s.id), name: `${s.schoolYear} ${s.name}学期` });
  102. });
  103. }
  104. // id 越大学期越新, 按新→旧排列
  105. list.sort((a, b) => (parseInt(b.id) || 0) - (parseInt(a.id) || 0));
  106. }
  107. } catch (e) { /* dataQuery 失败, 走兜底 */ }
  108. }
  109. // 兜底: 仅使用页面自带的当前学期
  110. if (list.length === 0) {
  111. if (!currentSemesterId) {
  112. throw new Error("无法获取学期列表, 请确认已登录教务系统");
  113. }
  114. list = [{ id: currentSemesterId, name: "当前学期" }];
  115. }
  116. // 计算默认选中项: 当前学期优先
  117. let defaultIndex = 0;
  118. if (currentSemesterId) {
  119. const idx = list.findIndex(s => s.id === currentSemesterId);
  120. if (idx >= 0) defaultIndex = idx;
  121. }
  122. const idx = await window.shiguangBridgePromise.showSingleSelection(
  123. "选择学期", JSON.stringify(list.map(s => s.name)), defaultIndex
  124. );
  125. return idx !== null ? list[idx] : null;
  126. }
  127. /**
  128. * 拉取指定学期的完整课表页面(包含全部周次的 TaskActivity 数据)
  129. */
  130. async function fetchCourseTableHtml(semesterId, ids) {
  131. return await request(`${BASE_URL}/courseTableForStd!courseTable.action?sf_request_type=ajax`, {
  132. method: "POST",
  133. headers: { "Content-Type": "application/x-www-form-urlencoded" },
  134. body: `ignoreHead=1&setting.kind=std&startWeek=&project.id=1&semester.id=${encodeURIComponent(semesterId)}&ids=${encodeURIComponent(ids)}`
  135. });
  136. }
  137. /**
  138. * 解析课表 HTML 中的 TaskActivity JS 数据 (上师大版本)
  139. *
  140. * 树维 EAMS 页面结构:
  141. * var teachers = new Array();
  142. * var actTeachers = new Array();
  143. * teachers[0] = "张三";
  144. * actTeachers[0] = {id: 123, name: "张三"};
  145. * activity = new TaskActivity(参数中引号字段依次为: [0]编号? [1]课程名 [2]? [3]地点 [4]周次bitmap [5]...);
  146. * index = day * unitCount + slot; // day: 0=周一, slot: 0-based 节次
  147. *
  148. * 周次 bitmap: 第 i 位为 '1' 表示第 i 周有课(第 0 位恒为 '0')
  149. */
  150. function parseTaskActivities(html) {
  151. const rawResults = [];
  152. const unitCountMatch = html.match(/unitCount\s*=\s*(\d+)/);
  153. const unitCount = unitCountMatch ? parseInt(unitCountMatch[1]) : DEFAULT_UNIT_COUNT;
  154. const blocks = html.split(/activity\s*=\s*new\s+TaskActivity\s*\(/);
  155. for (let i = 1; i < blocks.length; i++) {
  156. const block = blocks[i];
  157. // 教师来自上一个分块末尾的 actTeachers 赋值(即本课程对应的教师数组)
  158. let teacher = "";
  159. const prevBlock = blocks[i - 1];
  160. // 风格一: 数组字面量 actTeachers = [{id:.., name:"张三"}, ...]
  161. const teacherAssigns = prevBlock.match(/actTeachers\s*=\s*\[([\s\S]*?)\]/g);
  162. if (teacherAssigns && teacherAssigns.length > 0) {
  163. const names = teacherAssigns[teacherAssigns.length - 1].match(/name\s*:\s*"([^"]*)"/g);
  164. if (names) {
  165. teacher = names.map(n => n.replace(/name\s*:\s*"/, "").replace(/"$/, "")).join(",");
  166. }
  167. }
  168. // 风格二: 元素赋值 actTeachers[0] = {id:.., name:"张三"};
  169. if (!teacher) {
  170. const elemAssigns = prevBlock.match(/actTeachers\[\d+\]\s*=\s*\{[^}]*\}/g);
  171. if (elemAssigns && elemAssigns.length > 0) {
  172. const names = elemAssigns.join(",").match(/name\s*:\s*"([^"]*)"/g);
  173. if (names) {
  174. teacher = names.map(n => n.replace(/name\s*:\s*"/, "").replace(/"$/, "")).join(",");
  175. }
  176. }
  177. }
  178. // 提取 TaskActivity(...) 参数 (截到第一个 ");")
  179. const argsMatch = block.match(/^([\s\S]*?)\)\s*;/);
  180. if (!argsMatch) continue;
  181. const argsStr = argsMatch[1];
  182. const quoted = [];
  183. const quotedMatches = argsStr.match(/"([^"]*)"/g);
  184. if (quotedMatches) {
  185. quotedMatches.forEach(q => quoted.push(q.substring(1, q.length - 1)));
  186. }
  187. if (quoted.length < 5) continue;
  188. // 课程名: 去掉末尾的 "(xx)" 班级号后缀
  189. let courseName = (quoted[1] || "").trim();
  190. courseName = courseName.replace(/\([^)]*\)\s*$/, "").trim();
  191. if (!courseName) courseName = "未知课程";
  192. // 周次 bitmap: 优先取第 5 个引号字段, 不符时回退为首个形如 0/1 串的字段
  193. let weeksBitmap = quoted[4];
  194. if (!/^[01]+$/.test(weeksBitmap)) {
  195. const bitmapIdx = quoted.findIndex(q => /^[01]{6,}$/.test(q));
  196. if (bitmapIdx < 0) continue;
  197. weeksBitmap = quoted[bitmapIdx];
  198. }
  199. // 地点: bitmap 前一个引号字段 (标准结构), 兜底取第 4 个
  200. let position = quoted[3] || "";
  201. const finalBitmapIdx = quoted.indexOf(weeksBitmap);
  202. if (finalBitmapIdx > 0 && finalBitmapIdx !== 4) {
  203. position = quoted[finalBitmapIdx - 1] || position;
  204. }
  205. position = position.replace(/\(.*?\)/g, "").trim() || "未知地点";
  206. // 周次: bitmap 第 i 位 = 第 i 周
  207. const weeks = [];
  208. for (let j = 0; j < weeksBitmap.length; j++) {
  209. if (weeksBitmap.charAt(j) === "1") weeks.push(j);
  210. }
  211. if (weeks.length === 0) continue;
  212. // 上课时间: index = day * unitCount + slot
  213. const idxRegex = /index\s*=\s*(\d+)\s*\*\s*unitCount\s*\+\s*(\d+)\s*;/g;
  214. let m;
  215. while ((m = idxRegex.exec(block)) !== null) {
  216. rawResults.push({
  217. name: courseName,
  218. teacher: teacher || "未知教师",
  219. position: position,
  220. day: parseInt(m[1]) + 1, // 1=周一 ... 7=周日
  221. startSection: parseInt(m[2]) + 1, // 1-based 节次
  222. endSection: parseInt(m[2]) + 1,
  223. weeks: weeks
  224. });
  225. }
  226. }
  227. return mergeContinuousLessons(rawResults);
  228. }
  229. /**
  230. * 全局课程合并逻辑
  231. */
  232. function mergeContinuousLessons(lessons) {
  233. if (!lessons || lessons.length === 0) return [];
  234. // 1. 建立基于 (课程名|教师|地点|星期几) 的分组
  235. const groups = {};
  236. lessons.forEach(l => {
  237. const key = `${l.name}|${l.teacher}|${l.position}|${l.day}`;
  238. if (!groups[key]) {
  239. groups[key] = {
  240. name: l.name,
  241. teacher: l.teacher,
  242. position: l.position,
  243. day: l.day,
  244. // 假设大学最多 50 周,构建一个:第 N 周对应哪些节次的矩阵
  245. weeksMatrix: Array.from({ length: 50 }, () => new Set())
  246. };
  247. }
  248. // 将系统传来的凌乱数据彻底打散,按“周”填入对应的“节”中,Set自动去重
  249. if (l.weeks && Array.isArray(l.weeks)) {
  250. l.weeks.forEach(w => {
  251. if (w >= 0 && w < 50) {
  252. for (let s = l.startSection; s <= l.endSection; s++) {
  253. groups[key].weeksMatrix[w].add(s);
  254. }
  255. }
  256. });
  257. }
  258. });
  259. const merged = [];
  260. // 2. 根据矩阵重新组装绝对精确的课程块
  261. for (const key in groups) {
  262. const group = groups[key];
  263. const matrix = group.weeksMatrix;
  264. // 用于记录相同的“连续节次块”分布在哪些周次
  265. // 例如 blockMap["1-2"] = [1, 2, 3, 4, 5, 6, 7, 8, 9]
  266. // 例如 blockMap["2-2"] = [10]
  267. const blockMap = {};
  268. for (let w = 0; w < matrix.length; w++) {
  269. const sections = Array.from(matrix[w]).sort((a, b) => a - b);
  270. if (sections.length === 0) continue;
  271. // 寻找当前周的连续节次块
  272. let start = sections[0];
  273. let prev = sections[0];
  274. for (let i = 1; i < sections.length; i++) {
  275. const curr = sections[i];
  276. if (curr === prev + 1) {
  277. prev = curr; // 节次连续,继续延伸
  278. } else {
  279. // 节次断开,结算上一个块
  280. const blockKey = `${start}-${prev}`;
  281. if (!blockMap[blockKey]) blockMap[blockKey] = [];
  282. blockMap[blockKey].push(w);
  283. // 开启新块
  284. start = curr;
  285. prev = curr;
  286. }
  287. }
  288. // 结算每周最后一个块
  289. const blockKey = `${start}-${prev}`;
  290. if (!blockMap[blockKey]) blockMap[blockKey] = [];
  291. blockMap[blockKey].push(w);
  292. }
  293. // 3. 将聚合好的 blockMap 转换为最终的 JSON 对象
  294. for (const blockKey in blockMap) {
  295. const [startSec, endSec] = blockKey.split('-').map(Number);
  296. merged.push({
  297. name: group.name,
  298. teacher: group.teacher,
  299. position: group.position,
  300. day: group.day,
  301. startSection: startSec,
  302. endSection: endSec,
  303. weeks: blockMap[blockKey]
  304. });
  305. }
  306. }
  307. // 4. 排序以便输出整洁美观
  308. merged.sort((a, b) => {
  309. if (a.day !== b.day) return a.day - b.day;
  310. if (a.startSection !== b.startSection) return a.startSection - b.startSection;
  311. return a.name.localeCompare(b.name);
  312. });
  313. return merged;
  314. }
  315. /**
  316. * 导入上师大预设作息时间
  317. */
  318. async function applyTimeSlots() {
  319. return await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(SHNU_TIME_SLOTS));
  320. }
  321. /**
  322. * 编排整个课程导入流程。
  323. * 在任何一步用户取消或发生错误时,都会立即退出,notifyTaskCompletion()只在成功后调用
  324. */
  325. async function runImportFlow() {
  326. try {
  327. window.shiguangBridge.showToast("开始探测教务参数...");
  328. const html = await fetchCourseTablePage();
  329. const params = detectParameters(html);
  330. if (!params) throw new Error("未能识别教务参数, 请先登录教务系统再点击导入");
  331. window.shiguangBridge.showToast("正在获取学期列表...");
  332. const semester = await getSelectedSemester(params.tagId, params.currentSemesterId);
  333. if (!semester) return; // 用户取消
  334. window.shiguangBridge.showToast(`正在同步 ${semester.name} 课表...`);
  335. const tableHtml = await fetchCourseTableHtml(semester.id, params.ids);
  336. const courses = parseTaskActivities(tableHtml);
  337. if (!courses || courses.length === 0) throw new Error("未解析到课程数据, 可能为该学期无课或页面结构变化");
  338. await applyTimeSlots();
  339. const saveResult = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  340. if (saveResult) {
  341. window.shiguangBridge.showToast(`成功导入 ${courses.length} 个课程条目`);
  342. window.shiguangBridge.notifyTaskCompletion();
  343. }
  344. } catch (e) {
  345. console.error(`[SHNU适配器异常] ${e.message}`);
  346. window.shiguangBridge.showToast(e.message);
  347. }
  348. }
  349. runImportFlow();