Greasy Fork 支持简体中文。

NUIST TimeTable Export

南信大课表导出为 iCal 格式

// ==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)