惠州学院 HZU | 教务系统课程表导出助手

解析惠州学院教务系统(正方教务系统)的课程表页面,导出为标准 .ics 日历文件,供第三方日历使用。核心代码改自 31415926535x 。

// ==UserScript==
// @name         惠州学院 HZU | 教务系统课程表导出助手
// @namespace    http://tampermonkey.net/
// @version      0.7.1
// @description  解析惠州学院教务系统(正方教务系统)的课程表页面,导出为标准 .ics 日历文件,供第三方日历使用。核心代码改自 31415926535x 。
// @author       Ckrvxr, 31415926535x
// @homepage     https://greasyfork.org/en/scripts/537439
// @homepage     https://github.com/Ckrvxr
// @compatible   chrome
// @license      Apache-2.0 license
// @include      *://jwxt.hzu.edu.cn/*
// @run-at       document-start
// ==/UserScript==

(function () {
    'use strict';

    const ClassScheduleURL = "kbcx/xskbcx_cxXskbcxIndex.html";
    unsafeWindow.addEventListener("load", main);
    function main() {
        const windowURL = window.location.href;
        if (windowURL.indexOf(ClassScheduleURL) !== -1) {
            ClassScheduleToICS();
        }
    }

    function ClassScheduleToICS() {

        function pageFullyLoaded() {
            let div = document.getElementsByClassName("btn-toolbar pull-right")[0];
            if (!div) return;

            let btn = document.createElement("button");
            btn.className = "btn btn-default";
            btn.id = "exportbtn";

            let sp = document.createElement("span");
            sp.innerText = "将导出课表为.ics";
            sp.className = "bigger-120 glyphicon glyphicon-file";
            btn.appendChild(sp);

            div.appendChild(btn);

            btn.onclick = function () {
                let input = document.createElement("input");
                input.type = "date";
                input.value = "2025-02-17";

                let dialog = document.createElement("div");
                dialog.style.position = "fixed";
                dialog.style.top = "40%";
                dialog.style.left = "50%";
                dialog.style.transform = "translate(-50%, -50%)";
                dialog.style.padding = "20px";
                dialog.style.backgroundColor = "#fff";
                dialog.style.border = "1px solid #ccc";
                dialog.style.zIndex = "9999";
                dialog.style.boxShadow = "0 0 10px rgba(0,0,0,0.3)";
                dialog.innerHTML = "<p>请选择要导出的学期第一个星期一:</p>";

                dialog.appendChild(input);

                let confirmBtn = document.createElement("button");
                confirmBtn.textContent = "确定";
                confirmBtn.style.marginLeft = "10px";
                confirmBtn.className = "btn btn-primary btn-sm";
                confirmBtn.onclick = function () {
                    startDate = input.value;
                    document.body.removeChild(dialog);
                    generateCalendar(parseCourses(parseTable(startDate)));
                    alert("请自行一一核对,以免漏课旷课!\n出现以上问题,概不负责!");
                };

                let cancelBtn = document.createElement("button");
                cancelBtn.textContent = "取消";
                cancelBtn.className = "btn btn-secondary btn-sm";
                cancelBtn.onclick = function () {
                    document.body.removeChild(dialog);
                };

                dialog.appendChild(confirmBtn);
                dialog.appendChild(cancelBtn);

                document.body.appendChild(dialog);
            };
        }

        var startDate;
        var Week;

        (function (Week) {
            Week[Week["Monday"] = 1] = "Monday";
            Week[Week["TuesDay"] = 2] = "TuesDay";
            Week[Week["Wednesday"] = 3] = "Wednesday";
            Week[Week["ThursDay"] = 4] = "ThursDay";
            Week[Week["Friday"] = 5] = "Friday";
            Week[Week["Saturday"] = 6] = "Saturday";
            Week[Week["Sunday"] = 7] = "Sunday";
        })(Week || (Week = {}));

        function parseTable() {
            let table = document.getElementById("kbgrid_table_0");
            let tds = table.querySelectorAll("td");
            let week = [];
            let divs = [];
            tds.forEach(element => {
                if (element.hasAttribute("id")) {
                    if (element.hasChildNodes()) {
                        let div = Array.from(element.getElementsByTagName("div"));
                        divs = divs.concat(div);
                        let wk = Week[element.getAttribute("id")[0]];
                        for (let i = 0; i < div.length; ++i) {
                            week.push(wk);
                        }
                    }
                }
            });
            return { week: week, divs: divs };
        }

        class Course {
            constructor(course) {
                if (course) {
                    this.name = course.name;
                    this.week = course.week;
                    this.info = course.info;
                    this.startTime = course.startTime;
                    this.endTime = course.endTime;
                    this.startWeek = course.startWeek;
                    this.endWeek = course.endWeek;
                    this.isSingleOrDouble = course.isSingleOrDouble;
                    this.location = course.location;
                    this.teacher = course.teacher;
                    this.exam = course.exam;
                }
            }
        }

        function parseCourses(data) {
            var courses = [];
            for (let i = 0; i < data.divs.length; ++i) {
                let course = new Course();
                course.week = data.week[i];
                course.name = data.divs[i].getElementsByTagName("span")[0]
                                          .getElementsByTagName("font")[0]
                                          .innerText;
                data.divs[i].querySelectorAll("p").forEach(p => {
                    if (p.getElementsByTagName("span")[0]?.getAttribute("title") === "节/周") {
                        (function (str = p.getElementsByTagName("font")[1]?.innerText || "") {
                            course.info = str;
                            let time = str.substring(str.indexOf("(") + 1, str.indexOf(")") + 1 - 1);
                            let wk = str.substring(str.indexOf(")") + 1, str.length).split(",");
                            course.startTime = parseInt(time.substring(0, time.indexOf("-")));
                            course.endTime = parseInt(time.substring(time.indexOf("-") + 1, time.indexOf("节")));
                            course.isSingleOrDouble = [];
                            course.startWeek = [];
                            course.endWeek = [];
                            wk.forEach(w => {
                                if (w.indexOf("单") !== -1 || w.indexOf("双") !== -1) {
                                    course.isSingleOrDouble.push(2);
                                } else {
                                    course.isSingleOrDouble.push(1);
                                }
                                let startWeek, endWeek;
                                if (w.indexOf("-") === -1) {
                                    startWeek = endWeek = parseInt(w.substring(0, w.indexOf("周")));
                                } else {
                                    startWeek = parseInt(w.substring(0, w.indexOf("-")));
                                    endWeek = parseInt(w.substring(w.indexOf("-") + 1, w.indexOf("周")));
                                }
                                course.startWeek.push(startWeek);
                                course.endWeek.push(endWeek);
                            });
                        })();
                    } else if (p.getElementsByTagName("span")[0]?.getAttribute("title") === "上课地点") {
                        course.location = (p.getElementsByTagName("font")[1]?.innerText || "").replace(/\s*本校区\s*/g, '');
                    } else if (p.getElementsByTagName("span")[0]?.getAttribute("title") === "教师 ") {
                        course.teacher = p.getElementsByTagName("font")[1]?.innerText || "";
                    } else if (p.getElementsByTagName("span")[0]?.getAttribute("title") === "考核方式") {
                        course.exam = p.getElementsByTagName("font")[1]?.innerText || "";
                    }
                });
                courses.push(course);
            }
            return courses;
        }

        function getTime(num, StartOrEnd) {
            const periodMap = [
                { start: "080000", end: "084500" },
                { start: "085000", end: "093500" },
                { start: "095500", end: "104000" },
                { start: "104500", end: "113000" },
                { start: "143000", end: "151500" },
                { start: "152000", end: "160500" },
                { start: "162000", end: "170500" },
                { start: "171000", end: "175500" },
                { start: "193000", end: "201500" },
                { start: "202000", end: "210500" },
                { start: "211000", end: "215500" }
            ];
            if (num < 1 || num > periodMap.length) {
                console.error("无效的节次编号:" + num);
                return "000000";
            }
            const period = periodMap[num - 1];
            return StartOrEnd === 0 ? period.start : period.end;
        }

        function getFixedLen(s, len) {
            if (s.length < len) {
                return getFixedLen("0" + s, len);
            } else if (s.length > len) {
                return s.slice(0, len);
            } else {
                return s;
            }
        }

        function getDate(num, wk) {
            let date = new Date(startDate.toString());
            date.setDate(date.getDate() + (num - 1) * 7 + Week[wk] - 1);
            let res = "";
            res += getFixedLen(date.getUTCFullYear().toString(), 4);
            res += getFixedLen((date.getUTCMonth() + 1).toString(), 2);
            res += getFixedLen(date.getUTCDate().toString(), 2);
            res += "T";
            return res;
        }

        function generateCalendar(courses) {
            let res = new ICS();
            courses.forEach(course => {
                for (let i = 0; i < course.isSingleOrDouble.length; ++i) {
                    let e = new ICSEvent(
                        getDate(course.startWeek[i], course.week) + getTime(course.startTime, 0),
                        getDate(course.startWeek[i], course.week) + getTime(course.endTime, 1),
                        course.name,
                        course.location
                    );
                    e.setDescription(course.name, course.teacher, course.info, course.location, course.exam);
                    e.setRRULE("WEEKLY", res.Calendar.WKST,
                        "" + (course.endWeek[i] - course.startWeek[i] + course.isSingleOrDouble[i]) / course.isSingleOrDouble[i],
                        "" + course.isSingleOrDouble[i],
                        "" + course.week.substr(0, 2).toUpperCase()
                    );
                    res.pushEvent(e);
                }
            });

            // 建立一个周数事件,持续 20 周
            (function () {
                for (let i = 1; i < 20; ++i) {
                    let e = new ICSEvent("" + getDate(i, Week[1]) + "060000",
                        "" + getDate(i, Week[1]) + "070000",
                        "" + "第" + i + "周",
                        ""
                    );
                    res.pushEvent(e);
                }
            })();

            res.pushCalendarEnd();
            res.exportIcs(startDate);
        }

        const CRLF = "\n";
        const SPACE = " ";

        class ICS {
            constructor() {
                this.Calendar = {
                    PRODID: "-//ckrvxr//HZU Calendar Exporter v0.7.1//CN",
                    VERSION: "2.0",
                    CALSCALE: "GREGORIAN",
                    TIMEZONE: "Asia/Shanghai",
                    ISVALARM: true,
                    VALARM: "-P0DT0H15M0S", //提醒时间
                    WKST: "SU"
                };
                this.ics = [
                    "BEGIN:VCALENDAR",
                    "VERSION:" + this.Calendar.VERSION,
                    "PRODID:" + this.Calendar.PRODID,
                    "CALSCALE:" + this.Calendar.CALSCALE
                ];
            }

            pushEvent(e) {
                this.ics.push("BEGIN:VEVENT");
                this.ics.push(e.getDTSTART());
                this.ics.push(e.getDTEND());
                if (e.isrrule) this.ics.push(e.getRRULE());
                this.ics.push(e.getSUMMARY());
                this.ics.push(e.getDESCRIPTION());
                if (e.LOCATION) this.ics.push(e.getLOCATION());
                if (this.Calendar.ISVALARM) this.pushAlarm();
                this.ics.push("END:VEVENT");
                this.ics.push(CRLF);
            }

            pushAlarm() {
                this.ics.push("BEGIN:VALARM");
                this.ics.push("ACTION:DISPLAY");
                this.ics.push("DESCRIPTION:This is an event reminder");
                this.ics.push("TRIGGER:" + this.Calendar.VALARM);
                this.ics.push("END:VALARM");
            }

            pushCalendarEnd() {
                this.ics.push("END:VCALENDAR");
            }

            getFixedIcs() {
                const MAX_LINE_LENGTH = 75;
                const FOLD_SPACE = " ";
                this.res = "";

                this.ics.forEach(line => {
                    let pos = 0;
                    while (pos < line.length) {
                        let end = Math.min(pos + MAX_LINE_LENGTH, line.length);
                        this.res += line.substring(pos, end) + CRLF;
                        pos = end;
                        if (pos < line.length) {
                            this.res += FOLD_SPACE;
                        }
                    }
                });

                return this.res;
            }

            exportIcs(startDate) {
                this.getFixedIcs();
                let link = window.URL.createObjectURL(new Blob([this.res], {
                    type: "text/x-vCalendar"
                }));
                const fileName = startDate + ".ics";
                let a = document.createElement("a");
                a.setAttribute("href", link);
                a.setAttribute("download", fileName);
                a.click();
            }
        }

        class ICSEvent {
            constructor(DTSTART, DTEND, SUMMARY, LOCATION) {
                this.DTSTART = DTSTART;
                this.DTEND = DTEND;
                this.SUMMARY = SUMMARY;
                this.LOCATION = LOCATION || null;
                this.DESCRIPTION = "";
            }

            isrrule = false;
            RRULE;
            LOCATION;

            setRRULE(FREQ, WKST, COUNT, INTERVAL, BYDAY) {
                this.isrrule = true;
                this.RRULE = "RRULE:FREQ=" + FREQ + ";WKST=" + WKST + ";COUNT=" + COUNT + ";INTERVAL=" + INTERVAL + ";BYDAY=" + BYDAY;
            }

            getRRULE() { return this.RRULE; }
            getDTSTART() { return "DTSTART:" + this.DTSTART; }
            getDTEND() { return "DTEND:" + this.DTEND; }
            getSUMMARY() { return "SUMMARY:" + this.SUMMARY; }
            getDESCRIPTION() { return this.DESCRIPTION ? "DESCRIPTION:" + this.DESCRIPTION : ""; }

            setDescription(name, teacher, info, location, exam) {
                this.DESCRIPTION = `课程名称:${name.trim()}\\n时间安排:${info.trim()}\\n上课地点:${location.trim()}\\n授课教师:${teacher.trim()}\\n考核方式:${exam.trim()}`;
            }

            getLOCATION() { return this.LOCATION ? "LOCATION:" + this.LOCATION : ""; }
        }

        pageFullyLoaded();
    }
})();