// 上海师范大学(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();