NUIST TimeTable Export

南信大课表导出为 iCal 格式

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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)