| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388 |
- // 上海师范大学(course.shnu.edu.cn) 时光课程表适配脚本
- // 适配对象: 树维EAMS经典版 (courseTableForStd)
- // 登录方式: 统一身份认证(cas.shnu.edu.cn), 登录成功回到教务系统页面后点击导入
- // 非该大学开发者适配,开发者无法及时发现问题
- // 出现问题请提issues或者提交pr更改,这更加快速
- const BASE_URL = "https://course.shnu.edu.cn/eams";
- // 上师大每天 14 节(EAMS unitCount 默认 14), 作息时间参考上师大教务处公开作息
- const DEFAULT_UNIT_COUNT = 14;
- // 节次 → 时间 (第 N 节), 与教务系统课表一致
- const SHNU_TIME_SLOTS = [
- { "number": 1, "startTime": "08:00", "endTime": "08:45" },
- { "number": 2, "startTime": "08:50", "endTime": "09:30" },
- { "number": 3, "startTime": "09:45", "endTime": "10:30" },
- { "number": 4, "startTime": "10:35", "endTime": "11:15" },
- { "number": 5, "startTime": "11:25", "endTime": "12:10" },
- { "number": 6, "startTime": "13:00", "endTime": "13:45" },
- { "number": 7, "startTime": "13:50", "endTime": "14:30" },
- { "number": 8, "startTime": "14:45", "endTime": "15:30" },
- { "number": 9, "startTime": "15:35", "endTime": "16:15" },
- { "number": 10, "startTime": "16:25", "endTime": "17:10" },
- { "number": 11, "startTime": "18:00", "endTime": "18:45" },
- { "number": 12, "startTime": "18:50", "endTime": "19:30" },
- { "number": 13, "startTime": "19:40", "endTime": "20:25" },
- { "number": 14, "startTime": "20:30", "endTime": "21:10" }
- ];
- /**
- * 统一网络请求(携带登录 Cookie)
- */
- async function request(url, options = {}) {
- const res = await fetch(url, { credentials: "include", ...options });
- if (!res.ok) throw new Error(`网络请求失败: ${res.status}`);
- return await res.text();
- }
- /**
- * 获取课表初始化页面, 优先走 AJAX 接口(无冗余布局), 失败则回退完整页面
- */
- async function fetchCourseTablePage() {
- try {
- return await request(`${BASE_URL}/courseTableForStd.action?sf_request_type=ajax`);
- } catch (e) {
- return await request(`${BASE_URL}/courseTableForStd.action`);
- }
- }
- /**
- * 从课表初始化页面提取关键参数
- * ids: 个人课表id(第一个匹配为 std 个人课表, 第二个为 class 班级课表)
- * tagId: 学期选择条 id, 用于 dataQuery 查询全部学期
- * currentSemesterId: 页面隐藏域中的当前学期 id
- */
- function detectParameters(html) {
- const idsMatch = html.match(/bg\.form\.addInput\(form,\s*"ids"\s*,\s*"(\d+)"\)/);
- if (!idsMatch) return null;
- const tagIdMatch = html.match(/id="(semesterBar\d+Semester)"/);
- const semMatch = html.match(/name="semester\.id"\s+value="(\d+)"/);
- return {
- ids: idsMatch[1],
- tagId: tagIdMatch ? tagIdMatch[1] : null,
- currentSemesterId: semMatch ? semMatch[1] : null
- };
- }
- /**
- * 通过 dataQuery 接口获取所有学期, 解析失败时返回 null
- */
- function parseSemesterCalendar(raw) {
- // 树维返回的是不带引号 key 的 JS 字面量, 用 Function 求值
- try {
- const data = Function("return (" + raw + ");")();
- if (data && data.semesters) return data.semesters;
- } catch (e) { /* 忽略, 走正则兜底 */ }
- const m = raw.match(/semesters\s*:\s*(\{[\s\S]*\})/);
- if (m) {
- try {
- const data = Function("return ({semesters: " + m[1] + "});")();
- if (data && data.semesters) return data.semesters;
- } catch (e) { /* 兜底也失败 */ }
- }
- return null;
- }
- /**
- * 获取学期列表并让用户选择
- * 默认选中当前学期(id 与页面隐藏域一致), 其次为 id 最大的(最新)学期
- */
- async function getSelectedSemester(tagId, currentSemesterId) {
- let list = [];
- if (tagId) {
- try {
- const raw = await request(`${BASE_URL}/dataQuery.action?sf_request_type=ajax`, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: `tagId=${encodeURIComponent(tagId)}&dataType=semesterCalendar&empty=false`
- });
- const semesters = parseSemesterCalendar(raw);
- if (semesters) {
- const seen = {};
- for (const key in semesters) {
- const arr = semesters[key];
- if (!Array.isArray(arr)) continue;
- arr.forEach(s => {
- if (!s || s.id == null || seen[s.id]) return;
- seen[s.id] = true;
- list.push({ id: String(s.id), name: `${s.schoolYear} ${s.name}学期` });
- });
- }
- // id 越大学期越新, 按新→旧排列
- list.sort((a, b) => (parseInt(b.id) || 0) - (parseInt(a.id) || 0));
- }
- } catch (e) { /* dataQuery 失败, 走兜底 */ }
- }
- // 兜底: 仅使用页面自带的当前学期
- if (list.length === 0) {
- if (!currentSemesterId) {
- throw new Error("无法获取学期列表, 请确认已登录教务系统");
- }
- list = [{ id: currentSemesterId, name: "当前学期" }];
- }
- // 计算默认选中项: 当前学期优先
- let defaultIndex = 0;
- if (currentSemesterId) {
- const idx = list.findIndex(s => s.id === currentSemesterId);
- if (idx >= 0) defaultIndex = idx;
- }
- const idx = await window.shiguangBridgePromise.showSingleSelection(
- "选择学期", JSON.stringify(list.map(s => s.name)), defaultIndex
- );
- return idx !== null ? list[idx] : null;
- }
- /**
- * 拉取指定学期的完整课表页面(包含全部周次的 TaskActivity 数据)
- */
- async function fetchCourseTableHtml(semesterId, ids) {
- return await request(`${BASE_URL}/courseTableForStd!courseTable.action?sf_request_type=ajax`, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: `ignoreHead=1&setting.kind=std&startWeek=&project.id=1&semester.id=${encodeURIComponent(semesterId)}&ids=${encodeURIComponent(ids)}`
- });
- }
- /**
- * 解析课表 HTML 中的 TaskActivity JS 数据 (上师大版本)
- *
- * 树维 EAMS 页面结构:
- * var teachers = new Array();
- * var actTeachers = new Array();
- * teachers[0] = "张三";
- * actTeachers[0] = {id: 123, name: "张三"};
- * activity = new TaskActivity(参数中引号字段依次为: [0]编号? [1]课程名 [2]? [3]地点 [4]周次bitmap [5]...);
- * index = day * unitCount + slot; // day: 0=周一, slot: 0-based 节次
- *
- * 周次 bitmap: 第 i 位为 '1' 表示第 i 周有课(第 0 位恒为 '0')
- */
- function parseTaskActivities(html) {
- const rawResults = [];
- const unitCountMatch = html.match(/unitCount\s*=\s*(\d+)/);
- const unitCount = unitCountMatch ? parseInt(unitCountMatch[1]) : DEFAULT_UNIT_COUNT;
- const blocks = html.split(/activity\s*=\s*new\s+TaskActivity\s*\(/);
- for (let i = 1; i < blocks.length; i++) {
- const block = blocks[i];
- // 教师来自上一个分块末尾的 actTeachers 赋值(即本课程对应的教师数组)
- let teacher = "";
- const prevBlock = blocks[i - 1];
- // 风格一: 数组字面量 actTeachers = [{id:.., name:"张三"}, ...]
- const teacherAssigns = prevBlock.match(/actTeachers\s*=\s*\[([\s\S]*?)\]/g);
- if (teacherAssigns && teacherAssigns.length > 0) {
- const names = teacherAssigns[teacherAssigns.length - 1].match(/name\s*:\s*"([^"]*)"/g);
- if (names) {
- teacher = names.map(n => n.replace(/name\s*:\s*"/, "").replace(/"$/, "")).join(",");
- }
- }
- // 风格二: 元素赋值 actTeachers[0] = {id:.., name:"张三"};
- if (!teacher) {
- const elemAssigns = prevBlock.match(/actTeachers\[\d+\]\s*=\s*\{[^}]*\}/g);
- if (elemAssigns && elemAssigns.length > 0) {
- const names = elemAssigns.join(",").match(/name\s*:\s*"([^"]*)"/g);
- if (names) {
- teacher = names.map(n => n.replace(/name\s*:\s*"/, "").replace(/"$/, "")).join(",");
- }
- }
- }
- // 提取 TaskActivity(...) 参数 (截到第一个 ");")
- const argsMatch = block.match(/^([\s\S]*?)\)\s*;/);
- if (!argsMatch) continue;
- const argsStr = argsMatch[1];
- const quoted = [];
- const quotedMatches = argsStr.match(/"([^"]*)"/g);
- if (quotedMatches) {
- quotedMatches.forEach(q => quoted.push(q.substring(1, q.length - 1)));
- }
- if (quoted.length < 5) continue;
- // 课程名: 去掉末尾的 "(xx)" 班级号后缀
- let courseName = (quoted[1] || "").trim();
- courseName = courseName.replace(/\([^)]*\)\s*$/, "").trim();
- if (!courseName) courseName = "未知课程";
- // 周次 bitmap: 优先取第 5 个引号字段, 不符时回退为首个形如 0/1 串的字段
- let weeksBitmap = quoted[4];
- if (!/^[01]+$/.test(weeksBitmap)) {
- const bitmapIdx = quoted.findIndex(q => /^[01]{6,}$/.test(q));
- if (bitmapIdx < 0) continue;
- weeksBitmap = quoted[bitmapIdx];
- }
- // 地点: bitmap 前一个引号字段 (标准结构), 兜底取第 4 个
- let position = quoted[3] || "";
- const finalBitmapIdx = quoted.indexOf(weeksBitmap);
- if (finalBitmapIdx > 0 && finalBitmapIdx !== 4) {
- position = quoted[finalBitmapIdx - 1] || position;
- }
- position = position.replace(/\(.*?\)/g, "").trim() || "未知地点";
- // 周次: bitmap 第 i 位 = 第 i 周
- const weeks = [];
- for (let j = 0; j < weeksBitmap.length; j++) {
- if (weeksBitmap.charAt(j) === "1") weeks.push(j);
- }
- if (weeks.length === 0) continue;
- // 上课时间: index = day * unitCount + slot
- const idxRegex = /index\s*=\s*(\d+)\s*\*\s*unitCount\s*\+\s*(\d+)\s*;/g;
- let m;
- while ((m = idxRegex.exec(block)) !== null) {
- rawResults.push({
- name: courseName,
- teacher: teacher || "未知教师",
- position: position,
- day: parseInt(m[1]) + 1, // 1=周一 ... 7=周日
- startSection: parseInt(m[2]) + 1, // 1-based 节次
- endSection: parseInt(m[2]) + 1,
- weeks: weeks
- });
- }
- }
- return mergeContinuousLessons(rawResults);
- }
- /**
- * 全局课程合并逻辑
- */
- function mergeContinuousLessons(lessons) {
- if (!lessons || lessons.length === 0) return [];
- // 1. 建立基于 (课程名|教师|地点|星期几) 的分组
- const groups = {};
- lessons.forEach(l => {
- const key = `${l.name}|${l.teacher}|${l.position}|${l.day}`;
- if (!groups[key]) {
- groups[key] = {
- name: l.name,
- teacher: l.teacher,
- position: l.position,
- day: l.day,
- // 假设大学最多 50 周,构建一个:第 N 周对应哪些节次的矩阵
- weeksMatrix: Array.from({ length: 50 }, () => new Set())
- };
- }
- // 将系统传来的凌乱数据彻底打散,按“周”填入对应的“节”中,Set自动去重
- if (l.weeks && Array.isArray(l.weeks)) {
- l.weeks.forEach(w => {
- if (w >= 0 && w < 50) {
- for (let s = l.startSection; s <= l.endSection; s++) {
- groups[key].weeksMatrix[w].add(s);
- }
- }
- });
- }
- });
- const merged = [];
- // 2. 根据矩阵重新组装绝对精确的课程块
- for (const key in groups) {
- const group = groups[key];
- const matrix = group.weeksMatrix;
- // 用于记录相同的“连续节次块”分布在哪些周次
- // 例如 blockMap["1-2"] = [1, 2, 3, 4, 5, 6, 7, 8, 9]
- // 例如 blockMap["2-2"] = [10]
- const blockMap = {};
- for (let w = 0; w < matrix.length; w++) {
- const sections = Array.from(matrix[w]).sort((a, b) => a - b);
- if (sections.length === 0) continue;
- // 寻找当前周的连续节次块
- let start = sections[0];
- let prev = sections[0];
- for (let i = 1; i < sections.length; i++) {
- const curr = sections[i];
- if (curr === prev + 1) {
- prev = curr; // 节次连续,继续延伸
- } else {
- // 节次断开,结算上一个块
- const blockKey = `${start}-${prev}`;
- if (!blockMap[blockKey]) blockMap[blockKey] = [];
- blockMap[blockKey].push(w);
- // 开启新块
- start = curr;
- prev = curr;
- }
- }
- // 结算每周最后一个块
- const blockKey = `${start}-${prev}`;
- if (!blockMap[blockKey]) blockMap[blockKey] = [];
- blockMap[blockKey].push(w);
- }
- // 3. 将聚合好的 blockMap 转换为最终的 JSON 对象
- for (const blockKey in blockMap) {
- const [startSec, endSec] = blockKey.split('-').map(Number);
- merged.push({
- name: group.name,
- teacher: group.teacher,
- position: group.position,
- day: group.day,
- startSection: startSec,
- endSection: endSec,
- weeks: blockMap[blockKey]
- });
- }
- }
- // 4. 排序以便输出整洁美观
- merged.sort((a, b) => {
- if (a.day !== b.day) return a.day - b.day;
- if (a.startSection !== b.startSection) return a.startSection - b.startSection;
- return a.name.localeCompare(b.name);
- });
- return merged;
- }
- /**
- * 导入上师大预设作息时间
- */
- async function applyTimeSlots() {
- return await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(SHNU_TIME_SLOTS));
- }
- /**
- * 编排整个课程导入流程。
- * 在任何一步用户取消或发生错误时,都会立即退出,notifyTaskCompletion()只在成功后调用
- */
- async function runImportFlow() {
- try {
- window.shiguangBridge.showToast("开始探测教务参数...");
- const html = await fetchCourseTablePage();
- const params = detectParameters(html);
- if (!params) throw new Error("未能识别教务参数, 请先登录教务系统再点击导入");
- window.shiguangBridge.showToast("正在获取学期列表...");
- const semester = await getSelectedSemester(params.tagId, params.currentSemesterId);
- if (!semester) return; // 用户取消
- window.shiguangBridge.showToast(`正在同步 ${semester.name} 课表...`);
- const tableHtml = await fetchCourseTableHtml(semester.id, params.ids);
- const courses = parseTaskActivities(tableHtml);
- if (!courses || courses.length === 0) throw new Error("未解析到课程数据, 可能为该学期无课或页面结构变化");
- await applyTimeSlots();
- const saveResult = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
- if (saveResult) {
- window.shiguangBridge.showToast(`成功导入 ${courses.length} 个课程条目`);
- window.shiguangBridge.notifyTaskCompletion();
- }
- } catch (e) {
- console.error(`[SHNU适配器异常] ${e.message}`);
- window.shiguangBridge.showToast(e.message);
- }
- }
- runImportFlow();
|