NUIST TimeTable Export

南信大课表导出为 iCal 格式

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         NUIST TimeTable Export
// @version      0.1
// @description  南信大课表导出为 iCal 格式
// @author       凌莞
// @license MIT
// @match        http://bkxk.nuist.edu.cn/*/student/mykebiaoall1.aspx
// @icon         https://www.google.com/s2/favicons?sz=64&domain=nuist.edu.cn
// @grant        none
// @namespace https://greasyfork.org/users/874549
// ==/UserScript==

((neko, nya) => {
    const INFO_SEPERATOR = '◇';
    const TIMETABLE = [
        // [begin, end]
        ['080000', '094000'],
        ['101000', '115000'],
        ['134500', '152500'],
        ['155500', '173500'],
        ['184500', '202500'],
    ];
    const WEEKS = 16;
    // 开学第一周的星期一
    const FIRST_SCHOOL_DAY = new Date('2022-02-21');

    const ONE_DAY = 24 * 60 * 60 * 1000;
    const lastOf = (iterable) => iterable[iterable.length - 1];
    const $ = neko
    Date.prototype.format = function (fmt) {
        var o = {
            'M+': this.getMonth() + 1, //月份
            'd+': this.getDate(), //日
            'h+': this.getHours(), //小时
            'm+': this.getMinutes(), //分
            's+': this.getSeconds(), //秒
            'q+': Math.floor((this.getMonth() + 3) / 3), //季度
            S: this.getMilliseconds(), //毫秒
        }
        if (/(y+)/.test(fmt)) {
            fmt = fmt.replace(RegExp.$1, (this.getFullYear() + '').substr(4 - RegExp.$1.length))
        }
        for (var k in o) {
            if (new RegExp('(' + k + ')').test(fmt)) {
                fmt = fmt.replace(RegExp.$1, RegExp.$1.length === 1 ? o[k] : ('00' + o[k]).substr(('' + o[k]).length))
            }
        }
        return fmt
    }

    /**
     * 将课表上的课程文本转换成对象
     * @param {string} lesson 课表上的课程文本,暂不支持黑色菱形(多节课)
     * 
     * 如:数字图像处理◇范春年(1-16)(软工合作20(2)班;软工合作20(1)班;)◇(西苑)揽江楼N505◇多媒体教室◇{12节}
     */
    function parselesson(lesson) {
        if (!lesson.trim()) return null
        const info = lesson.split(INFO_SEPERATOR);
        const name = info[0].trim();
        let place = info.length === 5 ? info[2].trim() : '';
        if (['(西苑)', '(东苑)', '(中苑)'].some(it => place.startsWith(it))) {
            place = place.substring(4);
        }
        const infoPart2 = parseInfo(info[1].trim());
        const teacher = infoPart2[0];
        // 一起上课的班级
        const coClass = infoPart2.length === 3 ? infoPart2[2] : '';
        let weeks = [1, WEEKS];
        if (infoPart2.length === 3) {
            const weeksExec = /(\d+)-(\d+)/.exec(infoPart2[1]);
            weeks = [parseInt(weeksExec[1]), parseInt(weeksExec[2])];
        }
        let weekSpec = 'all';
        lastOf(info).startsWith('单周') && (weekSpec = 'odd');
        lastOf(info).startsWith('双周') && (weekSpec = 'even');
        return { name, place, teacher, coClass, weeks, weekSpec, raw: lesson };
    }

    /**
     * 解析课程文本的第二串
     * @param {string} info 第二串信息,大概包含老师姓名,周次,班级
     * 
     * 如:范春年(1-16)(软工合作20(2)班;软工合作20(1)班;)
     */
    function parseInfo(info) {
        let lastBrackletLength = 0;
        const infoArray = [''];
        for (const i of info) {
            if (i === '(') {
                lastBrackletLength++;
                if (lastBrackletLength === 1)
                    lastOf(infoArray) !== '' && infoArray.push('');
                else
                    infoArray[infoArray.length - 1] += i;
            }
            else if (i === ')') {
                lastBrackletLength--;
                if (lastBrackletLength === 0)
                    lastOf(infoArray) !== '' && infoArray.push('');
                else
                    infoArray[infoArray.length - 1] += i;
            }
            else {
                infoArray[infoArray.length - 1] += i;
            }

        }
        if (lastOf(infoArray) === '')
            infoArray.pop();
        return infoArray;
    }

    function getSessionTimeWrapper(beginEnd) {
        return (weekday, session, isEvenWeek, firstWeek) => {
            weekday += firstWeek - 1;
            isEvenWeek && (weekday += 7);
            const firstLessonDay = new Date(FIRST_SCHOOL_DAY.getTime() + weekday * ONE_DAY);
            return `${firstLessonDay.format('yyyyMMdd')}T${TIMETABLE[session][beginEnd]}`;
        }
    }

    const getSessionBeginTime = getSessionTimeWrapper(0);
    const getSessionEndTime = getSessionTimeWrapper(1);

    /**
     * 根据网页获取课程列表
     */
    function getLessonInfos() {
        const $rows = $('table#TABLE1>tbody>tr');
        const weekdays = [];
        for (let i = 1; i < 7; i++) {
            const $row = $rows.eq(i);
            const sessions = [];
            for (let j = 1; j < 6; j++) {
                sessions.push(parselesson($row.children().eq(j).text()));
            }
            weekdays.push(sessions);
        }
        return weekdays;
    }

    function getPageInfo() {
        const schoolYear = $('select#DropDownList1').val()
        const semester = $('select#DropDownList2').val()
        return `${schoolYear} 学年第 ${semester} 学期课表`
    }

    function convertTimeTableToRfc5545(title, timeTable) {
        let icsData = `BEGIN:VCALENDAR
PRODID:-//Clansty//NUIST TimeTable Export 1.0//EN
VERSION:2.0
CALSCALE:GREGORIAN
X-WR-CALNAME:${title}
X-WR-TIMEZONE:Asia/Shanghai
BEGIN:VTIMEZONE
TZID:Asia/Shanghai
X-LIC-LOCATION:Asia/Shanghai
BEGIN:STANDARD
TZOFFSETFROM:+0800
TZOFFSETTO:+0800
TZNAME:CST
DTSTART:19700101T000000
END:STANDARD
END:VTIMEZONE`

        let idCount = 1000;
        const id = () => ++idCount;

        /**
         * 把课程写入 ics
         * @param {object} lesson 课程对象
         * @param {number} weekday 星期几
         * @param {number} session 第几节
         */
        const writeLesson = (lesson, weekday, session) => {
            if (!lesson) return;
            const WEEKDAYS = ['MO', 'TU', 'WE', 'TH', 'FR'];
            const { name, place, teacher, coClass, weeks, weekSpec, raw } = lesson;
            const lessonWeeks = weeks[1] - weeks[0] + 1;
            icsData += `
BEGIN:VEVENT
DTSTART;TZID=Asia/Shanghai:${getSessionBeginTime(weekday, session, weekSpec === 'even', weeks[0])}
DTEND;TZID=Asia/Shanghai:${getSessionEndTime(weekday, session, weekSpec === 'even', weeks[0])}
DTSTAMP:${new Date().format('yyyyMMddThhmmss')}
UID:${id()}@clansty.com
SUMMARY:${name}
DESCRIPTION:${teacher}\\n${coClass}\\n\\n${raw}
LOCATION:${place}
RRULE:FREQ=WEEKLY;WKST=MO;INTERVAL=${weekSpec === 'all' ? 1 : 2};BYDAY=${WEEKDAYS[weekday]
                };COUNT=${Math.floor(weekSpec === 'all' ? lessonWeeks : lessonWeeks / 2)}
END:VEVENT`
        }

        // 节次
        for (let session = 0; session < timeTable.length; session++) {
            // 星期几
            for (let weekday = 0; weekday < timeTable[session].length; weekday++) {
                const lesson = timeTable[session][weekday];
                lesson && writeLesson(lesson, weekday, session);
            }
        }

        icsData += '\nEND:VCALENDAR';
        return icsData;
    }

    function downloadText(content, filename) {
        const blob = new Blob([content]);
        const url = URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.href = url;
        link.download = filename;
        link.click();
    }

    function doGetIcs() {
        const title = getPageInfo();
        const icsContent = convertTimeTableToRfc5545(title, getLessonInfos());
        downloadText(icsContent, `${title}-${new Date().format('yyyyMMddhhmmss')}.ics`);
    }

    function injectExportButton() {
        const btn = document.createElement('input');
        btn.type = 'button';
        btn.value = '导出为 iCal';
        btn.onclick = doGetIcs;

        $('input#Button1').after(btn);
    }

    injectExportButton();
})(jQuery)