Jelajahi Sumber

Merge pull request #758 from ShiGuangSchedule/pending

星河欲转 1 bulan lalu
induk
melakukan
fc237e9e5b
3 mengubah file dengan 420 tambahan dan 0 penghapusan
  1. 6 0
      index/root_index.yaml
  2. 405 0
      resources/SHIEP/SHIEP.js
  3. 9 0
      resources/SHIEP/adapters.yaml

+ 6 - 0
index/root_index.yaml

@@ -1127,3 +1127,9 @@ schools:
     name: "广东科贸职业学院"
     initial: "G"
     resource_folder: "GDKM"
+
+  - id: "SHIEP"
+    name: "上海电力大学"
+    initial: "S"
+    resource_folder: "SHIEP"
+    

+ 405 - 0
resources/SHIEP/SHIEP.js

@@ -0,0 +1,405 @@
+// resources/SUEP/SUEP_03.js
+// 上海电力大学 本科教务系统(eams / 上海树维)拾光课程表适配脚本 —— 接口探测版
+//
+// 本脚本改自本仓库中天津农学院 (TJAU) 的适配脚本(作者:星河欲转)。
+// 两者是同一套「树维 eams」系统,因此沿用它的整体思路:
+//   - 用接口探测 ids / tagId,不依赖用户先打开「我的课表」页面;
+//   - 用「按周建矩阵、再切连续节次块」的算法合并课程,能正确处理
+//     「同一门课不同周次占用不同节次」这种不规则情况。
+// 只把 URL、解析规则和节次时间换成了上海电力大学的实际情况。
+//
+// 导入页(adapters.yaml 的 import_url):https://jw.shiep.edu.cn/eams/index.action
+//
+// 前置条件:**只需登录**,不需要打开课表页。
+//
+// 与 SUEP_01.js / SUEP_02.js 的差别:
+//   SUEP_01 读当前页面内联脚本;SUEP_02 已登录后调接口、但参数取自当前页面;
+//   本脚本连参数都由接口探测,因此对页面状态没有要求。
+
+const EAMS_BASE = "https://jw.shiep.edu.cn/eams";
+
+/**
+ * 同一接口在短时间内被连续请求时,服务端会返回一个约 4 KB 的「精简版」页面
+ * (不含工具栏,也不含 ids / tagId 等标记),间隔约 1 秒再请求就恢复正常。
+ * 实测:14 次连发只有第 1 次完整;间隔 2 秒的 4 次请求则全部完整。
+ * 这不是登录失效,所以收到这种响应应当退避重试,而不是直接报错。
+ */
+const MAX_ATTEMPTS = 4;
+const RETRY_DELAY_MS = 1000;
+
+function powerSplit(paramsRaw) {
+    const args = [];
+    let current = "";
+    let depth = 0;
+    let inQuote = false;
+    let quoteChar = "";
+
+    for (let i = 0; i < paramsRaw.length; i++) {
+        let char = paramsRaw[i];
+        if ((char === '"' || char === "'") && (i === 0 || paramsRaw[i - 1] !== '\\')) {
+            if (!inQuote) { inQuote = true; quoteChar = char; }
+            else if (char === quoteChar) { inQuote = false; }
+        }
+        if (!inQuote) {
+            if (char === '(' || char === '[' || char === '{') depth++;
+            if (char === ')' || char === ']' || char === '}') depth--;
+        }
+        if (char === ',' && depth === 0 && !inQuote) {
+            args.push(cleanArg(current));
+            current = "";
+        } else {
+            current += char;
+        }
+    }
+    args.push(cleanArg(current));
+    return args;
+}
+
+function cleanArg(s) {
+    s = s.trim();
+    if (s === "null") return null;
+    return s.replace(/^["']|["']$/g, "");
+}
+
+/**
+ * 去掉课程名末尾的课程序号。
+ * 教务返回的课程名形如 "高等数学A(1)(2800001.32)",末尾括号里是课程序号,
+ * 展示时应去掉;课程名自带的括号要保留,例如 "体育1(篮球)"、
+ * "大学英语C读写(1)"(全角括号)。
+ * 注意不能简单地 split('(')[0] —— 那会把 "体育1(篮球)" 变成 "体育1"。
+ */
+function stripCourseNo(courseName) {
+    return String(courseName).replace(/\s*\(\s*\d+(?:\.\d+)?\s*\)\s*$/, "").trim();
+}
+
+/**
+ * 全局课程合并逻辑(沿用 TJAU 脚本的算法)。
+ * 按 (课程名|教师|地点|星期) 分组,再把课表打散成「每一周各占哪些节」的矩阵,
+ * 最后把矩阵里形状相同的连续节次块聚合成一条课程,因此能正确处理
+ * 例如「第 1-9 周上 1-2 节、第 10 周只上第 2 节」这类不规则安排。
+ */
+function mergeContinuousLessons(lessons) {
+    if (!lessons || lessons.length === 0) return [];
+
+    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 = [];
+
+    for (const key in groups) {
+        const group = groups[key];
+        const matrix = group.weeksMatrix;
+
+        // 记录相同的「连续节次块」出现在哪些周
+        // 例如 blockMap["1-2"] = [1, 2, 3, ...];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);
+        }
+
+        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],
+                isCustomTime: false,
+                customStartTime: null,
+                customEndTime: null
+            });
+        }
+    }
+
+    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;
+}
+
+/**
+ * 解析课表 HTML。
+ *
+ * 上海电力的响应结构与天津农学院不同:**没有** `var teachers =` / `actTeachers`
+ * 那套结构,教师姓名直接就在 TaskActivity 的参数里。所以这里改成按
+ * 「activity = new TaskActivity」切块,教师取第 2 个参数。
+ *
+ * TaskActivity 七个参数(名称取自系统自己的 TaskActivity_New.js):
+ *   teacherId, teacherName, courseId, courseName, roomId, roomName, vaildWeeks
+ */
+function parseTaskActivities(html) {
+    const rawResults = [];
+
+    const unitCountMatch = html.match(/unitCount\s*=\s*(\d+)/);
+    const unitCount = unitCountMatch ? parseInt(unitCountMatch[1], 10) : 13;
+
+    const blocks = html.split(/activity\s*=\s*new\s+TaskActivity/);
+
+    for (let i = 1; i < blocks.length; i++) {
+        const block = blocks[i];
+
+        const argsMatch = block.match(/^\s*\(([\s\S]*?)\)\s*;/);
+        if (!argsMatch) continue;
+
+        const args = powerSplit(argsMatch[1]);
+        if (args.length < 7) continue;
+
+        const name = stripCourseNo(args[3] || "未知课程");
+        const teacher = (args[1] || "").trim() || "未知教师";
+        const position = (args[5] || "").trim() || "未知地点";
+        const weeksBitmap = args[6] || "";
+
+        // 周次位图:第 i 个字符(i 从 1 开始)为 "1" 表示第 i 周有课,
+        // 第 0 位是占位符,恒为 "0",不能当成第 1 周。
+        const weeks = [];
+        for (let j = 1; j < weeksBitmap.length; j++) {
+            if (weeksBitmap[j] === "1") weeks.push(j);
+        }
+        if (weeks.length === 0) continue;
+
+        // index = 星期索引 * unitCount + 节次索引,反推即各自 +1
+        const idxRegex = /index\s*=\s*(\d+)\s*\*\s*unitCount\s*\+\s*(\d+)\s*;/g;
+        let m;
+        while ((m = idxRegex.exec(block)) !== null) {
+            const section = parseInt(m[2], 10) + 1;
+            rawResults.push({
+                name: name,
+                teacher: teacher,
+                position: position,
+                day: parseInt(m[1], 10) + 1,
+                startSection: section,
+                endSection: section,
+                weeks: weeks
+            });
+        }
+    }
+
+    return mergeContinuousLessons(rawResults);
+}
+
+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();
+}
+
+function sleep(ms) {
+    return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+function looksLikeLoginPage(html) {
+    return /authserver|统一身份认证|应用未注册/.test(html);
+}
+
+/**
+ * 带退避重试的请求:只有通过 validate 的响应才算拿到完整内容。
+ * 传输错误和「响应不完整」都会重试,因为这个接口确实会间歇性地返回精简版页面。
+ */
+async function requestValidated(url, options, validate, label) {
+    let incompleteLength = -1;
+    let lastError = null;
+
+    for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+        try {
+            const text = await request(url, options);
+            if (looksLikeLoginPage(text)) {
+                throw new Error("请求被重定向到统一身份认证,请先登录教务系统");
+            }
+            if (validate(text)) return text;
+            incompleteLength = text.length;
+            lastError = null;
+        } catch (error) {
+            lastError = error;
+        }
+
+        if (attempt < MAX_ATTEMPTS) {
+            console.log(`[拾光] ${label} 第 ${attempt} 次未拿到完整响应,${RETRY_DELAY_MS}ms 后重试`);
+            await sleep(RETRY_DELAY_MS);
+        }
+    }
+
+    if (lastError) throw lastError;
+    throw new Error(`${label}连续 ${MAX_ATTEMPTS} 次响应都不完整(最后一次 ${incompleteLength} 字节),请稍后重试`);
+}
+
+/**
+ * 探测调用课表接口所需的参数。
+ * 课表页把 ids(学生内部编号)写在 bg.form.addInput(form,"ids","...") 里,
+ * 学期组件的 id 形如 semesterBarXXXSemester,当前学期的编号则写在
+ * semesterCalendar({...value:"424"}) 里。
+ */
+async function detectParameters() {
+    // 精简版页面里没有这两个标记,所以必须校验后重试
+    const validate = html => /bg\.form\.addInput\(form,"ids","\d+"\)/.test(html)
+        && /id="semesterBar\d+Semester"/.test(html);
+
+    const html = await requestValidated(
+        `${EAMS_BASE}/courseTableForStd.action`, {}, validate, "探测课表参数"
+    );
+
+    const idsMatch = html.match(/bg\.form\.addInput\(form,"ids","(\d+)"\)/);
+    const tagIdMatch = html.match(/id="(semesterBar\d+Semester)"/);
+    const currentSemesterMatch = html.match(/semesterCalendar\(\{[^}]*value:"(\d+)"/);
+    return {
+        ids: idsMatch[1],
+        tagId: tagIdMatch[1],
+        currentSemesterId: currentSemesterMatch ? currentSemesterMatch[1] : ""
+    };
+}
+
+/**
+ * 解析学期列表。
+ * 响应是一段 JS 对象字面量(不是标准 JSON,键没有引号):
+ *   {yearDom:"...", termDom:"...",
+ *    semesters:{y0:[{id:32,schoolYear:"2013-2014",name:"2"}], ...},
+ *    yearIndex:"13", termIndex:"0", semesterId:"424"}
+ * 这里不 eval 它(不执行服务端返回的代码),只用正则抽取值;顺序天然按学年排列。
+ */
+function parseSemesterList(text) {
+    const pattern = /id:(\d+)\s*,\s*schoolYear:"([^"]*)"\s*,\s*name:"([^"]*)"/g;
+    const list = [];
+    let m;
+    while ((m = pattern.exec(text)) !== null) {
+        list.push({ id: m[1], name: `${m[2]}学年${m[3]}学期` });
+    }
+    return list;
+}
+
+async function getSelectedSemester(tagId, currentSemesterId) {
+    const raw = await requestValidated(`${EAMS_BASE}/dataQuery.action`, {
+        method: "POST",
+        headers: { "Content-Type": "application/x-www-form-urlencoded" },
+        body: `tagId=${encodeURIComponent(tagId)}&dataType=semesterCalendar`
+            + `&value=${encodeURIComponent(currentSemesterId)}&empty=false`
+    }, text => parseSemesterList(text).length > 0, "获取学期列表");
+
+    const list = parseSemesterList(raw);
+    if (list.length === 0) throw new Error("未解析到学期列表");
+
+    // 默认选中当前学期,避免默认停在最早的学期上
+    let defaultIndex = 0;
+    for (let i = 0; i < list.length; i++) {
+        if (list[i].id === currentSemesterId) defaultIndex = i;
+    }
+
+    const idx = await window.shiguangBridgePromise.showSingleSelection(
+        "选择学期", JSON.stringify(list.map(s => s.name)), defaultIndex
+    );
+    return idx !== null ? list[idx] : null;
+}
+
+async function fetchAndParseCourses(semesterId, ids) {
+    // 真正的课表响应一定含 "new CourseTable"(哪怕是空课表),
+    // 精简版页面没有它 —— 用它区分「响应不完整要重试」和「这个学期确实没课」
+    const html = await requestValidated(`${EAMS_BASE}/courseTableForStd!courseTable.action`, {
+        method: "POST",
+        headers: { "Content-Type": "application/x-www-form-urlencoded" },
+        body: `ignoreHead=1&setting.kind=std&semester.id=${semesterId}&ids=${ids}`
+    }, text => /new CourseTable/.test(text), "获取课表数据");
+
+    if (!/new TaskActivity/.test(html)) {
+        throw new Error("该学期没有课程数据(课表为空),请换一个学期再试");
+    }
+    return parseTaskActivities(html);
+}
+
+/**
+ * 上海电力大学各节次起止时间(共 13 节)。
+ * eams 只给节次编号、不给时间,所以按学校作息表写死。
+ * 时间整理自公开资料,请以教务处公布的最新作息为准;如有出入直接改这里。
+ */
+async function applyTimeSlots() {
+    const slots = [
+        { "number": 1, "startTime": "08:20", "endTime": "09:05" },
+        { "number": 2, "startTime": "09:10", "endTime": "09:55" },
+        { "number": 3, "startTime": "10:10", "endTime": "10:55" },
+        { "number": 4, "startTime": "11:00", "endTime": "11:45" },
+        { "number": 5, "startTime": "11:50", "endTime": "12:30" },
+        { "number": 6, "startTime": "13:20", "endTime": "14:05" },
+        { "number": 7, "startTime": "14:10", "endTime": "14:55" },
+        { "number": 8, "startTime": "15:10", "endTime": "15:55" },
+        { "number": 9, "startTime": "16:00", "endTime": "16:45" },
+        { "number": 10, "startTime": "16:50", "endTime": "17:30" },
+        { "number": 11, "startTime": "18:15", "endTime": "19:00" },
+        { "number": 12, "startTime": "19:05", "endTime": "19:50" },
+        { "number": 13, "startTime": "19:55", "endTime": "20:35" }
+    ];
+    return await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(slots));
+}
+
+async function runImportFlow() {
+    try {
+        window.shiguangBridge.showToast("开始探测教务参数...");
+        const params = await detectParameters();
+
+        const semester = await getSelectedSemester(params.tagId, params.currentSemesterId);
+        if (!semester) return;
+
+        window.shiguangBridge.showToast("正在同步课表...");
+        const courses = await fetchAndParseCourses(semester.id, params.ids);
+
+        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(`[异常] ${e.message}`);
+        window.shiguangBridge.showToast(e.message);
+    }
+}
+
+runImportFlow();

+ 9 - 0
resources/SHIEP/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/SUEP/adapters.yaml
+adapters:
+  - adapter_id: "SHIEP_01"
+    adapter_name: "上海电力大学本科教务系统"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "SHIEP.js"
+    import_url: "https://jw.shiep.edu.cn/eams/index.action"
+    maintainer: "zt11125"
+    description: "从统一身份认证入口登录后进入教务系统"