Просмотр исходного кода

Merge pull request #447 from XingHeYuZhuan/pending

星河欲转 1 месяц назад
Родитель
Сommit
eec61a5af1

+ 10 - 0
index/root_index.yaml

@@ -697,3 +697,13 @@ schools:
     name: "黑龙江大学"
     initial: "H"
     resource_folder: "HLJU"
+
+  - id: "SHUFEZJ"
+    name: "上海财经大学浙江学院"
+    initial: "S"
+    resource_folder: "SHUFEZJ"
+    
+  - id: "YZU"
+    name: "扬州大学"
+    initial: "Y"
+    resource_folder: "YZU"   

+ 9 - 0
resources/SHUFEZJ/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/SHUFEZJ/adapters.yaml
+adapters:
+  - adapter_id: "SHUFEZJ"
+    adapter_name: "上海财经大学浙江学院正方教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "shufezj.js"
+    import_url: "https://jwxt.shufe-zj.edu.cn/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151&layout=default"
+    maintainer: "ZnonEn"
+    description: "上海财经大学浙江学院正方教务,通过CAS统一身份认证登录后,进入个人课表查询页面即可点击导入。非本校开发者适配如果有误建议提交issues"

+ 390 - 0
resources/SHUFEZJ/shufezj.js

@@ -0,0 +1,390 @@
+// 上海财经大学浙江学院 (shufe-zj.edu.cn) 拾光课程表适配脚本
+// 基于正方教务系统接口适配
+// 教务系统: jwxt.shufe-zj.edu.cn (正方教务 ZFSoft)
+// 登录方式: CAS 统一身份认证 (ty.shufe-zj.edu.cn)
+// 参照仓库内 GDUST(广东科技学院)正方 v9 适配模板编写
+
+/**
+ * 解析周次字符串,兼容多种格式:
+ *   - "1-16周"                     -> [1..16]
+ *   - "1-8周(单)" / "1-8周(双)"    -> 单/双周
+ *   - "1-3周,5周,7-9周(单)"        -> 多段混合
+ *   - 无"周"字的 "1-16" / "1-3,5"
+ */
+function parseWeeks(weekStr) {
+    if (!weekStr) return [];
+    // 去掉花括号包裹的内容(部分正方版本返回 "{第1-16周}")
+    const cleaned = weekStr.replace(/\{[^}]*\}/g, '').trim();
+    const weekSets = cleaned.split(',');
+    const weeks = [];
+
+    for (let set of weekSets) {
+        set = set.trim();
+        const isSingle = set.includes('(单)') || set.includes('(单周)');
+        const isDouble = set.includes('(双)') || set.includes('(双周)');
+
+        const rangeMatch = set.match(/(\d+)\s*[-~—]\s*(\d+)/);
+        const singleMatch = set.match(/(\d+)/);
+
+        let start, end;
+        if (rangeMatch) {
+            start = Number(rangeMatch[1]);
+            end = Number(rangeMatch[2]);
+        } else if (singleMatch) {
+            start = end = Number(singleMatch[1]);
+        } else {
+            continue;
+        }
+
+        if (start > end) [start, end] = [end, start];
+        if (end > 60) end = 60; // 防御异常数据
+
+        for (let w = start; w <= end; w++) {
+            if (isSingle && w % 2 === 0) continue; // 单周跳过偶数
+            if (isDouble && w % 2 !== 0) continue; // 双周跳过奇数
+            weeks.push(w);
+        }
+    }
+
+    // 去重并排序
+    return [...new Set(weeks)].sort((a, b) => a - b);
+}
+
+/**
+ * 解析节次,兼容两种格式:
+ *   - "1-2"  (范围)
+ *   - "0102" (两位补零,老版本正方)
+ */
+function parseSections(jcs) {
+    if (!jcs) return [];
+    const str = String(jcs).trim();
+    if (/^\d+-\d+$/.test(str)) {
+        const [s, e] = str.split('-').map(Number);
+        if (s > e) return [];
+        return Array.from({ length: e - s + 1 }, (_, i) => s + i);
+    }
+    // 两位补零格式
+    if (/^\d+$/.test(str) && str.length % 2 === 0) {
+        const arr = [];
+        for (let i = 0; i < str.length; i += 2) {
+            arr.push(Number(str.substr(i, 2)));
+        }
+        return arr;
+    }
+    return [];
+}
+
+/**
+ * 解析 API 返回的 JSON 数据(kbList)。
+ */
+function parseJsonData(jsonData) {
+    console.log("JS: parseJsonData 正在解析 JSON 数据...");
+
+    if (!jsonData || !Array.isArray(jsonData.kbList)) {
+        console.warn("JS: JSON 数据结构错误或缺少 kbList 字段。");
+        return [];
+    }
+
+    const rawCourseList = jsonData.kbList;
+    const finalCourseList = [];
+
+    for (const rawCourse of rawCourseList) {
+        const kcmc = rawCourse.kcmc;
+        const xm = rawCourse.xm;
+        const cdmc = rawCourse.cdmc;
+        const xqj = Number(rawCourse.xqj);
+        const zcd = rawCourse.zcd;
+
+        if (!kcmc || !xm || !cdmc || !rawCourse.jcs || !zcd) continue;
+
+        const weeksArray = parseWeeks(zcd);
+        if (weeksArray.length === 0) continue;
+
+        const sections = parseSections(rawCourse.jcs);
+        if (sections.length === 0) continue;
+
+        const startSection = sections[0];
+        const endSection = sections[sections.length - 1];
+
+        if (isNaN(xqj) || xqj < 1 || xqj > 7 || startSection > endSection) continue;
+
+        finalCourseList.push({
+            name: String(kcmc).trim(),
+            teacher: String(xm).trim(),
+            position: String(cdmc).trim(),
+            day: xqj,
+            startSection: startSection,
+            endSection: endSection,
+            weeks: weeksArray
+        });
+    }
+
+    finalCourseList.sort((a, b) =>
+        a.day - b.day ||
+        a.startSection - b.startSection ||
+        a.name.localeCompare(b.name)
+    );
+
+    console.log(`JS: JSON 数据解析完成,共找到 ${finalCourseList.length} 门课程。`);
+    return finalCourseList;
+}
+
+function validateYearInput(input) {
+    if (/^[0-9]{4}$/.test(input)) return false;
+    return "请输入四位数字的学年!";
+}
+
+async function promptUserToStart() {
+    return await window.AndroidBridgePromise.showAlert(
+        "上海财经大学浙江学院 · 教务导入",
+        "请先确保已在当前页面成功登录教务系统(CAS 统一身份认证)。\n\n导入时会自动识别页面上当前选中的学年和学期,无需手动选择。",
+        "我已登录,开始导入"
+    );
+}
+
+async function getAcademicYear() {
+    const currentYear = new Date().getFullYear().toString();
+    return await window.AndroidBridgePromise.showPrompt(
+        "选择学年",
+        "请输入要导入课程的起始学年(例如 2025-2026 学年请输入 2025):",
+        currentYear,
+        "validateYearInput"
+    );
+}
+
+async function selectSemester() {
+    const semesters = ["第 1 学期", "第 2 学期"];
+    const semesterIndex = await window.AndroidBridgePromise.showSingleSelection(
+        "选择学期",
+        JSON.stringify(semesters),
+        0
+    );
+    return semesterIndex;
+}
+
+/**
+ * 获取学期码候选列表。
+ * 正方教务标准编码为 3(第一学期)/12(第二学期),部分学校自定义为 1/2。
+ * 依次尝试,取第一个能返回非空课表的。
+ */
+function getSemesterCodeCandidates(semesterIndex) {
+    return semesterIndex === 0 ? ["3", "1"] : ["12", "2"];
+}
+
+/**
+ * 从当前页面 URL 中提取 su 参数(学号),正方教务课表页通常带该参数。
+ */
+function getStudentIdFromUrl() {
+    try {
+        const params = new URLSearchParams(window.location.search);
+        const su = params.get("su");
+        return su ? su.trim() : "";
+    } catch (e) {
+        return "";
+    }
+}
+
+/**
+ * 从当前页面自动识别选中的学年(xnm)与学期(xqm)。
+ * 正方教务课表页的学年/学期下拉框通常以 xnm / xqm 命名。
+ * 识别失败返回 null,由调用方回退到手动输入。
+ */
+function getCurrentSemesterFromPage() {
+    try {
+        const pairs = [
+            ["#xnm", "#xqm"],
+            ["select[name='xnm']", "select[name='xqm']"],
+            ["#xnmSel", "#xqmSel"],
+            ["select[name='xnm_id']", "select[name='xqm_id']"]
+        ];
+        for (const [yearSel, semSel] of pairs) {
+            const yearEl = document.querySelector(yearSel);
+            const semEl = document.querySelector(semSel);
+            if (yearEl && semEl && yearEl.value !== "" && semEl.value !== "") {
+                return { xnm: String(yearEl.value).trim(), xqm: String(semEl.value).trim() };
+            }
+        }
+        // 兜底:按 name/id 模糊匹配学年学期下拉框
+        let yearEl = null, semEl = null;
+        document.querySelectorAll("select").forEach((s) => {
+            const id = ((s.id || "") + " " + (s.name || "")).toLowerCase();
+            if (!yearEl && /xnm|xnsel|schoolyear|学年/.test(id)) yearEl = s;
+            if (!semEl && /xqm|xqsel|semester|学期/.test(id)) semEl = s;
+        });
+        if (yearEl && semEl && yearEl.value !== "" && semEl.value !== "") {
+            return { xnm: String(yearEl.value).trim(), xqm: String(semEl.value).trim() };
+        }
+    } catch (e) {
+        console.error("JS: 自动识别学年学期失败:", e);
+    }
+    return null;
+}
+
+/**
+ * 请求并解析课程数据。
+ * xnm: 学年(如 2025);xqmCandidates: 学期码候选列表,依次尝试。
+ */
+async function fetchAndParseCourses(xnm, xqmCandidates) {
+    const semesterCandidates = Array.isArray(xqmCandidates) ? xqmCandidates : [xqmCandidates];
+    const studentId = getStudentIdFromUrl();
+
+    // 课表数据接口候选(均已通过 HTTP 302->CAS 验证存在)
+    const apiPaths = [
+        "/kbcx/xskbcx_cxXsKb.html",
+        "/kbcx/xskbcx_cxXsgrkb.html"
+    ];
+    const baseUrls = [
+        "https://jwxt.shufe-zj.edu.cn"
+    ];
+
+    // 组装入口 URL 列表:域名 × 接口 × (学号可选)
+    const targetUrls = [];
+    for (const base of baseUrls) {
+        for (const path of apiPaths) {
+            const suQuery = studentId ? `&su=${encodeURIComponent(studentId)}` : "";
+            targetUrls.push(`${base}${path}?gnmkdm=N2151${suQuery}`);
+        }
+    }
+
+    for (const semesterCode of semesterCandidates) {
+        const requestBody = `xnm=${xnm}&xqm=${semesterCode}&kzlx=ck&xsdm=&kclbdm=`;
+
+        for (const url of targetUrls) {
+            try {
+                AndroidBridge.showToast("正在获取课表数据,请稍候...");
+                const response = await fetch(url, {
+                    method: "POST",
+                    headers: {
+                        "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
+                    },
+                    body: requestBody,
+                    credentials: "include"
+                });
+
+                if (response.ok) {
+                    const jsonText = await response.text();
+                    const jsonData = JSON.parse(jsonText);
+                    if (jsonData && Array.isArray(jsonData.kbList) && jsonData.kbList.length > 0) {
+                        const parsedCourses = parseJsonData(jsonData);
+                        if (parsedCourses.length > 0) {
+                            console.log(`JS: 使用学期码 ${semesterCode} 通过 ${url} 获取成功`);
+                            return {
+                                courses: parsedCourses,
+                                config: {
+                                    semesterStartDate: null,
+                                    semesterTotalWeeks: 20
+                                }
+                            };
+                        }
+                    }
+                }
+            } catch (e) {
+                console.error(`Entry failed: ${url} (semester=${semesterCode})`);
+            }
+        }
+    }
+
+    AndroidBridge.showToast("未能获取课表数据,请检查登录状态或网络环境。");
+    return null;
+}
+
+async function saveCourses(parsedCourses) {
+    AndroidBridge.showToast(`正在保存 ${parsedCourses.length} 门课程...`);
+    try {
+        await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(parsedCourses, null, 2));
+        return true;
+    } catch (error) {
+        AndroidBridge.showToast(`课程保存失败: ${error.message}`);
+        return false;
+    }
+}
+
+// 作息时间(上海财经大学浙江学院,每节40分钟,课间10分钟)
+const TimeSlots = [
+    { number: 1,  startTime: "08:00", endTime: "08:40" },
+    { number: 2,  startTime: "08:50", endTime: "09:30" },
+    { number: 3,  startTime: "09:40", endTime: "10:20" },
+    { number: 4,  startTime: "10:30", endTime: "11:10" },
+    { number: 5,  startTime: "11:20", endTime: "12:00" },
+    { number: 6,  startTime: "14:00", endTime: "14:40" },
+    { number: 7,  startTime: "14:50", endTime: "15:30" },
+    { number: 8,  startTime: "15:40", endTime: "16:20" },
+    { number: 9,  startTime: "16:30", endTime: "17:10" },
+    { number: 10, startTime: "18:30", endTime: "19:10" },
+    { number: 11, startTime: "19:20", endTime: "20:00" },
+    { number: 12, startTime: "20:10", endTime: "20:50" },
+    { number: 13, startTime: "21:00", endTime: "21:40" },
+];
+
+async function importPresetTimeSlots(timeSlots) {
+    if (timeSlots.length > 0) {
+        AndroidBridge.showToast(`正在导入 ${timeSlots.length} 个预设时间段...`);
+        try {
+            await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
+            AndroidBridge.showToast("预设时间段导入成功!");
+        } catch (error) {
+            AndroidBridge.showToast("导入时间段失败: " + error.message);
+        }
+    }
+}
+
+async function runImportFlow() {
+    const alertConfirmed = await promptUserToStart();
+    if (!alertConfirmed) {
+        AndroidBridge.showToast("用户取消了导入。");
+        return;
+    }
+
+    // 自动识别页面当前选中的学年学期;识别失败才回退到手动输入
+    const autoSemester = getCurrentSemesterFromPage();
+    let xnm, xqmCandidates;
+
+    if (autoSemester) {
+        xnm = autoSemester.xnm;
+        // 页面当前值优先,附带对应备选编码(兼容标准编码 3/12 与自定义 1/2)
+        xqmCandidates = [autoSemester.xqm];
+        const alt = { "3": "1", "1": "3", "12": "2", "2": "12" };
+        if (alt[autoSemester.xqm]) xqmCandidates.push(alt[autoSemester.xqm]);
+        AndroidBridge.showToast(`已自动识别学年学期 ${xnm} / ${autoSemester.xqm},开始获取课表...`);
+        console.log(`JS: 自动识别学年学期 xnm=${xnm}, xqm=${autoSemester.xqm}`);
+    } else {
+        const academicYear = await getAcademicYear();
+        if (academicYear === null) {
+            AndroidBridge.showToast("导入已取消。");
+            return;
+        }
+
+        const semesterIndex = await selectSemester();
+        if (semesterIndex === null || semesterIndex === -1) {
+            AndroidBridge.showToast("导入已取消。");
+            return;
+        }
+        xnm = academicYear;
+        xqmCandidates = getSemesterCodeCandidates(semesterIndex);
+    }
+
+    const result = await fetchAndParseCourses(xnm, xqmCandidates);
+    if (result === null) {
+        return;
+    }
+    const { courses, config } = result;
+
+    const saveResult = await saveCourses(courses);
+    if (!saveResult) {
+        return;
+    }
+
+    try {
+        await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify(config));
+        AndroidBridge.showToast(`课表配置更新成功!总周数:${config.semesterTotalWeeks}周。`);
+    } catch (error) {
+        console.error('JS: Save Config Error:', error);
+    }
+
+    await importPresetTimeSlots(TimeSlots);
+
+    AndroidBridge.showToast(`课程导入成功,共导入 ${courses.length} 门课程!`);
+    AndroidBridge.notifyTaskCompletion();
+}
+
+runImportFlow();

+ 9 - 0
resources/YZU/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/YZU/adapters.yaml
+adapters:
+  - adapter_id: "YZU_01"
+    adapter_name: "扬州大学URP教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "yzu.js"
+    import_url: "https://webvpn.yzu.edu.cn/login"
+    maintainer: "Thanatos"
+    description: "扬州大学URP教务适配,通过网瑞达WebVPN访问,登录后需进入PC端教务系统「选课管理」页面,选择历年学期课表,再点击导入."

+ 161 - 0
resources/YZU/yzu.js

@@ -0,0 +1,161 @@
+// 扬州大学(yzu.edu.cn) 拾光课程表适配脚本
+// 基于URP教务系统接口适配
+
+const PAGE_URL = window.location.href;
+const VPN_BASE = (PAGE_URL.match(/^(https?:\/\/[^/]+\/http[^/]*\/[^/]+)/) || [])[1];
+if (!VPN_BASE) {
+    AndroidBridge.showToast("请通过扬州大学 WebVPN 进入教务系统后使用");
+    throw new Error("无法定位 WebVPN 基址");
+}
+
+const API_PATH = "/student/courseSelect/thisSemesterCurriculum/ajaxStudentSchedule/callback";
+
+function parseWeeks(s) {
+    const w = [];
+    if (!s) return w;
+    for (let i = 0; i < s.length; i++) if (s[i] === '1') w.push(i + 1);
+    return w;
+}
+
+function cleanTeacher(t) {
+    return (t || "").replace(/\*/g, "").trim();
+}
+
+function parseCourses(data) {
+    const courses = [];
+    const seen = new Set();
+    const list = (data && data.xkxx) || [];
+    for (let i = 0; i < list.length; i++) {
+        const map = list[i];
+        if (!map) continue;
+        const keys = Object.keys(map);
+        for (let j = 0; j < keys.length; j++) {
+            const c = map[keys[j]];
+            if (!c || !c.courseName || !c.timeAndPlaceList) continue;
+            const teacher = cleanTeacher(c.attendClassTeacher);
+            const tpl = c.timeAndPlaceList;
+            for (let k = 0; k < tpl.length; k++) {
+                const tp = tpl[k];
+                const start = tp.classSessions;
+                const weeks = parseWeeks(tp.classWeek);
+                if (!start || !tp.classDay || weeks.length === 0) continue;
+                const key = c.courseName + '|' + teacher + '|' + tp.classDay + '|' + start + '|' + (tp.classSessions + tp.continuingSession - 1) + '|' + weeks.join(',');
+                if (seen.has(key)) continue;
+                seen.add(key);
+                courses.push({
+                    name: c.courseName,
+                    teacher: teacher,
+                    position: ((tp.campusName || "") + (tp.classroomName || "")).trim() || "待定",
+                    day: tp.classDay,
+                    startSection: start,
+                    endSection: tp.classSessions + tp.continuingSession - 1,
+                    weeks: weeks,
+                    isCustomTime: false
+                });
+            }
+        }
+    }
+    return courses;
+}
+
+function parseTimeSlots(data) {
+    const slots = [];
+    const list = (data && data.jcsjbs) || [];
+    for (let i = 0; i < list.length; i++) {
+        const it = list[i];
+        const n = parseInt(it.jc, 10);
+        const st = it.kssj, et = it.jssj;
+        if (isNaN(n) || !st || st.length !== 4 || !et || et.length !== 4) continue;
+        slots.push({
+            number: n,
+            startTime: st.substring(0, 2) + ":" + st.substring(2),
+            endTime: et.substring(0, 2) + ":" + et.substring(2)
+        });
+    }
+    return slots.sort((a, b) => a.number - b.number);
+}
+
+const FALLBACK_TIME_SLOTS = [
+    { "number": 1, "startTime": "08:00", "endTime": "08:45" },
+    { "number": 2, "startTime": "08:55", "endTime": "09:40" },
+    { "number": 3, "startTime": "09:55", "endTime": "10:40" },
+    { "number": 4, "startTime": "10:50", "endTime": "11:35" },
+    { "number": 5, "startTime": "11:45", "endTime": "12:30" },
+    { "number": 6, "startTime": "14:30", "endTime": "15:15" },
+    { "number": 7, "startTime": "15:25", "endTime": "16:10" },
+    { "number": 8, "startTime": "16:20", "endTime": "17:05" },
+    { "number": 9, "startTime": "17:15", "endTime": "18:00" },
+    { "number": 10, "startTime": "18:10", "endTime": "18:55" },
+    { "number": 11, "startTime": "19:30", "endTime": "20:15" },
+    { "number": 12, "startTime": "20:25", "endTime": "21:10" }
+];
+
+async function runImportFlow() {
+    try {
+        const confirmed = await window.AndroidBridgePromise.showAlert(
+            "扬州大学课表导入",
+            "请确认:\n1. 已登录扬州大学 WebVPN(webvpn.yzu.edu.cn)\n2. 已进入教务「我的课表」页面(能看到学期下拉框)\n未在课表页会导致获取失败。",
+            "好的,开始导入"
+        );
+        if (!confirmed) return;
+
+        const select = document.getElementById("planCode");
+        if (!select || select.options.length === 0) {
+            AndroidBridge.showToast("未找到学期选择框,请先进入课表页面");
+            return;
+        }
+        const texts = [];
+        const values = [];
+        for (let i = 0; i < select.options.length; i++) {
+            texts.push(select.options[i].text);
+            values.push(select.options[i].value);
+        }
+        const defaultIndex = select.selectedIndex >= 0 ? select.selectedIndex : 0;
+        const idx = await window.AndroidBridgePromise.showSingleSelection(
+            "选择学期",
+            JSON.stringify(texts),
+            defaultIndex
+        );
+        if (idx === null || idx < 0) {
+            AndroidBridge.showToast("导入已取消");
+            return;
+        }
+
+        AndroidBridge.showToast("正在获取教务数据...");
+        const res = await fetch(VPN_BASE + API_PATH, {
+            "headers": {
+                "Accept": "application/json, text/javascript, */*; q=0.01",
+                "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
+                "X-Requested-With": "XMLHttpRequest"
+            },
+            "body": "planCode=" + values[idx],
+            "method": "POST",
+            "credentials": "include"
+        });
+        if (!res.ok) throw new Error("网络请求失败,状态码: " + res.status);
+
+        const data = await res.json();
+        const courses = parseCourses(data);
+        if (courses.length === 0) {
+            AndroidBridge.showToast("未解析到课程数据,请确认所选学期有课");
+            return;
+        }
+
+        const timeSlots = parseTimeSlots(data);
+        const ok = await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
+        if (!ok) {
+            AndroidBridge.showToast("课程保存失败");
+            return;
+        }
+        await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots.length ? timeSlots : FALLBACK_TIME_SLOTS));
+        await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify({ semesterTotalWeeks: 20 }));
+
+        AndroidBridge.showToast("成功导入 " + courses.length + " 个课程时段");
+        AndroidBridge.notifyTaskCompletion();
+    } catch (e) {
+        console.error("导入异常:", e);
+        AndroidBridge.showToast("导入失败: " + e.message);
+    }
+}
+
+runImportFlow();