Răsfoiți Sursa

feat: 泰山学院(TSU)青果教务适配升级v2组合作息 (#811)

sLingli 4 săptămâni în urmă
părinte
comite
45495fea5c
2 a modificat fișierele cu 332 adăugiri și 127 ștergeri
  1. 1 1
      resources/TSU/adapters.yaml
  2. 331 126
      resources/TSU/tsu_01.js

+ 1 - 1
resources/TSU/adapters.yaml

@@ -6,4 +6,4 @@ adapters:
     asset_js_path: "tsu_01.js"
     import_url: "http://jwxt.tsu.edu.cn"
     maintainer: "素绫理"
-    description: "泰山学院青果教务系统课表适配,登陆后点击左侧【教学安排】,再点击【执行导入】即可,导入后不要忘记设置本学期的开学日期"
+    description: "泰山学院青果教务系统课表适配,登陆教务系统后在任意页面点击【执行导入】即可(课表数据经接口获取,不依赖当前页面)。导入时可选择学期,开学日期自动获取;脚本会自动提交夏/冬令时组合作息(夏令时5月1日–10月7日,冬令时10月8日–次年4月30日)"

+ 331 - 126
resources/TSU/tsu_01.js

@@ -1,4 +1,12 @@
-const SECTION_TIMES = [
+// 依赖接口:
+//   POST /frame/droplist/getDropLists.action                     学期列表(JSON)
+//   GET  /student/xkjg.wdkb.jsp?menucode=S20301                  内部学号 xh(GBK HTML)
+//   GET  /student/wsxk.xskcb10319.jsp?params=base64              课表数据(GBK HTML)
+//   POST /frame/desk/showLessonScheduleInfosV14.action           第一周周一日期
+
+// ===== 作息时间 =====
+// 夏令时(每年 5月1日–10月7日):作为基础时间段(骨架)提交
+const SUMMER_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" },
@@ -8,135 +16,138 @@ const SECTION_TIMES = [
     { number: 7,  startTime: "16:30", endTime: "17:15" },
     { number: 8,  startTime: "17:25", endTime: "18:10" },
     { number: 9,  startTime: "19:00", endTime: "19:45" },
-    { number: 10, startTime: "19:55", endTime: "20:40" },
+    { number: 10, startTime: "19:55", endTime: "20:40" }
 ];
 
-function parseWeeks(weekStr, parity) {
-    const weeks = [];
-    weekStr.split(',').forEach(part => {
-        if (part.includes('-')) {
-            const [start, end] = part.split('-').map(Number);
-            for (let i = start; i <= end; i++) weeks.push(i);
-        } else {
-            const w = parseInt(part);
-            if (!isNaN(w)) weeks.push(w);
-        }
-    });
-    if (parity === '单') return weeks.filter(w => w % 2 === 1);
-    if (parity === '双') return weeks.filter(w => w % 2 === 0);
-    return weeks;
+// 冬令时(每年 10月8日–次年4月30日)差量:仅下午 5–8 节整体提前 30 分钟,
+// 上午 1–4 节与晚间 9–10 节由骨架自动补齐
+const WINTER_DIFF_SLOTS = [
+    { number: 5, startTime: "14:00", endTime: "14:45" },
+    { number: 6, startTime: "14:55", endTime: "15:40" },
+    { number: 7, startTime: "16:00", endTime: "16:45" },
+    { number: 8, startTime: "16:55", endTime: "17:40" }
+];
+
+// ===== 日期工具 =====
+function formatDate(date) {
+    const year = date.getFullYear();
+    const month = String(date.getMonth() + 1).padStart(2, "0");
+    const day = String(date.getDate()).padStart(2, "0");
+    return `${year}-${month}-${day}`;
 }
 
-function findTable(win) {
-    const t = Array.from(win.document.querySelectorAll('table'))
-        .find(x => x.innerText.includes("星期一") && x.innerText.includes("["));
-    if (t) return t;
-    for (let i = 0; i < win.frames.length; i++) {
-        try {
-            const st = findTable(win.frames[i]);
-            if (st) return st;
-        } catch (e) {}
-    }
-    return null;
+// 夏令时:本年 5月1日–10月7日;冬令时:10月8日–次年4月30日(1–4月导入时自上一年起算)
+function buildComboDateRanges() {
+    const now = new Date();
+    const year = now.getFullYear();
+    const month = now.getMonth() + 1;
+    const winterStartYear = month <= 4 ? year - 1 : year;
+    return {
+        summer: { startDate: `${year}-05-01`, endDate: `${year}-10-07` },
+        winter: { startDate: `${winterStartYear}-10-08`, endDate: `${winterStartYear + 1}-04-30` }
+    };
+}
+
+// 开学日期弹窗默认值的兜底:本周周一(周日归上一周)
+function getMondayOfCurrentWeek() {
+    const now = new Date();
+    const offset = now.getDay() === 0 ? -6 : 1 - now.getDay();
+    const monday = new Date(now);
+    monday.setDate(now.getDate() + offset);
+    return formatDate(monday);
 }
 
-async function fetchAndParseCourses() {
-    const table = findTable(window);
-    if (!table) {
-        throw new Error("未检测到课表数据,请确保已切换到显示课表的页面!");
+// showPrompt 的全局验证函数:false=通过,字符串=错误文案
+function validateDateInput(input) {
+    if (!/^\d{4}-\d{2}-\d{2}$/.test(input)) {
+        return "请输入 YYYY-MM-DD 格式的日期!";
+    }
+    const date = new Date(input + "T00:00:00");
+    if (isNaN(date.getTime()) || formatDate(date) !== input) {
+        return "该日期不存在,请检查!";
     }
+    return false;
+}
 
+// ===== 周次解析(支持 12-16 / 8 / 1-8单 / 9-17双 / 1,3,5)=====
+function parseWeeks(weekStr) {
+    const weeks = [];
+    weekStr.split(",").forEach(group => {
+        const g = group.trim();
+        const isSingle = g.includes("单");
+        const isDouble = g.includes("双");
+        const rangeMatch = g.match(/(\d+)\s*-\s*(\d+)/);
+        if (rangeMatch) {
+            for (let i = parseInt(rangeMatch[1]); i <= parseInt(rangeMatch[2]); i++) {
+                if (isSingle && i % 2 === 0) continue;
+                if (isDouble && i % 2 !== 0) continue;
+                weeks.push(i);
+            }
+        } else {
+            const num = parseInt(g.replace(/[^\d]/g, ""));
+            if (!isNaN(num)) weeks.push(num);
+        }
+    });
+    return Array.from(new Set(weeks)).sort((a, b) => a - b);
+}
+
+// ===== 课表 HTML 解析 =====
+// 输入:xskcb10319.jsp 返回的 GBK 解码后 HTML
+// 结构:每个星期列是 td.td;每门课一个 <div style='padding-bottom:5px...'>,
+//       div 内按 <br> 分行依次为 课程名/教师/周次[起止节次]/地点
+function parseCourseTableHtml(htmlText) {
+    const doc = new DOMParser().parseFromString(htmlText, "text/html");
     const rawItems = [];
-    Array.from(table.rows).forEach(row => {
-        const cells = Array.from(row.cells);
-        if (cells.length < 7) return;
-
-        cells.forEach((cell, colIndex) => {
-            const distanceToLast = cells.length - 1 - colIndex;
-            if (distanceToLast > 6) return;
-            const day = 7 - distanceToLast;
-            const rawText = cell.innerText.trim();
-            if (!rawText.includes('[')) return;
-
-            // 过滤空行、清理隐藏的乱码字符
-            const lines = rawText.split('\n').map(l => l.replace(/[\s\u200B-\u200D\uFEFF]/g, '').trim()).filter(l => l);
-
-            // 找出所有时间的行号(锚点)
-            const timeIndices = [];
-            lines.forEach((line, index) => {
-                if (/([\d\-,]+)\s*(?:单|双)?\s*\[(\d+)-(\d+)\]/.test(line)) {
-                    timeIndices.push(index);
-                }
-            });
 
-            timeIndices.forEach((currTimeIdx, k) => {
-                const nextTimeIdx = (k + 1 < timeIndices.length) ? timeIndices[k + 1] : lines.length;
-                const match = lines[currTimeIdx].match(/([\d\-,]+)\s*(单|双)?\s*\[(\d+)-(\d+)\]/);
-
-                if (match) {
-                    // 1. 抓取课名和老师
-                    let name = "未知课程";
-                    let teacher = "未知教师";
-                    let nameTeacherLines = (k === 0) ? lines.slice(0, currTimeIdx) : lines.slice(timeIndices[k - 1] + 1, currTimeIdx);
-
-                    if (nameTeacherLines.length > 2) {
-                        nameTeacherLines = nameTeacherLines.slice(nameTeacherLines.length - 2);
-                    }
-
-                    if (nameTeacherLines.length >= 2) {
-                        name = nameTeacherLines[0];
-                        teacher = nameTeacherLines[1];
-                    } else if (nameTeacherLines.length === 1) {
-                        name = nameTeacherLines[0];
-                        teacher = "";
-                    }
-
-                    // 2. 抓取地点
-                    let position = "";
-                    const gap = nextTimeIdx - currTimeIdx - 1;
-
-                    if (k === timeIndices.length - 1) {
-                        if (gap > 0) position = lines.slice(currTimeIdx + 1).join(' ');
-                    } else {
-                        if (gap === 3) {
-                            position = lines[currTimeIdx + 1];
-                        } else if (gap > 3) {
-                            position = lines.slice(currTimeIdx + 1, nextTimeIdx - 2).join(' ');
-                        } else {
-                            position = "";
-                        }
-                    }
-
-                    rawItems.push({
-                        name: name,
-                        teacher: teacher,
-                        position: position,
-                        day: day,
-                        startSection: parseInt(match[3]),
-                        endSection: parseInt(match[4]),
-                        weeks: parseWeeks(match[1], match[2])
-                    });
-                }
+    doc.querySelectorAll("tr").forEach(row => {
+        const cells = row.querySelectorAll("td.td");
+        cells.forEach((cell, dayIndex) => {
+            const day = dayIndex + 1; // 一行内 td.td 依次对应 星期一~星期日
+            const divs = cell.querySelectorAll("div[style*='padding-bottom:5px']");
+            divs.forEach(div => {
+                const lines = Array.from(div.childNodes)
+                    .map(n => n.textContent.trim())
+                    .filter(t => t.length > 0);
+                if (lines.length < 3) return;
+                const timeMatch = lines[2].match(/^(.+?)\s*\[(\d+)-(\d+)\]\s*$/);
+                if (!timeMatch) return;
+                rawItems.push({
+                    name: lines[0],
+                    teacher: lines[1] || "",
+                    position: lines[3] || "未知地点",
+                    day: day,
+                    startSection: parseInt(timeMatch[2]),
+                    endSection: parseInt(timeMatch[3]),
+                    weeks: parseWeeks(timeMatch[1])
+                });
             });
         });
     });
 
+    // 完全重复项去重(青果网格可能对跨节次课程重复渲染)
+    const seen = new Set();
+    const uniqueItems = rawItems.filter(item => {
+        const key = `${item.name}|${item.teacher}|${item.position}|${item.day}|${item.startSection}|${item.endSection}|${item.weeks.join(",")}`;
+        if (seen.has(key)) return false;
+        seen.add(key);
+        return true;
+    });
+
+    // 合并去重:同(名|师|地|星期)按周次展开为节次矩阵,再按连续节次聚合
     const groupMap = new Map();
-    rawItems.forEach(item => {
+    uniqueItems.forEach(item => {
         const key = `${item.name}|${item.teacher}|${item.position}|${item.day}`;
         if (!groupMap.has(key)) groupMap.set(key, {});
         const weekMap = groupMap.get(key);
         item.weeks.forEach(w => {
             if (!weekMap[w]) weekMap[w] = new Set();
-            for (let s = item.startSection; s <= item.endSection; s++) {
-                weekMap[w].add(s);
-            }
+            for (let s = item.startSection; s <= item.endSection; s++) weekMap[w].add(s);
         });
     });
 
     const finalCourses = [];
     groupMap.forEach((weekMap, key) => {
-        const [name, teacher, position, day] = key.split('|');
+        const [name, teacher, position, day] = key.split("|");
         const patternMap = new Map();
         Object.keys(weekMap).forEach(w => {
             const week = parseInt(w);
@@ -144,49 +155,243 @@ async function fetchAndParseCourses() {
             if (sections.length === 0) return;
             let start = sections[0];
             for (let i = 0; i < sections.length; i++) {
-                if (i === sections.length - 1 || sections[i+1] !== sections[i] + 1) {
+                if (i === sections.length - 1 || sections[i + 1] !== sections[i] + 1) {
                     const pKey = `${start}-${sections[i]}`;
-                    if (!patternMap.has(pKey)) patternMap.set(pKey, []);
-                    patternMap.get(pKey).push(week);
-                    if (i < sections.length - 1) start = sections[i+1];
+                    if (!patternMap.has(pKey)) patternMap.set(pKey, new Set());
+                    patternMap.get(pKey).add(week);
+                    if (i < sections.length - 1) start = sections[i + 1];
                 }
             }
         });
-        patternMap.forEach((weeks, pKey) => {
-            const [sStart, sEnd] = pKey.split('-').map(Number);
+        patternMap.forEach((weekSet, pKey) => {
+            const [sStart, sEnd] = pKey.split("-").map(Number);
             finalCourses.push({
                 name, teacher, position,
                 day: parseInt(day),
                 startSection: sStart,
                 endSection: sEnd,
-                weeks: weeks.sort((a, b) => a - b)
+                weeks: Array.from(weekSet).sort((a, b) => a - b)
             });
         });
     });
     return finalCourses;
 }
 
+// ===== 接口封装 =====
+const JWXT_BASE = window.location.origin;
+
+// 学期列表 → 用户选择 → { xn, xq, name }
+async function selectSemester() {
+    window.shiguangBridge.showToast("正在获取学期列表...");
+    const resp = await fetch(JWXT_BASE + "/frame/droplist/getDropLists.action", {
+        method: "POST",
+        headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
+        body: "comboBoxName=StMsXnxqDxDesc&paramValue=&isYXB=0&isCDDW=0&isXQ=0&isDJKSLB=0&isZY=0",
+        credentials: "include"
+    });
+    const list = await resp.json();
+    if (!Array.isArray(list) || list.length === 0) throw new Error("学期列表为空");
+    const names = list.map(item => item.name);
+    const selectedIndex = await window.shiguangBridgePromise.showSingleSelection("选择导入学期", JSON.stringify(names), 0);
+    if (selectedIndex === null) return null;
+    const [xn, xq] = list[selectedIndex].code.split("-");
+    return { xn, xq, name: list[selectedIndex].name };
+}
+
+// 内部学号(≠登录账号):wdkb.jsp 的隐藏域,实时 DOM 兜底
+async function getInternalStudentId() {
+    const resp = await fetch(JWXT_BASE + "/student/xkjg.wdkb.jsp?menucode=S20301", { credentials: "include" });
+    const html = new TextDecoder("gbk").decode(await resp.arrayBuffer());
+    const tagMatch = html.match(/<input[^>]*id=["']xh["'][^>]*>/i);
+    if (tagMatch) {
+        const valueMatch = tagMatch[0].match(/value=["']([^"']+)["']/i);
+        if (valueMatch) return valueMatch[1];
+    }
+    let fromDom = null;
+    (function walk(win) {
+        if (fromDom) return;
+        try {
+            const el = win.document.getElementById("xh");
+            if (el && el.value) { fromDom = el.value; return; }
+        } catch (e) {}
+        for (let i = 0; i < win.frames.length; i++) { try { walk(win.frames[i]); } catch (e) {} }
+    })(window);
+    return fromDom;
+}
+
+// 课表 HTML(GBK)
+async function fetchCourseHtml(xn, xq, xh) {
+    const params = btoa(`xn=${xn}&xq=${xq}&xh=${xh}`);
+    const resp = await fetch(`${JWXT_BASE}/student/wsxk.xskcb10319.jsp?params=${params}`, { credentials: "include" });
+    return new TextDecoder("gbk").decode(await resp.arrayBuffer());
+}
+
+// 第一周周一日期(教学日历接口,jxz=1 即第 1 周,周一列头日期即开学日期)
+// 注意:泰山该接口返回 UTF-8(课表接口才是 GBK);失败返回 null,由调用方兜底
+async function fetchSemesterStartDate(xn, xq) {
+    const resp = await fetch(`${JWXT_BASE}/frame/desk/showLessonScheduleInfosV14.action?xn=${xn}&xq=${xq}&jxz=1`, {
+        method: "POST",
+        headers: { "x-requested-with": "XMLHttpRequest" },
+        credentials: "include"
+    });
+    const html = await resp.text();
+    const match = html.match(/<br\s*\/?>\s*(\d{2})-(\d{2})/);
+    if (!match) return null;
+    const year = xq === "1" ? String(parseInt(xn) + 1) : String(xn);
+    return `${year}-${match[1]}-${match[2]}`;
+}
+
+// ===== 导入步骤 =====
+async function saveCourseConfig(startDate) {
+    try {
+        await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
+            semesterStartDate: startDate
+        }));
+        window.shiguangBridge.showToast("开学日期已保存");
+        return true;
+    } catch (error) {
+        window.shiguangBridge.showToast("保存开学日期失败: " + error.message);
+        return false;
+    }
+}
+
+async function importTimeSlots() {
+    try {
+        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(SUMMER_TIME_SLOTS));
+        return true;
+    } catch (error) {
+        window.shiguangBridge.showToast("导入时间段失败: " + error.message);
+        return false;
+    }
+}
+
+async function importComboSchedule() {
+    const ranges = buildComboDateRanges();
+    const comboSchedule = {
+        name: "泰山学院夏冬作息方案",
+        publicSchedules: [
+            {
+                name: "夏令时",
+                startDate: ranges.summer.startDate,
+                endDate: ranges.summer.endDate,
+                timeSlots: [] // 与骨架一致,全部由骨架补齐
+            },
+            {
+                name: "冬令时",
+                startDate: ranges.winter.startDate,
+                endDate: ranges.winter.endDate,
+                timeSlots: WINTER_DIFF_SLOTS // 仅提交下午 5–8 节差异
+            }
+        ]
+    };
+
+    try {
+        await window.shiguangBridgePromise.saveComboSchedule(JSON.stringify(comboSchedule));
+        return true;
+    } catch (error) {
+        window.shiguangBridge.showToast("组合作息导入失败: " + error.message);
+        return false;
+    }
+}
+
+// ===== 流程编排 =====
+// notifyTaskCompletion() 只在成功后调用;任何取消/失败立即终止
 async function runImportFlow() {
+    const confirmed = await window.shiguangBridgePromise.showAlert(
+        "青果教务导入",
+        "将从教务接口直接获取课表数据,请确保已在当前浏览器登录教务系统。",
+        "开始导入"
+    );
+    if (!confirmed) {
+        window.shiguangBridge.showToast("导入已取消。");
+        return;
+    }
+
+    // 1. 学期列表 + 用户选择(列表拉取失败大概率是未登录)
+    let semester;
     try {
-        window.shiguangBridge.showToast("泰山学院引擎启动,抓取数据中...");
-        const courses = await fetchAndParseCourses();
-        if (!courses || courses.length === 0) {
-            window.shiguangBridge.showToast("解析完成,但当前课表为空");
-            window.shiguangBridge.notifyTaskCompletion();
-            return;
-        }
+        semester = await selectSemester();
+    } catch (error) {
+        window.shiguangBridge.showToast("获取学期列表失败,请确认已登录教务系统");
+        return;
+    }
+    if (semester === null) {
+        window.shiguangBridge.showToast("导入已取消。");
+        return;
+    }
 
-        const saveResult = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
-        if (saveResult !== true) return;
+    // 2. 内部学号
+    let studentId;
+    try {
+        studentId = await getInternalStudentId();
+    } catch (error) {
+        studentId = null;
+    }
+    if (!studentId) {
+        window.shiguangBridge.showToast("获取学号失败,请稍后重试或向维护者反馈");
+        return;
+    }
 
-        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(SECTION_TIMES));
+    // 3. 拉取并解析课表
+    window.shiguangBridge.showToast(`正在抓取「${semester.name}」课表数据...`);
+    let courses;
+    try {
+        const html = await fetchCourseHtml(semester.xn, semester.xq, studentId);
+        courses = parseCourseTableHtml(html);
+    } catch (error) {
+        window.shiguangBridge.showToast("导入失败:" + error.message);
+        return;
+    }
+    if (!courses || courses.length === 0) {
+        window.shiguangBridge.showToast("未解析到课程数据,请确认所选学期有课程安排");
+        return;
+    }
 
-        window.shiguangBridge.showToast(`导入大成功!合并生成 ${courses.length} 个课块`);
-        window.shiguangBridge.notifyTaskCompletion();
+    // 4. 开学日期:接口自动获取 → 用户确认(取消则终止)
+    let apiStartDate = null;
+    try {
+        apiStartDate = await fetchSemesterStartDate(semester.xn, semester.xq);
     } catch (error) {
-        window.shiguangBridge.showToast("⚠️ " + error.message);
-        window.shiguangBridge.notifyTaskCompletion();
+        apiStartDate = null;
     }
+    const startDate = await window.shiguangBridgePromise.showPrompt(
+        "确认开学日期",
+        "请确认本学期第一周周一的日期(用于校准周数)",
+        apiStartDate || getMondayOfCurrentWeek(),
+        "validateDateInput"
+    );
+    if (startDate === null) {
+        window.shiguangBridge.showToast("导入已取消。");
+        return;
+    }
+
+    // 5. 保存开学日期
+    const configSaved = await saveCourseConfig(startDate);
+    if (!configSaved) return;
+
+    // 6. 保存课程
+    try {
+        const saveResult = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+        if (saveResult !== true) {
+            window.shiguangBridge.showToast("导入失败:课程数据未能保存");
+            return;
+        }
+    } catch (error) {
+        window.shiguangBridge.showToast("导入失败:" + error.message);
+        return;
+    }
+
+    // 7. 时间段(骨架)→ 组合作息
+    const timeSlotSaved = await importTimeSlots();
+    if (timeSlotSaved) {
+        await importComboSchedule();
+    } else {
+        window.shiguangBridge.showToast("时间段导入失败,跳过组合作息。");
+    }
+
+    // 8. 全部成功
+    window.shiguangBridge.showToast(`导入成功,共 ${courses.length} 门课程,夏/冬令时将按日期自动切换`);
+    window.shiguangBridge.notifyTaskCompletion();
 }
 
 runImportFlow();