cqut_01.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. /**
  2. * 重庆理工大学课表导入脚本
  3. * author: Dawn Drizzle
  4. */
  5. const API_BASE = 'https://timetable-cfc.cqut.edu.cn/api/courseSchedule';
  6. const MAX_WEEK_REQUEST_CONCURRENCY = 5;
  7. const REQUEST_RETRY_COUNT = 1;
  8. const RETRY_DELAY_MS = 300;
  9. const wait = (duration) => new Promise((resolve) => setTimeout(resolve, duration));
  10. // 仅允许在课表站点内执行,避免跨站点误触发
  11. const checkLogin = () => window.location.hostname === 'timetable-cfc.cqut.edu.cn';
  12. // 统一的接口请求封装:POST + JSON + 携带 Cookie;网络错误、限流和服务端错误会重试一次
  13. const baseFetch = async (path, body, description) => {
  14. const requestBody = body === undefined ? undefined : JSON.stringify(body);
  15. for (let attempt = 0; attempt <= REQUEST_RETRY_COUNT; attempt++) {
  16. let response;
  17. try {
  18. response = await fetch(`${API_BASE}/${path}`, {
  19. method: 'POST',
  20. credentials: 'include',
  21. headers: {
  22. 'Content-Type': 'application/json',
  23. },
  24. body: requestBody,
  25. });
  26. } catch (error) {
  27. if (attempt < REQUEST_RETRY_COUNT) {
  28. await wait(RETRY_DELAY_MS * (attempt + 1));
  29. continue;
  30. }
  31. throw new Error(`获取${description}失败: ${error.message}`);
  32. }
  33. if (!response.ok) {
  34. const retryable = response.status === 429 || response.status >= 500;
  35. if (retryable && attempt < REQUEST_RETRY_COUNT) {
  36. await wait(RETRY_DELAY_MS * (attempt + 1));
  37. continue;
  38. }
  39. throw new Error(`获取${description}失败: ${response.status} ${response.statusText}`);
  40. }
  41. try {
  42. return await response.json();
  43. } catch (error) {
  44. throw new Error(`解析${description}失败: ${error.message}`);
  45. }
  46. }
  47. throw new Error(`获取${description}失败`);
  48. };
  49. // 获取当前登录用户信息(包含 username、校区等)
  50. const getUserInfo = async () => await baseFetch('getUserInfo', {}, '用户信息');
  51. // 获取指定校区的节次时间表
  52. const getCampusTimeInfo = async (campusName) => await baseFetch('getCampusTimeInfo', { campusName }, '时间表');
  53. // 获取指定周课程事件列表;weekNum/yearTerm 为空时,接口返回当前学期/当前周信息
  54. const getWeekEvents = async (userID, weekNum, yearTerm, description) => await baseFetch(
  55. 'listWeekEvents',
  56. {
  57. userID: String(userID),
  58. weekNum,
  59. yearTerm,
  60. },
  61. description,
  62. );
  63. // 使用固定数量的工作协程处理每周请求,避免短时间内向教务接口发出过多请求
  64. const mapWithConcurrency = async (items, concurrency, mapper) => {
  65. const results = new Array(items.length);
  66. let nextIndex = 0;
  67. const workerCount = Math.min(concurrency, items.length);
  68. const workers = Array.from({ length: workerCount }, async () => {
  69. while (nextIndex < items.length) {
  70. const currentIndex = nextIndex;
  71. nextIndex += 1;
  72. results[currentIndex] = await mapper(items[currentIndex], currentIndex);
  73. }
  74. });
  75. await Promise.all(workers);
  76. return results;
  77. };
  78. const normalizePositiveIntegerList = (values) => [...new Set(
  79. (Array.isArray(values) ? values : [])
  80. .map(Number)
  81. .filter((value) => Number.isInteger(value) && value > 0)
  82. )].sort((left, right) => left - right);
  83. const normalizeWeekList = (weekList) => normalizePositiveIntegerList(weekList);
  84. // 检查接口响应中的学期是否与用户选择一致,防止服务端忽略 yearTerm 参数
  85. const assertMatchingYearTerm = (weekData, expectedYearTerm, description, required = false) => {
  86. const actualYearTerm = String(weekData?.yearTerm ?? '').trim();
  87. if (!actualYearTerm) {
  88. if (required) {
  89. throw new Error(`${description}缺少学期标识`);
  90. }
  91. return;
  92. }
  93. if (actualYearTerm !== expectedYearTerm) {
  94. throw new Error(`${description}返回了错误学期: ${actualYearTerm}`);
  95. }
  96. };
  97. // 将接口中的学期标识转换为更易读的选项文本
  98. const formatYearTerm = (yearTerm) => {
  99. const value = String(yearTerm ?? '').trim();
  100. const match = value.match(/^(\d{4})-(\d{4})-(\d+)$/);
  101. return match ? `${match[1]}-${match[2]}学年 第${match[3]}学期` : value;
  102. };
  103. // 从当前周接口返回值中读取可用学期,并让用户选择要导入的学期
  104. const selectYearTerm = async (weekData) => {
  105. const currentYearTerm = String(weekData?.yearTerm ?? '').trim();
  106. const yearTermList = Array.isArray(weekData?.yearTermList)
  107. ? weekData.yearTermList.map((item) => String(item ?? '').trim()).filter(Boolean)
  108. : [];
  109. const yearTerms = [...new Set(yearTermList)];
  110. // 兼容接口暂未返回 yearTermList 的情况,仍允许导入当前学期
  111. if (currentYearTerm && !yearTerms.includes(currentYearTerm)) {
  112. yearTerms.unshift(currentYearTerm);
  113. }
  114. if (yearTerms.length === 0) {
  115. throw new Error('学期列表为空');
  116. }
  117. const currentIndex = yearTerms.indexOf(currentYearTerm);
  118. const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
  119. '选择学期',
  120. JSON.stringify(yearTerms.map(formatYearTerm)),
  121. currentIndex >= 0 ? currentIndex : 0,
  122. );
  123. if (selectedIndex === null || Number(selectedIndex) === -1) {
  124. window.shiguangBridge.showToast('已取消课程导入');
  125. return null;
  126. }
  127. const normalizedIndex = Number(selectedIndex);
  128. if (!Number.isInteger(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= yearTerms.length) {
  129. throw new Error(`无效的学期选项索引: ${selectedIndex}`);
  130. }
  131. return yearTerms[normalizedIndex];
  132. };
  133. const normalizeTime = (value) => {
  134. const match = String(value ?? '').trim().match(/^(\d{1,2}):(\d{2})$/);
  135. if (!match) {
  136. return null;
  137. }
  138. const hour = Number(match[1]);
  139. const minute = Number(match[2]);
  140. if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
  141. return null;
  142. }
  143. return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
  144. };
  145. const timeToMinutes = (value) => {
  146. const [hour, minute] = value.split(':').map(Number);
  147. return hour * 60 + minute;
  148. };
  149. // 将接口的节次时间转换为统一结构,过滤无效项、按节次去重并升序排序
  150. const parseTimeSlots = (timeSlots) => {
  151. const parsedTimeSlots = new Map();
  152. for (const timeSlot of Array.isArray(timeSlots) ? timeSlots : []) {
  153. const number = Number(timeSlot?.sessionNum);
  154. const startTime = normalizeTime(timeSlot?.startTime);
  155. const endTime = normalizeTime(timeSlot?.endTime);
  156. if (
  157. !Number.isInteger(number)
  158. || number <= 0
  159. || !startTime
  160. || !endTime
  161. || timeToMinutes(endTime) <= timeToMinutes(startTime)
  162. ) {
  163. continue;
  164. }
  165. if (!parsedTimeSlots.has(number)) {
  166. parsedTimeSlots.set(number, { number, startTime, endTime });
  167. }
  168. }
  169. return [...parsedTimeSlots.values()].sort((left, right) => left.number - right.number);
  170. };
  171. // 推算学期开始日期(YYYY-MM-DD):使用 weekDayList 第一条的月/日 + yearTerm 中的学年信息
  172. const parseSemesterStartDate = (yearTerm, weekDayList) => {
  173. const firstWeekDate = weekDayList?.[0]?.weekDate;
  174. if (!yearTerm || !firstWeekDate) {
  175. return null;
  176. }
  177. const yearTermMatch = String(yearTerm).match(/^(\d{4})-(\d{4})-([12])$/);
  178. const weekDateMatch = String(firstWeekDate).trim().match(/^(\d{1,2})\/(\d{1,2})$/);
  179. if (!yearTermMatch || !weekDateMatch) {
  180. return null;
  181. }
  182. const [, startYear, endYear, termPart] = yearTermMatch;
  183. const month = Number(weekDateMatch[1]);
  184. const day = Number(weekDateMatch[2]);
  185. const year = termPart === '1' ? Number(startYear) : Number(endYear);
  186. const date = new Date(Date.UTC(year, month - 1, day));
  187. if (
  188. Number.isNaN(date.getTime())
  189. || date.getUTCFullYear() !== year
  190. || date.getUTCMonth() !== month - 1
  191. || date.getUTCDate() !== day
  192. ) {
  193. return null;
  194. }
  195. return date.toISOString().split('T')[0];
  196. };
  197. // 将接口的 event 解析为课程结构(节次、星期、周次等)
  198. const parseCourse = (event) => {
  199. if (!event || typeof event !== 'object') {
  200. return null;
  201. }
  202. const sessionList = normalizePositiveIntegerList(event.sessionList);
  203. const declaredStartSection = Number(event.sessionStart);
  204. const startSection = Number.isInteger(declaredStartSection) && declaredStartSection > 0
  205. ? declaredStartSection
  206. : sessionList[0];
  207. const declaredDuration = Number(event.sessionLast);
  208. const duration = Number.isInteger(declaredDuration) && declaredDuration > 0 ? declaredDuration : 1;
  209. const endSection = sessionList[sessionList.length - 1] ?? startSection + duration - 1;
  210. const name = String(event.eventName ?? '').trim();
  211. const day = Number(event.weekDay);
  212. const weeks = normalizeWeekList(event.weekList);
  213. if (
  214. !name
  215. || !Number.isInteger(day)
  216. || day < 1
  217. || day > 7
  218. || !Number.isInteger(startSection)
  219. || startSection <= 0
  220. || !Number.isInteger(endSection)
  221. || endSection < startSection
  222. || weeks.length === 0
  223. ) {
  224. return null;
  225. }
  226. return {
  227. name,
  228. teacher: String(event.memberName ?? '').trim(),
  229. position: String(event.address ?? '').trim(),
  230. day,
  231. startSection,
  232. endSection,
  233. weeks,
  234. };
  235. };
  236. // 合并完全相同(课程名/老师/地点/星期/节次范围一致)的课程,将周次去重合并
  237. const mergeCourses = (events) => {
  238. const mergedCourses = new Map();
  239. for (const event of events) {
  240. const course = parseCourse(event);
  241. if (!course) {
  242. continue;
  243. }
  244. const key = [
  245. course.name,
  246. course.teacher,
  247. course.position,
  248. course.day,
  249. course.startSection,
  250. course.endSection,
  251. ].join('||');
  252. if (!mergedCourses.has(key)) {
  253. mergedCourses.set(key, course);
  254. continue;
  255. }
  256. const existingCourse = mergedCourses.get(key);
  257. existingCourse.weeks = [...new Set([...existingCourse.weeks, ...course.weeks])].sort((left, right) => left - right);
  258. }
  259. return [...mergedCourses.values()];
  260. };
  261. const saveBridgeData = async (description, saveAction) => {
  262. try {
  263. const result = await saveAction();
  264. if (result !== true) {
  265. throw new Error(`返回值异常: ${String(result)}`);
  266. }
  267. } catch (error) {
  268. throw new Error(`${description}保存失败: ${error?.message || String(error)}`);
  269. }
  270. };
  271. // 顺序保存,并将课程放在最后,避免配置或作息保存失败时提前覆盖现有课程
  272. const saveSchedule = async (parsedSchedule) => {
  273. await saveBridgeData(
  274. '课表配置',
  275. () => window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(parsedSchedule.courseConfig)),
  276. );
  277. await saveBridgeData(
  278. '节次时间',
  279. () => window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(parsedSchedule.timeSlots)),
  280. );
  281. await saveBridgeData(
  282. '课程数据',
  283. () => window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedSchedule.courses)),
  284. );
  285. };
  286. // 主流程:校验页面 → 拉取用户/校区 → 获取并选择学期 → 拉取所选学期每周课程 → 合并保存
  287. const runImportFlow = async () => {
  288. if (!checkLogin()) {
  289. throw new Error('当前不在重庆理工大学课表页面');
  290. }
  291. const userInfo = await getUserInfo();
  292. const userID = userInfo?.username;
  293. const campusName = userInfo?.userCustomSetting?.campusName;
  294. if (!userID || !campusName) {
  295. throw new Error('用户信息不完整');
  296. }
  297. const [timeSlotData, semesterOverview] = await Promise.all([
  298. getCampusTimeInfo(campusName),
  299. getWeekEvents(userID, null, null, '学期信息'),
  300. ]);
  301. const yearTerm = await selectYearTerm(semesterOverview);
  302. if (yearTerm === null) {
  303. return;
  304. }
  305. // 选择当前学期时复用首次请求;选择其他学期时重新获取其周次和日期信息
  306. const selectedWeekData = String(semesterOverview?.yearTerm ?? '').trim() === yearTerm
  307. ? semesterOverview
  308. : await getWeekEvents(userID, null, yearTerm, `${formatYearTerm(yearTerm)}信息`);
  309. assertMatchingYearTerm(selectedWeekData, yearTerm, '所选学期信息', true);
  310. const weekList = normalizeWeekList(selectedWeekData?.weekList);
  311. const timeSlots = parseTimeSlots(timeSlotData);
  312. if (weekList.length === 0) {
  313. throw new Error(`学期信息不完整: ${yearTerm}`);
  314. }
  315. if (timeSlots.length === 0) {
  316. throw new Error('未获取到有效的节次时间');
  317. }
  318. // semesterStartDate 必须取自第一周;首次接口可能返回的是当前周,不能直接使用其中的日期
  319. const firstWeekNum = weekList[0];
  320. const selectedWeekNum = Number(selectedWeekData?.weekNum);
  321. const firstWeekData = selectedWeekNum === firstWeekNum
  322. ? selectedWeekData
  323. : await getWeekEvents(userID, String(firstWeekNum), yearTerm, `第${firstWeekNum}周课程`);
  324. assertMatchingYearTerm(firstWeekData, yearTerm, `第${firstWeekNum}周课程`);
  325. const semesterStartDate = parseSemesterStartDate(yearTerm, firstWeekData?.weekDayList);
  326. const preloadedWeekData = new Map([[String(firstWeekNum), firstWeekData]]);
  327. if (weekList.includes(selectedWeekNum)) {
  328. preloadedWeekData.set(String(selectedWeekNum), selectedWeekData);
  329. }
  330. const weekResults = await mapWithConcurrency(
  331. weekList,
  332. MAX_WEEK_REQUEST_CONCURRENCY,
  333. (weekNum) => preloadedWeekData.get(String(weekNum))
  334. ?? getWeekEvents(userID, String(weekNum), yearTerm, `第${weekNum}周课程`),
  335. );
  336. weekResults.forEach((result, index) => {
  337. assertMatchingYearTerm(result, yearTerm, `第${weekList[index]}周课程`);
  338. });
  339. const events = weekResults.flatMap((result) => Array.isArray(result?.eventList) ? result.eventList : []);
  340. const courses = mergeCourses(events);
  341. if (courses.length === 0) {
  342. window.shiguangBridge.showToast(`${formatYearTerm(yearTerm)}未找到课程,未修改现有课表`);
  343. return;
  344. }
  345. await saveSchedule({
  346. courseConfig: {
  347. semesterStartDate,
  348. semesterTotalWeeks: weekList[weekList.length - 1],
  349. },
  350. timeSlots,
  351. courses,
  352. });
  353. window.shiguangBridge.notifyTaskCompletion();
  354. };
  355. // 所有非用户取消类错误都在入口统一反馈,避免未处理的 Promise 拒绝
  356. const runImportFlowSafely = async () => {
  357. try {
  358. await runImportFlow();
  359. } catch (error) {
  360. const message = error?.message || String(error);
  361. console.error(`[课程导入失败] ${message}`);
  362. window.shiguangBridge.showToast(`课程导入失败:${message}`);
  363. }
  364. };
  365. runImportFlowSafely();