cqut_01.js 17 KB

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