nankai.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. // 南开大学(研究生)教育综合管理系统 拾光课程表适配脚本
  2. // 适用系统: https://yjs.nankai.edu.cn/ 入口: 培养 → 个人课表 (py/page/student/grkcb.htm)
  3. //
  4. // 要点:
  5. // 1. 服务端渲染(JSP)输出表格, 无 JSON 接口, 只能解析 DOM;
  6. // 2. 表格按「周次」分周渲染, 同一门课只在它真正上课的那一周出现 —— 逐周抓取第 1 ~ N 周, 每周抓
  7. // 到什么就记一条 weeks=[N] 的原子记录; 跨周合并 / 连续节次合并 / 去重交给官方函数处理;
  8. // 3. 只抓一次不带参数的课表页: 学年 / 学期 / 周次三个 <select>(选中项即学校默认学期)与作息
  9. // 说明都在这一份响应里; 学年学期不给用户挑, 确认框列出学年 / 学期 / 开学日期后直接导入。
  10. //
  11. // 维护者: Cure | 出现问题请提 issues 或提交 PR
  12. /* 包在 IIFE 里: 不污染教务页面全局, 重复注入时顶层 const 也不会重复声明(函数体不再缩进一级)。 */
  13. (function () {
  14. 'use strict';
  15. /* ============================ 常量 ============================ */
  16. // 课表页路径(系统内所有页面均为 /<模块>/page/<角色>/<页面>.htm 结构)
  17. const NKU_KB_PAGE = '/py/page/student/grkcb.htm';
  18. // 逐周抓取的并发数(同一 JSP 会话并发过高会被串行化, 3 比较稳)
  19. const NKU_FETCH_CONCURRENCY = 3;
  20. // 兜底作息: 正常从课表页正文解析, 这里只作解析失败(页面改版)时的保底; 下标 + 1 即节次
  21. const NKU_FALLBACK_TIME_SLOTS = [
  22. '08:00-08:45', '08:55-09:40', '10:00-10:45', '10:55-11:40', '12:00-12:45',
  23. '12:55-13:40', '14:00-14:45', '14:55-15:40', '16:00-16:45', '16:55-17:40',
  24. '18:30-19:15', '19:25-20:10', '20:20-21:05', '21:15-22:00'
  25. ].map((range, index) => {
  26. const [startTime, endTime] = range.split('-');
  27. return { number: index + 1, startTime, endTime };
  28. });
  29. // 开学日期(第 1 周周一)推算基准 = [月, 日, 年偏移]: 取基准日当天或之后的第一个周一(系统无校历)
  30. const NKU_TERM_BASE = {
  31. '11': [9, 1, 0], // 第一学期: 当年 9 月 1 日
  32. '12': [2, 20, 1], // 第二学期: 次年 2 月 20 日
  33. '13': [7, 1, 0] // 短学期: 当年 7 月 1 日
  34. };
  35. /* ============================ 通用工具 ============================ */
  36. const CN_DIGITS = { 零: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
  37. /** 中文数字 -> 整数(支持 一 ~ 九十九); 无法解析返回 NaN */
  38. function cnToInt(text) {
  39. const s = String(text || '').replace(/\s/g, '');
  40. if (!s) return NaN;
  41. if (s === '十') return 10;
  42. if (s.length === 1) return Object.prototype.hasOwnProperty.call(CN_DIGITS, s) ? CN_DIGITS[s] : NaN;
  43. const idx = s.indexOf('十');
  44. if (idx === -1) return NaN;
  45. const high = idx === 0 ? 1 : CN_DIGITS[s[idx - 1]];
  46. const low = idx === s.length - 1 ? 0 : CN_DIGITS[s[idx + 1]];
  47. return (high === undefined || low === undefined) ? NaN : high * 10 + low;
  48. }
  49. /** 规整空白与全角空格 */
  50. function normalizeText(text) {
  51. return String(text == null ? '' : text).replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
  52. }
  53. /** "8:00" / "08:00:00" -> "08:00"; 非法返回 null */
  54. function formatTime(value) {
  55. const match = typeof value === 'string' ? value.match(/^(\d{1,2}):(\d{1,2})(?::\d{1,2})?$/) : null;
  56. if (!match) return null;
  57. const hour = Number(match[1]);
  58. const minute = Number(match[2]);
  59. if (hour > 23 || minute > 59) return null;
  60. return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
  61. }
  62. /** "08:45" -> 525; 非法返回 -1 */
  63. function timeToMinutes(value) {
  64. const t = formatTime(value);
  65. return t ? Number(t.slice(0, 2)) * 60 + Number(t.slice(3)) : -1;
  66. }
  67. /** 日期 -> "YYYY-MM-DD" */
  68. function formatDate(date) {
  69. const pad = n => String(n).padStart(2, '0');
  70. return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
  71. }
  72. /* ============================ 桥接层 ============================ */
  73. function showToast(message) {
  74. try { window.shiguangBridge.showToast(message); } catch { /* 提示失败不打断导入 */ }
  75. }
  76. function notifyTaskCompletion() {
  77. try { window.shiguangBridge.notifyTaskCompletion(); } catch { /* 结束信号交给宿主处理 */ }
  78. }
  79. const bridgeSupports = method =>
  80. !!(window.shiguangBridgePromise && typeof window.shiguangBridgePromise[method] === 'function');
  81. /* ============================ 页面解析 ============================ */
  82. /** 把表格展开成二维网格, 正确处理 rowspan / colspan(课表用 rowspan 跨节次) */
  83. function buildTableGrid(table) {
  84. const grid = [];
  85. const occupied = [];
  86. Array.from(table.querySelectorAll('tr')).forEach((tr, rowIndex) => {
  87. let col = 0;
  88. Array.from(tr.children).forEach(cell => {
  89. const tag = (cell.tagName || '').toLowerCase();
  90. if (tag !== 'td' && tag !== 'th') return;
  91. while (occupied[rowIndex] && occupied[rowIndex][col]) col++;
  92. const colspan = Math.max(parseInt(cell.getAttribute('colspan') || '1', 10) || 1, 1);
  93. const rowspan = Math.max(parseInt(cell.getAttribute('rowspan') || '1', 10) || 1, 1);
  94. for (let i = 0; i < rowspan; i++) {
  95. const r = rowIndex + i;
  96. grid[r] = grid[r] || [];
  97. occupied[r] = occupied[r] || [];
  98. for (let j = 0; j < colspan; j++) {
  99. if (!i && !j) grid[r][col] = cell;
  100. occupied[r][col + j] = true;
  101. }
  102. }
  103. col += colspan;
  104. });
  105. });
  106. return grid;
  107. }
  108. /** 表头形如 <th colspan="2">时间</th><th>星期一</th>… -> { 列下标: 星期(1~7) } */
  109. function findDayColumns(grid) {
  110. const dayChars = '一二三四五六日';
  111. const map = {};
  112. (grid[0] || []).forEach((cell, col) => {
  113. if (!cell) return;
  114. const match = normalizeText(cell.textContent).replace(/\s/g, '').match(/^星期([一二三四五六日天])$/);
  115. if (!match) return;
  116. const day = dayChars.indexOf(match[1] === '天' ? '日' : match[1]) + 1;
  117. if (day >= 1 && day <= 7) map[col] = day;
  118. });
  119. return map;
  120. }
  121. /** 课表主表格 */
  122. function findTimetable(doc) {
  123. const direct = doc.querySelector('table.table-course');
  124. if (direct && /星期/.test(direct.textContent || '')) return direct;
  125. return Array.from(doc.querySelectorAll('table')).find(table =>
  126. /星期一/.test(table.textContent || '') && /第\s*\d+\s*节/.test(table.textContent || '')) || null;
  127. }
  128. /** 单元格里的 <a> -> 按行切分的文本数组(<br> 视为换行): 课程名 / 周次说明 / 第X节 -- 第Y节 / 教师 / 地点 */
  129. function anchorToLines(anchor) {
  130. const clone = anchor.cloneNode(true);
  131. Array.from(clone.querySelectorAll('br')).forEach(br => {
  132. br.parentNode.replaceChild(document.createTextNode('\n'), br);
  133. });
  134. return String(clone.textContent || '').replace(/\u00a0/g, ' ').split('\n')
  135. .map(line => normalizeText(line)).filter(Boolean);
  136. }
  137. /** 解析一个课程链接 -> { name, teacher, position, day, startSection, endSection }
  138. * 周次不在这里判断: 抓取哪一周, 这条记录就属于哪一周(见 crawlAllWeeks 的原子化) */
  139. function parseCourseAnchor(anchor, day) {
  140. const lines = anchorToLines(anchor);
  141. const strong = anchor.querySelector('strong');
  142. let name = normalizeText(strong ? strong.textContent : '') || normalizeText(lines[0] || '');
  143. name = name.replace(/^[|\s]+/, '');
  144. if (!name) return null;
  145. // 「第X节 -- 第Y节」所在行
  146. const sectionIdx = lines.findIndex(line => /第\s*\d+\s*节/.test(line));
  147. if (sectionIdx < 0) return null;
  148. const numbers = (lines[sectionIdx].match(/第\s*(\d+)\s*节/g) || [])
  149. .map(token => Number(token.replace(/\D/g, ''))).filter(num => num > 0);
  150. if (!numbers.length) return null;
  151. // 教师 / 地点紧随节次行; 有些课程没有独立教师行, 此时第一行其实是地点
  152. let teacher = normalizeText(lines[sectionIdx + 1] || '');
  153. let position = normalizeText(lines[sectionIdx + 2] || '');
  154. if (!position && /楼|室|馆|场地|校区|通知|待定/.test(teacher)) {
  155. position = teacher;
  156. teacher = '';
  157. }
  158. if (!(day >= 1 && day <= 7)) return null;
  159. return {
  160. name,
  161. teacher,
  162. position: position || '待定',
  163. day,
  164. startSection: Math.min.apply(null, numbers),
  165. endSection: Math.max.apply(null, numbers)
  166. };
  167. }
  168. /** 解析某一周课表页面里的所有课程; null 表示页面里找不到课表(可能登录已失效) */
  169. function parseTimetableDoc(doc) {
  170. const table = findTimetable(doc);
  171. if (!table) return null;
  172. const grid = buildTableGrid(table);
  173. const dayColumns = findDayColumns(grid);
  174. const colKeys = Object.keys(dayColumns);
  175. if (colKeys.length === 0) return null;
  176. const courses = [];
  177. for (let row = 1; row < grid.length; row++) {
  178. colKeys.forEach(key => {
  179. const cell = grid[row] ? grid[row][Number(key)] : null;
  180. if (!cell) return;
  181. // 同一格可能塞了多门课(冲突课程), 逐个解析
  182. Array.from(cell.querySelectorAll('a'))
  183. .filter(anchor => /第\s*\d+\s*节/.test(anchor.textContent || ''))
  184. .forEach(anchor => {
  185. const course = parseCourseAnchor(anchor, dayColumns[key]);
  186. if (course) courses.push(course);
  187. });
  188. });
  189. }
  190. return courses;
  191. }
  192. /** 从页面底部说明文字解析作息时间, 形如 "上午:第一节 8:00-8:45 第二节8:55-9:40 …"
  193. * 时间段必须从 1 开始且编号连续, 否则返回 null 交给兜底表 */
  194. function parseTimeSlotsFromDoc(doc) {
  195. const bodyText = doc && doc.body ? doc.body.textContent || '' : '';
  196. if (!bodyText) return null;
  197. const normalized = bodyText
  198. .replace(/[:]/g, ':')
  199. .replace(/[–—~~至]/g, '-')
  200. .replace(/\s+/g, ' ');
  201. const pattern = /第([一二三四五六七八九十]+)节?:?\s*(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})/g;
  202. const collected = new Map();
  203. let match;
  204. while ((match = pattern.exec(normalized)) !== null) {
  205. const number = cnToInt(match[1]);
  206. const startTime = formatTime(match[2]);
  207. const endTime = formatTime(match[3]);
  208. if (!number || !startTime || !endTime) continue;
  209. if (timeToMinutes(startTime) >= timeToMinutes(endTime)) continue;
  210. collected.set(number, { number, startTime, endTime });
  211. }
  212. const slots = Array.from(collected.values()).sort((a, b) => a.number - b.number);
  213. if (!slots.length) return null;
  214. return slots.every((slot, index) => slot.number === index + 1) ? slots : null;
  215. }
  216. /* ============================ 合并与去重(官方参考实现)============================ */
  217. /** 拾光官方《课程合并与去重函数》的参考实现, 照搬(只精简了注释):
  218. * https://github.com/XingHeYuZhuan/shiguangschedule/wiki/课程合并与去重函数
  219. * 输入 / 输出都是原子课程 { name, teacher, position, day, startSection, endSection, weeks } */
  220. function mergeAndDistinctCourses(courses) {
  221. if (!Array.isArray(courses) || courses.length <= 1) return courses;
  222. const list = courses.map(course => Object.assign({}, course, {
  223. name: course.name || '',
  224. teacher: course.teacher || '',
  225. position: course.position || '',
  226. weeks: Array.isArray(course.weeks) ? [].concat(course.weeks).sort((a, b) => a - b) : []
  227. }));
  228. // 阶段 1: 合并连续节次与完全重复的记录(前提: 名称 / 教师 / 地点 / 星期 / 周次一致)
  229. list.sort((a, b) =>
  230. a.name.localeCompare(b.name) ||
  231. a.teacher.localeCompare(b.teacher) ||
  232. a.position.localeCompare(b.position) ||
  233. (a.day || 0) - (b.day || 0) ||
  234. a.weeks.join(',').localeCompare(b.weeks.join(',')) ||
  235. (a.startSection || 0) - (b.startSection || 0));
  236. const withSections = [];
  237. let current = list[0];
  238. for (let i = 1; i < list.length; i++) {
  239. const next = list[i];
  240. const sameWeeks = current.name === next.name
  241. && current.teacher === next.teacher
  242. && current.position === next.position
  243. && current.day === next.day
  244. && current.weeks.join(',') === next.weeks.join(',');
  245. if (sameWeeks && current.endSection + 1 === next.startSection) {
  246. current.endSection = next.endSection;
  247. } else if (sameWeeks && current.startSection === next.startSection && current.endSection === next.endSection) {
  248. continue;
  249. } else {
  250. withSections.push(current);
  251. current = next;
  252. }
  253. }
  254. withSections.push(current);
  255. // 阶段 2: 合并同节次的周次(前提: 名称 / 教师 / 地点 / 星期 / 起止节次一致)
  256. withSections.sort((a, b) =>
  257. a.name.localeCompare(b.name) ||
  258. a.teacher.localeCompare(b.teacher) ||
  259. a.position.localeCompare(b.position) ||
  260. (a.day || 0) - (b.day || 0) ||
  261. (a.startSection || 0) - (b.startSection || 0) ||
  262. (a.endSection || 0) - (b.endSection || 0));
  263. const merged = [];
  264. let head = withSections[0];
  265. for (let i = 1; i < withSections.length; i++) {
  266. const next = withSections[i];
  267. const sameSection = head.name === next.name
  268. && head.teacher === next.teacher
  269. && head.position === next.position
  270. && head.day === next.day
  271. && head.startSection === next.startSection
  272. && head.endSection === next.endSection;
  273. if (sameSection) {
  274. head.weeks = Array.from(new Set([].concat(head.weeks, next.weeks))).sort((a, b) => a - b);
  275. } else {
  276. merged.push(head);
  277. head = next;
  278. }
  279. }
  280. merged.push(head);
  281. return merged;
  282. }
  283. /* ============================ 列表读取与抓取 ============================ */
  284. /** <select> 的选项 -> [{ value, label, selected }] */
  285. function readSelectOptions(selectEl) {
  286. if (!selectEl) return [];
  287. return Array.from(selectEl.querySelectorAll('option'))
  288. .map(option => ({
  289. value: normalizeText(option.value),
  290. label: normalizeText(option.textContent || option.value),
  291. selected: option.selected === true || option.hasAttribute('selected')
  292. }))
  293. .filter(option => option.value !== '');
  294. }
  295. /** 页面已选中的选项值; 没有选中项时返回空串 */
  296. function selectedValue(options) {
  297. const selected = options.find(option => option.selected);
  298. return selected ? selected.value : '';
  299. }
  300. /** 文档是否为本系统的课表页 —— 用来区分「这一周没课」和「登录失效 / 请求被拦」 */
  301. const looksLikeCoursePage = doc =>
  302. !!(doc && (doc.querySelector('#kcbForm') || doc.querySelector('#zc') || doc.querySelector('#xn')));
  303. /** 课表页地址; 不带任何参数即为系统默认的当前学期 */
  304. const kbPageUrl = (xn, xj, zc) =>
  305. `${NKU_KB_PAGE}?xn=${encodeURIComponent(xn)}&xj=${encodeURIComponent(xj)}&zc=${encodeURIComponent(zc)}`;
  306. /** 抓一个页面并解析成 DOM。请求失败 / 状态异常一律抛出, 由调用方统一按「没抓到」处理 */
  307. async function fetchCourseDoc(url) {
  308. const response = await fetch(url, { method: 'GET', credentials: 'include' });
  309. if (!response.ok) throw new Error('页面请求失败');
  310. return new DOMParser().parseFromString(await response.text(), 'text/html');
  311. }
  312. /** 简单的并发池 */
  313. async function runWithConcurrency(tasks, limit) {
  314. const results = new Array(tasks.length);
  315. let cursor = 0;
  316. const worker = async () => {
  317. while (cursor < tasks.length) {
  318. const index = cursor++;
  319. results[index] = await tasks[index]();
  320. }
  321. };
  322. await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
  323. return results;
  324. }
  325. /** 逐周抓取: 第 N 周抓到的每门课都记成一条原子记录(weeks = [N]), 不做任何归并。
  326. * 返回 { courses, okWeeks }; okWeeks = 成功取到课表页的周次数(0 表示整轮都没抓到)。 */
  327. async function crawlAllWeeks(xn, xj, weeks) {
  328. const tasks = weeks.map(week => async () => {
  329. try {
  330. const doc = await fetchCourseDoc(kbPageUrl(xn, xj, week));
  331. const list = parseTimetableDoc(doc);
  332. if (list) return { week, list };
  333. // 是本系统的课表页却没有表格 —— 多为超出本学期周次范围, 按当周无课处理
  334. return looksLikeCoursePage(doc) ? { week, list: [] } : null;
  335. } catch {
  336. return null;
  337. }
  338. });
  339. const courses = [];
  340. let okWeeks = 0;
  341. (await runWithConcurrency(tasks, NKU_FETCH_CONCURRENCY)).forEach(result => {
  342. if (!result) return;
  343. okWeeks++;
  344. result.list.forEach(course => courses.push(Object.assign({}, course, { weeks: [result.week] })));
  345. });
  346. return { courses, okWeeks };
  347. }
  348. /* ============================ 页面读取与确认 ============================ */
  349. /** 取某个值在选项里的显示文本(学年形如 2026-2027, 学期形如 第一学期); 找不到就用原始值 */
  350. const labelOf = (options, value) => (options.find(option => option.value === value) || {}).label || value;
  351. /** 第 1 周周一 = 基准日当天或之后的第一个周一; 学期代码认不出来时按第一学期 */
  352. function guessStartDate(xn, xj) {
  353. const [month, day, offset] = NKU_TERM_BASE[String(xj)] || NKU_TERM_BASE['11'];
  354. const date = new Date((Number(xn) || new Date().getFullYear()) + offset, month - 1, day);
  355. date.setDate(date.getDate() + (8 - date.getDay()) % 7); // 当天就是周一则不动
  356. return formatDate(date);
  357. }
  358. /** 抓一次不带参数的课表页, 一次读齐: 学年与学期的值及显示文本(页面选中项 = 学校当前学期)、
  359. * 周次列表、作息时间、按学期规则推算的开学日期。读不到下拉框(登录失效 / 页面改版)返回 null */
  360. async function loadCoursePage() {
  361. let doc;
  362. try {
  363. doc = await fetchCourseDoc(NKU_KB_PAGE);
  364. } catch {
  365. return null;
  366. }
  367. const xnOptions = readSelectOptions(doc.querySelector('#xn'));
  368. const xjOptions = readSelectOptions(doc.querySelector('#xj'));
  369. const weeks = readSelectOptions(doc.querySelector('#zc'))
  370. .map(option => Number(option.value)).filter(value => value > 0);
  371. const xn = selectedValue(xnOptions);
  372. const xj = selectedValue(xjOptions);
  373. if (!xn || !xj || !weeks.length) return null;
  374. return {
  375. xn,
  376. xj,
  377. xnLabel: labelOf(xnOptions, xn),
  378. xjLabel: labelOf(xjOptions, xj),
  379. startDate: guessStartDate(xn, xj),
  380. weeks,
  381. timeSlots: parseTimeSlotsFromDoc(doc)
  382. };
  383. }
  384. /** 导入信息确认框: 列出学年 / 学期 / 开学日期, 只能确认或关掉(关掉 = 放弃导入) */
  385. async function confirmTerm(page) {
  386. if (!bridgeSupports('showAlert')) return true;
  387. const content = '即将导入以下内容,请确认:\n\n'
  388. + ` 学年   ${page.xnLabel}\n`
  389. + ` 学期   ${page.xjLabel}\n`
  390. + ` 开学日期 ${page.startDate}(周一)\n\n`
  391. + '学年与学期取自教务系统的默认学期,开学日期按学期规则推算。';
  392. try {
  393. return await window.shiguangBridgePromise.showAlert('确认导入信息', content, '开始导入') === true;
  394. } catch {
  395. return true;
  396. }
  397. }
  398. /* ============================ 保存 ============================ */
  399. /** 调用桥接层的保存接口; 失败时给一句笼统提示(不带原始异常)并返回 false */
  400. async function saveVia(method, payload, failMessage) {
  401. try {
  402. await window.shiguangBridgePromise[method](JSON.stringify(payload));
  403. return true;
  404. } catch {
  405. showToast(failMessage);
  406. return false;
  407. }
  408. }
  409. /** 从作息时间推算单节课与课间时长, 用于课表配置 */
  410. function deriveDurations(timeSlots) {
  411. const fallback = { classDuration: 45, breakDuration: 10 };
  412. if (!Array.isArray(timeSlots) || timeSlots.length < 2) return fallback;
  413. const classDuration = timeToMinutes(timeSlots[0].endTime) - timeToMinutes(timeSlots[0].startTime);
  414. const breakDuration = timeToMinutes(timeSlots[1].startTime) - timeToMinutes(timeSlots[0].endTime);
  415. return (classDuration > 0 && breakDuration >= 0) ? { classDuration, breakDuration } : fallback;
  416. }
  417. /* ============================ 主流程 ============================ */
  418. async function importFlow() {
  419. // 1. 抓一次无参课表页: 学年学期默认值、周次列表、作息时间都出在这一份响应里; 抓不到即中止
  420. const page = await loadCoursePage();
  421. if (!page) {
  422. showToast('导入失败:未获取到课表数据,请重新登录后重试。');
  423. return;
  424. }
  425. // 2. 确认框: 列出学年 / 学期 / 开学日期, 只能确认或关掉
  426. if (!await confirmTerm(page)) {
  427. showToast('已取消导入。');
  428. return;
  429. }
  430. // 3. 逐周抓取 -> 原子课程 -> 交给官方函数合并去重
  431. const crawl = await crawlAllWeeks(page.xn, page.xj, page.weeks);
  432. if (!crawl.okWeeks) {
  433. showToast('导入失败:未获取到课表数据,请重新登录后重试。');
  434. return;
  435. }
  436. if (!crawl.courses.length) {
  437. showToast('未查询到课程,请确认所选学年学期是否正确。');
  438. return;
  439. }
  440. const courses = mergeAndDistinctCourses(crawl.courses);
  441. // 4. 保存: 课程 -> 作息时间 -> 课表配置
  442. const timeSlots = page.timeSlots || NKU_FALLBACK_TIME_SLOTS;
  443. if (!await saveVia('saveImportedCourses', courses, '课程保存失败')) return;
  444. await saveVia('savePresetTimeSlots', timeSlots, '导入作息时间失败');
  445. // 开学日期用推算出来的第 1 周周一
  446. const durations = deriveDurations(timeSlots);
  447. await saveVia('saveCourseConfig', {
  448. semesterStartDate: page.startDate,
  449. semesterTotalWeeks: courses.reduce((max, course) => Math.max(max, ...course.weeks), 1),
  450. defaultClassDuration: durations.classDuration,
  451. defaultBreakDuration: durations.breakDuration,
  452. firstDayOfWeek: 1
  453. }, '保存课表配置失败');
  454. // 5. 完成
  455. notifyTaskCompletion();
  456. }
  457. /* ============================ 入口 ============================ */
  458. // 宿主在用户点击「开始导入」后注入本脚本, 所以顶层直接启动: 抓一次课表页 -> 确认框 -> 逐周抓取
  459. importFlow();
  460. })();