Browse Source

add: 北京邮电大学本科教务管理适配 (#255)

* feat: add BUPT undergraduate adapter
cstkn 1 week ago
parent
commit
1cab7a1bb0
3 changed files with 341 additions and 1 deletions
  1. 6 1
      index/root_index.yaml
  2. 9 0
      resources/BUPT/adapters.yaml
  3. 326 0
      resources/BUPT/bupt_01.js

+ 6 - 1
index/root_index.yaml

@@ -28,6 +28,11 @@ schools:
     initial: "U"
     resource_folder: "urp_jiaowu"
 
+  - id: "BUPT"
+    name: "北京邮电大学"
+    initial: "B"
+    resource_folder: "BUPT"
+
   - id: "CQU"
     name: "重庆大学"
     initial: "C"
@@ -415,4 +420,4 @@ schools:
   - id: "SCUEC"
     name: "中南民族大学"
     initial: "Z"
-    resource_folder: "SCUEC"
+    resource_folder: "SCUEC"

+ 9 - 0
resources/BUPT/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/BUPT/adapters.yaml
+adapters:
+  - adapter_id: "BUPT_01"
+    adapter_name: "北京邮电大学本科教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "bupt_01.js"
+    import_url: "https://jwgl.bupt.edu.cn/jsxsd/"
+    maintainer: "cstkn"
+    description: "适配北京邮电大学本科教务管理系统,登录后导入个人课表"

+ 326 - 0
resources/BUPT/bupt_01.js

@@ -0,0 +1,326 @@
+// 北京邮电大学本科教务管理系统拾光课表适配脚本
+// 适配页面:https://jwgl.bupt.edu.cn/jsxsd/xskb/xskb_list.do
+// 当前版本只解析已打开的“学期理论课表”页面,不主动请求接口。
+
+(function () {
+    function toast(message) {
+        if (window.AndroidBridge && AndroidBridge.showToast) {
+            AndroidBridge.showToast(message);
+        } else {
+            console.log(message);
+        }
+    }
+
+    async function alertUser(title, message) {
+        if (window.AndroidBridgePromise && window.AndroidBridgePromise.showAlert) {
+            return await window.AndroidBridgePromise.showAlert(title, message, "确定");
+        }
+        alert(title + "\n" + message);
+        return true;
+    }
+
+    function normalizeText(text) {
+        return String(text || "")
+            .replace(/\u00a0/g, " ")
+            .replace(/ /gi, " ")
+            .replace(/[0-9]/g, function (ch) {
+                return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0);
+            })
+            .replace(/[,、]/g, ",")
+            .replace(/[-–—~~至到]/g, "-")
+            .replace(/[()]/g, function (ch) {
+                return ch === "(" ? "(" : ")";
+            })
+            .replace(/\s+/g, " ")
+            .trim();
+    }
+
+    function findScheduleDocument() {
+        if (document.querySelector("#kbtable")) return document;
+
+        const frames = Array.from(document.querySelectorAll("iframe"));
+        for (const frame of frames) {
+            try {
+                const frameDoc = frame.contentDocument || frame.contentWindow.document;
+                if (frameDoc && frameDoc.querySelector("#kbtable")) return frameDoc;
+            } catch (e) {
+                // Ignore cross-origin or inaccessible frames.
+            }
+        }
+
+        return null;
+    }
+
+    function getTitleText(container, title) {
+        const node = container.querySelector(
+            `font[title="${title}"], span[title="${title}"], div[title="${title}"]`
+        );
+        return normalizeText(node ? node.textContent : "");
+    }
+
+    function extractCourseName(courseDiv) {
+        const clone = courseDiv.cloneNode(true);
+
+        Array.from(clone.querySelectorAll("font[title], span[title], div[title]")).forEach(function (node) {
+            node.remove();
+        });
+        Array.from(clone.querySelectorAll("span")).forEach(function (node) {
+            const text = normalizeText(node.textContent);
+            if (/^[A-Z]$/.test(text) || /^[●★○]+$/.test(text)) node.remove();
+        });
+
+        const holder = document.createElement("div");
+        holder.innerHTML = clone.innerHTML.replace(/<br\s*\/?>/gi, "\n");
+        const lines = holder.textContent
+            .split(/\n+/)
+            .map(normalizeText)
+            .filter(function (line) {
+                return line && line !== "-" && !/^\(\d+\)$/.test(line);
+            });
+
+        return normalizeText((lines[0] || "").replace(/[●★○]/g, ""));
+    }
+
+    function parseDay(courseDiv, fallbackDay) {
+        const id = courseDiv.getAttribute("id") || "";
+        const match = id.match(/-(\d)-\d$/);
+        if (match) return parseInt(match[1], 10);
+        return fallbackDay || 0;
+    }
+
+    function parseWeeks(weekText) {
+        const text = normalizeText(weekText)
+            .replace(/\[[^\]]*\]/g, "")
+            .replace(/\(周\)/g, "")
+            .replace(/周/g, "")
+            .replace(/\s/g, "");
+
+        const weeks = new Set();
+        text.split(/[;,;]/).forEach(function (part) {
+            if (!part) return;
+            const isOdd = /单/.test(part);
+            const isEven = /双/.test(part);
+            const ranges = part.match(/\d+(?:-\d+)?/g) || [];
+
+            ranges.forEach(function (rangeText) {
+                const range = rangeText.split("-").map(function (value) {
+                    return parseInt(value, 10);
+                });
+                const start = range[0];
+                const end = range.length > 1 ? range[1] : start;
+                if (!start || !end || start > end) return;
+
+                for (let week = start; week <= end; week++) {
+                    if (isOdd && week % 2 === 0) continue;
+                    if (isEven && week % 2 !== 0) continue;
+                    weeks.add(week);
+                }
+            });
+        });
+
+        return Array.from(weeks).sort(function (a, b) { return a - b; });
+    }
+
+    function parseSections(weekText) {
+        const text = normalizeText(weekText).replace(/\s/g, "");
+        const match = text.match(/\[([^\]]+)\]/);
+        if (!match) return [];
+
+        const numbers = match[1].match(/\d+/g) || [];
+        if (numbers.length === 0) return [];
+
+        const start = parseInt(numbers[0], 10);
+        const end = parseInt(numbers[numbers.length - 1], 10);
+        if (!start || !end || start > end) return [];
+
+        const sections = [];
+        for (let section = start; section <= end; section++) {
+            sections.push(section);
+        }
+        return sections;
+    }
+
+    function parseCourseDiv(courseDiv, fallbackDay) {
+        const rawText = normalizeText(courseDiv.textContent);
+        if (!rawText || rawText === "&nbsp;" || rawText.length < 2) return null;
+
+        const name = extractCourseName(courseDiv);
+        const teacher = getTitleText(courseDiv, "老师") || getTitleText(courseDiv, "教师");
+        const weekText = getTitleText(courseDiv, "周次(节次)");
+        const position = getTitleText(courseDiv, "教室") || "未知地点";
+        const weeks = parseWeeks(weekText);
+        const sections = parseSections(weekText);
+        const day = parseDay(courseDiv, fallbackDay);
+
+        if (!name || !day || weeks.length === 0 || sections.length === 0) return null;
+
+        return {
+            name: name,
+            teacher: teacher || "未知教师",
+            position: position,
+            day: day,
+            startSection: sections[0],
+            endSection: sections[sections.length - 1],
+            weeks: weeks
+        };
+    }
+
+    function parseCourses(doc) {
+        const table = doc.querySelector("#kbtable");
+        if (!table) return [];
+
+        const courses = [];
+        Array.from(table.querySelectorAll("tr")).forEach(function (row) {
+            const cells = Array.from(row.querySelectorAll("td"));
+            cells.forEach(function (cell, index) {
+                const fallbackDay = index + 1;
+                Array.from(cell.querySelectorAll("div.kbcontent")).forEach(function (courseDiv) {
+                    if (courseDiv.classList.contains("sykb2")) return;
+                    const course = parseCourseDiv(courseDiv, fallbackDay);
+                    if (course) courses.push(course);
+                });
+            });
+        });
+
+        return mergeCourses(courses);
+    }
+
+    function mergeCourses(courses) {
+        const map = new Map();
+
+        courses.forEach(function (course) {
+            const key = [
+                course.name,
+                course.teacher,
+                course.position,
+                course.day,
+                course.startSection,
+                course.endSection
+            ].join("|");
+
+            if (!map.has(key)) {
+                map.set(key, {
+                    name: course.name,
+                    teacher: course.teacher,
+                    position: course.position,
+                    day: course.day,
+                    startSection: course.startSection,
+                    endSection: course.endSection,
+                    weeks: course.weeks.slice()
+                });
+                return;
+            }
+
+            const existing = map.get(key);
+            existing.weeks = Array.from(new Set(existing.weeks.concat(course.weeks)));
+        });
+
+        return Array.from(map.values())
+            .map(function (course) {
+                course.weeks = course.weeks.sort(function (a, b) { return a - b; });
+                return course;
+            })
+            .sort(function (a, b) {
+                return a.day - b.day ||
+                    a.startSection - b.startSection ||
+                    a.name.localeCompare(b.name);
+            });
+    }
+
+    function parseTimeSlots(doc) {
+        const table = doc.querySelector("#kbtable");
+        if (!table) return [];
+
+        const map = new Map();
+        Array.from(table.querySelectorAll("tr")).forEach(function (row) {
+            const header = row.querySelector("th");
+            if (!header) return;
+
+            const text = normalizeText(header.textContent);
+            const match = text.match(/^(\d+).*?(\d{1,2}:\d{2})-(\d{1,2}:\d{2})/);
+            if (!match) return;
+
+            const number = parseInt(match[1], 10);
+            if (!number || map.has(number)) return;
+
+            map.set(number, {
+                number: number,
+                startTime: match[2].padStart(5, "0"),
+                endTime: match[3].padStart(5, "0")
+            });
+        });
+
+        return Array.from(map.values()).sort(function (a, b) { return a.number - b.number; });
+    }
+
+    async function saveToApp(courses, timeSlots) {
+        const maxWeek = Math.max.apply(null, courses.flatMap(function (course) { return course.weeks; }));
+        const config = {
+            semesterTotalWeeks: Number.isFinite(maxWeek) && maxWeek > 0 ? maxWeek : 20,
+            firstDayOfWeek: 1,
+            defaultClassDuration: 45,
+            defaultBreakDuration: 5
+        };
+
+        if (window.AndroidBridgePromise && window.AndroidBridgePromise.saveCourseConfig) {
+            await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify(config));
+        }
+        if (timeSlots.length > 0 && window.AndroidBridgePromise && window.AndroidBridgePromise.savePresetTimeSlots) {
+            await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
+        }
+
+        if (window.AndroidBridgePromise && window.AndroidBridgePromise.saveImportedCourses) {
+            return await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
+        }
+
+        console.log("BUPT parsed courses:", JSON.stringify(courses, null, 2));
+        console.log("BUPT parsed time slots:", JSON.stringify(timeSlots, null, 2));
+        return true;
+    }
+
+    async function runImportFlow() {
+        try {
+            const doc = findScheduleDocument();
+            if (!doc) {
+                await alertUser(
+                    "未找到课表",
+                    "请不要在教务系统主页直接导入。请先进入“学期理论课表”页面,并等待课表加载完成后再点击导入。"
+                );
+                return;
+            }
+
+            const confirmed = await alertUser(
+                "北邮课表导入",
+                "请确认当前不是教务系统主页,而是已经进入“学期理论课表”页面。脚本将直接解析当前页面显示的课表,请确认学期正确且页面已加载完成。"
+            );
+            if (!confirmed) return;
+
+            const courses = parseCourses(doc);
+            const timeSlots = parseTimeSlots(doc);
+
+            if (courses.length === 0) {
+                await alertUser(
+                    "未解析到课程",
+                    "当前页面没有解析到有效课程。请确认课表页面中存在课程块,或把一段 kbcontent HTML 发给我继续微调。"
+                );
+                return;
+            }
+
+            const saved = await saveToApp(courses, timeSlots);
+            if (!saved) {
+                toast("课程保存失败,请重试");
+                return;
+            }
+
+            toast(`导入成功:${courses.length} 个课程时段${timeSlots.length ? ",已同步作息时间" : ""}`);
+            if (window.AndroidBridge && AndroidBridge.notifyTaskCompletion) {
+                AndroidBridge.notifyTaskCompletion();
+            }
+        } catch (error) {
+            console.error("BUPT import failed:", error);
+            await alertUser("导入失败", error && error.message ? error.message : String(error));
+        }
+    }
+
+    runImportFlow();
+})();