// 南开大学(研究生)教育综合管理系统 拾光课程表适配脚本 // 适用系统: https://yjs.nankai.edu.cn/ 入口: 培养 → 个人课表 (py/page/student/grkcb.htm) // // 要点: // 1. 服务端渲染(JSP)输出表格, 无 JSON 接口, 只能解析 DOM; // 2. 表格按「周次」分周渲染, 同一门课只在它真正上课的那一周出现 —— 逐周抓取第 1 ~ N 周, 每周抓 // 到什么就记一条 weeks=[N] 的原子记录; 跨周合并 / 连续节次合并 / 去重交给官方函数处理; // 3. 只抓一次不带参数的课表页: 学年 / 学期 / 周次三个 的选项 -> [{ value, label, selected }] */ function readSelectOptions(selectEl) { if (!selectEl) return []; return Array.from(selectEl.querySelectorAll('option')) .map(option => ({ value: normalizeText(option.value), label: normalizeText(option.textContent || option.value), selected: option.selected === true || option.hasAttribute('selected') })) .filter(option => option.value !== ''); } /** 页面已选中的选项值; 没有选中项时返回空串 */ function selectedValue(options) { const selected = options.find(option => option.selected); return selected ? selected.value : ''; } /** 文档是否为本系统的课表页 —— 用来区分「这一周没课」和「登录失效 / 请求被拦」 */ const looksLikeCoursePage = doc => !!(doc && (doc.querySelector('#kcbForm') || doc.querySelector('#zc') || doc.querySelector('#xn'))); /** 课表页地址; 不带任何参数即为系统默认的当前学期 */ const kbPageUrl = (xn, xj, zc) => `${NKU_KB_PAGE}?xn=${encodeURIComponent(xn)}&xj=${encodeURIComponent(xj)}&zc=${encodeURIComponent(zc)}`; /** 抓一个页面并解析成 DOM。请求失败 / 状态异常一律抛出, 由调用方统一按「没抓到」处理 */ async function fetchCourseDoc(url) { const response = await fetch(url, { method: 'GET', credentials: 'include' }); if (!response.ok) throw new Error('页面请求失败'); return new DOMParser().parseFromString(await response.text(), 'text/html'); } /** 简单的并发池 */ async function runWithConcurrency(tasks, limit) { const results = new Array(tasks.length); let cursor = 0; const worker = async () => { while (cursor < tasks.length) { const index = cursor++; results[index] = await tasks[index](); } }; await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker)); return results; } /** 逐周抓取: 第 N 周抓到的每门课都记成一条原子记录(weeks = [N]), 不做任何归并。 * 返回 { courses, okWeeks }; okWeeks = 成功取到课表页的周次数(0 表示整轮都没抓到)。 */ async function crawlAllWeeks(xn, xj, weeks) { const tasks = weeks.map(week => async () => { try { const doc = await fetchCourseDoc(kbPageUrl(xn, xj, week)); const list = parseTimetableDoc(doc); if (list) return { week, list }; // 是本系统的课表页却没有表格 —— 多为超出本学期周次范围, 按当周无课处理 return looksLikeCoursePage(doc) ? { week, list: [] } : null; } catch { return null; } }); const courses = []; let okWeeks = 0; (await runWithConcurrency(tasks, NKU_FETCH_CONCURRENCY)).forEach(result => { if (!result) return; okWeeks++; result.list.forEach(course => courses.push(Object.assign({}, course, { weeks: [result.week] }))); }); return { courses, okWeeks }; } /* ============================ 页面读取与确认 ============================ */ /** 取某个值在选项里的显示文本(学年形如 2026-2027, 学期形如 第一学期); 找不到就用原始值 */ const labelOf = (options, value) => (options.find(option => option.value === value) || {}).label || value; /** 第 1 周周一 = 基准日当天或之后的第一个周一; 学期代码认不出来时按第一学期 */ function guessStartDate(xn, xj) { const [month, day, offset] = NKU_TERM_BASE[String(xj)] || NKU_TERM_BASE['11']; const date = new Date((Number(xn) || new Date().getFullYear()) + offset, month - 1, day); date.setDate(date.getDate() + (8 - date.getDay()) % 7); // 当天就是周一则不动 return formatDate(date); } /** 抓一次不带参数的课表页, 一次读齐: 学年与学期的值及显示文本(页面选中项 = 学校当前学期)、 * 周次列表、作息时间、按学期规则推算的开学日期。读不到下拉框(登录失效 / 页面改版)返回 null */ async function loadCoursePage() { let doc; try { doc = await fetchCourseDoc(NKU_KB_PAGE); } catch { return null; } const xnOptions = readSelectOptions(doc.querySelector('#xn')); const xjOptions = readSelectOptions(doc.querySelector('#xj')); const weeks = readSelectOptions(doc.querySelector('#zc')) .map(option => Number(option.value)).filter(value => value > 0); const xn = selectedValue(xnOptions); const xj = selectedValue(xjOptions); if (!xn || !xj || !weeks.length) return null; return { xn, xj, xnLabel: labelOf(xnOptions, xn), xjLabel: labelOf(xjOptions, xj), startDate: guessStartDate(xn, xj), weeks, timeSlots: parseTimeSlotsFromDoc(doc) }; } /** 导入信息确认框: 列出学年 / 学期 / 开学日期, 只能确认或关掉(关掉 = 放弃导入) */ async function confirmTerm(page) { if (!bridgeSupports('showAlert')) return true; const content = '即将导入以下内容,请确认:\n\n' + ` 学年   ${page.xnLabel}\n` + ` 学期   ${page.xjLabel}\n` + ` 开学日期 ${page.startDate}(周一)\n\n` + '学年与学期取自教务系统的默认学期,开学日期按学期规则推算。'; try { return await window.shiguangBridgePromise.showAlert('确认导入信息', content, '开始导入') === true; } catch { return true; } } /* ============================ 保存 ============================ */ /** 调用桥接层的保存接口; 失败时给一句笼统提示(不带原始异常)并返回 false */ async function saveVia(method, payload, failMessage) { try { await window.shiguangBridgePromise[method](JSON.stringify(payload)); return true; } catch { showToast(failMessage); return false; } } /** 从作息时间推算单节课与课间时长, 用于课表配置 */ function deriveDurations(timeSlots) { const fallback = { classDuration: 45, breakDuration: 10 }; if (!Array.isArray(timeSlots) || timeSlots.length < 2) return fallback; const classDuration = timeToMinutes(timeSlots[0].endTime) - timeToMinutes(timeSlots[0].startTime); const breakDuration = timeToMinutes(timeSlots[1].startTime) - timeToMinutes(timeSlots[0].endTime); return (classDuration > 0 && breakDuration >= 0) ? { classDuration, breakDuration } : fallback; } /* ============================ 主流程 ============================ */ async function importFlow() { // 1. 抓一次无参课表页: 学年学期默认值、周次列表、作息时间都出在这一份响应里; 抓不到即中止 const page = await loadCoursePage(); if (!page) { showToast('导入失败:未获取到课表数据,请重新登录后重试。'); return; } // 2. 确认框: 列出学年 / 学期 / 开学日期, 只能确认或关掉 if (!await confirmTerm(page)) { showToast('已取消导入。'); return; } // 3. 逐周抓取 -> 原子课程 -> 交给官方函数合并去重 const crawl = await crawlAllWeeks(page.xn, page.xj, page.weeks); if (!crawl.okWeeks) { showToast('导入失败:未获取到课表数据,请重新登录后重试。'); return; } if (!crawl.courses.length) { showToast('未查询到课程,请确认所选学年学期是否正确。'); return; } const courses = mergeAndDistinctCourses(crawl.courses); // 4. 保存: 课程 -> 作息时间 -> 课表配置 const timeSlots = page.timeSlots || NKU_FALLBACK_TIME_SLOTS; if (!await saveVia('saveImportedCourses', courses, '课程保存失败')) return; await saveVia('savePresetTimeSlots', timeSlots, '导入作息时间失败'); // 开学日期用推算出来的第 1 周周一 const durations = deriveDurations(timeSlots); await saveVia('saveCourseConfig', { semesterStartDate: page.startDate, semesterTotalWeeks: courses.reduce((max, course) => Math.max(max, ...course.weeks), 1), defaultClassDuration: durations.classDuration, defaultBreakDuration: durations.breakDuration, firstDayOfWeek: 1 }, '保存课表配置失败'); // 5. 完成 notifyTaskCompletion(); } /* ============================ 入口 ============================ */ // 宿主在用户点击「开始导入」后注入本脚本, 所以顶层直接启动: 抓一次课表页 -> 确认框 -> 逐周抓取 importFlow(); })();