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