// 山东水利职业学院树维 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();