Bläddra i källkod

Merge pull request #729 from XingHeYuZhuan/pending

星河欲转 2 dagar sedan
förälder
incheckning
3763c9c567

+ 12 - 1
index/root_index.yaml

@@ -1081,4 +1081,15 @@ schools:
   - id: "HUUC"
     name: "河南城建学院"
     initial: "H"
-    resource_folder: "HUUC"
+    resource_folder: "HUUC"
+
+  - id: "NANKAI"
+    name: "南开大学"
+    initial: "N"
+    resource_folder: "NANKAI"
+    
+  - id: "DLUT"
+    name: "大连理工大学"
+    initial: "D"
+    resource_folder: "DLUT"    
+    

+ 9 - 0
resources/DLUT/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/DLUT/adapters.yaml
+adapters:
+  - adapter_id: "DLUT_01"
+    adapter_name: "大连理工大学本科教务系统"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "dlut_01.js"
+    import_url: "http://jxgl.dlut.edu.cn/student/for-std/course-table"
+    maintainer: "dffzcqs78694"
+    description: "登录统一身份认证后自动跳转到课表页面。\n支持自动选择学期、导入课程与作息时间、自动配置开学日期。"

+ 430 - 0
resources/DLUT/dlut_01.js

@@ -0,0 +1,430 @@
+// 大连理工大学本科教务系统适配脚本
+// 适配人: dffzcqs78694
+//
+// 接口链路(与页面实际请求一致):
+// 1. GET  /student/for-std/course-table                  → 解析学期下拉框 + 提取 stdPersonId
+// 2. GET  /student/ws/schedule-table/get-data?bizTypeId=2&semesterId={id} → lessonIds / timeTableLayoutId / weekIndices
+// 3. POST /student/ws/schedule-table/datum               → lessonList(课程) + scheduleList(排课明细)
+// 4. POST /student/ws/schedule-table/timetable-layout    → 节次时间定义
+// 5. GET  /student/ws/semester/get/{semesterId}          → 学期起止日期(用于推算开学日期, 失败则回退)
+
+// ---- 节次时间表兜底数据(接口失败时使用, 与大工 timetable-layout 一致) ----
+const FALLBACK_TIME_SLOTS = [
+    { "number": 1, "startTime": "08:00", "endTime": "08:45" },
+    { "number": 2, "startTime": "08:50", "endTime": "09:35" },
+    { "number": 3, "startTime": "10:05", "endTime": "10:50" },
+    { "number": 4, "startTime": "10:55", "endTime": "11:40" },
+    { "number": 5, "startTime": "13:30", "endTime": "14:15" },
+    { "number": 6, "startTime": "14:20", "endTime": "15:05" },
+    { "number": 7, "startTime": "15:35", "endTime": "16:20" },
+    { "number": 8, "startTime": "16:25", "endTime": "17:10" },
+    { "number": 9, "startTime": "18:00", "endTime": "18:45" },
+    { "number": 10, "startTime": "18:50", "endTime": "19:35" },
+    { "number": 11, "startTime": "19:40", "endTime": "20:25" },
+    { "number": 12, "startTime": "20:30", "endTime": "21:15" }
+];
+
+// 时间数字格式化: 1800 -> "18:00"
+function formatTimeNum(timeNum) {
+    if (timeNum === null || timeNum === undefined) return "";
+    const str = String(timeNum).padStart(4, '0');
+    return `${str.slice(0, 2)}:${str.slice(2, 4)}`;
+}
+
+// 判断时间数字是否在 [start, end] 区间内(用于节次映射)
+function timeInRange(timeNum, startNum, endNum) {
+    return timeNum >= startNum && timeNum <= endNum;
+}
+
+// 构建节次列表(数字格式): 优先用接口返回的 courseUnitList, 失败时用内置兜底作息表
+function buildCourseUnits(layout) {
+    if (layout && Array.isArray(layout.courseUnitList) && layout.courseUnitList.length > 0) {
+        return layout.courseUnitList;
+    }
+    // 兜底: 内置节次表(与大工 timetable-layout 实际数据一致)
+    return FALLBACK_TIME_SLOTS.map(slot => ({
+        indexNo: slot.number,
+        startTime: parseInt(slot.startTime.replace(':', ''), 10),
+        endTime: parseInt(slot.endTime.replace(':', ''), 10)
+    }));
+}
+
+// ---- 纯函数: 根据课程原始排课明细解析为拾光课程格式 ----
+// printData: datum 响应中的 result 对象
+// layout: timetable-layout 响应中的 result 对象
+// 返回课程数组
+function parseCourses(printData, layout) {
+    const lessonList = printData.lessonList || [];
+    const scheduleList = printData.scheduleList || [];
+    const lessonById = {};
+    lessonList.forEach(lesson => { lessonById[lesson.id] = lesson; });
+
+    // 从 layout 中构建节次列表 (用于 startTime/endTime -> 节次号)
+    const units = buildCourseUnits(layout);
+
+    // 按 (lessonId, weekday, startTime, endTime) 分组, 收集周次
+    const groups = {};
+    scheduleList.forEach(schedule => {
+        const key = `${schedule.lessonId}|${schedule.weekday}|${schedule.startTime}|${schedule.endTime}`;
+        if (!groups[key]) {
+            groups[key] = { schedule: schedule, weeks: [] };
+        }
+        if (schedule.weekIndex !== null && schedule.weekIndex !== undefined) {
+            groups[key].weeks.push(schedule.weekIndex);
+        }
+    });
+
+    const courses = [];
+    Object.keys(groups).forEach(key => {
+        const group = groups[key];
+        const schedule = group.schedule;
+        const lesson = lessonById[schedule.lessonId] || {};
+
+        // 星期几
+        const day = schedule.weekday;
+
+        // 节次映射: 找到所有 startTime>=调度开始 && endTime<=调度结束 的节次
+        let startSection = null;
+        let endSection = null;
+        if (units.length > 0 && schedule.startTime !== null && schedule.endTime !== null) {
+            const matched = units.filter(unit =>
+                timeInRange(unit.startTime, schedule.startTime, schedule.endTime) &&
+                timeInRange(unit.endTime, schedule.startTime, schedule.endTime)
+            );
+            if (matched.length > 0) {
+                startSection = matched[0].indexNo;
+                endSection = matched[matched.length - 1].indexNo;
+            }
+        }
+
+        // 教师: 优先取排课明细中的教师, 否则取课程下的教师列表
+        let teacher = schedule.personName || "";
+        if (!teacher && lesson.teacherAssignmentList) {
+            teacher = lesson.teacherAssignmentList.map(t => t.name).filter(Boolean).join(" ");
+        }
+
+        // 教室
+        let position = "";
+        if (schedule.room && schedule.room.nameZh) {
+            position = schedule.room.nameZh;
+        } else if (schedule.customPlace) {
+            position = schedule.customPlace;
+        }
+
+        // 周次去重排序
+        const weeks = Array.from(new Set(group.weeks)).sort((a, b) => a - b);
+
+        const course = {
+            name: lesson.courseName || "",
+            teacher: teacher,
+            position: position,
+            day: day,
+            startSection: startSection,
+            endSection: endSection,
+            weeks: weeks
+        };
+        courses.push(course);
+    });
+
+    return courses;
+}
+
+// ---- 纯函数: 生成预设时间段 ----
+// layout: timetable-layout 响应中的 result 对象
+function buildTimeSlots(layout) {
+    if (layout && Array.isArray(layout.courseUnitList) && layout.courseUnitList.length > 0) {
+        return layout.courseUnitList.map(unit => ({
+            number: unit.indexNo,
+            startTime: formatTimeNum(unit.startTime),
+            endTime: formatTimeNum(unit.endTime)
+        }));
+    }
+    return FALLBACK_TIME_SLOTS;
+}
+
+// ---- 纯函数: 推算学期开始日期(第1周周一) ----
+// 排课明细的 date + weekIndex 已知, 用最小的 date 算出其所在周周一,
+// 再按 weekIndex 差值回退到第 1 周周一。
+// 例: 第3周周一=2026-09-14, 则第1周周一 = 09-14 - 2周 = 2026-08-31
+function calcSemesterStartDate(printData) {
+    let minDate = null;
+    let minWeekIndex = null;
+
+    (printData.scheduleList || []).forEach(s => {
+        if (s.date && s.date.length >= 10 && s.weekIndex !== null && s.weekIndex !== undefined) {
+            if (!minDate || s.date < minDate) {
+                minDate = s.date;
+                minWeekIndex = s.weekIndex;
+            }
+        }
+    });
+
+    if (!minDate || !minWeekIndex) return null;
+
+    // 该日期所在周的周一
+    const dt = new Date(minDate);
+    const day = dt.getDay(); // 0=周日
+    const diff = (day === 0 ? -6 : 1 - day);
+    dt.setDate(dt.getDate() + diff);
+
+    // 回退到第1周
+    dt.setDate(dt.getDate() - (minWeekIndex - 1) * 7);
+    return dt.toISOString().split('T')[0];
+}
+
+// ---- 桥接封装 ----
+async function bridgeSaveCourses(courses) {
+    const result = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+    if (result === true) {
+        window.shiguangBridge.showToast(`成功导入 ${courses.length} 门课程!`);
+        return true;
+    }
+    window.shiguangBridge.showToast("课程导入未成功,请查看日志。");
+    return false;
+}
+
+async function bridgeSaveTimeSlots(timeSlots) {
+    const result = await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
+    if (result === true) {
+        window.shiguangBridge.showToast(`成功导入 ${timeSlots.length} 个时间段!`);
+        return true;
+    }
+    window.shiguangBridge.showToast("时间段导入失败,请查看日志。");
+    return false;
+}
+
+async function bridgeSaveConfig(config) {
+    const result = await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
+    if (result === true) {
+        window.shiguangBridge.showToast("课表配置导入成功!");
+        return true;
+    }
+    window.shiguangBridge.showToast("课表配置导入失败,请查看日志。");
+    return false;
+}
+
+// ---- 1. 公告 ----
+async function promptUserToStart() {
+    try {
+        const confirmed = await window.shiguangBridgePromise.showAlert(
+            "导入提醒",
+            "请确保您已登录教务系统,导入前请确认当前学期正确。",
+            "好的,开始导入"
+        );
+        return confirmed === true;
+    } catch (e) {
+        console.error("公告弹窗出错:", e);
+        window.shiguangBridge.showToast("显示公告出错: " + e.message);
+        return false;
+    }
+}
+
+// ---- 2. 获取学期列表 ----
+async function getSemesterOptions() {
+    try {
+        const response = await fetch(`/student/for-std/course-table`);
+        const htmlString = await response.text();
+        const parser = new DOMParser();
+        const dom = parser.parseFromString(htmlString, 'text/html');
+        const selectElement = dom.getElementById('semesters') || dom.getElementById('allSemesters');
+        if (!selectElement) throw new Error("页面中未找到学期选择框");
+
+        const options = Array.from(selectElement.options);
+        const validOptions = options.filter(opt => opt.value && opt.value !== "all");
+        if (validOptions.length === 0) throw new Error("未解析到有效的学期列表");
+
+        const semesterTexts = validOptions.map(opt => opt.text.trim());
+        const semesterValues = validOptions.map(opt => opt.value);
+        return { semesterTexts, semesterValues };
+    } catch (e) {
+        console.error("获取学期列表失败:", e);
+        window.shiguangBridge.showToast("获取学期列表失败: " + e.message);
+        return null;
+    }
+}
+
+// ---- 3. 获取 stdPersonId (从页面 JS 变量中提取) ----
+async function fetchStdPersonId() {
+    try {
+        const response = await fetch(`/student/for-std/course-table`);
+        const htmlString = await response.text();
+        // 常见模式: stdPersonId = 3284653 或 stdPersonId: 3284653 或 stdPersonId":"3284653"
+        const patterns = [
+            /stdPersonId\s*[:=]\s*["']?(\d+)["']?/,
+            /std_person_id\s*[:=]\s*["']?(\d+)["']?/,
+            /stdPersonId["']?\s*[:=]\s*["']?(\d+)["']?/
+        ];
+        for (const pattern of patterns) {
+            const match = htmlString.match(pattern);
+            if (match) return match[1];
+        }
+        return null;
+    } catch (e) {
+        console.error("提取 stdPersonId 失败:", e);
+        return null;
+    }
+}
+
+// ---- 4. 获取课程基础数据 (get-data) ----
+async function fetchGetData(semesterId) {
+    const url = `/student/for-std/course-table/get-data?bizTypeId=2&semesterId=${semesterId}`;
+    const response = await fetch(url);
+    if (!response.ok) throw new Error(`get-data 请求失败, 状态码: ${response.status}`);
+    return await response.json();
+}
+
+// ---- 5. 获取排课明细 (datum) ----
+async function fetchDatum(lessonIds, stdPersonId) {
+    const body = {
+        lessonIds: lessonIds,
+        studentId: null,
+        stdPersonId: stdPersonId,
+        weekIndex: null
+    };
+    const response = await fetch(`/student/ws/schedule-table/datum`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(body)
+    });
+    if (!response.ok) throw new Error(`datum 请求失败, 状态码: ${response.status}`);
+    return await response.json();
+}
+
+// ---- 6. 获取节次时间表 (timetable-layout) ----
+async function fetchTimeTableLayout(timeTableLayoutId) {
+    try {
+        const response = await fetch(`/student/ws/schedule-table/timetable-layout`, {
+            method: "POST",
+            headers: { "Content-Type": "application/json" },
+            body: JSON.stringify({ id: timeTableLayoutId })
+        });
+        if (!response.ok) throw new Error(`timetable-layout 请求失败, 状态码: ${response.status}`);
+        const json = await response.json();
+        if (!json || !json.result || !json.result.courseUnitList) throw new Error("timetable-layout 响应结构异常");
+        return json.result;
+    } catch (e) {
+        console.warn("获取节次时间表失败, 使用内置兜底数据:", e);
+        return { courseUnitList: null };
+    }
+}
+
+// ---- 7. 获取学期日期信息 (用于推算开学日期, 可选) ----
+async function fetchSemesterInfo(semesterId) {
+    try {
+        const response = await fetch(`/student/ws/semester/get/${semesterId}`);
+        if (!response.ok) return null;
+        return await response.json();
+    } catch (e) {
+        return null;
+    }
+}
+
+// ---- 主流程 ----
+async function runImportFlow() {
+    window.shiguangBridge.showToast("课程导入流程即将开始...");
+
+    // 1. 公告确认
+    const alertConfirmed = await promptUserToStart();
+    if (!alertConfirmed) {
+        return;
+    }
+
+    // 2. 选择学期
+    const semesterOptions = await getSemesterOptions();
+    if (!semesterOptions) return;
+    const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
+        "选择学期",
+        JSON.stringify(semesterOptions.semesterTexts),
+        0
+    );
+    if (selectedIndex === null || selectedIndex < 0) {
+        window.shiguangBridge.showToast("导入已取消。");
+        return;
+    }
+    const semesterId = semesterOptions.semesterValues[selectedIndex];
+    window.shiguangBridge.showToast(`已选择: ${semesterOptions.semesterTexts[selectedIndex]}`);
+
+    try {
+        // 3. 获取课程基础数据
+        window.shiguangBridge.showToast("正在获取课程数据...");
+        const getData = await fetchGetData(semesterId);
+        const lessonIds = getData.lessonIds || [];
+        const timeTableLayoutId = getData.timeTableLayoutId;
+
+        // 4. 获取 stdPersonId
+        let stdPersonId = await fetchStdPersonId();
+
+        // 5. 获取排课明细
+        window.shiguangBridge.showToast("正在获取排课明细...");
+        const datum = await fetchDatum(lessonIds, stdPersonId);
+        const result = datum.result;
+        if (!result || !result.lessonList || !result.scheduleList) {
+            throw new Error("datum 响应结构异常, 请检查是否已登录");
+        }
+
+        // 6. 获取节次时间表
+        const layout = await fetchTimeTableLayout(timeTableLayoutId);
+
+        // 7. 解析课程
+        const courses = parseCourses(result, layout);
+        if (courses.length === 0) {
+            window.shiguangBridge.showToast("未解析到课程数据。");
+            return;
+        }
+
+        // 8. 生成时间段
+        const timeSlots = buildTimeSlots(layout);
+
+        // 9. 生成课表配置
+        const config = {
+            semesterStartDate: null,
+            semesterTotalWeeks: 20,
+            defaultClassDuration: 45,
+            defaultBreakDuration: 5,
+            firstDayOfWeek: 1
+        };
+
+        // 优先尝试从学期接口获取开学日期与周数
+        const semesterInfo = await fetchSemesterInfo(semesterId);
+        if (semesterInfo && semesterInfo.startDate) {
+            config.semesterStartDate = semesterInfo.startDate;
+            if (semesterInfo.endDate) {
+                const start = new Date(semesterInfo.startDate);
+                const end = new Date(semesterInfo.endDate);
+                const diffDays = Math.ceil((end - start) / (1000 * 60 * 60 * 24));
+                const weeks = Math.ceil(diffDays / 7);
+                if (weeks > 0) config.semesterTotalWeeks = weeks;
+            }
+        } else {
+            // 回退: 从排课明细推算开学日期
+            const startDate = calcSemesterStartDate(result);
+            if (startDate) config.semesterStartDate = startDate;
+            // 回退: 从课程实际最大周次推算总周数(避免出现大量空白周)
+            if (courses.length > 0) {
+                let maxWeek = 0;
+                courses.forEach(c => c.weeks.forEach(w => { if (w > maxWeek) maxWeek = w; }));
+                if (maxWeek > 0) config.semesterTotalWeeks = maxWeek;
+            }
+        }
+
+        // 10. 依次保存
+        const timeSlotOk = await bridgeSaveTimeSlots(timeSlots);
+        if (!timeSlotOk) return;
+
+        const configOk = await bridgeSaveConfig(config);
+        if (!configOk) return;
+
+        const coursesOk = await bridgeSaveCourses(courses);
+        if (!coursesOk) return;
+
+        // 11. 完成
+        window.shiguangBridge.showToast("课程导入完成!");
+        window.shiguangBridge.notifyTaskCompletion();
+    } catch (e) {
+        console.error("导入流程发生错误:", e);
+        window.shiguangBridge.showToast("导入失败: " + e.message);
+    }
+}
+
+// 启动
+runImportFlow();

+ 9 - 0
resources/NANKAI/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/NKU/adapters.yaml
+adapters:
+  - adapter_id: "NANKAI"
+    adapter_name: "南开大学研究生教育综合管理系统"
+    category: "POSTGRADUATE"
+    asset_js_path: "nankai.js"
+    import_url: "https://yjs.nankai.edu.cn/allogene/page/home.htm"
+    maintainer: "Cure"
+    description: "南开大学研究生院(yjs.nankai.edu.cn)适配, 需校园网环境,登录后即可直接导入, "

+ 540 - 0
resources/NANKAI/nankai.js

@@ -0,0 +1,540 @@
+// 南开大学(研究生)教育综合管理系统 拾光课程表适配脚本
+// 适用系统: https://yjs.nankai.edu.cn/   入口: 培养 → 个人课表 (py/page/student/grkcb.htm)
+//
+// 要点:
+//   1. 服务端渲染(JSP)输出表格, 无 JSON 接口, 只能解析 DOM;
+//   2. 表格按「周次」分周渲染, 同一门课只在它真正上课的那一周出现 —— 逐周抓取第 1 ~ N 周, 每周抓
+//      到什么就记一条 weeks=[N] 的原子记录; 跨周合并 / 连续节次合并 / 去重交给官方函数处理;
+//   3. 只抓一次不带参数的课表页: 学年 / 学期 / 周次三个 <select>(选中项即学校默认学期)与作息
+//      说明都在这一份响应里; 学年学期不给用户挑, 确认框列出学年 / 学期 / 开学日期后直接导入。
+//
+// 维护者: Cure   |   出现问题请提 issues 或提交 PR
+
+/* 包在 IIFE 里: 不污染教务页面全局, 重复注入时顶层 const 也不会重复声明(函数体不再缩进一级)。 */
+(function () {
+'use strict';
+
+/* ============================ 常量 ============================ */
+
+// 课表页路径(系统内所有页面均为 /<模块>/page/<角色>/<页面>.htm 结构)
+const NKU_KB_PAGE = '/py/page/student/grkcb.htm';
+
+// 逐周抓取的并发数(同一 JSP 会话并发过高会被串行化, 3 比较稳)
+const NKU_FETCH_CONCURRENCY = 3;
+
+// 兜底作息: 正常从课表页正文解析, 这里只作解析失败(页面改版)时的保底; 下标 + 1 即节次
+const NKU_FALLBACK_TIME_SLOTS = [
+    '08:00-08:45', '08:55-09:40', '10:00-10:45', '10:55-11:40', '12:00-12:45',
+    '12:55-13:40', '14:00-14:45', '14:55-15:40', '16:00-16:45', '16:55-17:40',
+    '18:30-19:15', '19:25-20:10', '20:20-21:05', '21:15-22:00'
+].map((range, index) => {
+    const [startTime, endTime] = range.split('-');
+    return { number: index + 1, startTime, endTime };
+});
+
+// 开学日期(第 1 周周一)推算基准 = [月, 日, 年偏移]: 取基准日当天或之后的第一个周一(系统无校历)
+const NKU_TERM_BASE = {
+    '11': [9, 1, 0],  // 第一学期: 当年 9 月 1 日
+    '12': [2, 20, 1], // 第二学期: 次年 2 月 20 日
+    '13': [7, 1, 0]   // 短学期:   当年 7 月 1 日
+};
+
+/* ============================ 通用工具 ============================ */
+
+const CN_DIGITS = { 零: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
+
+/** 中文数字 -> 整数(支持 一 ~ 九十九); 无法解析返回 NaN */
+function cnToInt(text) {
+    const s = String(text || '').replace(/\s/g, '');
+    if (!s) return NaN;
+    if (s === '十') return 10;
+    if (s.length === 1) return Object.prototype.hasOwnProperty.call(CN_DIGITS, s) ? CN_DIGITS[s] : NaN;
+    const idx = s.indexOf('十');
+    if (idx === -1) return NaN;
+    const high = idx === 0 ? 1 : CN_DIGITS[s[idx - 1]];
+    const low = idx === s.length - 1 ? 0 : CN_DIGITS[s[idx + 1]];
+    return (high === undefined || low === undefined) ? NaN : high * 10 + low;
+}
+
+/** 规整空白与全角空格 */
+function normalizeText(text) {
+    return String(text == null ? '' : text).replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
+}
+
+/** "8:00" / "08:00:00" -> "08:00"; 非法返回 null */
+function formatTime(value) {
+    const match = typeof value === 'string' ? value.match(/^(\d{1,2}):(\d{1,2})(?::\d{1,2})?$/) : null;
+    if (!match) return null;
+    const hour = Number(match[1]);
+    const minute = Number(match[2]);
+    if (hour > 23 || minute > 59) return null;
+    return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
+}
+
+/** "08:45" -> 525; 非法返回 -1 */
+function timeToMinutes(value) {
+    const t = formatTime(value);
+    return t ? Number(t.slice(0, 2)) * 60 + Number(t.slice(3)) : -1;
+}
+
+/** 日期 -> "YYYY-MM-DD" */
+function formatDate(date) {
+    const pad = n => String(n).padStart(2, '0');
+    return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
+}
+
+/* ============================ 桥接层 ============================ */
+
+function showToast(message) {
+    try { window.shiguangBridge.showToast(message); } catch { /* 提示失败不打断导入 */ }
+}
+
+function notifyTaskCompletion() {
+    try { window.shiguangBridge.notifyTaskCompletion(); } catch { /* 结束信号交给宿主处理 */ }
+}
+
+const bridgeSupports = method =>
+    !!(window.shiguangBridgePromise && typeof window.shiguangBridgePromise[method] === 'function');
+
+/* ============================ 页面解析 ============================ */
+
+/** 把表格展开成二维网格, 正确处理 rowspan / colspan(课表用 rowspan 跨节次) */
+function buildTableGrid(table) {
+    const grid = [];
+    const occupied = [];
+
+    Array.from(table.querySelectorAll('tr')).forEach((tr, rowIndex) => {
+        let col = 0;
+        Array.from(tr.children).forEach(cell => {
+            const tag = (cell.tagName || '').toLowerCase();
+            if (tag !== 'td' && tag !== 'th') return;
+
+            while (occupied[rowIndex] && occupied[rowIndex][col]) col++;
+            const colspan = Math.max(parseInt(cell.getAttribute('colspan') || '1', 10) || 1, 1);
+            const rowspan = Math.max(parseInt(cell.getAttribute('rowspan') || '1', 10) || 1, 1);
+
+            for (let i = 0; i < rowspan; i++) {
+                const r = rowIndex + i;
+                grid[r] = grid[r] || [];
+                occupied[r] = occupied[r] || [];
+                for (let j = 0; j < colspan; j++) {
+                    if (!i && !j) grid[r][col] = cell;
+                    occupied[r][col + j] = true;
+                }
+            }
+            col += colspan;
+        });
+    });
+
+    return grid;
+}
+
+/** 表头形如 <th colspan="2">时间</th><th>星期一</th>… -> { 列下标: 星期(1~7) } */
+function findDayColumns(grid) {
+    const dayChars = '一二三四五六日';
+    const map = {};
+    (grid[0] || []).forEach((cell, col) => {
+        if (!cell) return;
+        const match = normalizeText(cell.textContent).replace(/\s/g, '').match(/^星期([一二三四五六日天])$/);
+        if (!match) return;
+        const day = dayChars.indexOf(match[1] === '天' ? '日' : match[1]) + 1;
+        if (day >= 1 && day <= 7) map[col] = day;
+    });
+    return map;
+}
+
+/** 课表主表格 */
+function findTimetable(doc) {
+    const direct = doc.querySelector('table.table-course');
+    if (direct && /星期/.test(direct.textContent || '')) return direct;
+
+    return Array.from(doc.querySelectorAll('table')).find(table =>
+        /星期一/.test(table.textContent || '') && /第\s*\d+\s*节/.test(table.textContent || '')) || null;
+}
+
+/** 单元格里的 <a> -> 按行切分的文本数组(<br> 视为换行): 课程名 / 周次说明 / 第X节 -- 第Y节 / 教师 / 地点 */
+function anchorToLines(anchor) {
+    const clone = anchor.cloneNode(true);
+    Array.from(clone.querySelectorAll('br')).forEach(br => {
+        br.parentNode.replaceChild(document.createTextNode('\n'), br);
+    });
+    return String(clone.textContent || '').replace(/\u00a0/g, ' ').split('\n')
+        .map(line => normalizeText(line)).filter(Boolean);
+}
+
+/** 解析一个课程链接 -> { name, teacher, position, day, startSection, endSection }
+ *  周次不在这里判断: 抓取哪一周, 这条记录就属于哪一周(见 crawlAllWeeks 的原子化) */
+function parseCourseAnchor(anchor, day) {
+    const lines = anchorToLines(anchor);
+    const strong = anchor.querySelector('strong');
+    let name = normalizeText(strong ? strong.textContent : '') || normalizeText(lines[0] || '');
+    name = name.replace(/^[|\s]+/, '');
+    if (!name) return null;
+
+    // 「第X节 -- 第Y节」所在行
+    const sectionIdx = lines.findIndex(line => /第\s*\d+\s*节/.test(line));
+    if (sectionIdx < 0) return null;
+
+    const numbers = (lines[sectionIdx].match(/第\s*(\d+)\s*节/g) || [])
+        .map(token => Number(token.replace(/\D/g, ''))).filter(num => num > 0);
+    if (!numbers.length) return null;
+
+    // 教师 / 地点紧随节次行; 有些课程没有独立教师行, 此时第一行其实是地点
+    let teacher = normalizeText(lines[sectionIdx + 1] || '');
+    let position = normalizeText(lines[sectionIdx + 2] || '');
+    if (!position && /楼|室|馆|场地|校区|通知|待定/.test(teacher)) {
+        position = teacher;
+        teacher = '';
+    }
+
+    if (!(day >= 1 && day <= 7)) return null;
+    return {
+        name,
+        teacher,
+        position: position || '待定',
+        day,
+        startSection: Math.min.apply(null, numbers),
+        endSection: Math.max.apply(null, numbers)
+    };
+}
+
+/** 解析某一周课表页面里的所有课程; null 表示页面里找不到课表(可能登录已失效) */
+function parseTimetableDoc(doc) {
+    const table = findTimetable(doc);
+    if (!table) return null;
+
+    const grid = buildTableGrid(table);
+    const dayColumns = findDayColumns(grid);
+    const colKeys = Object.keys(dayColumns);
+    if (colKeys.length === 0) return null;
+
+    const courses = [];
+    for (let row = 1; row < grid.length; row++) {
+        colKeys.forEach(key => {
+            const cell = grid[row] ? grid[row][Number(key)] : null;
+            if (!cell) return;
+            // 同一格可能塞了多门课(冲突课程), 逐个解析
+            Array.from(cell.querySelectorAll('a'))
+                .filter(anchor => /第\s*\d+\s*节/.test(anchor.textContent || ''))
+                .forEach(anchor => {
+                    const course = parseCourseAnchor(anchor, dayColumns[key]);
+                    if (course) courses.push(course);
+                });
+        });
+    }
+    return courses;
+}
+
+/** 从页面底部说明文字解析作息时间, 形如 "上午:第一节 8:00-8:45 第二节8:55-9:40 …"
+ *  时间段必须从 1 开始且编号连续, 否则返回 null 交给兜底表 */
+function parseTimeSlotsFromDoc(doc) {
+    const bodyText = doc && doc.body ? doc.body.textContent || '' : '';
+    if (!bodyText) return null;
+
+    const normalized = bodyText
+        .replace(/[:]/g, ':')
+        .replace(/[–—~~至]/g, '-')
+        .replace(/\s+/g, ' ');
+    const pattern = /第([一二三四五六七八九十]+)节?:?\s*(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})/g;
+    const collected = new Map();
+    let match;
+
+    while ((match = pattern.exec(normalized)) !== null) {
+        const number = cnToInt(match[1]);
+        const startTime = formatTime(match[2]);
+        const endTime = formatTime(match[3]);
+        if (!number || !startTime || !endTime) continue;
+        if (timeToMinutes(startTime) >= timeToMinutes(endTime)) continue;
+        collected.set(number, { number, startTime, endTime });
+    }
+
+    const slots = Array.from(collected.values()).sort((a, b) => a.number - b.number);
+    if (!slots.length) return null;
+    return slots.every((slot, index) => slot.number === index + 1) ? slots : null;
+}
+
+/* ============================ 合并与去重(官方参考实现)============================ */
+
+/** 拾光官方《课程合并与去重函数》的参考实现, 照搬(只精简了注释):
+ *  https://github.com/XingHeYuZhuan/shiguangschedule/wiki/课程合并与去重函数
+ *  输入 / 输出都是原子课程 { name, teacher, position, day, startSection, endSection, weeks } */
+function mergeAndDistinctCourses(courses) {
+    if (!Array.isArray(courses) || courses.length <= 1) return courses;
+
+    const list = courses.map(course => Object.assign({}, course, {
+        name: course.name || '',
+        teacher: course.teacher || '',
+        position: course.position || '',
+        weeks: Array.isArray(course.weeks) ? [].concat(course.weeks).sort((a, b) => a - b) : []
+    }));
+
+    // 阶段 1: 合并连续节次与完全重复的记录(前提: 名称 / 教师 / 地点 / 星期 / 周次一致)
+    list.sort((a, b) =>
+        a.name.localeCompare(b.name) ||
+        a.teacher.localeCompare(b.teacher) ||
+        a.position.localeCompare(b.position) ||
+        (a.day || 0) - (b.day || 0) ||
+        a.weeks.join(',').localeCompare(b.weeks.join(',')) ||
+        (a.startSection || 0) - (b.startSection || 0));
+
+    const withSections = [];
+    let current = list[0];
+    for (let i = 1; i < list.length; i++) {
+        const next = list[i];
+        const sameWeeks = current.name === next.name
+            && current.teacher === next.teacher
+            && current.position === next.position
+            && current.day === next.day
+            && current.weeks.join(',') === next.weeks.join(',');
+        if (sameWeeks && current.endSection + 1 === next.startSection) {
+            current.endSection = next.endSection;
+        } else if (sameWeeks && current.startSection === next.startSection && current.endSection === next.endSection) {
+            continue;
+        } else {
+            withSections.push(current);
+            current = next;
+        }
+    }
+    withSections.push(current);
+
+    // 阶段 2: 合并同节次的周次(前提: 名称 / 教师 / 地点 / 星期 / 起止节次一致)
+    withSections.sort((a, b) =>
+        a.name.localeCompare(b.name) ||
+        a.teacher.localeCompare(b.teacher) ||
+        a.position.localeCompare(b.position) ||
+        (a.day || 0) - (b.day || 0) ||
+        (a.startSection || 0) - (b.startSection || 0) ||
+        (a.endSection || 0) - (b.endSection || 0));
+
+    const merged = [];
+    let head = withSections[0];
+    for (let i = 1; i < withSections.length; i++) {
+        const next = withSections[i];
+        const sameSection = head.name === next.name
+            && head.teacher === next.teacher
+            && head.position === next.position
+            && head.day === next.day
+            && head.startSection === next.startSection
+            && head.endSection === next.endSection;
+        if (sameSection) {
+            head.weeks = Array.from(new Set([].concat(head.weeks, next.weeks))).sort((a, b) => a - b);
+        } else {
+            merged.push(head);
+            head = next;
+        }
+    }
+    merged.push(head);
+
+    return merged;
+}
+
+/* ============================ 列表读取与抓取 ============================ */
+
+/** <select> 的选项 -> [{ value, label, selected }] */
+function readSelectOptions(selectEl) {
+    if (!selectEl) return [];
+    return Array.from(selectEl.querySelectorAll('option'))
+        .map(option => ({
+            value: normalizeText(option.value),
+            label: normalizeText(option.textContent || option.value),
+            selected: option.selected === true || option.hasAttribute('selected')
+        }))
+        .filter(option => option.value !== '');
+}
+
+/** 页面已选中的选项值; 没有选中项时返回空串 */
+function selectedValue(options) {
+    const selected = options.find(option => option.selected);
+    return selected ? selected.value : '';
+}
+
+/** 文档是否为本系统的课表页 —— 用来区分「这一周没课」和「登录失效 / 请求被拦」 */
+const looksLikeCoursePage = doc =>
+    !!(doc && (doc.querySelector('#kcbForm') || doc.querySelector('#zc') || doc.querySelector('#xn')));
+
+/** 课表页地址; 不带任何参数即为系统默认的当前学期 */
+const kbPageUrl = (xn, xj, zc) =>
+    `${NKU_KB_PAGE}?xn=${encodeURIComponent(xn)}&xj=${encodeURIComponent(xj)}&zc=${encodeURIComponent(zc)}`;
+
+/** 抓一个页面并解析成 DOM。请求失败 / 状态异常一律抛出, 由调用方统一按「没抓到」处理 */
+async function fetchCourseDoc(url) {
+    const response = await fetch(url, { method: 'GET', credentials: 'include' });
+    if (!response.ok) throw new Error('页面请求失败');
+    return new DOMParser().parseFromString(await response.text(), 'text/html');
+}
+
+/** 简单的并发池 */
+async function runWithConcurrency(tasks, limit) {
+    const results = new Array(tasks.length);
+    let cursor = 0;
+    const worker = async () => {
+        while (cursor < tasks.length) {
+            const index = cursor++;
+            results[index] = await tasks[index]();
+        }
+    };
+    await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
+    return results;
+}
+
+/** 逐周抓取: 第 N 周抓到的每门课都记成一条原子记录(weeks = [N]), 不做任何归并。
+ *  返回 { courses, okWeeks }; okWeeks = 成功取到课表页的周次数(0 表示整轮都没抓到)。 */
+async function crawlAllWeeks(xn, xj, weeks) {
+    const tasks = weeks.map(week => async () => {
+        try {
+            const doc = await fetchCourseDoc(kbPageUrl(xn, xj, week));
+            const list = parseTimetableDoc(doc);
+            if (list) return { week, list };
+            // 是本系统的课表页却没有表格 —— 多为超出本学期周次范围, 按当周无课处理
+            return looksLikeCoursePage(doc) ? { week, list: [] } : null;
+        } catch {
+            return null;
+        }
+    });
+
+    const courses = [];
+    let okWeeks = 0;
+
+    (await runWithConcurrency(tasks, NKU_FETCH_CONCURRENCY)).forEach(result => {
+        if (!result) return;
+        okWeeks++;
+        result.list.forEach(course => courses.push(Object.assign({}, course, { weeks: [result.week] })));
+    });
+
+    return { courses, okWeeks };
+}
+
+/* ============================ 页面读取与确认 ============================ */
+
+/** 取某个值在选项里的显示文本(学年形如 2026-2027, 学期形如 第一学期); 找不到就用原始值 */
+const labelOf = (options, value) => (options.find(option => option.value === value) || {}).label || value;
+
+/** 第 1 周周一 = 基准日当天或之后的第一个周一; 学期代码认不出来时按第一学期 */
+function guessStartDate(xn, xj) {
+    const [month, day, offset] = NKU_TERM_BASE[String(xj)] || NKU_TERM_BASE['11'];
+    const date = new Date((Number(xn) || new Date().getFullYear()) + offset, month - 1, day);
+    date.setDate(date.getDate() + (8 - date.getDay()) % 7); // 当天就是周一则不动
+    return formatDate(date);
+}
+
+/** 抓一次不带参数的课表页, 一次读齐: 学年与学期的值及显示文本(页面选中项 = 学校当前学期)、
+ *  周次列表、作息时间、按学期规则推算的开学日期。读不到下拉框(登录失效 / 页面改版)返回 null */
+async function loadCoursePage() {
+    let doc;
+    try {
+        doc = await fetchCourseDoc(NKU_KB_PAGE);
+    } catch {
+        return null;
+    }
+
+    const xnOptions = readSelectOptions(doc.querySelector('#xn'));
+    const xjOptions = readSelectOptions(doc.querySelector('#xj'));
+    const weeks = readSelectOptions(doc.querySelector('#zc'))
+        .map(option => Number(option.value)).filter(value => value > 0);
+    const xn = selectedValue(xnOptions);
+    const xj = selectedValue(xjOptions);
+    if (!xn || !xj || !weeks.length) return null;
+
+    return {
+        xn,
+        xj,
+        xnLabel: labelOf(xnOptions, xn),
+        xjLabel: labelOf(xjOptions, xj),
+        startDate: guessStartDate(xn, xj),
+        weeks,
+        timeSlots: parseTimeSlotsFromDoc(doc)
+    };
+}
+
+/** 导入信息确认框: 列出学年 / 学期 / 开学日期, 只能确认或关掉(关掉 = 放弃导入) */
+async function confirmTerm(page) {
+    if (!bridgeSupports('showAlert')) return true;
+
+    const content = '即将导入以下内容,请确认:\n\n'
+        + ` 学年   ${page.xnLabel}\n`
+        + ` 学期   ${page.xjLabel}\n`
+        + ` 开学日期 ${page.startDate}(周一)\n\n`
+        + '学年与学期取自教务系统的默认学期,开学日期按学期规则推算。;
+    try {
+        return await window.shiguangBridgePromise.showAlert('确认导入信息', content, '开始导入') === true;
+    } catch {
+        return true;
+    }
+}
+
+/* ============================ 保存 ============================ */
+
+/** 调用桥接层的保存接口; 失败时给一句笼统提示(不带原始异常)并返回 false */
+async function saveVia(method, payload, failMessage) {
+    try {
+        await window.shiguangBridgePromise[method](JSON.stringify(payload));
+        return true;
+    } catch {
+        showToast(failMessage);
+        return false;
+    }
+}
+
+/** 从作息时间推算单节课与课间时长, 用于课表配置 */
+function deriveDurations(timeSlots) {
+    const fallback = { classDuration: 45, breakDuration: 10 };
+    if (!Array.isArray(timeSlots) || timeSlots.length < 2) return fallback;
+
+    const classDuration = timeToMinutes(timeSlots[0].endTime) - timeToMinutes(timeSlots[0].startTime);
+    const breakDuration = timeToMinutes(timeSlots[1].startTime) - timeToMinutes(timeSlots[0].endTime);
+    return (classDuration > 0 && breakDuration >= 0) ? { classDuration, breakDuration } : fallback;
+}
+
+/* ============================ 主流程 ============================ */
+
+async function importFlow() {
+    // 1. 抓一次无参课表页: 学年学期默认值、周次列表、作息时间都出在这一份响应里; 抓不到即中止
+    const page = await loadCoursePage();
+    if (!page) {
+        showToast('导入失败:未获取到课表数据,请重新登录后重试。');
+        return;
+    }
+
+    // 2. 确认框: 列出学年 / 学期 / 开学日期, 只能确认或关掉
+    if (!await confirmTerm(page)) {
+        showToast('已取消导入。');
+        return;
+    }
+
+    // 3. 逐周抓取 -> 原子课程 -> 交给官方函数合并去重
+    const crawl = await crawlAllWeeks(page.xn, page.xj, page.weeks);
+    if (!crawl.okWeeks) {
+        showToast('导入失败:未获取到课表数据,请重新登录后重试。');
+        return;
+    }
+    if (!crawl.courses.length) {
+        showToast('未查询到课程,请确认所选学年学期是否正确。');
+        return;
+    }
+    const courses = mergeAndDistinctCourses(crawl.courses);
+
+    // 4. 保存: 课程 -> 作息时间 -> 课表配置
+    const timeSlots = page.timeSlots || NKU_FALLBACK_TIME_SLOTS;
+    if (!await saveVia('saveImportedCourses', courses, '课程保存失败')) return;
+    await saveVia('savePresetTimeSlots', timeSlots, '导入作息时间失败');
+
+    // 开学日期用推算出来的第 1 周周一
+    const durations = deriveDurations(timeSlots);
+    await saveVia('saveCourseConfig', {
+        semesterStartDate: page.startDate,
+        semesterTotalWeeks: courses.reduce((max, course) => Math.max(max, ...course.weeks), 1),
+        defaultClassDuration: durations.classDuration,
+        defaultBreakDuration: durations.breakDuration,
+        firstDayOfWeek: 1
+    }, '保存课表配置失败');
+
+    // 5. 完成
+    notifyTaskCompletion();
+}
+
+/* ============================ 入口 ============================ */
+
+// 宿主在用户点击「开始导入」后注入本脚本, 所以顶层直接启动: 抓一次课表页 -> 确认框 -> 逐周抓取
+importFlow();
+
+})();