// 山东水利职业学院树维 EAMS 拾光适配脚本
// 适配作者:小漫君
// 参数拆分、周次位图、逐周合并与日期校验复用拾光 ZUA/zua.js(沿用 HFNU/hfnu.js 的 EAMS 实现)。
// TaskActivity 解析参考以上实现;教学周历英文日期格式参考 HIIT/hiit_01.js。
// 上游:https://github.com/ShiGuangSchedule/shiguang_warehouse/tree/pending/resources
// 按上游 MIT 许可保留来源;账号、学生参数、学期和开学日期均不写死。
const BASE_URL = 'https://jw.sdwcvc.edu.cn';
const MAX_SUPPORTED_WEEK = 60;
// 来源:学校官网“作息时间”公布的统一作息(第 1~10 节)。
// https://www.sdwcvc.edu.cn/ggfw/zxsj.htm
const SDWCVC_TIME_SLOTS = [
{ number: 1, startTime: '08:15', endTime: '09:00' },
{ number: 2, startTime: '09:10', endTime: '09:55' },
{ number: 3, startTime: '10:10', endTime: '10:55' },
{ number: 4, startTime: '11:05', endTime: '11:50' },
{ number: 5, startTime: '14:30', endTime: '15:15' },
{ number: 6, startTime: '15:25', endTime: '16:10' },
{ number: 7, startTime: '16:20', endTime: '17:05' },
{ number: 8, startTime: '17:15', endTime: '18:00' },
{ number: 9, startTime: '19:30', endTime: '20:15' },
{ number: 10, startTime: '20:25', endTime: '21:10' }
];
function powerSplit(paramsRaw) {
const args = [];
let current = "";
let depth = 0;
let inQuote = false;
let quoteChar = "";
for (let i = 0; i < paramsRaw.length; i++) {
const char = paramsRaw[i];
if ((char === '"' || char === "'") && (i === 0 || paramsRaw[i - 1] !== "\\")) {
if (!inQuote) {
inQuote = true;
quoteChar = char;
} else if (char === quoteChar) {
inQuote = false;
}
}
if (!inQuote) {
if (char === "(" || char === "[" || char === "{") depth++;
if (char === ")" || char === "]" || char === "}") depth--;
}
if (char === "," && depth === 0 && !inQuote) {
args.push(cleanArg(current));
current = "";
} else {
current += char;
}
}
args.push(cleanArg(current));
return args;
}
function cleanArg(value) {
const trimmed = value.trim();
if (trimmed === "null") return null;
return trimmed.replace(/^["']|["']$/g, "");
}
function parseWeeksBitmap(bitmap) {
const weeks = [];
const value = String(bitmap || "");
// 树维 EAMS 位图的下标就是周次,下标 0 是占位符;拾光使用 1 基周次。
for (let week = 1; week < value.length && week <= MAX_SUPPORTED_WEEK; week++) {
if (value[week] === "1") weeks.push(week);
}
return weeks;
}
/**
* 沿用 HFNU 的按周次矩阵合并算法:只有课程名、教师、地点和星期均相同
* 且在同一周内节次连续时才合并。remark 不参与分组,重复实验项会由 Set 去重。
*/
function mergeContinuousLessons(lessons) {
if (!lessons || lessons.length === 0) return [];
const groups = {};
lessons.forEach(lesson => {
const key = `${lesson.name}|${lesson.teacher}|${lesson.position}|${lesson.day}`;
if (!groups[key]) {
groups[key] = {
name: lesson.name,
teacher: lesson.teacher,
position: lesson.position,
day: lesson.day,
weeksMatrix: Array.from({ length: MAX_SUPPORTED_WEEK + 1 }, () => new Set())
};
}
if (Array.isArray(lesson.weeks)) {
lesson.weeks.forEach(week => {
if (Number.isInteger(week) && week > 0 && week <= MAX_SUPPORTED_WEEK) {
for (let section = lesson.startSection; section <= lesson.endSection; section++) {
groups[key].weeksMatrix[week].add(section);
}
}
});
}
});
const merged = [];
for (const key in groups) {
const group = groups[key];
const blockMap = {};
for (let week = 1; week < group.weeksMatrix.length; week++) {
const sections = Array.from(group.weeksMatrix[week]).sort((a, b) => a - b);
if (sections.length === 0) continue;
let start = sections[0];
let previous = sections[0];
for (let i = 1; i < sections.length; i++) {
const current = sections[i];
if (current === previous + 1) {
previous = current;
} else {
const blockKey = `${start}-${previous}`;
if (!blockMap[blockKey]) blockMap[blockKey] = [];
blockMap[blockKey].push(week);
start = current;
previous = current;
}
}
const blockKey = `${start}-${previous}`;
if (!blockMap[blockKey]) blockMap[blockKey] = [];
blockMap[blockKey].push(week);
}
for (const blockKey in blockMap) {
const [startSection, endSection] = blockKey.split("-").map(Number);
merged.push({
name: group.name,
teacher: group.teacher,
position: group.position,
day: group.day,
startSection,
endSection,
weeks: blockMap[blockKey]
});
}
}
merged.sort((a, b) => {
if (a.day !== b.day) return a.day - b.day;
if (a.startSection !== b.startSection) return a.startSection - b.startSection;
if (a.name !== b.name) return a.name.localeCompare(b.name);
return a.position.localeCompare(b.position);
});
return merged;
}
function normalizeNumericDate(year, month, day) {
const yearNumber = Number(year);
const monthNumber = Number(month);
const dayNumber = Number(day);
const date = new Date(Date.UTC(yearNumber, monthNumber - 1, dayNumber));
if (
date.getUTCFullYear() !== yearNumber ||
date.getUTCMonth() + 1 !== monthNumber ||
date.getUTCDate() !== dayNumber
) {
return null;
}
return [
String(yearNumber).padStart(4, "0"),
String(monthNumber).padStart(2, "0"),
String(dayNumber).padStart(2, "0")
].join("-");
}
function parseParameters(html) {
const student = html.match(/bg\.form\.addInput\(\s*form\s*,\s*["']ids["']\s*,\s*["'](\d+)["']\s*\)/);
const semester = html.match(/\bentityId\s*:\s*["'](\d+)["']/);
if (!student || !semester) throw new Error('未识别到教务课表参数,请先登录并进入“教务系统”。');
return { ids: student[1], semesterId: semester[1] };
}
function parseSemesters(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const semesters = Array.from(doc.querySelectorAll('option')).map(function (option) {
const id = option.value.trim();
const name = option.textContent.trim();
if (!/^\d+$/.test(id) || !/^\d{4}-\d{4}-\d+$/.test(name)) throw new Error('学期列表格式不匹配。');
return { id, name };
});
if (semesters.length === 0) throw new Error('未获取到可选学期。');
return semesters;
}
function parseTeacherName(block) {
const teachers = block.match(/actTeachers\s*=\s*\[([\s\S]*?)\]\s*;/);
if (!teachers) throw new Error('课程教师字段结构不匹配。');
const names = Array.from(teachers[1].matchAll(/\bname\s*:\s*"([^"]*)"/g), function (match) { return match[1]; });
if (teachers[1].trim() && names.length === 0) throw new Error('课程教师字段结构不匹配。');
return Array.from(new Set(names)).join(',');
}
function parseTaskActivities(html) {
const unitCountMatch = html.match(/\bunitCount\s*=\s*(\d+)\s*;/);
if (!unitCountMatch || Number(unitCountMatch[1]) < 1) throw new Error('未获取到有效节次定义。');
const unitCount = Number(unitCountMatch[1]);
const indexRegex = new RegExp('index\\s*=\\s*(\\d+)\\s*\\*\\s*unitCount\\s*\\+\\s*(\\d+)\\s*;', 'g');
const rawResults = [];
const blocks = html.split(/var\s+teachers\s*=/);
const expectedCount = (html.match(/new\s+TaskActivity\(/g) || []).length;
let parsedCount = 0;
for (let i = 1; i < blocks.length; i++) {
const block = blocks[i];
const teacher = parseTeacherName(block);
const activities = Array.from(block.matchAll(/new\s+TaskActivity\(([\s\S]*?)\)\s*;/g));
for (let j = 0; j < activities.length; j++) {
parsedCount++;
const activity = activities[j];
const args = powerSplit(activity[1]);
if (args.length < 7 || typeof args[3] !== 'string' || typeof args[5] !== 'string' || !/^[01]+$/.test(args[6])) {
throw new Error('课程活动字段结构不匹配。');
}
// EAMS 的课程显示名在名称后附加 ...,保留名称中的罗马数字等正文。
const name = args[3].replace(/\([^()]*\)\s*$/, '')
.replace(/]*>[\s\S]*?<\/sup>\s*$/i, '').trim();
const position = args[5].trim();
if (!name || args[6].length > MAX_SUPPORTED_WEEK + 1) throw new Error('课程名称或周次位图无效。');
const weeks = parseWeeksBitmap(args[6]);
// 全零位图没有实际教学周,不生成课程。
if (weeks.length === 0) continue;
const end = j + 1 < activities.length ? activities[j + 1].index : block.length;
const scope = block.slice(activity.index + activity[0].length, end);
indexRegex.lastIndex = 0;
let match;
let sectionCount = 0;
while ((match = indexRegex.exec(scope)) !== null) {
const day = Number(match[1]) + 1;
const section = Number(match[2]) + 1;
if (day < 1 || day > 7 || section < 1 || section > unitCount) throw new Error('课程星期或节次超出教务定义。');
rawResults.push({ name, teacher, position, day, startSection: section, endSection: section, weeks: [...weeks] });
sectionCount++;
}
if (sectionCount === 0) throw new Error('课程活动缺少实际上课节次。');
}
}
if (parsedCount !== expectedCount) throw new Error('课程活动结构不匹配。');
return mergeContinuousLessons(rawResults);
}
function parseCalendarInfo(html, semesterName) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const termCell = Array.from(doc.querySelectorAll('td')).find(function (cell) {
return /学年度\s*\/\s*学期/.test(cell.textContent);
});
if (termCell?.nextElementSibling?.textContent.trim().replace(/\s+/g, '-') !== semesterName) {
throw new Error('返回校历与所选学期不一致。');
}
const text = doc.body.textContent;
const match = text.match(/([A-Za-z]{3})\s+(\d{1,2}),\s+(\d{4})\s*~\s*([A-Za-z]{3})\s+(\d{1,2}),\s+(\d{4})\s*\((\d+)\)/);
if (!match) throw new Error('未识别到教学周历起止日期。');
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const startDate = normalizeNumericDate(match[3], months.indexOf(match[1]) + 1, match[2]);
const endDate = normalizeNumericDate(match[6], months.indexOf(match[4]) + 1, match[5]);
const totalWeeks = Number(match[7]);
if (!startDate || !endDate || endDate < startDate || totalWeeks < 1 || totalWeeks > MAX_SUPPORTED_WEEK ||
new Date(startDate + 'T00:00:00Z').getUTCDay() !== 1) {
throw new Error('教学周历日期或总周数无效。');
}
return { semesterStartDate: startDate, semesterTotalWeeks: totalWeeks, firstDayOfWeek: 1, defaultClassDuration: 45, defaultBreakDuration: 10 };
}
async function requestText(path, parameters) {
const options = { credentials: 'include' };
if (parameters !== undefined) {
options.method = 'POST';
options.headers = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' };
options.body = new URLSearchParams(parameters).toString();
}
const response = await fetch(BASE_URL + path, options);
if (!response.ok) throw new Error('教务请求失败(HTTP ' + response.status + ')。');
const responseUrl = new URL(response.url);
if (responseUrl.origin === BASE_URL && responseUrl.pathname === '/eams/loginExt.action') {
throw new Error('教务接口返回了登录页,请从统一认证门户重新打开“教务系统”后导入。');
}
return response.text();
}
async function runImportFlow() {
try {
if (window.location.origin !== BASE_URL) {
await window.shiguangBridgePromise.showAlert('请进入教务系统', '请先登录统一认证,并在门户中打开“教务系统”后导入。', '知道了');
return;
}
window.shiguangBridge.showToast('正在读取学期信息…');
const parameters = parseParameters(await requestText('/eams/courseTableForStd.action'));
const semesters = parseSemesters(await requestText('/eams/dataQuery.action', {
dataType: 'semester', entityId: parameters.semesterId
}));
const currentSemesterIndex = semesters.findIndex(function (semester) { return semester.id === parameters.semesterId; });
if (currentSemesterIndex < 0) throw new Error('学期列表缺少教务当前学期。');
const picked = await window.shiguangBridgePromise.showSingleSelection(
'选择学期', JSON.stringify(semesters.map(function (semester) { return semester.name; })),
currentSemesterIndex
);
if (picked === null) return;
if (!Number.isInteger(picked) || picked < 0 || picked >= semesters.length) throw new Error('学期选择无效。');
const semester = semesters[picked];
window.shiguangBridge.showToast('正在获取整学期课表…');
const courseHtml = await requestText('/eams/courseTableForStd!courseTable.action', {
ignoreHead: '1', 'setting.kind': 'std', startWeek: '', 'semester.id': semester.id, ids: parameters.ids
});
const courses = parseTaskActivities(courseHtml);
if (courses.length === 0) {
await window.shiguangBridgePromise.showAlert('该学期暂无课程', '所选学期没有已安排上课周次和节次的课程。', '知道了');
return;
}
const config = parseCalendarInfo(await requestText('/eams/base/calendar-info.action', {
version: '1', semesterId: semester.id
}), semester.name);
if (courses.some(function (course) { return course.weeks.some(function (week) { return week > config.semesterTotalWeeks; }); })) {
throw new Error('课程周次超出所选学期校历。');
}
if (courses.some(function (course) { return course.endSection > SDWCVC_TIME_SLOTS.length; })) {
throw new Error('课程节次超出学校统一作息表的第 1~10 节。');
}
if (!await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config))) throw new Error('课表配置保存失败。');
if (!await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(SDWCVC_TIME_SLOTS))) throw new Error('作息时间保存失败。');
if (!await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses))) throw new Error('课程保存失败。');
window.shiguangBridge.showToast('已获取 ' + courses.length + ' 条课程时段,并应用学校统一作息。');
window.shiguangBridge.notifyTaskCompletion();
} catch (error) {
await window.shiguangBridgePromise.showAlert('导入失败', error.message, '知道了');
}
}
runImportFlow();