|
|
@@ -0,0 +1,468 @@
|
|
|
+// 郑州航空工业管理学院 (zua.edu.cn) 拾光课程表适配脚本
|
|
|
+// 复用 HFNU 的树维 EAMS 解析流程,并针对 ZUA 的课程名和教室格式做适配。
|
|
|
+
|
|
|
+const BASE_URL = "http://jwglxt.zua.edu.cn";
|
|
|
+const MAX_SUPPORTED_WEEK = 60;
|
|
|
+const ZUA_TIME_SLOTS = [
|
|
|
+ { number: 1, startTime: "08:00", endTime: "08:45" },
|
|
|
+ { number: 2, startTime: "08:55", endTime: "09:40" },
|
|
|
+ { number: 3, startTime: "10:00", endTime: "10:45" },
|
|
|
+ { number: 4, startTime: "10:55", endTime: "11:40" },
|
|
|
+ { number: 5, startTime: "14:30", endTime: "15:15" },
|
|
|
+ { number: 6, startTime: "15:25", endTime: "16:10" },
|
|
|
+ { number: 7, startTime: "16:30", endTime: "17:15" },
|
|
|
+ { number: 8, startTime: "17:25", endTime: "18:10" },
|
|
|
+ { 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 cleanCourseName(name) {
|
|
|
+ return String(name || "未知课程").replace(/\([^()]*\)\s*$/, "").trim();
|
|
|
+}
|
|
|
+
|
|
|
+function cleanPosition(position) {
|
|
|
+ return String(position || "未知地点").replace(/\s+/g, " ").trim();
|
|
|
+}
|
|
|
+
|
|
|
+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 parseTeacherName(block) {
|
|
|
+ const teachersMatch = block.match(/actTeachers\s*=\s*\[([\s\S]*?)\]\s*;/);
|
|
|
+ if (!teachersMatch) return "未知教师";
|
|
|
+
|
|
|
+ const names = [];
|
|
|
+ const nameRegex = /\bname\s*:\s*"([^"]+)"/g;
|
|
|
+ let match;
|
|
|
+ while ((match = nameRegex.exec(teachersMatch[1])) !== null) {
|
|
|
+ if (!names.includes(match[1])) names.push(match[1]);
|
|
|
+ }
|
|
|
+ return names.length > 0 ? names.join(",") : "未知教师";
|
|
|
+}
|
|
|
+
|
|
|
+function parseTaskActivities(html) {
|
|
|
+ const rawResults = [];
|
|
|
+ const unitCountMatch = html.match(/\bunitCount\s*=\s*(\d+)\s*;/);
|
|
|
+ const unitCount = unitCountMatch ? parseInt(unitCountMatch[1], 10) : 14;
|
|
|
+ const indexRegex = new RegExp(
|
|
|
+ `index\\s*=\\s*(\\d+)\\s*\\*\\s*(?:unitCount|${unitCount})\\s*\\+\\s*(\\d+)\\s*;`,
|
|
|
+ "g"
|
|
|
+ );
|
|
|
+ const blocks = html.split(/var\s+teachers\s*=/);
|
|
|
+
|
|
|
+ for (let i = 1; i < blocks.length; i++) {
|
|
|
+ const block = blocks[i];
|
|
|
+ const teacher = parseTeacherName(block);
|
|
|
+ const activityRegex = /new\s+TaskActivity\(([\s\S]*?)\)\s*;/g;
|
|
|
+ const activities = [];
|
|
|
+ let activityMatch;
|
|
|
+ while ((activityMatch = activityRegex.exec(block)) !== null) {
|
|
|
+ activities.push({
|
|
|
+ argsRaw: activityMatch[1],
|
|
|
+ start: activityMatch.index,
|
|
|
+ end: activityRegex.lastIndex
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ for (let activityIndex = 0; activityIndex < activities.length; activityIndex++) {
|
|
|
+ const activity = activities[activityIndex];
|
|
|
+ const args = powerSplit(activity.argsRaw);
|
|
|
+ if (args.length < 7) continue;
|
|
|
+
|
|
|
+ const name = cleanCourseName(args[3]);
|
|
|
+ const position = cleanPosition(args[5]);
|
|
|
+ const weeks = parseWeeksBitmap(args[6]);
|
|
|
+ if (weeks.length === 0) continue;
|
|
|
+
|
|
|
+ const nextActivityStart = activityIndex + 1 < activities.length
|
|
|
+ ? activities[activityIndex + 1].start
|
|
|
+ : block.length;
|
|
|
+ const activityScope = block.slice(activity.end, nextActivityStart);
|
|
|
+ indexRegex.lastIndex = 0;
|
|
|
+ let indexMatch;
|
|
|
+ while ((indexMatch = indexRegex.exec(activityScope)) !== null) {
|
|
|
+ const rawDay = parseInt(indexMatch[1], 10);
|
|
|
+ const rawSection = parseInt(indexMatch[2], 10);
|
|
|
+ if (rawDay < 0 || rawDay > 6 || rawSection < 0 || rawSection >= unitCount) continue;
|
|
|
+
|
|
|
+ const day = rawDay + 1;
|
|
|
+ const section = rawSection + 1;
|
|
|
+ rawResults.push({
|
|
|
+ name,
|
|
|
+ teacher,
|
|
|
+ position,
|
|
|
+ day,
|
|
|
+ startSection: section,
|
|
|
+ endSection: section,
|
|
|
+ weeks: [...weeks]
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return mergeContinuousLessons(rawResults);
|
|
|
+}
|
|
|
+
|
|
|
+function parseParameters(html) {
|
|
|
+ const idsMatch = html.match(/bg\.form\.addInput\(\s*form\s*,\s*["']ids["']\s*,\s*["'](\d+)["']\s*\)/);
|
|
|
+ const tagIdMatch = html.match(/id=["'](semesterBar\d+Semester)["']/);
|
|
|
+ if (!idsMatch || !tagIdMatch) return null;
|
|
|
+
|
|
|
+ const tagId = tagIdMatch[1];
|
|
|
+ const escapedTagId = tagId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
+ const elementMatch = html.match(new RegExp(`<[^>]*\\bid=["']${escapedTagId}["'][^>]*>`, "i"));
|
|
|
+ const valueMatch = elementMatch ? elementMatch[0].match(/\bvalue=["'](\d+)["']/i) : null;
|
|
|
+
|
|
|
+ return {
|
|
|
+ ids: idsMatch[1],
|
|
|
+ tagId,
|
|
|
+ currentSemesterId: valueMatch ? valueMatch[1] : null
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function parseSemesterResponse(raw) {
|
|
|
+ const data = Function(`return (${raw});`)();
|
|
|
+ const semesters = [];
|
|
|
+
|
|
|
+ for (const key of Object.keys(data.semesters || {})) {
|
|
|
+ const entries = Array.isArray(data.semesters[key]) ? data.semesters[key] : [];
|
|
|
+ entries.forEach(semester => {
|
|
|
+ if (semester && semester.id !== undefined) {
|
|
|
+ const term = String(semester.name || "").trim();
|
|
|
+ const label = /^第.*学期$/.test(term) ? term : `第${term}学期`;
|
|
|
+ semesters.push({
|
|
|
+ id: String(semester.id),
|
|
|
+ schoolYear: String(semester.schoolYear || ""),
|
|
|
+ term,
|
|
|
+ name: `${semester.schoolYear} ${label}`.trim()
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ semesters.sort((a, b) => {
|
|
|
+ const yearCompare = b.schoolYear.localeCompare(a.schoolYear);
|
|
|
+ if (yearCompare !== 0) return yearCompare;
|
|
|
+ return b.term.localeCompare(a.term, undefined, { numeric: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ semesters,
|
|
|
+ currentSemesterId: data.semesterId === undefined ? null : String(data.semesterId)
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+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 parseCalendarInfo(html) {
|
|
|
+ const text = String(html || "")
|
|
|
+ .replace(/<[^>]*>/g, " ")
|
|
|
+ .replace(/ | |�*A0;/gi, " ")
|
|
|
+ .replace(/\u00a0/g, " ");
|
|
|
+ const match = text.match(
|
|
|
+ /开始\s*\/\s*结束日期\s*[::]?\s*(\d{4})\s*-\s*(\d{1,2})\s*-\s*(\d{1,2})\s*~\s*(\d{4})\s*-\s*(\d{1,2})\s*-\s*(\d{1,2})\s*\(\s*(\d+)\s*\)/
|
|
|
+ );
|
|
|
+ if (!match) return null;
|
|
|
+
|
|
|
+ const semesterStartDate = normalizeNumericDate(match[1], match[2], match[3]);
|
|
|
+ const semesterEndDate = normalizeNumericDate(match[4], match[5], match[6]);
|
|
|
+ const semesterTotalWeeks = Number(match[7]);
|
|
|
+ if (
|
|
|
+ !semesterStartDate ||
|
|
|
+ !semesterEndDate ||
|
|
|
+ semesterEndDate < semesterStartDate ||
|
|
|
+ !Number.isInteger(semesterTotalWeeks) ||
|
|
|
+ semesterTotalWeeks < 1 ||
|
|
|
+ semesterTotalWeeks > MAX_SUPPORTED_WEEK
|
|
|
+ ) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ semesterStartDate,
|
|
|
+ semesterEndDate,
|
|
|
+ semesterTotalWeeks,
|
|
|
+ firstDayOfWeek: 1
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function getZuaTimeSlots() {
|
|
|
+ return ZUA_TIME_SLOTS.map(slot => ({ ...slot }));
|
|
|
+}
|
|
|
+
|
|
|
+async function request(url, options = {}) {
|
|
|
+ const response = await fetch(url, { credentials: "include", ...options });
|
|
|
+ if (!response.ok) throw new Error(`网络请求失败: ${response.status}`);
|
|
|
+ return await response.text();
|
|
|
+}
|
|
|
+
|
|
|
+async function detectParameters() {
|
|
|
+ const html = await request(`${BASE_URL}/eams/courseTableForStd.action`);
|
|
|
+ return parseParameters(html);
|
|
|
+}
|
|
|
+
|
|
|
+async function getSelectedSemester(tagId, currentSemesterId) {
|
|
|
+ const form = new URLSearchParams();
|
|
|
+ form.set("tagId", tagId);
|
|
|
+ form.set("dataType", "semesterCalendar");
|
|
|
+ if (currentSemesterId) form.set("value", currentSemesterId);
|
|
|
+ form.set("empty", "false");
|
|
|
+
|
|
|
+ const raw = await request(`${BASE_URL}/eams/dataQuery.action`, {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
|
|
+ body: form.toString()
|
|
|
+ });
|
|
|
+ const parsed = parseSemesterResponse(raw);
|
|
|
+ if (parsed.semesters.length === 0) throw new Error("未获取到可选学期");
|
|
|
+
|
|
|
+ const selectedId = currentSemesterId || parsed.currentSemesterId;
|
|
|
+ const defaultIndex = parsed.semesters.findIndex(semester => semester.id === selectedId);
|
|
|
+ const index = await window.AndroidBridgePromise.showSingleSelection(
|
|
|
+ "选择学期",
|
|
|
+ JSON.stringify(parsed.semesters.map(semester => semester.name)),
|
|
|
+ defaultIndex
|
|
|
+ );
|
|
|
+ return Number.isInteger(index) && index >= 0 && index < parsed.semesters.length
|
|
|
+ ? parsed.semesters[index]
|
|
|
+ : null;
|
|
|
+}
|
|
|
+
|
|
|
+async function fetchAndParseCourses(semesterId, ids) {
|
|
|
+ const form = new URLSearchParams();
|
|
|
+ form.set("ignoreHead", "1");
|
|
|
+ form.set("setting.kind", "std");
|
|
|
+ form.set("startWeek", "");
|
|
|
+ form.set("semester.id", String(semesterId));
|
|
|
+ form.set("ids", String(ids));
|
|
|
+
|
|
|
+ const html = await request(`${BASE_URL}/eams/courseTableForStd!courseTable.action`, {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
|
|
+ body: form.toString()
|
|
|
+ });
|
|
|
+ return parseTaskActivities(html);
|
|
|
+}
|
|
|
+
|
|
|
+async function fetchCalendarInfo(semesterId) {
|
|
|
+ const form = new URLSearchParams();
|
|
|
+ form.set("version", "1");
|
|
|
+ form.set("semesterId", String(semesterId));
|
|
|
+
|
|
|
+ const html = await request(`${BASE_URL}/eams/base/calendar-info.action`, {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
|
|
+ body: form.toString()
|
|
|
+ });
|
|
|
+ const calendarInfo = parseCalendarInfo(html);
|
|
|
+ if (!calendarInfo) throw new Error("未能解析学期日历");
|
|
|
+ return calendarInfo;
|
|
|
+}
|
|
|
+
|
|
|
+async function trySaveCalendarInfo(semesterId) {
|
|
|
+ try {
|
|
|
+ const calendarInfo = await fetchCalendarInfo(semesterId);
|
|
|
+ const saveResult = await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify({
|
|
|
+ semesterStartDate: calendarInfo.semesterStartDate,
|
|
|
+ semesterTotalWeeks: calendarInfo.semesterTotalWeeks,
|
|
|
+ firstDayOfWeek: calendarInfo.firstDayOfWeek
|
|
|
+ }));
|
|
|
+ return saveResult === true;
|
|
|
+ } catch (error) {
|
|
|
+ console.warn(`[ZUA 学期信息设置失败] ${error.message}`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function trySaveTimeSlots() {
|
|
|
+ try {
|
|
|
+ const saveResult = await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(getZuaTimeSlots()));
|
|
|
+ return saveResult === true;
|
|
|
+ } catch (error) {
|
|
|
+ console.warn(`[ZUA 作息时间设置失败] ${error.message}`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function buildCompletionMessage(calendarSaved, timeSlotsSaved) {
|
|
|
+ if (calendarSaved && timeSlotsSaved) return "成功导入课表、学期信息和郑航作息";
|
|
|
+ if (!calendarSaved && !timeSlotsSaved) {
|
|
|
+ return "课表已导入,学期日期和作息时间设置失败,请在设置中确认";
|
|
|
+ }
|
|
|
+ if (!calendarSaved) return "课表已导入,学期日期获取失败,请在设置中确认";
|
|
|
+ return "课表已导入,作息时间设置失败,请在设置中确认";
|
|
|
+}
|
|
|
+
|
|
|
+async function runImportFlow() {
|
|
|
+ try {
|
|
|
+ AndroidBridge.showToast("开始探测郑航教务参数...");
|
|
|
+ const params = await detectParameters();
|
|
|
+ if (!params) throw new Error("未能识别教务参数,请确认已登录郑航教务系统");
|
|
|
+
|
|
|
+ const semester = await getSelectedSemester(params.tagId, params.currentSemesterId);
|
|
|
+ if (!semester) return;
|
|
|
+
|
|
|
+ AndroidBridge.showToast("正在同步课表...");
|
|
|
+ const courses = await fetchAndParseCourses(semester.id, params.ids);
|
|
|
+ if (!courses || courses.length === 0) throw new Error("未解析到课程数据");
|
|
|
+
|
|
|
+ const saveResult = await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
|
|
|
+ if (!saveResult) throw new Error("课程保存失败");
|
|
|
+
|
|
|
+ const calendarSaved = await trySaveCalendarInfo(semester.id);
|
|
|
+ const timeSlotsSaved = await trySaveTimeSlots();
|
|
|
+ AndroidBridge.showToast(buildCompletionMessage(calendarSaved, timeSlotsSaved));
|
|
|
+ AndroidBridge.notifyTaskCompletion();
|
|
|
+ } catch (error) {
|
|
|
+ console.error(`[ZUA 课表导入异常] ${error.message}`);
|
|
|
+ AndroidBridge.showToast(error.message);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+runImportFlow();
|