|
|
@@ -0,0 +1,713 @@
|
|
|
+// 郑州汽车工程职业学院 (zzvcae.edu.cn) 拾光课程表适配脚本
|
|
|
+// 平台:树维 EAMS 教务(与郑州航空工业管理学院 jwglxt.zua.edu.cn 同平台)
|
|
|
+// 参考:郑州航院 zua.js(树维 EAMS Fetch 解析流程),针对本校 CAS 单点登录与作息做适配
|
|
|
+//
|
|
|
+// 使用说明:
|
|
|
+// 1. 在 App 内本适配的入口地址会打开 CAS 统一身份认证登录页
|
|
|
+// 2. 登录 CAS 成功后,进入【服务大厅】→【学生课表查询】,等待课表页面加载完成
|
|
|
+// 3. 回到 App 点击【执行导入】即可
|
|
|
+// 注意:教务系统禁止从 jw.zzvcae.edu.cn 直接登录,必须经由 CAS 登录并在服务大厅访问过一次课表,
|
|
|
+// 这样 jw.zzvcae.edu.cn 域下才会建立有效的教务会话,脚本的接口请求才能成功。
|
|
|
+
|
|
|
+const BASE_URL = "https://jw.zzvcae.edu.cn";
|
|
|
+const MAX_SUPPORTED_WEEK = 60;
|
|
|
+
|
|
|
+// 本校作息兜底(学校未发布公开作息表时使用;正常情况下脚本会优先从课表页动态解析作息)
|
|
|
+// 这里暂按常见高职作息填写占位,实测后以教务系统课表页解析出的作息为准
|
|
|
+const ZZQCC_TIME_SLOTS_FALLBACK = [
|
|
|
+ { 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(/<[^>]*>/g, "") // 去掉教务页嵌入的 HTML 标签(如 <sup style='...'></sup> 颜色标记)
|
|
|
+ .replace(/ | /gi, " ")
|
|
|
+ .replace(/\s+/g, " ")
|
|
|
+ .replace(/\([^()]*\)\s*$/, "") // 去除尾部纯括号说明(如"(二)")
|
|
|
+ .trim();
|
|
|
+}
|
|
|
+
|
|
|
+function cleanPosition(position) {
|
|
|
+ return String(position || "未知地点")
|
|
|
+ .replace(/<[^>]*>/g, "")
|
|
|
+ .replace(/ | /gi, " ")
|
|
|
+ .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;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 按周次矩阵合并算法:只有课程名、教师、地点和星期均相同
|
|
|
+ * 且在同一周内节次连续时才合并。
|
|
|
+ */
|
|
|
+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) {
|
|
|
+ // 多种可能的 ids 表达方式
|
|
|
+ const idsPatterns = [
|
|
|
+ /bg\.form\.addInput\(\s*form\s*,\s*["']ids["']\s*,\s*["'](\d+)["']\s*\)/,
|
|
|
+ /["']ids["']\s*,\s*["'](\d+)["']/,
|
|
|
+ /name=["']ids["'][^>]*value=["'](\d+)["']/i,
|
|
|
+ /value=["'](\d+)["'][^>]*name=["']ids["']/i,
|
|
|
+ /ids\s*=\s*["'](\d+)["']/
|
|
|
+ ];
|
|
|
+ let ids = null;
|
|
|
+ for (const pattern of idsPatterns) {
|
|
|
+ const match = html.match(pattern);
|
|
|
+ if (match && match[1]) {
|
|
|
+ ids = match[1];
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 学期标签 id 匹配:优先 semesterBar\d+Semester,其次任意含 semester 的 id
|
|
|
+ let tagIdMatch = html.match(/id=["'](semesterBar\d+Semester)["']/);
|
|
|
+ if (!tagIdMatch) {
|
|
|
+ tagIdMatch = html.match(/id=["']([^"']*[Ss]emester[^"']*)["']/);
|
|
|
+ }
|
|
|
+ if (!ids || !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,
|
|
|
+ tagId,
|
|
|
+ currentSemesterId: valueMatch ? valueMatch[1] : null
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 从课表页 HTML 中动态解析作息时间(树维 EAMS 的节次表头为 th[id="0_节次"],文本形如 "(08:00-08:45)")
|
|
|
+ */
|
|
|
+function parseTimeSlotsFromHtml(html) {
|
|
|
+ const timeSlots = [];
|
|
|
+ const thRegex = /<th[^>]*id=["']0_(\d+)["'][^>]*>([\s\S]*?)<\/th>/gi;
|
|
|
+ let match;
|
|
|
+ while ((match = thRegex.exec(html)) !== null) {
|
|
|
+ const number = parseInt(match[1], 10);
|
|
|
+ const text = match[2].replace(/<[^>]*>/g, " ").replace(/ | /gi, " ").replace(/\u00a0/g, " ");
|
|
|
+ const timeMatch = text.match(/(\d{1,2}:\d{2})\s*[-~至]\s*(\d{1,2}:\d{2})/);
|
|
|
+ if (timeMatch && !isNaN(number)) {
|
|
|
+ timeSlots.push({
|
|
|
+ number,
|
|
|
+ startTime: timeMatch[1].padStart(5, "0"),
|
|
|
+ endTime: timeMatch[2].padStart(5, "0")
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ timeSlots.sort((a, b) => a.number - b.number);
|
|
|
+ // 校验编号从 1 开始连续
|
|
|
+ for (let i = 0; i < timeSlots.length; i++) {
|
|
|
+ if (timeSlots[i].number !== i + 1) return [];
|
|
|
+ }
|
|
|
+ return timeSlots;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 从学期对象中提取日期字段(兼容多种字段名与格式)
|
|
|
+ */
|
|
|
+function extractDateFromObj(obj, keys) {
|
|
|
+ for (const k of keys) {
|
|
|
+ const v = obj ? obj[k] : null;
|
|
|
+ if (v !== null && v !== undefined && v !== "") {
|
|
|
+ const m = String(v).match(/(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})日?/);
|
|
|
+ if (m) {
|
|
|
+ const d = normalizeNumericDate(m[1], m[2], m[3]);
|
|
|
+ if (d) return d;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 兜底:扫描对象所有字段值里是否含日期
|
|
|
+ for (const k in obj || {}) {
|
|
|
+ const v = obj[k];
|
|
|
+ if (typeof v === "string") {
|
|
|
+ const m = v.match(/(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})日?/);
|
|
|
+ if (m) {
|
|
|
+ const d = normalizeNumericDate(m[1], m[2], m[3]);
|
|
|
+ if (d) return d;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return 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(),
|
|
|
+ startDate: extractDateFromObj(semester, ["startDate", "beginDate", "start", "startTime", "dateBegin"]),
|
|
|
+ endDate: extractDateFromObj(semester, ["endDate", "end", "endTime", "finishDate", "dateEnd"])
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ 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, " ")
|
|
|
+ .replace(/\s+/g, " ");
|
|
|
+
|
|
|
+ // 兼容多种表达:开始/结束日期、开始~结束、起止、YYYY-M-D / YYYY年M月D日 等
|
|
|
+ const patterns = [
|
|
|
+ /开始\s*\/\s*结束日期\s*[::]?\s*(\d{4})[-/年]\s*(\d{1,2})[-/月]\s*(\d{1,2})日?\s*[~—~至到]\s*(\d{4})[-/年]\s*(\d{1,2})[-/月]\s*(\d{1,2})日?\s*\(?\s*(\d+)\s*周?\s*\)?/i,
|
|
|
+ /开始\s*[::]?\s*(\d{4})[-/年]\s*(\d{1,2})[-/月]\s*(\d{1,2})日?\s*[~—~至到结束]?\s*(\d{4})?[-/年]?\s*(\d{1,2})?[-/月]?\s*(\d{1,2})?日?\s*[^0-9]{0,10}(\d+)\s*周?/i,
|
|
|
+ /(\d{4})[-/年]\s*(\d{1,2})[-/月]\s*(\d{1,2})日?\s*[~—~至到]\s*(\d{4})[-/年]\s*(\d{1,2})[-/月]\s*(\d{1,2})日?/
|
|
|
+ ];
|
|
|
+ let match = null;
|
|
|
+ for (const pattern of patterns) {
|
|
|
+ match = text.match(pattern);
|
|
|
+ if (match) break;
|
|
|
+ }
|
|
|
+ if (!match) return null;
|
|
|
+
|
|
|
+ // 计算总周数:优先取显式数字,否则按起止日期估算
|
|
|
+ let semesterTotalWeeks = null;
|
|
|
+ const explicitWeeks = match[7] ? Number(match[7]) : NaN;
|
|
|
+ if (Number.isInteger(explicitWeeks) && explicitWeeks >= 1 && explicitWeeks <= MAX_SUPPORTED_WEEK) {
|
|
|
+ semesterTotalWeeks = explicitWeeks;
|
|
|
+ }
|
|
|
+
|
|
|
+ const semesterStartDate = normalizeNumericDate(match[1], match[2], match[3]);
|
|
|
+ if (!semesterStartDate) return null;
|
|
|
+
|
|
|
+ // 结束日期用于估算周数(可选)
|
|
|
+ let semesterEndDate = null;
|
|
|
+ if (match[4] && match[5] && match[6]) {
|
|
|
+ semesterEndDate = normalizeNumericDate(match[4], match[5], match[6]);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!semesterTotalWeeks && semesterEndDate && semesterEndDate >= semesterStartDate) {
|
|
|
+ const days = Math.round((new Date(semesterEndDate) - new Date(semesterStartDate)) / 86400000) + 1;
|
|
|
+ const estimated = Math.round(days / 7);
|
|
|
+ if (estimated >= 1 && estimated <= MAX_SUPPORTED_WEEK) semesterTotalWeeks = estimated;
|
|
|
+ }
|
|
|
+ if (!semesterTotalWeeks) return null;
|
|
|
+
|
|
|
+ return {
|
|
|
+ semesterStartDate,
|
|
|
+ semesterEndDate,
|
|
|
+ semesterTotalWeeks,
|
|
|
+ firstDayOfWeek: 1
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+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`);
|
|
|
+ const params = parseParameters(html);
|
|
|
+ if (!params) {
|
|
|
+ console.warn("[参数识别失败] 未能从课表页识别 ids 与 semesterBar");
|
|
|
+ throw new Error("未能识别教务参数,请确认已通过服务大厅登录并访问过课表页");
|
|
|
+ }
|
|
|
+ return params;
|
|
|
+}
|
|
|
+
|
|
|
+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.shiguangBridgePromise.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()
|
|
|
+ });
|
|
|
+ // 如果返回了登录页,说明教务会话未建立
|
|
|
+ if (/loginExt|登录/i.test(html) && !html.includes("courseTableForStd") && html.length < 30000) {
|
|
|
+ throw new Error("教务会话未建立(请求被重定向到登录页),请先在服务大厅打开过课表页面");
|
|
|
+ }
|
|
|
+ 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) {
|
|
|
+ const cleaned = String(html || "")
|
|
|
+ .replace(/<[^>]*>/g, " ")
|
|
|
+ .replace(/ | /gi, " ")
|
|
|
+ .replace(/\u00a0/g, " ")
|
|
|
+ .replace(/\s+/g, " ").trim();
|
|
|
+ console.warn(`[学期日历] 未能解析学期日历,原文(清理后前400字): ${cleaned.slice(0, 400)}`);
|
|
|
+ throw new Error("未能解析学期日历");
|
|
|
+ }
|
|
|
+ return calendarInfo;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 获取作息时间:优先从课表页 HTML 动态解析,失败则使用兜底作息
|
|
|
+ */
|
|
|
+async function fetchTimeSlots() {
|
|
|
+ try {
|
|
|
+ const html = await request(`${BASE_URL}/eams/courseTableForStd.action`);
|
|
|
+ const parsed = parseTimeSlotsFromHtml(html);
|
|
|
+ if (parsed && parsed.length > 0) {
|
|
|
+ console.log(`[作息] 已从课表页解析出 ${parsed.length} 个节次时间段`);
|
|
|
+ return parsed;
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.warn(`[作息] 课表页解析失败,使用兜底作息: ${error.message}`);
|
|
|
+ }
|
|
|
+ console.log(`[作息] 使用兜底作息(${ZZQCC_TIME_SLOTS_FALLBACK.length} 个节次)`);
|
|
|
+ return ZZQCC_TIME_SLOTS_FALLBACK.map(slot => ({ ...slot }));
|
|
|
+}
|
|
|
+
|
|
|
+async function trySaveCalendarInfo(semesterId) {
|
|
|
+ try {
|
|
|
+ const calendarInfo = await fetchCalendarInfo(semesterId);
|
|
|
+ const saveResult = await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
|
|
|
+ semesterStartDate: calendarInfo.semesterStartDate,
|
|
|
+ semesterTotalWeeks: calendarInfo.semesterTotalWeeks,
|
|
|
+ firstDayOfWeek: calendarInfo.firstDayOfWeek
|
|
|
+ }));
|
|
|
+ return saveResult === true;
|
|
|
+ } catch (error) {
|
|
|
+ console.warn(`[学期信息设置失败] ${error.message}`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function trySaveTimeSlots() {
|
|
|
+ try {
|
|
|
+ const timeSlots = await fetchTimeSlots();
|
|
|
+ const saveResult = await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
|
|
|
+ return saveResult === true;
|
|
|
+ } catch (error) {
|
|
|
+ console.warn(`[作息时间设置失败] ${error.message}`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function buildCompletionMessage(calendarSaved, timeSlotsSaved) {
|
|
|
+ if (calendarSaved && timeSlotsSaved) return "成功导入课表、学期信息和本校作息";
|
|
|
+ if (!calendarSaved && !timeSlotsSaved) {
|
|
|
+ return "课表已导入,学期日期和作息时间设置失败,请在设置中确认";
|
|
|
+ }
|
|
|
+ if (!calendarSaved) return "课表已导入,学期日期获取失败,请在设置中确认";
|
|
|
+ return "课表已导入,作息时间设置失败,请在设置中确认";
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 从选中学期的数据中保存学期配置(开始日期 + 总周数=课表最大周次),失败返回 false
|
|
|
+ */
|
|
|
+async function trySaveSemesterConfig(semester, courses) {
|
|
|
+ try {
|
|
|
+ const startDate = semester && semester.startDate;
|
|
|
+ if (!startDate) {
|
|
|
+ console.warn("[学期配置] 学期数据中未找到开始日期,改用 calendar-info 兜底");
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ let maxWeek = 0;
|
|
|
+ (courses || []).forEach(course => {
|
|
|
+ (course.weeks || []).forEach(w => {
|
|
|
+ if (Number.isInteger(w) && w > maxWeek) maxWeek = w;
|
|
|
+ });
|
|
|
+ });
|
|
|
+ const totalWeeks = maxWeek >= 1 ? maxWeek : 20;
|
|
|
+ const config = { semesterStartDate: startDate, semesterTotalWeeks: totalWeeks, firstDayOfWeek: 1 };
|
|
|
+ console.log(`[学期配置] 保存: ${JSON.stringify(config)}`);
|
|
|
+ const saveResult = await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
|
|
|
+ return saveResult === true;
|
|
|
+ } catch (error) {
|
|
|
+ console.warn(`[学期配置] 保存失败: ${error.message}`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 日期输入校验函数(供 showPrompt 使用)
|
|
|
+ */
|
|
|
+function validateDateInput(input) {
|
|
|
+ const m = String(input || "").trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
|
|
+ if (!m) return "请输入 YYYY-MM-DD 格式(如 2026-09-07)";
|
|
|
+ const d = normalizeNumericDate(m[1], m[2], m[3]);
|
|
|
+ if (!d) return "日期无效,请检查月份和日期";
|
|
|
+ return false;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 最终兜底:让用户输入本学期开学日期,并保存学期配置
|
|
|
+ */
|
|
|
+async function promptSemesterStartDate(courses) {
|
|
|
+ try {
|
|
|
+ const input = await window.shiguangBridgePromise.showPrompt(
|
|
|
+ "设置本学期开学日期",
|
|
|
+ "未能自动获取学期日期,请输入本学期开学日期(格式 YYYY-MM-DD,例如 2026-09-07)",
|
|
|
+ "2026-09-07",
|
|
|
+ "validateDateInput"
|
|
|
+ );
|
|
|
+ if (!input) {
|
|
|
+ window.shiguangBridge.showToast("已跳过开学日期设置");
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ const m = String(input).trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
|
|
+ if (!m) return false;
|
|
|
+ const startDate = normalizeNumericDate(m[1], m[2], m[3]);
|
|
|
+ if (!startDate) return false;
|
|
|
+
|
|
|
+ let maxWeek = 0;
|
|
|
+ (courses || []).forEach(course => {
|
|
|
+ (course.weeks || []).forEach(w => {
|
|
|
+ if (Number.isInteger(w) && w > maxWeek) maxWeek = w;
|
|
|
+ });
|
|
|
+ });
|
|
|
+ const totalWeeks = maxWeek >= 1 ? maxWeek : 20;
|
|
|
+ const config = { semesterStartDate: startDate, semesterTotalWeeks: totalWeeks, firstDayOfWeek: 1 };
|
|
|
+ console.log(`[学期配置] 用户输入开学日期保存: ${JSON.stringify(config)}`);
|
|
|
+ const saveResult = await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
|
|
|
+ return saveResult === true;
|
|
|
+ } catch (error) {
|
|
|
+ console.warn(`[学期配置] 用户输入保存失败: ${error.message}`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function runImportFlow() {
|
|
|
+ try {
|
|
|
+ const alertConfirmed = await window.shiguangBridgePromise.showAlert(
|
|
|
+ "郑州汽车工程职业学院课表导入",
|
|
|
+ "请确认已完成以下步骤:\n1. 已在页面上通过统一身份认证(CAS)登录\n2. 已从【服务大厅】→【学生课表查询】打开过课表页面\n3. 当前页面处于 jw.zzvcae.edu.cn 域下\n\n确认后开始同步课表。",
|
|
|
+ "开始同步"
|
|
|
+ );
|
|
|
+ if (!alertConfirmed) return;
|
|
|
+
|
|
|
+ window.shiguangBridge.showToast("开始探测教务参数...");
|
|
|
+ const params = await detectParameters();
|
|
|
+ if (!params) throw new Error("未能识别教务参数,请确认已通过服务大厅登录并访问过课表页");
|
|
|
+
|
|
|
+ const semester = await getSelectedSemester(params.tagId, params.currentSemesterId);
|
|
|
+ if (!semester) return;
|
|
|
+
|
|
|
+ window.shiguangBridge.showToast("正在同步课表...");
|
|
|
+ const courses = await fetchAndParseCourses(semester.id, params.ids);
|
|
|
+ if (!courses || courses.length === 0) {
|
|
|
+ console.warn("[课程解析] TaskActivity 解析结果为空,请检查上方[诊断]输出中的 TaskActivity/var teachers 标记");
|
|
|
+ throw new Error("未解析到课程数据(请把控制台[诊断]信息反馈给开发者)");
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log(`[课程解析] 成功解析 ${courses.length} 门课程时段`);
|
|
|
+ const saveResult = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
|
|
|
+ if (!saveResult) throw new Error("课程保存失败");
|
|
|
+
|
|
|
+ // 学期配置:优先学期数据 → calendar-info 兜底 → 用户手动输入兜底
|
|
|
+ let calendarSaved = await trySaveSemesterConfig(semester, courses);
|
|
|
+ if (!calendarSaved) {
|
|
|
+ calendarSaved = await trySaveCalendarInfo(semester.id);
|
|
|
+ }
|
|
|
+ if (!calendarSaved) {
|
|
|
+ calendarSaved = await promptSemesterStartDate(courses);
|
|
|
+ }
|
|
|
+ const timeSlotsSaved = await trySaveTimeSlots();
|
|
|
+ window.shiguangBridge.showToast(buildCompletionMessage(calendarSaved, timeSlotsSaved));
|
|
|
+ window.shiguangBridge.notifyTaskCompletion();
|
|
|
+ } catch (error) {
|
|
|
+ console.error(`[郑州汽车工程职业学院课表导入异常] ${error && error.stack ? error.stack : error}`);
|
|
|
+ try {
|
|
|
+ await window.shiguangBridgePromise.showAlert("导入失败", error.message, "知道了");
|
|
|
+ } catch (dialogError) {
|
|
|
+ window.shiguangBridge.showToast(`导入失败: ${error.message}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+runImportFlow();
|