ソースを参照

add: 郑州航空工业管理学院教务适配 (#443)

xBefore 1 ヶ月 前
コミット
75040f87ac
3 ファイル変更482 行追加0 行削除
  1. 5 0
      index/root_index.yaml
  2. 9 0
      resources/ZUA/adapters.yaml
  3. 468 0
      resources/ZUA/zua.js

+ 5 - 0
index/root_index.yaml

@@ -563,6 +563,11 @@ schools:
     initial: "H"
     resource_folder: "HFNU"
 
+  - id: "ZUA"
+    name: "郑州航空工业管理学院"
+    initial: "Z"
+    resource_folder: "ZUA"
+
   - id: "JHZYEDU"
     name: "江西航空职业技术学院"
     initial: "J"

+ 9 - 0
resources/ZUA/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/ZUA/adapters.yaml
+adapters:
+  - adapter_id: "ZUA_01"
+    adapter_name: "郑州航空工业管理学院教务系统"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "zua.js"
+    import_url: "http://jwglxt.zua.edu.cn/eams/loginExt.action"
+    maintainer: "xBefore"
+    description: "郑州航空工业管理学院树维 EAMS 教务课表导入。请在右上角切换为电脑版后登录教务;若页面显示异常,请手动刷新。登录成功后即可执行导入,无需进入“我的课表”等具体课程页面。支持自动同步学期日期与学校标准作息。"

+ 468 - 0
resources/ZUA/zua.js

@@ -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(/&nbsp;|&#160;|&#x0*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();