cust_chaoyang.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. // 文件: cust_chaoyang.js
  2. // 长春理工大学朝阳校区 kbpro 课程表导入脚本
  3. //
  4. // 适用校区:朝阳校区;沿用现有第 1~12 节作息配置。
  5. // 导入学生个人课表,课程地点仍按接口返回的校区、教学楼和教室显示。
  6. // 导入流程:判断页面 → 获取学期和课程 → 合并课程 → 保存课程 → 保存每节作息 → 通知完成。
  7. // 数据分开保存:课程包含星期、周次和起止节次;作息表包含各节次的开始、结束时间。
  8. // 本脚本使用 V2 的 window.shiguangBridge / window.shiguangBridgePromise。
  9. // 课表页面用于建立当前登录会话;两个接口请求均使用该会话的 Cookie。
  10. const SCHEDULE_PAGE_URL = 'https://kbpro.cust.edu.cn/Schedule/';
  11. const SCHEDULE_INFO_API = 'https://kbpro.cust.edu.cn/Schedule/scheduleInfo';
  12. const SCHEDULE_JSON_API = 'https://kbpro.cust.edu.cn/Schedule/getSchedulejson';
  13. // 判断当前是否已经进入新版课表页面
  14. function isOnSchedulePage() {
  15. try {
  16. const url = new URL(window.location.href);
  17. return url.hostname.toLowerCase() === 'kbpro.cust.edu.cn'
  18. && url.pathname.toLowerCase().startsWith('/schedule');
  19. } catch (error) {
  20. return false;
  21. }
  22. }
  23. // 直接进入新版课表页面,不再进入旧教务系统 Student 页面
  24. // 跳转只负责打开目标页面;当前执行不会在跳转后接着请求数据,需要在新页面执行脚本。
  25. function redirectToSchedulePage() {
  26. console.log('当前不在新版课表页面,正在跳转:', SCHEDULE_PAGE_URL);
  27. window.shiguangBridge.showToast('正在打开课程表...');
  28. window.location.replace(SCHEDULE_PAGE_URL);
  29. }
  30. // 通用 GET JSON 请求。Cookie 由浏览器当前登录会话自动携带,不写死 JSESSIONID。
  31. async function fetchJson(url, description) {
  32. const response = await fetch(url, {
  33. method: 'GET',
  34. headers: {
  35. 'Accept': 'application/json, text/plain, */*',
  36. 'Cache-Control': 'no-cache',
  37. 'Pragma': 'no-cache'
  38. },
  39. credentials: 'include',
  40. cache: 'no-store'
  41. });
  42. if (!response.ok) {
  43. throw new Error(`${description}失败,HTTP ${response.status}`);
  44. }
  45. const contentType = response.headers.get('content-type') || '';
  46. // 登录失效时可能返回登录页 HTML,不能把它当作课程 JSON 继续导入。
  47. if (!contentType.toLowerCase().includes('application/json')) {
  48. throw new Error(`${description}返回的不是 JSON,可能登录状态已失效`);
  49. }
  50. return await response.json();
  51. }
  52. // 获取学期、开学日期、当前周以及周次日期映射
  53. // 当前实现只将这些信息输出到日志,没有调用 saveCourseConfig 保存学期开始日期或总周数。
  54. async function fetchScheduleInfo() {
  55. try {
  56. console.log('正在获取学期信息...');
  57. const data = await fetchJson(SCHEDULE_INFO_API, '获取学期信息');
  58. console.log('学期信息:', data);
  59. return data;
  60. } catch (error) {
  61. console.error('获取学期信息失败:', error);
  62. window.shiguangBridge.showToast('获取学期信息失败: ' + error.message);
  63. return null;
  64. }
  65. }
  66. // 获取课程明细
  67. // 预期返回课程数组:courseName/teacherName 为课程和教师,dayOfWeek 为星期,
  68. // beginSection/endSection 为节次,weekDescription 为周次位图,beginTime/endTime 为时刻。
  69. async function fetchScheduleData() {
  70. try {
  71. console.log('正在获取课程明细...');
  72. const data = await fetchJson(SCHEDULE_JSON_API, '获取课程数据');
  73. if (!Array.isArray(data)) {
  74. throw new Error('课程接口返回格式异常');
  75. }
  76. console.log(`成功获取 ${data.length} 条课程明细`);
  77. console.log('课程接口原始数据:', data);
  78. return data;
  79. } catch (error) {
  80. console.error('获取课程数据失败:', error);
  81. window.shiguangBridge.showToast('获取课程失败: ' + error.message);
  82. return null;
  83. }
  84. }
  85. // weekDescription 是 25 位周次位图。
  86. // 下标直接对应 scheduleInfo.dateList 中的 weekOrder:
  87. // 第 0 位 = weekOrder 0(开学前一周),正式导入时忽略;第 1 位 = 第 1 周,以此类推。
  88. // 例如 "0101..." 表示第 1、3 周有课;这是接口约定,不能把所有下标统一加 1。
  89. function parseWeekDescription(weekDescription) {
  90. if (typeof weekDescription !== 'string') {
  91. return [];
  92. }
  93. const weeks = [];
  94. for (let index = 1; index < weekDescription.length; index++) {
  95. if (weekDescription[index] === '1') {
  96. weeks.push(index);
  97. }
  98. }
  99. return weeks;
  100. }
  101. // 教师显示名称:保持接口第一次出现的顺序,只把空格统一成“、”
  102. // 重复姓名只保留一次,例如 "张老师 李老师 张老师" 显示为 "张老师、李老师"。
  103. function normalizeTeacherDisplay(teacherName) {
  104. if (!teacherName) {
  105. return '';
  106. }
  107. const names = teacherName
  108. .trim()
  109. .split(/\s+/)
  110. .filter(Boolean);
  111. return [...new Set(names)].join('、');
  112. }
  113. // 教师比较键:忽略多人教师在不同周返回时的排列顺序
  114. // "张老师 李老师" 与 "李老师 张老师" 可归为同一教师集合,显示名称仍保持首次顺序。
  115. function normalizeTeacherKey(teacherName) {
  116. if (!teacherName) {
  117. return '';
  118. }
  119. const names = teacherName
  120. .trim()
  121. .split(/\s+/)
  122. .filter(Boolean);
  123. return [...new Set(names)].sort().join('、');
  124. }
  125. // 拼接完整上课地点
  126. // 按校区、教学楼、教室依次拼接;空字段跳过,全部为空时显示“未指定”。
  127. function buildPosition(item) {
  128. return [item.campus, item.buildingName, item.classroomName]
  129. .map(value => (value || '').trim())
  130. .filter(Boolean)
  131. .join(' ') || '未指定';
  132. }
  133. // 基于主仓库教程的 mergeAndDistinctCourses:先合并节次与重复记录,再合并同节次的周次。
  134. // 来源:https://github.com/ShiGuangSchedule/shiguangschedule/wiki/课程合并与去重函数
  135. // kbpro 补充:周次先去重、处理重叠节次,并在周次合并后继续处理新形成的连续节次。
  136. function mergeAndDistinctCourses(courses) {
  137. if (!Array.isArray(courses)) {
  138. return [];
  139. }
  140. // 复制课程和周次数组,避免合并过程修改原始数据。
  141. let list = courses.map(course => ({
  142. ...course,
  143. name: course.name || '',
  144. teacher: course.teacher || '',
  145. position: course.position || '',
  146. weeks: Array.isArray(course.weeks)
  147. ? Array.from(new Set(course.weeks)).sort((a, b) => a - b)
  148. : []
  149. }));
  150. if (list.length <= 1) {
  151. return list;
  152. }
  153. let previousCount;
  154. do {
  155. previousCount = list.length;
  156. // 阶段 1:名称、教师、地点、星期、周次一致时合并连续节次,并去掉重复或重叠部分。
  157. list.sort((a, b) =>
  158. a.name.localeCompare(b.name)
  159. || a.teacher.localeCompare(b.teacher)
  160. || a.position.localeCompare(b.position)
  161. || a.day - b.day
  162. || a.weeks.join(',').localeCompare(b.weeks.join(','))
  163. || a.startSection - b.startSection
  164. || a.endSection - b.endSection
  165. );
  166. const step1Merged = [];
  167. let current = list[0];
  168. for (let index = 1; index < list.length; index++) {
  169. const next = list[index];
  170. const isSameCourseAndWeeks = current.name === next.name
  171. && current.teacher === next.teacher
  172. && current.position === next.position
  173. && current.day === next.day
  174. && current.weeks.join(',') === next.weeks.join(',');
  175. // 已按开始节次排序,因此该条件同时覆盖连续、完全重复和部分重叠的节次。
  176. if (isSameCourseAndWeeks && next.startSection <= current.endSection + 1) {
  177. current.endSection = Math.max(current.endSection, next.endSection);
  178. } else {
  179. step1Merged.push(current);
  180. current = next;
  181. }
  182. }
  183. step1Merged.push(current);
  184. // 阶段 2:名称、教师、地点、星期及起止节次一致时,合并并排序上课周次。
  185. step1Merged.sort((a, b) =>
  186. a.name.localeCompare(b.name)
  187. || a.teacher.localeCompare(b.teacher)
  188. || a.position.localeCompare(b.position)
  189. || a.day - b.day
  190. || a.startSection - b.startSection
  191. || a.endSection - b.endSection
  192. );
  193. const step2Merged = [];
  194. let cur = step1Merged[0];
  195. for (let index = 1; index < step1Merged.length; index++) {
  196. const next = step1Merged[index];
  197. const isSameCourseAndSection = cur.name === next.name
  198. && cur.teacher === next.teacher
  199. && cur.position === next.position
  200. && cur.day === next.day
  201. && cur.startSection === next.startSection
  202. && cur.endSection === next.endSection;
  203. if (isSameCourseAndSection) {
  204. cur.weeks = Array.from(new Set([...cur.weeks, ...next.weeks])).sort((a, b) => a - b);
  205. } else {
  206. step2Merged.push(cur);
  207. cur = next;
  208. }
  209. }
  210. step2Merged.push(cur);
  211. list = step2Merged;
  212. // 周次合并可能让相邻时段的周次变得一致;每轮有合并时再处理,记录数不再减少即停止。
  213. } while (list.length < previousCount);
  214. return list;
  215. }
  216. // 将 getSchedulejson 返回结果转换成时光课表格式
  217. // 返回 { courses, timeSlots }:courses 用于课程导入,timeSlots 是接口给出的课程时段边界。
  218. // timeSlots 不是完整的每节作息表,后续由 generateTimeSlots 补齐为第 1~12 节。
  219. function convertScheduleData(apiData) {
  220. // 先解析为教程函数要求的标准课程数组,作息边界单独收集。
  221. const rawCourses = [];
  222. const teacherDisplaysMap = new Map();
  223. const timeSlotsMap = new Map();
  224. apiData.forEach(item => {
  225. const courseName = (item.courseName || '').trim();
  226. const teacherDisplay = normalizeTeacherDisplay(item.teacherName);
  227. const teacherKey = normalizeTeacherKey(item.teacherName);
  228. const position = buildPosition(item);
  229. // 接口字段可能是数字字符串,转为整数后再校验;星期 1~7 分别表示周一~周日。
  230. const day = Number(item.dayOfWeek);
  231. const startSection = Number(item.beginSection);
  232. const endSection = Number(item.endSection);
  233. const weeks = parseWeekDescription(item.weekDescription);
  234. // 无课程名称、无正式上课周次或星期/节次不合法的记录不导入。
  235. if (!courseName || weeks.length === 0) {
  236. return;
  237. }
  238. if (!Number.isInteger(day) || day < 1 || day > 7) {
  239. console.warn('忽略星期字段异常的课程:', item);
  240. return;
  241. }
  242. if (!Number.isInteger(startSection) || !Number.isInteger(endSection)
  243. || startSection <= 0 || endSection < startSection) {
  244. console.warn('忽略节次字段异常的课程:', item);
  245. return;
  246. }
  247. // 教程函数直接比较 teacher 字段,先使用排序后的教师集合,防止姓名顺序不同影响合并。
  248. // 显示名称另外保留,合并后还原为接口首次出现的顺序。
  249. const teacherDisplayKey = JSON.stringify([courseName, teacherKey, position, day]);
  250. if (!teacherDisplaysMap.has(teacherDisplayKey)) {
  251. teacherDisplaysMap.set(teacherDisplayKey, teacherDisplay);
  252. }
  253. rawCourses.push({
  254. name: courseName,
  255. teacher: teacherKey,
  256. position,
  257. day,
  258. startSection,
  259. endSection,
  260. weeks
  261. });
  262. // 收集 API 给出的时间范围,用于覆盖默认作息时间的起止边界
  263. // beginTime/endTime 表示整段课程的开始和结束,不能据此推算中间每节课的时间。
  264. const beginTime = (item.beginTime || '').trim();
  265. const endTime = (item.endTime || '').trim();
  266. if (beginTime && endTime) {
  267. const timeKey = `${startSection}-${endSection}-${beginTime}-${endTime}`;
  268. timeSlotsMap.set(timeKey, {
  269. startSection,
  270. endSection,
  271. startTime: beginTime,
  272. endTime
  273. });
  274. }
  275. });
  276. // 调用教程的合并去重函数,再还原教师显示名称,不向 App 输出内部比较字段。
  277. const courses = mergeAndDistinctCourses(rawCourses).map(course => ({
  278. ...course,
  279. teacher: teacherDisplaysMap.get(JSON.stringify([
  280. course.name, course.teacher, course.position, course.day
  281. ])) ?? course.teacher
  282. }));
  283. // 最终按星期、起止节次、课程名称排序,便于检查导入结果。
  284. courses.sort((a, b) => {
  285. if (a.day !== b.day) return a.day - b.day;
  286. if (a.startSection !== b.startSection) return a.startSection - b.startSection;
  287. if (a.endSection !== b.endSection) return a.endSection - b.endSection;
  288. return a.name.localeCompare(b.name, 'zh-CN');
  289. });
  290. console.log(`接口原始记录: ${apiData.length} 条`);
  291. console.log(`合并后的课程记录: ${courses.length} 条`);
  292. console.log('转换后的课程:', courses);
  293. return {
  294. courses,
  295. timeSlots: Array.from(timeSlotsMap.values())
  296. };
  297. }
  298. // 生成朝阳校区时间段配置
  299. // 每节都有独立的 number、startTime、endTime;当前共配置 12 节,时刻格式为 HH:mm。
  300. // 下表为现有朝阳校区的默认作息,不表示每一节都由本次接口返回。
  301. // 本文件的作息只用于朝阳校区;春明湖校区需另行配置独立脚本和时间表。
  302. // 课程记录只保存节次,App 使用这张作息表将节次对应到具体时间,不使用课程自定义时间模式。
  303. function generateTimeSlots(timeSlotsFromAPI) {
  304. const defaultTimeSlots = [
  305. { number: 1, startTime: '08:00', endTime: '08:45' },
  306. { number: 2, startTime: '08:55', endTime: '09:35' },
  307. { number: 3, startTime: '10:05', endTime: '10:50' },
  308. { number: 4, startTime: '11:00', endTime: '11:40' },
  309. { number: 5, startTime: '13:30', endTime: '14:15' },
  310. { number: 6, startTime: '14:25', endTime: '15:05' },
  311. { number: 7, startTime: '15:35', endTime: '16:20' },
  312. { number: 8, startTime: '16:30', endTime: '17:10' },
  313. { number: 9, startTime: '18:00', endTime: '18:45' },
  314. { number: 10, startTime: '18:45', endTime: '19:35' },
  315. { number: 11, startTime: '19:45', endTime: '20:30' },
  316. { number: 12, startTime: '20:30', endTime: '21:20' }
  317. ];
  318. if (Array.isArray(timeSlotsFromAPI)) {
  319. timeSlotsFromAPI.forEach(slot => {
  320. // 只覆盖首节的开始时间、末节的结束时间,其余节次继续使用默认值。
  321. // 例如接口返回 5-7 节 13:30~16:20:更新第 5 节开始、第 7 节结束,
  322. // 第 5 节结束、第 6 节起止、第 7 节开始仍按内置作息,不均分课程总时长。
  323. // 同一边界有多条不同时间时,后处理的记录覆盖先处理的记录。
  324. // 接口节次超出 1~12 时找不到对应作息,因此不会新增节次。
  325. const first = defaultTimeSlots.find(item => item.number === slot.startSection);
  326. const last = defaultTimeSlots.find(item => item.number === slot.endSection);
  327. if (first && slot.startTime) {
  328. first.startTime = slot.startTime;
  329. }
  330. if (last && slot.endTime) {
  331. last.endTime = slot.endTime;
  332. }
  333. });
  334. }
  335. return defaultTimeSlots;
  336. }
  337. // 主函数:获取并导入课程
  338. // 所有数据保存都 await V2 Promise 接口;获取失败、没有课程或课程保存失败时退出。
  339. async function importCourseSchedule() {
  340. try {
  341. console.log('开始导入朝阳校区 kbpro 课程表...');
  342. window.shiguangBridge.showToast('正在获取课程表...');
  343. // 1. 获取学期信息供日志查看;此处不修改 App 的学期配置。
  344. const scheduleInfo = await fetchScheduleInfo();
  345. if (!scheduleInfo) {
  346. return false;
  347. }
  348. console.log(
  349. `当前学期: ${scheduleInfo.termName || '未知'},`
  350. + `开学日期: ${scheduleInfo.schoolStartTime || '未知'},`
  351. + `当前周: ${scheduleInfo.weekNum ?? '未知'}`
  352. );
  353. // 2. 获取当前会话对应的课程数组,并转换为 App 需要的字段。
  354. const scheduleData = await fetchScheduleData();
  355. if (!scheduleData) {
  356. return false;
  357. }
  358. const { courses, timeSlots } = convertScheduleData(scheduleData);
  359. if (courses.length === 0) {
  360. window.shiguangBridge.showToast('未找到可导入的课程');
  361. return false;
  362. }
  363. // 3. 先保存课程:name、teacher、position、day、startSection、endSection、weeks。
  364. // 起止时刻不直接放入课程对象,下一步单独保存每节作息。
  365. const coursesResult = await window.shiguangBridgePromise.saveImportedCourses(
  366. JSON.stringify(courses)
  367. );
  368. if (coursesResult !== true) {
  369. console.error('课程导入失败,返回:', coursesResult);
  370. window.shiguangBridge.showToast('课程导入失败');
  371. return false;
  372. }
  373. console.log('课程导入成功');
  374. window.shiguangBridge.showToast(`成功导入 ${courses.length} 条课程记录!`);
  375. // 4. 以朝阳校区 12 节默认作息为基础应用接口边界,再提交完整作息表。
  376. const finalTimeSlots = generateTimeSlots(timeSlots);
  377. console.log('朝阳校区时间段配置:', finalTimeSlots);
  378. const timeSlotsResult = await window.shiguangBridgePromise.savePresetTimeSlots(
  379. JSON.stringify(finalTimeSlots)
  380. );
  381. if (timeSlotsResult === true) {
  382. console.log('时间段导入成功');
  383. window.shiguangBridge.showToast('朝阳校区作息配置成功!');
  384. } else {
  385. // 返回 false 时保留已导入课程,并提示作息失败;抛出异常时则进入下面的 catch。
  386. console.warn('时间段导入失败,返回:', timeSlotsResult);
  387. window.shiguangBridge.showToast('课程已导入,但时间段配置失败');
  388. }
  389. return true;
  390. } catch (error) {
  391. console.error('导入过程出错:', error);
  392. window.shiguangBridge.showToast('导入失败: ' + error.message);
  393. return false;
  394. }
  395. }
  396. // ========== 主执行逻辑 ==========
  397. if (!isOnSchedulePage()) {
  398. redirectToSchedulePage();
  399. } else {
  400. console.log('已进入长春理工大学朝阳校区新版课表导入流程');
  401. window.shiguangBridge.showToast('正在准备导入朝阳校区课程表...');
  402. // 等待页面和登录态稳定后请求接口
  403. setTimeout(async () => {
  404. const success = await importCourseSchedule();
  405. if (success) {
  406. // 完成信号用于通知 App 收尾;获取或课程保存失败时不发送。
  407. window.shiguangBridge.notifyTaskCompletion();
  408. }
  409. }, 1000);
  410. }