Browse Source

feat: 适配福建师范大学研究生教务系统 (#707)

Hui0069 8 hours ago
parent
commit
fffe99ba79
3 changed files with 204 additions and 0 deletions
  1. 5 0
      index/root_index.yaml
  2. 8 0
      resources/FJNU/adapters.yaml
  3. 191 0
      resources/FJNU/fjnu_01.js

+ 5 - 0
index/root_index.yaml

@@ -1107,3 +1107,8 @@ schools:
     name: "四川机电职业技术学院"
     initial: "S"
     resource_folder: "SCEMI"
+
+  - id: "FJNU"
+    name: "福建师范大学"
+    initial: "F"
+    resource_folder: "FJNU"

+ 8 - 0
resources/FJNU/adapters.yaml

@@ -0,0 +1,8 @@
+adapters:
+  - adapter_id: "FJNU_01"
+    adapter_name: "福建师范大学研究生教务系统"
+    category: "POSTGRADUATE"
+    asset_js_path: "fjnu_01.js"
+    import_url: "https://gedu.fjnu.edu.cn/cas/login?service=https%3A%2F%2Fgedu.fjnu.edu.cn%2Findex"
+    maintainer: "Hui0069"
+    description: "登录后进入课程表,选择目标学期并等待加载完成,再执行导入。导入课程及学校12节作息时间,学期开始日期和总周数需手动核对。支持连续节次、单双周和同一时段多门课程;自定时间课程需先在教务系统确认排课。"

+ 191 - 0
resources/FJNU/fjnu_01.js

@@ -0,0 +1,191 @@
+// 福建师范大学研究生教务系统(gedu.fjnu.edu.cn)拾光课程表适配。
+// 读取当前学期课表 DOM,不读取账号、密码、Cookie 或浏览器存储。
+(function () {
+    "use strict";
+
+    // 福建师范大学作息时间,由本校使用者核对提供;每节课 45 分钟。
+    const presetTimeSlots = [
+        { number: 1, startTime: "08:20", endTime: "09:05" },
+        { number: 2, startTime: "09:15", endTime: "10:00" },
+        { number: 3, startTime: "10:20", endTime: "11:05" },
+        { number: 4, startTime: "11:15", endTime: "12:00" },
+        { number: 5, startTime: "14:00", endTime: "14:45" },
+        { number: 6, startTime: "14:55", endTime: "15:40" },
+        { number: 7, startTime: "15:50", endTime: "16:35" },
+        { number: 8, startTime: "16:45", endTime: "17:30" },
+        { number: 9, startTime: "18:30", endTime: "19:15" },
+        { number: 10, startTime: "19:25", endTime: "20:10" },
+        { number: 11, startTime: "20:20", endTime: "21:05" },
+        { number: 12, startTime: "21:15", endTime: "22:00" }
+    ];
+
+    function text(element) {
+        return (element ? element.textContent : "").replace(/\u00a0/g, " ").trim();
+    }
+
+    function parseWeeks(value, parity) {
+        const normalized = value.replace(/第|周|\s/g, "")
+            .replace(/[,、;;]/g, ",").replace(/[-—–~~至]/g, "-");
+        const mode = parity.replace(/\s/g, "");
+        if (!["全部", "单", "双", "单周", "双周"].includes(mode)) {
+            throw new Error("无法识别单双周:" + parity);
+        }
+        const weeks = new Set();
+        for (const part of normalized.split(",")) {
+            const match = /^(\d{1,2})(?:-(\d{1,2}))?$/.exec(part);
+            if (!match) throw new Error("无法识别起始周:" + value);
+            const start = Number(match[1]);
+            const end = Number(match[2] || match[1]);
+            if (start < 1 || end > 53 || end < start) {
+                throw new Error("起始周超出有效范围:" + value);
+            }
+            for (let week = start; week <= end; week++) {
+                if (mode.startsWith("单") && week % 2 === 0) continue;
+                if (mode.startsWith("双") && week % 2 !== 0) continue;
+                weeks.add(week);
+            }
+        }
+        if (weeks.size === 0) throw new Error("起始周与单双周设置没有对应的上课周次。");
+        return Array.from(weeks).sort((a, b) => a - b);
+    }
+
+    function readFields(block) {
+        const fields = {};
+        const details = block.querySelector("table");
+        if (!details) throw new Error("课程详情结构已变化,请更新适配脚本。");
+        for (const row of Array.from(details.rows)) {
+            if (row.cells.length !== 2) throw new Error("课程详情字段结构异常。");
+            const key = text(row.cells[0]).replace(/[\s::]/g, "");
+            if (Object.prototype.hasOwnProperty.call(fields, key)) {
+                throw new Error("课程详情出现重复字段:" + key);
+            }
+            fields[key] = text(row.cells[1]);
+        }
+        for (const key of ["课程", "校区", "教室", "起始周", "单双周", "教师"]) {
+            if (!Object.prototype.hasOwnProperty.call(fields, key)) {
+                throw new Error("课程详情缺少字段:" + key);
+            }
+        }
+        if (!fields["课程"]) throw new Error("课表中存在未命名课程。");
+        return fields;
+    }
+
+    function parseTable(table) {
+        const rows = Array.from(table.rows);
+        const header = rows.shift();
+        const weekdays = header ? Array.from(header.cells).slice(1).map(text) : [];
+        if (weekdays.join(",") !== "周一,周二,周三,周四,周五,周六,周日,自定") {
+            throw new Error("课表星期表头已变化,无法安全确定课程位置。");
+        }
+        const courses = [];
+        const sections = rows.map(row => {
+            const label = Array.from(row.cells).find(cell => /^第\d+节$/.test(text(cell)));
+            return label ? Number(text(label).match(/\d+/)[0]) : null;
+        });
+        if (sections.filter(n => n !== null).join(",") !== "1,2,3,4,5,6,7,8,9,10,11,12") {
+            throw new Error("课表节次结构已变化,请更新适配脚本。");
+        }
+        const occupied = Array.from({ length: 8 }, () => new Set());
+        let blockCount = 0;
+        rows.forEach((row, rowIndex) => {
+            // 学校保留 rowspan 覆盖的 displaynone 占位格。不能过滤后再按列下标计算星期。
+            const cells = Array.from(row.cells).filter(cell => cell.classList.contains("cellStyle"));
+            if (cells.length !== 8) throw new Error("课表列数异常,请等待课表加载完成后重试。");
+            cells.forEach((cell, dayIndex) => {
+                const blocks = Array.from(cell.querySelectorAll(".tddiv"));
+                blockCount += blocks.length;
+                if (cell.classList.contains("displaynone")) {
+                    if (blocks.length || !occupied[dayIndex].has(rowIndex)) {
+                        throw new Error("课表隐藏单元格与连续节次不一致,请重新查询课表。");
+                    }
+                    return;
+                }
+                if (occupied[dayIndex].has(rowIndex)) throw new Error("课表连续节次重叠,无法确定排课位置。");
+                if (!blocks.length) {
+                    if (text(cell)) throw new Error("存在无法识别的课程内容,请更新适配脚本。");
+                    return;
+                }
+                if (dayIndex === 7 || sections[rowIndex] === null) {
+                    throw new Error("课表含自定时间课程,尚无明确星期或节次。请先确认排课后再导入,避免遗漏课程。");
+                }
+                const span = Number(cell.getAttribute("rowspan") || "1");
+                if (!Number.isInteger(span) || span < 1 || rowIndex + span > sections.length ||
+                    sections[rowIndex + span - 1] !== sections[rowIndex] + span - 1) {
+                    throw new Error("课程连续节次无效。");
+                }
+                for (let offset = 1; offset < span; offset++) occupied[dayIndex].add(rowIndex + offset);
+                for (const block of blocks) {
+                    const fields = readFields(block);
+                    courses.push({
+                        name: fields["课程"],
+                        teacher: fields["教师"],
+                        position: [fields["校区"], fields["教室"]].filter(Boolean).join(" "),
+                        day: dayIndex + 1,
+                        startSection: sections[rowIndex],
+                        endSection: sections[rowIndex] + span - 1,
+                        weeks: parseWeeks(fields["起始周"], fields["单双周"])
+                    });
+                }
+            });
+        });
+        if (blockCount !== table.querySelectorAll(".tddiv").length) {
+            throw new Error("存在未能解析的课程块,已停止导入。");
+        }
+        if (!courses.length) throw new Error("当前学期课表为空,请选择有课程的学期并等待加载完成。");
+        const merged = new Map();
+        for (const course of courses) {
+            const key = JSON.stringify([course.name, course.teacher, course.position,
+                course.day, course.startSection, course.endSection]);
+            if (merged.has(key)) {
+                const previous = merged.get(key);
+                previous.weeks = Array.from(new Set(previous.weeks.concat(course.weeks))).sort((a, b) => a - b);
+            } else merged.set(key, course);
+        }
+        return Array.from(merged.values());
+    }
+
+    async function runImportFlow() {
+        const bridge = window.shiguangBridgePromise;
+        if (window.location.origin !== "https://gedu.fjnu.edu.cn" ||
+            !/^#\/timeTable(?:[/?]|$)/.test(window.location.hash) ||
+            window.location.pathname !== "/index") {
+            throw new Error("请先登录福建师范大学研究生教务系统,进入“课程表”并选择目标学期,再执行导入。");
+        }
+        const table = document.querySelector("table#table1.timeTable");
+        if (!table || document.querySelector(".el-loading-mask:not([style*='display: none'])")) {
+            throw new Error("课表尚未加载完成,请稍后重试。");
+        }
+        const semester = document.querySelector('input[placeholder="学期"]');
+        if (!semester || !semester.value.trim()) throw new Error("未识别到当前学期,请重新查询课表。");
+        const snapshot = table.outerHTML;
+        const semesterName = semester.value;
+        const courses = parseTable(table);
+        const lastWeek = Math.max(...courses.flatMap(course => course.weeks));
+        const confirmed = await bridge.showAlert("福建师范大学研究生课表",
+            "当前学期:" + semesterName + "\n识别到 " + courses.length +
+            " 条课程安排,最晚上课周为第 " + lastWeek + " 周。将替换所选课表中的课程。" +
+            "\n\n同时导入学校作息时间,共 12 节(08:20—22:00),替换所选课表的节次时间。" +
+            "本页不提供第1周日期,导入后请在课表设置中核对学期开始日期和总周数。", "确认并导入");
+        if (!confirmed) return;
+        if (table.outerHTML !== snapshot || semester.value !== semesterName || !table.isConnected) {
+            throw new Error("导入期间课表发生变化,请等待加载完成后重新导入。");
+        }
+        // saveCourseConfig 会重置未提供的日期/时长字段,因此保留用户手动设置的配置。
+        await bridge.saveImportedCourses(JSON.stringify(courses));
+        try {
+            await bridge.savePresetTimeSlots(JSON.stringify(presetTimeSlots));
+        } catch (error) {
+            throw new Error("课程已保存,但作息时间导入失败,请重新执行导入:" + error.message);
+        }
+        window.shiguangBridge.showToast("福建师范大学研究生课表导入成功。");
+        window.shiguangBridge.notifyTaskCompletion();
+    }
+
+    runImportFlow().catch(async function (error) {
+        if (window.shiguangBridgePromise) {
+            await window.shiguangBridgePromise.showAlert("导入未完成", error.message, "知道了");
+        } else if (window.shiguangBridge) {
+            window.shiguangBridge.showToast("请在拾光课程表内执行适配脚本。");
+        }
+    });
+})();