Bläddra i källkod

feat: 新增上海师范大学(树维EAMS)适配 (#762)

FaQxD233 1 månad sedan
förälder
incheckning
fef1ac58a9
3 ändrade filer med 402 tillägg och 1 borttagningar
  1. 5 1
      index/root_index.yaml
  2. 9 0
      resources/SHNU/adapters.yaml
  3. 388 0
      resources/SHNU/shnu.js

+ 5 - 1
index/root_index.yaml

@@ -1137,4 +1137,8 @@ schools:
     name: "上海电力大学"
     initial: "S"
     resource_folder: "SHIEP"
-    
+
+  - id: "SHNU"
+    name: "上海师范大学"
+    initial: "S"
+    resource_folder: "SHNU"

+ 9 - 0
resources/SHNU/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/SHNU/adapters.yaml
+adapters:
+  - adapter_id: "SHNU_01"
+    adapter_name: "上海师范大学树维教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "shnu.js"
+    import_url: "https://course.shnu.edu.cn/"
+    maintainer: "FaQxD233"
+    description: "上海师范大学本科生教务系统(course.shnu.edu.cn/eams)适配,支持选择学期。登录之后直接导入不用点网页,加载不出来不重要。"

+ 388 - 0
resources/SHNU/shnu.js

@@ -0,0 +1,388 @@
+// 上海师范大学(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();