ソースを参照

feat(YNUFE): 支持跨大节课时自动适配下课时间 (#914)

AxelPolarNight 1 日 前
親
コミット
3fe13d24fa
1 ファイル変更、229 行追加、823 行削除
  1. 229 823
      resources/YNUFE/ynufe.js

+ 229 - 823
resources/YNUFE/ynufe.js

@@ -1,855 +1,261 @@
-// ========== 工具函数 ==========
-
-/**
- * 解析周数字符串
- * @param {string} Str 如:1-6,7-13周(单)
- * @returns {Array} 返回数组 [1,3,5,7,9,11,13]
- */
-function getWeeks(Str) {
-    function range(con, tag) {
-        let retWeek = [];
-        con.slice(0, -1).split(',').forEach(w => {
-            let tt = w.split('-');
-            let start = parseInt(tt[0]);
-            let end = parseInt(tt[tt.length - 1]);
-            if (tag === 1 || tag === 2) {
-                retWeek.push(...Array(end + 1 - start).fill(start).map((x, y) => x + y).filter(f => {
-                    return f % tag === 0;
-                }));
-            } else {
-                retWeek.push(...Array(end + 1 - start).fill(start).map((x, y) => x + y).filter(v => {
-                    return v % 2 !== 0;
-                }));
-            }
-        });
-        return retWeek;
-    }
-
-    Str = Str.replace(/[(){}|第\[\]]/g, "").replace(/到/g, "-");
-    let reWeek = [];
-    let week1 = [];
-    
-    while (Str.search(/周|\s/) !== -1) {
-        let index = Str.search(/周|\s/);
-        if (Str[index + 1] === '单' || Str[index + 1] === '双') {
-            week1.push(Str.slice(0, index + 2).replace(/周|\s/g, ""));
-            index += 2;
-        } else {
-            week1.push(Str.slice(0, index + 1).replace(/周|\s/g, ""));
-            index += 1;
+// 云南财经大学(xjwis.ynufe.edu.cn) 拾光课程表适配脚本
+
+// 整体包一层 IIFE:App 每次点「执行导入」都会在同一页面重新 evaluate 本文件而不刷新,
+// 顶层直接写 const 会与上一次执行冲突(Identifier '...' has already been declared)
+(function () {
+    const URL_SEMESTER = "/jsxsd/jxzl/jxzl_query";   // 教学周历:学期列表 / 开学日期 / 最大周次
+    const URL_COURSE = "/jsxsd/xskb/xskb_list.do";   // 课表查询
+
+    const clean = (s = "") => s.replace(/[\s\u00a0]+/g, " ").trim();
+    const parseDoc = html => new DOMParser().parseFromString(html, "text/html");
+
+    // ========== 解析 ==========
+
+    /** 周次:"1-16(周)[01-02节]" / "1-6,7-13周(单)" → [1..16];单双周按整串判断 */
+    function parseWeeks(text = "") {
+        const weeks = new Set();
+        const fallback = /单/.test(text) ? 1 : /双/.test(text) ? 0 : -1;   // -1 = 不区分单双
+        for (const seg of text.split("[")[0].match(/\d+(?:\s*-\s*\d+)?[^,\d]*/g) || []) {
+            const [start, end = start] = (seg.match(/\d+/g) || []).map(Number);
+            if (!start) continue;
+            const parity = /单/.test(seg) ? 1 : /双/.test(seg) ? 0 : fallback;
+            for (let w = start; w <= end; w++) if (parity < 0 || w % 2 === parity) weeks.add(w);
         }
-        Str = Str.slice(index);
-        index = Str.search(/\d/);
-        if (index !== -1) Str = Str.slice(index);
-        else Str = "";
-    }
-    
-    if (Str.length !== 0) week1.push(Str);
-    
-    week1.forEach(v => {
-        if (v.slice(-1) === "双") {
-            reWeek.push(...range(v, 2));
-        } else if (v.slice(-1) === "单") {
-            reWeek.push(...range(v, 3));
-        } else {
-            reWeek.push(...range(v + "全", 1));
-        }
-    });
-    
-    return reWeek;
-}
-
-/**
- * 解析节次字符串
- * @param {string} Str 如: 1-4节 或 1-2-3-4节
- * @returns {Array} [1,2,3,4]
- */
-function getSection(Str) {
-    let reJc = [];
-    let strArr = Str.replace("节", "").trim().split("-");
-    
-    if (strArr.length <= 2) {
-        for (let i = Number(strArr[0]); i <= Number(strArr[strArr.length - 1]); i++) {
-            reJc.push(Number(i));
-        }
-    } else {
-        strArr.forEach(v => {
-            reJc.push(Number(v));
-        });
-    }
-    
-    return reJc;
-}
-
-/**
- * 检查是否在登录页面
- * @returns {boolean}
- */
-function isLoginPage() {
-    const url = window.location.href;
-    // 检查URL是否包含登录页面特征
-    return url.includes('login') || url.includes('Login') || 
-           document.querySelector('input[type="password"]') !== null;
-}
-
-/**
- * 解析课程HTML数据
- * @param {string} html 课程表HTML
- * @returns {Array} 课程数组
- */
-function parseScheduleHtml(html) {
-    let result = [];
-    let uniqueCourses = []; // 移到外部作用域
-    
-    try {
-        // 创建临时div来解析HTML
-        const tempDiv = document.createElement('div');
-        tempDiv.innerHTML = html;
-        
-        console.log('开始解析课程表HTML...');
-        
-        // 查找课程表格
-        const table = tempDiv.querySelector('#kbtable') || tempDiv.querySelector('table');
-        if (!table) {
-            throw new Error('未找到课程表格');
-        }
-        
-        // 遍历所有行(每行是一个大节时间段)
-        const rows = table.querySelectorAll('tr');
-        console.log(`找到 ${rows.length} 行`);
-        
-        // 用于记录已处理的div,避免重复(跨大节课程会在多行出现)
-        const processedDivs = new Set();
-        
-        rows.forEach((tr, rowIdx) => {
-            const tds = tr.querySelectorAll('td');
-            
-            // 遍历这一行的所有td列
-            // 注意: querySelectorAll('td')只选择td元素,不包括th
-            // 所以td[0]就是星期一, td[1]是星期二, ..., td[6]是星期日
-            tds.forEach((td, colIdx) => {
-                // 查找这个单元格里的课程内容div
-                const hiddenDiv = td.querySelector('div.kbcontent');
-                
-                // 如果没有隐藏div或内容为空,跳过
-                if (!hiddenDiv) {
-                    return;
-                }
-                
-                // 检查是否已经处理过这个div(根据name属性去重)
-                const divName = hiddenDiv.getAttribute('name') || hiddenDiv.getAttribute('id');
-                if (divName && processedDivs.has(divName)) {
-                    return; // 已处理过,跳过
-                }
-                
-                const divText = hiddenDiv.textContent.trim();
-                if (!divText || divText.length <= 6) {
-                    return;
-                }
-                
-                // 从div的name属性提取星期信息
-                // name格式: "hash-星期-序号" 例如 "EBC6F96389D143DC9C53084617F9C7D2-2-1"
-                // 其中第二部分的数字: 1=星期一, 2=星期二, ..., 7=星期日
-                let day = colIdx + 1; // 默认使用列索引
-                if (divName) {
-                    const nameParts = divName.split('-');
-                    if (nameParts.length >= 3) {
-                        const dayFromName = parseInt(nameParts[1]);
-                        if (!isNaN(dayFromName) && dayFromName >= 1 && dayFromName <= 7) {
-                            day = dayFromName;
-                        }
-                    }
-                }
-                
-                // 标记为已处理
-                if (divName) {
-                    processedDivs.add(divName);
-                }
-                
-                console.log(`\n[行${rowIdx} 列${colIdx} 星期${day}]`);
-                console.log(`内容预览: ${divText.substring(0, 50)}...`);
-                
-                // 可能包含多个课程,用 ----- 分隔
-                const courseSections = hiddenDiv.innerHTML.split(/-----+/);
-                console.log(`分割成 ${courseSections.length} 个课程段`);
-                
-                // 用于课程段去重(避免完全相同的课程段被重复添加)
-                const processedSections = new Set();
-                
-                // 遍历每个课程段
-                courseSections.forEach((section, sectionIdx) => {
-                    const sectionText = section.replace(/<[^>]*>/g, '').trim();
-                    if (!sectionText || sectionText.length < 3) {
-                        return;
-                    }
-                    
-                    // 检查是否已经处理过完全相同的课程段(内容去重)
-                    if (processedSections.has(sectionText)) {
-                        console.log(`  跳过重复课程段 ${sectionIdx + 1}`);
-                        return;
-                    }
-                    processedSections.add(sectionText);
-                    
-                    console.log(`  课程段 ${sectionIdx + 1}:`);
-                    console.log(`  原始HTML:`, section.substring(0, 200));
-                    
-                    let course = {
-                        day: day, // 星期几(1=周一, 2=周二, ..., 7=周日)
-                        weeks: [],
-                        sections: [],
-                        name: '',
-                        teacher: '',
-                        position: ''
-                    };
-                    
-                    // 解析HTML,按br分割成行
-                    const lines = section.split(/<br\s*\/?>/i);
-                    console.log(`  分割成 ${lines.length} 行`);
-                    
-                    let firstTextLine = true; // 标记是否是第一个有效文本行
-                    
-                    lines.forEach((line, lineIdx) => {
-                        // 跳过空行
-                        const plainText = line.replace(/<[^>]*>/g, '').trim();
-                        if (!plainText || plainText === '&nbsp;') {
-                            return;
-                        }
-                        
-                        console.log(`    行${lineIdx}: ${line.substring(0, 100)}`);
-                        console.log(`    纯文本: ${plainText}`);
-                        
-                        // 第一个有效文本行就是课程名(没有title属性)
-                        if (firstTextLine && !course.name) {
-                            // 移除span标签(包含调课标记如&nbspO)但保留其他内容
-                            let courseName = line.replace(/<span[^>]*>.*?<\/span>/gi, '').trim();
-                            // 提取纯文本
-                            courseName = courseName.replace(/<[^>]*>/g, '').trim();
-                            // 清理HTML实体
-                            courseName = courseName.replace(/&nbsp;/g, ' ').trim();
-                            
-                            course.name = courseName;
-                            console.log(`    ✓ 第一行作为课程名: ${course.name}`);
-                            firstTextLine = false;
-                            return;
-                        }
-                        firstTextLine = false;
-                        
-                        // 检查这一行的title属性(使用双引号)
-                        if (line.includes('title="老师"')) {
-                            course.teacher = plainText;
-                            console.log(`    ✓ 匹配老师: ${course.teacher}`);
-                        }
-                        else if (line.includes('title="教室"')) {
-                            // 对于教室,需要先移除隐藏的font标签,再提取文本
-                            const cleanLine = line.replace(/<font[^>]*style="display:none;"[^>]*>.*?<\/font>/gi, '');
-                            const cleanText = cleanLine.replace(/<[^>]*>/g, '').trim();
-                            // 再移除可能残留的前导数字
-                            const finalPosition = cleanText.replace(/^[\d-]+/, '').trim();
-                            course.position = finalPosition;
-                            console.log(`    ✓ 匹配教室: ${course.position}`);
-                        }
-                        else if (line.includes('title="周次(节次)"')) {
-                            console.log(`    ✓ 匹配周次节次: ${plainText}`);
-                            
-                            // 解析周次: "1-18(周)[06-07节]"
-                            const weekMatch = plainText.match(/^(.+?)\(周\)/);
-                            if (weekMatch) {
-                                const weekStr = weekMatch[1];
-                                course.weeks = getWeeks(weekStr + '周');
-                                console.log(`    -> 周: ${course.weeks}`);
-                            }
-                            
-                            // 解析节次: "[06-07节]"
-                            const sectionMatch = plainText.match(/\[(.+?)节?\]/);
-                            if (sectionMatch) {
-                                const sectionStr = sectionMatch[1];
-                                course.sections = getSection(sectionStr + '节');
-                                console.log(`    -> 节: ${course.sections}`);
-                            }
-                        }
-                        // 如果没有找到教室,尝试从包含隐藏font的行提取
-                        // 这行可能格式如: <font style="display:none;">01-02</font><font style="display:none;">20</font>北院卓媒220
-                        else if (!course.position && line.includes('style="display:none;"')) {
-                            // 移除所有隐藏的font标签
-                            const visibleText = line.replace(/<font[^>]*style="display:none;"[^>]*>.*?<\/font>/gi, '')
-                                                    .replace(/<[^>]*>/g, '')
-                                                    .trim();
-                            if (visibleText && visibleText.length > 0) {
-                                // 移除所有前导的数字和连字符(如 "01-0220" 或 "06-0722")
-                                // 匹配模式:开头的数字-数字组合
-                                const cleanPosition = visibleText.replace(/^[\d-]+/, '').trim();
-                                if (cleanPosition.length > 0) {
-                                    course.position = cleanPosition;
-                                    console.log(`    ✓ 提取教室(清理后): ${course.position}`);
-                                }
-                            }
-                        }
-                    });
-                    
-                    // 验证并添加课程
-                    if (course.name && course.weeks.length > 0 && course.sections.length > 0) {
-                        course.teacher = course.teacher || "未知教师";
-                        course.position = course.position || "未知地点";
-                        
-                        console.log(`  ✓ 完整课程:`, {
-                            name: course.name,
-                            teacher: course.teacher,
-                            position: course.position,
-                            day: course.day,
-                            weeks: `${course.weeks.length}周`,
-                            sections: course.sections
-                        });
-                        result.push(course);
-                    } else {
-                        console.warn(`  ✗ 信息不完整:`, {
-                            name: course.name || '无',
-                            teacher: course.teacher || '无',
-                            weeks: course.weeks.length,
-                            sections: course.sections.length
-                        });
-                    }
-                });
-            });
-        });
-        
-        console.log(`\n解析完成,共得到 ${result.length} 条课程记录(去重前)`);
-        
-        // 合并完全相同的课程(去重),并合并仅周次不同的同节次课程
-        const courseMap = new Map();
-        
-        result.forEach(course => {
-            // 生成课程唯一标识: 名称+老师+地点+星期+节次(不包含周次)
-            const mergeKey = `${course.name}|${course.teacher}|${course.position}|${course.day}|${course.sections.join(',')}`;
-            
-            if (!courseMap.has(mergeKey)) {
-                // 深拷贝一份以避免引用问题
-                courseMap.set(mergeKey, { ...course, weeks: [...course.weeks] });
-            } else {
-                console.log(`  合并同时间不同周次的课程: ${course.name} (${course.teacher})`);
-                const existingCourse = courseMap.get(mergeKey);
-                // 合并并去重然后重新排序周次
-                existingCourse.weeks = [...new Set([...existingCourse.weeks, ...course.weeks])].sort((a, b) => a - b);
-            }
-        });
-        
-        uniqueCourses = Array.from(courseMap.values());
-        
-        console.log(`合并/去重后剩余 ${uniqueCourses.length} 条课程记录`);
-        
-    } catch (err) {
-        console.error('解析课程表出错:', err);
-        throw new Error('解析课程表失败: ' + err.message);
+        return [...weeks].sort((a, b) => a - b);
     }
 
-    return uniqueCourses;
-}
-
-/**
- * 转换课程数据格式以符合拾光课程表规范
- * @param {Array} rawCourses 原始课程数据
- * @returns {Array} 转换后的课程数据
- */
-function convertCoursesToStandardFormat(rawCourses) {
-    const validCourses = [];
-    
-    rawCourses.forEach((course, index) => {
-        try {
-            // 处理节次:将原始格式转换为startSection和endSection
-            let startSection = 1;
-            let endSection = 1;
-            
-            if (course.sections && course.sections.length > 0) {
-                const sections = course.sections.sort((a, b) => a - b);
-                startSection = sections[0];
-                endSection = sections[sections.length - 1];
-            }
-
-            // 验证必需字段
-            if (!startSection || !endSection || startSection < 1 || endSection < 1) {
-                console.error(`课程 ${index + 1} 缺少有效的节次信息:`, course);
-                throw new Error(`课程节次信息无效: startSection=${startSection}, endSection=${endSection}`);
-            }
-
-            if (!course.day || course.day < 1 || course.day > 7) {
-                console.error(`课程 ${index + 1} 星期数据无效:`, course);
-                throw new Error(`课程星期数据无效: day=${course.day}`);
-            }
-
-            if (!course.weeks || course.weeks.length === 0) {
-                console.error(`课程 ${index + 1} 缺少周次信息:`, course);
-                throw new Error(`课程周次信息缺失`);
-            }
-
-            const convertedCourse = {
-                name: course.name || "未知课程",
-                teacher: course.teacher || "未知教师", 
-                position: course.position || "未知地点",
-                day: course.day,
-                startSection: startSection,
-                endSection: endSection,
-                weeks: course.weeks
-            };
-
-            validCourses.push(convertedCourse);
-            
-        } catch (err) {
-            console.error(`转换课程 ${index + 1} 时出错:`, err.message);
-            // 如果任何课程转换失败,抛出错误
-            throw new Error(`课程数据验证失败: ${err.message}`);
-        }
-    });
-
-    return validCourses;
-}
-
-/**
- * 生成时间段配置
- * @param {number} campusIdx 校区索引(0为龙泉校区,1为安宁校区)
- * @returns {Array} 时间段数组
- */
-function generateTimeSlots(campusIdx = 0) {
-    // 云南财经大学默认时间配置(龙泉校区)
-    const timeSlots = [
-        { "number": 1, "startTime": "08:00", "endTime": "08:40" },
-        { "number": 2, "startTime": "08:50", "endTime": "09:30" },
-        { "number": 3, "startTime": "10:00", "endTime": "10:40" },
-        { "number": 4, "startTime": "10:50", "endTime": "11:30" },
-        { "number": 5, "startTime": "11:40", "endTime": "12:20" },
-        { "number": 6, "startTime": "14:30", "endTime": "15:10" },
-        { "number": 7, "startTime": "15:20", "endTime": "16:00" },
-        { "number": 8, "startTime": "16:30", "endTime": "17:10" },
-        { "number": 9, "startTime": "17:20", "endTime": "18:00" },
-        { "number": 10, "startTime": "18:10", "endTime": "18:30" },
-        { "number": 11, "startTime": "19:00", "endTime": "19:40" },
-        { "number": 12, "startTime": "19:50", "endTime": "20:30" },
-        { "number": 13, "startTime": "20:50", "endTime": "21:30" },
-        { "number": 14, "startTime": "21:40", "endTime": "22:20" }
-    ];
-
-    // 安宁校区时间配置
-    const timeSlots_AN = [
-        { "number": 1, "startTime": "08:20", "endTime": "09:00" },
-        { "number": 2, "startTime": "09:10", "endTime": "09:50" },
-        { "number": 3, "startTime": "10:10", "endTime": "10:50" },
-        { "number": 4, "startTime": "11:00", "endTime": "11:40" },
-        { "number": 5, "startTime": "11:50", "endTime": "12:30" },
-        { "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:40", "endTime": "17:20" },
-        { "number": 10, "startTime": "17:30", "endTime": "18:10" },
-        { "number": 11, "startTime": "19:00", "endTime": "19:40" },
-        { "number": 12, "startTime": "19:50", "endTime": "20:30" },
-        { "number": 13, "startTime": "20:40", "endTime": "21:20" }
-    ];
-
-    return campusIdx === 1 ? timeSlots_AN : timeSlots;
-}
-
-// ========== 网络请求功能 ==========
-
-/**
- * POST 教学周历页面(获取当前学期的开学日期与最大周次)
- * @param {string} semesterId 形如 "2025-2026-2"
- */
-async function requestSemesterDetailPage(semesterId) {
-    const response = await fetch(`/jsxsd/jxzl/jxzl_query`, {
-        method: "POST",
-        headers: { "Content-Type": "application/x-www-form-urlencoded" },
-        body: `xnxq01id=${semesterId}`,
-        credentials: "include"
-    });
-    return await response.text();
-}
-
-/**
- * 获取当前学期的开学日期 + 最大周次
- * @param {string} semesterId 形如 "2025-2026-2"
- * @returns {Promise<{startDate: string|null, totalWeeks: number|null}>}
- */
-async function fetchSemesterInfo(semesterId) {
-    const html = await requestSemesterDetailPage(semesterId);
-    const doc = new DOMParser().parseFromString(html, "text/html");
-    const timetable = doc.getElementById("kbtable");
-    if (!timetable) return { startDate: null, totalWeeks: null };
-
-    const rows = Array.from(timetable.querySelectorAll("tr")).filter(r => r.querySelector("td"));
-    if (rows.length === 0) return { startDate: null, totalWeeks: null };
-
-    const firstDateCell = rows[0].querySelector("td[title]");
-    const title = firstDateCell?.getAttribute("title") || "";
-    const match = title.match(/^(\d{4})年(\d{2})月(\d{2})$/);
-    const startDate = match ? `${match[1]}-${match[2]}-${match[3]}` : null;
-
-    // 最大周次:所有行第一个 td 中能解析为整数的最大值
-    let totalWeeks = null;
-    for (const row of rows) {
-        const firstTd = row.querySelector("td");
-        if (!firstTd) continue;
-        const n = parseInt(firstTd.textContent.trim(), 10);
-        if (!isNaN(n) && n > 0) {
-            if (totalWeeks === null || n > totalWeeks) totalWeeks = n;
-        }
+    /** 节次:"...[06-07节]" → { startSection: 6, endSection: 7 } */
+    function parseSections(text = "") {
+        const nums = ((text.match(/\[([^\]]*)\]/) || [])[1] || "").match(/\d+/g)?.map(Number);
+        return nums ? { startSection: Math.min(...nums), endSection: Math.max(...nums) } : null;
     }
 
-    return { startDate, totalWeeks };
-}
-
-/**
- * 获取学期列表
- * @returns {Promise<Object>} 学期列表数据
- */
-async function getSemesterList() {
-    window.shiguangBridge.showToast('正在获取学期列表...');
-    const response = await fetch('/jsxsd/xskb/xskb_list.do', { method: 'GET', credentials: 'include' });
-    const htmlText = await response.text();
-    const parser = new DOMParser();
-    let doc = parser.parseFromString(htmlText, 'text/html');
-
-    const selectElem = doc.getElementById('xnxq01id');
-    let semesters = [];
-    let semesterValues = [];
-    let defaultIndex = 0;
-    let currentSemesterValue = null;
-
-    if (selectElem) {
-        const options = selectElem.querySelectorAll('option');
-        options.forEach((opt, index) => {
-            const isCurrentSemester = opt.hasAttribute('selected') || opt.selected;
-            semesters.push(opt.innerText.trim());
-            semesterValues.push(opt.value);
-            if (isCurrentSemester) {
-                defaultIndex = index;
-                currentSemesterValue = opt.value;
-            }
-        });
+    /** 单个课程块;字段不全返回 null */
+    function parseBlock(html, day) {
+        const box = document.createElement("div");
+        box.innerHTML = html;
+        // 强智把节次/周次提示藏在 display:none 的 font 里,先剔除再取文本
+        const hidden = box.querySelectorAll('[style*="display:none"],[style*="display: none"]');
+        hidden.forEach(node => node.remove());
+        const text = selector => clean(box.querySelector(selector)?.textContent);
+
+        // 课程名 = 首个非空文本行(去掉调课标记 span)
+        const lines = html.split(/<br\s*\/?>/i);
+        const name = lines.map(line => clean(line.replace(/<span[^>]*>[\s\S]*?<\/span>/gi, "").replace(/<[^>]*>/g, ""))).find(Boolean);
+        const weekText = text('font[title="周次(节次)"]');
+        const sections = parseSections(weekText);
+        const weeks = parseWeeks(weekText);
+        if (!name || !sections || weeks.length === 0) return null;
+
+        // 少数页面没有 title="教室",教室名和隐藏的节次提示挤在同一行
+        const room = hidden.length === 0 ? "" : lines
+            .filter(line => /display:\s*none/.test(line))
+            .map(line => clean(line.replace(/<font[^>]*display:\s*none[^>]*>[\s\S]*?<\/font>/gi, "").replace(/<[^>]*>/g, "")))
+            .find(item => item && item !== name);
+
+        return {
+            name,
+            teacher: text('font[title="老师"],font[title="教师"]').replace(/^任课教师[::]/, "") || "未知教师",
+            position: text('font[title="教室"]').replace(/^[\d\-\s]+/, "") || room || "未知地点",
+            day,
+            startSection: sections.startSection,
+            endSection: sections.endSection,
+            weeks
+        };
     }
 
-    // 仅保留最新学期往前五年内的学期选项
-    const semesterYearPattern = /^(\d{4})-(\d{4})-(\d+)$/;
-    const semesterStartYears = [];
-    const semesterRecords = semesterValues.map((value, index) => {
-        const label = semesters[index] || '';
-        const valueMatch = String(value).match(semesterYearPattern);
-        const labelMatch = String(label).match(semesterYearPattern);
-        const startYear = valueMatch ? Number(valueMatch[1]) : (labelMatch ? Number(labelMatch[1]) : null);
-
-        if (startYear !== null) {
-            semesterStartYears.push(startYear);
-        }
-
-        return { index, value, label, startYear };
-    });
-
-    if (semesterStartYears.length > 0) {
-        const latestStartYear = Math.max(...semesterStartYears);
-        const minStartYear = latestStartYear - 4;
-        const filteredSemesters = [];
-        const filteredSemesterValues = [];
-        let filteredDefaultIndex = -1;
-
-        semesterRecords.forEach((record) => {
-            if (record.startYear !== null && record.startYear >= minStartYear) {
-                const isCurrentSemester = record.value === currentSemesterValue;
-                filteredSemesters.push(isCurrentSemester ? `${record.label}(当前学期)` : record.label);
-                filteredSemesterValues.push(record.value);
-                if (isCurrentSemester || record.index === defaultIndex) {
-                    filteredDefaultIndex = filteredSemesters.length - 1;
-                }
-            }
-        });
-
-        semesters = filteredSemesters;
-        semesterValues = filteredSemesterValues;
-
-        if (filteredDefaultIndex !== -1) {
-            defaultIndex = filteredDefaultIndex;
-        } else if (defaultIndex >= semesterValues.length) {
-            defaultIndex = Math.max(semesterValues.length - 1, 0);
+    /** 同课、同师、同地、同星期、同节次 → 合并周次 */
+    function mergeCourses(list) {
+        const map = new Map();
+        for (const course of list) {
+            const key = [course.name, course.teacher, course.position, course.day, course.startSection, course.endSection].join("|");
+            const exist = map.get(key);
+            if (exist) exist.weeks = [...new Set([...exist.weeks, ...course.weeks])].sort((a, b) => a - b);
+            else map.set(key, course);
         }
+        return [...map.values()];
     }
 
-    return { semesters, semesterValues, defaultIndex, htmlText };
-}
-
-/**
- * 根据学期值获取课程表HTML
- * @param {string} semesterValue 学期参数值
- * @returns {Promise<string>} 课程表HTML
- */
-async function fetchScheduleForSemester(semesterValue, semesterPageHtml = '', timeModeValue = null) {
-    let formData = new URLSearchParams();
-    formData.append('jx0404id', '');
-    formData.append('cj0701id', '');
-    formData.append('zc', '');
-    formData.append('demo', '');
-    formData.append('xnxq01id', semesterValue);
-    formData.append('kbjcmsid', timeModeValue === null
-        ? getTimeModeValue(semesterPageHtml, option => option.textContent.trim() === '全部')
-        : timeModeValue);
-
-    const postResponse = await fetch('/jsxsd/xskb/xskb_list.do', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-        body: formData.toString(),
-        credentials: 'include'
-    });
-    return await postResponse.text();
-}
-
-/**
- * 根据文本匹配时间模式选项 value
- * @param {string} html 页面HTML
- * @param {Function} matcher 选项匹配函数
- * @returns {string} 时间模式 value
- */
-function getTimeModeValue(html, matcher) {
-    const sourceDoc = html
-        ? new DOMParser().parseFromString(html, 'text/html')
-        : document;
-    const modeSelect = sourceDoc.getElementById('kbjcmsid');
-    if (!modeSelect) return '';
-
-    const option = Array.from(modeSelect.options).find(matcher);
-    return option ? option.value : '';
-}
-
-/**
- * 判断课表页面是否存在课程
- * @param {string} html 课表HTML
- * @returns {boolean} 是否存在课程
- */
-function hasScheduleCourses(html) {
-    const doc = new DOMParser().parseFromString(html, 'text/html');
-    return Array.from(doc.querySelectorAll('#kbtable div.kbcontent'))
-        .some(course => course.textContent.trim().length > 6);
-}
-
-// ========== 主要功能函数 ==========
-
-/**
- * 获取和解析课程数据
- * @param {string} html 课程表HTML
- */
-async function fetchAndParseCourses(html) {
-    try {
-        console.log('开始解析获取到的课程表HTML...');
-        if (!html) {
-            console.warn('未传入有效的课程表HTML');
-            return null;
-        }
-
-        console.log('成功获取课程表HTML,开始解析...');
-
-        // 解析课程数据
-        const rawCourses = parseScheduleHtml(html);
-        if (!rawCourses || rawCourses.length === 0) {
-            console.warn('未解析到课程数据');
-            return null;
+    /** 课表 HTML → 课程数组;td[0] 是周一,div.kbcontent 的 name 属性(hash-星期-序)优先校正星期 */
+    function parseTimetable(doc) {
+        const table = doc.getElementById("kbtable");
+        if (!table) return [];
+        const courses = [];
+        for (const row of table.querySelectorAll("tr")) {
+            [...row.querySelectorAll("td")].forEach((cell, index) =>
+                [...cell.querySelectorAll("div.kbcontent")].forEach(div => {
+                    const fromName = Number((div.getAttribute("name") || "").split("-")[1]);
+                    const day = fromName >= 1 && fromName <= 7 ? fromName : index + 1;
+                    // 一格多门课,用 ----- 分隔
+                    div.innerHTML.split(/-{5,}/).forEach(block => courses.push(parseBlock(block, day)));
+                }));
         }
+        return mergeCourses(courses.filter(Boolean));
+    }
 
-        console.log(`原始解析到 ${rawCourses.length} 条课程记录`);
-
-        // 转换为标准格式
-        const courses = convertCoursesToStandardFormat(rawCourses);
-        console.log(`转换为标准格式后有 ${courses.length} 门课程`);
+    // ========== 网络 ==========
 
-        return courses;
-    } catch (error) {
-        console.error('获取或解析课程数据失败:', error);
-        return null;
-    }
-}
-
-/**
- * 保存课程数据到拾光课程表
- * @param {Array} courses 课程数组
- */
-async function saveCourses(courses) {
-    try {
-        console.log(`正在保存 ${courses.length} 门课程...`);
-        await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
-        console.log('课程数据保存成功');
-        return true;
-    } catch (error) {
-        console.error('保存课程失败:', error);
-        return false;
+    /** 同源请求:有 body 走 POST,否则 GET,统一返回 HTML 文本 */
+    const request = (url, body) => fetch(url, {
+        method: body === undefined ? "GET" : "POST",
+        headers: body === undefined ? undefined : { "Content-Type": "application/x-www-form-urlencoded" },
+        body,
+        credentials: "include"
+    }).then(response => response.text());
+
+    /** 学期列表;只保留最近 5 个学年 */
+    async function fetchSemesterList() {
+        const html = await request(URL_COURSE);
+        const all = [...parseDoc(html).querySelectorAll("#xnxq01id option")].map(opt => ({
+            value: opt.value, label: clean(opt.textContent), current: opt.hasAttribute("selected") || opt.selected
+        }));
+        if (all.length === 0) return { list: [], defaultIndex: -1, html };
+        const startYear = opt => Number(String(opt.value).slice(0, 4));
+        const years = all.map(startYear).filter(year => year > 2000);
+        const list = (years.length ? all.filter(opt => startYear(opt) >= Math.max(...years) - 4) : all)
+            .map(opt => ({ ...opt, label: opt.current ? `${opt.label}(当前学期)` : opt.label }));
+        return { list, defaultIndex: Math.max(0, list.findIndex(opt => opt.current)), html };
     }
-}
-
-/**
- * 导入预设时间段到拾光课程表
- * @param {number} campusIdx 校区索引
- */
-async function importPresetTimeSlots(campusIdx = 0) {
-    try {
-        console.log('正在导入时间段配置...');
-        const presetTimeSlots = generateTimeSlots(campusIdx);
-        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(presetTimeSlots));
-        console.log('时间段配置导入成功');
-        return true;
-    } catch (error) {
-        console.error('导入时间段失败:', error);
-        return false;
+
+    /** 开学日期 + 最大周次 */
+    async function fetchSemesterInfo(semesterId) {
+        const doc = parseDoc(await request(URL_SEMESTER, `xnxq01id=${semesterId}`));
+        const rows = [...doc.querySelectorAll("#kbtable tr")].filter(row => row.querySelector("td"));
+        const match = (rows[0]?.querySelector("td[title]")?.title || "").match(/^(\d{4})年(\d{1,2})月(\d{1,2})日?$/);
+        const totalWeeks = rows.reduce((max, row) => Math.max(max, parseInt(row.querySelector("td")?.textContent, 10) || 0), 0) || null;
+        return {
+            startDate: match ? `${match[1]}-${match[2].padStart(2, "0")}-${match[3].padStart(2, "0")}` : null,
+            totalWeeks
+        };
     }
-}
-
-// 导入剩余配置
-async function saveAppConfig(semesterStartDate, totalWeeks) {
-    const config = {
-        "semesterTotalWeeks": totalWeeks || 20,
-        "firstDayOfWeek": 1,
-        "defaultClassDuration": 40
-    };
-    if (semesterStartDate) {
-        config.semesterStartDate = semesterStartDate;
+
+    /** 按“时间模式”(作息/校区)取下拉框 value */
+    function getTimeModeValue(html, matcher) {
+        const option = [...parseDoc(html).querySelectorAll("#kbjcmsid option")].find(opt => matcher(clean(opt.textContent)));
+        return option ? option.value : "";
     }
-    return await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
-}
-
-// ========== 主执行流程 ==========
-
-/**
- * 主导入函数:云南财经大学课程表导入
- */
-async function importYnufeCourseSchedule() {
-    // 检查是否在登录页面
-    if (isLoginPage()) {
-        console.log('检测到在登录页面,终止导入');
-        window.shiguangBridge.showToast('请先登录教务系统!');
-        return; // 直接返回,不抛出错误,不调用notifyTaskCompletion
+
+    /** 课表页面;mode 未指定时用“全部”模式 */
+    async function fetchSchedule(semesterId, pageHtml, mode) {
+        const params = { jx0404id: "", cj0701id: "", zc: "", demo: "", xnxq01id: semesterId };
+        params.kbjcmsid = mode === undefined ? getTimeModeValue(pageHtml, text => text === "全部") : mode;
+        return await request(URL_COURSE, new URLSearchParams(params).toString());
     }
-    
-    try {
-        console.log('云南财经大学课程导入开始...');
 
-        // 获取学期列表
-        let semesters = [], semesterValues = [], defaultIndex = 0, htmlText = '';
-        try {
-            const listData = await getSemesterList();
-            semesters = listData.semesters;
-            semesterValues = listData.semesterValues;
-            defaultIndex = listData.defaultIndex;
-            htmlText = listData.htmlText;
-        } catch (e) {
-            console.warn('获取学期列表网络请求失败:', e);
+    // ========== 作息 ==========
+
+    // 只写早/中/晚的参数,节次时间由 buildTimeSlots 推出(参考 resources/ZHKU)
+    // start/end:该段首节上课与末节下课;count:节数;longBreak/longBreakAfter:第几节后放大课间(0 = 无)
+    // empty:教务系统里有节次编号但不排课的时段(如晚饭),只占位,时间仅供参考
+    const CLASS_MINUTES = 40;
+    const SHORT_BREAK_MINUTES = 10;
+    const TIME_CONFIG = {
+        longquan: {
+            morning:   { start: "08:00", end: "12:20", count: 5, longBreak: 30, longBreakAfter: 2 },
+            afternoon: { start: "14:30", end: "18:00", count: 4, longBreak: 30, longBreakAfter: 2 },
+            evening:   { start: "19:00", end: "22:20", count: 4, longBreak: 20, longBreakAfter: 2 },
+            empty: [{ number: 10, start: "18:10", end: "18:30" }]   // 第 10 节:晚饭时间
+        },
+        anning: {
+            morning:   { start: "08:20", end: "12:30", count: 5, longBreak: 20, longBreakAfter: 2 },
+            afternoon: { start: "14:00", end: "18:10", count: 5, longBreak: 20, longBreakAfter: 3 },
+            evening:   { start: "19:00", end: "21:20", count: 3, longBreak: 0, longBreakAfter: 0 }
         }
-        
-        let targetHtml = htmlText;
-        let selectedCampusIdx = 0; // 默认龙泉校区
-        let semesterStartDate = null;
-        let totalWeeks = null;
-
-        if (semesters && semesters.length > 0) {
-            // 选择学期,允许用户取消导入
-            const selectedIdx = await window.shiguangBridgePromise.showSingleSelection(
-                "选择学期", 
-                JSON.stringify(semesters), 
-                defaultIndex
-            );
-            if (selectedIdx === null || selectedIdx === -1) {
-                window.shiguangBridge.showToast("用户取消了导入");
-                return false;
-            }
+    };
 
-            const semesterId = semesterValues[selectedIdx];
-
-            // 获取当前学期的开学日期与最大周次
-            const semesterInfo = await fetchSemesterInfo(semesterId);
-            semesterStartDate = semesterInfo.startDate;
-            totalWeeks = semesterInfo.totalWeeks;
-            
-            // 获取对应学期的课表HTML
-            window.shiguangBridge.showToast(`正在获取 [${semesters[selectedIdx]}] 的课表...`);
-            targetHtml = await fetchScheduleForSemester(semesterId, htmlText);
-
-            // 时间模式列表始终包含所有校区,必须使用当前所选学期的页面实际请求安宁校区模式
-            const anningModeValue = getTimeModeValue(targetHtml, option => {
-                return option.textContent.trim().includes('安宁校区');
-            });
-            if (anningModeValue) {
-                const anningScheduleHtml = await fetchScheduleForSemester(
-                    semesterId,
-                    targetHtml,
-                    anningModeValue
-                );
-                if (hasScheduleCourses(anningScheduleHtml)) {
-                    selectedCampusIdx = 1;
-                }
+    const toMinutes = hhmm => hhmm.split(":").reduce((total, part) => total * 60 + Number(part), 0);
+    const toClock = minutes => [Math.floor(minutes / 60), minutes % 60].map(n => String(n).padStart(2, "0")).join(":");
+
+    /** 生成节次表;withSegment 时附带早/中/晚标记(跨大节判定用) */
+    function buildTimeSlots(campus, withSegment = false) {
+        const config = TIME_CONFIG[campus];
+        const slots = [];
+        for (const segment of ["morning", "afternoon", "evening"]) {
+            const rule = config[segment];
+            let cursor = toMinutes(rule.start);
+            for (let i = 1; i <= rule.count; i++) {
+                const end = cursor + CLASS_MINUTES;
+                slots.push({ startTime: toClock(cursor), endTime: toClock(end), segment });
+                cursor = end + (i < rule.count ? (rule.longBreakAfter === i ? rule.longBreak : SHORT_BREAK_MINUTES) : 0);
             }
-            console.log(`根据时间模式自动选择${selectedCampusIdx === 1 ? '安宁校区' : '龙泉校区'}时间表`);
         }
+        // 空节按声明位置插入,保证节次编号与教务系统一致(后面的节次顺延)
+        for (const slot of config.empty ?? []) slots.splice(slot.number - 1, 0, { startTime: slot.start, endTime: slot.end, segment: "empty" });
+        return slots.map((slot, index) => {
+            const item = { number: index + 1, startTime: slot.startTime, endTime: slot.endTime };
+            return withSegment ? { ...item, segment: slot.segment } : item;
+        });
+    }
 
-        // 获取和解析课程数据
-        let courses = await fetchAndParseCourses(targetHtml);
-        
-        // 如果没有获取到任何课程
-        if (!courses || courses.length === 0) {
-            console.log('未获取到课程数据');
-            
-            // 检查是否真的是空课表
-            if (targetHtml && targetHtml.includes('kbtable')) {
-                // 找到了课表元素但没有课程,是真的空课表
-                console.log('检测到空课表');
-                window.shiguangBridge.showToast('当前学期课表为空!');
-                return false;
-            } else {
-                // 找不到课表元素,解析失败
-                window.shiguangBridge.showToast('获取课表失败,请检查网络和页面状态');
-                throw new Error('未找到课表数据');
+    /**
+     * 跨大节连堂课:大课间其实不休息,仍按「40 分钟 + 10 分钟课间」连着上,所以下课早于公共时间段。
+     * 只处理同一早/中/晚时段内的连堂,跨午休/晚饭(含空节)的保持公共时间段。
+     */
+    function applyCrossBlockTimes(courses, campus) {
+        const slots = buildTimeSlots(campus, true);
+        for (const course of courses) {
+            const span = slots.slice(course.startSection - 1, course.endSection);
+            if (!span.length || span[0].segment === "empty") continue;
+            if (!span.every(slot => slot.segment === span[0].segment)) continue;
+
+            const customEnd = toClock(toMinutes(span[0].startTime) + span.length * CLASS_MINUTES + (span.length - 1) * SHORT_BREAK_MINUTES);
+            if (customEnd !== span[span.length - 1].endTime) {
+                Object.assign(course, { isCustomTime: true, customStartTime: span[0].startTime, customEndTime: customEnd });
             }
-        } else {
-            console.log(`成功解析 ${courses.length} 门课程`);
-        }
-
-        // 保存课程数据
-        const saveResult = await saveCourses(courses);
-        if (!saveResult) {
-            return;
         }
+        return courses;
+    }
 
-        // 提前提示导入结果,避免被导入完成通知立即覆盖
-        if (courses.length > 0) {
-            const campusName = selectedCampusIdx === 1 ? "安宁校区" : "龙泉校区";
-            window.shiguangBridge.showToast(`成功导入 ${courses.length} 门课程!(${campusName})`);
-        }
+    // ========== 主流程 ==========
 
-        // 保存开学日期与最大周数
-        const configResult = await saveAppConfig(semesterStartDate, totalWeeks);
-        if (!configResult) {
-            return;
-        }
+    async function runImportFlow() {
+        if (!window.shiguangBridge || !window.shiguangBridgePromise) return false;
+        const bridge = window.shiguangBridge;
+        const fail = message => {
+            bridge.showToast(message);
+            return false;
+        };
 
-        // 导入时间段配置
-        const timeSlotResult = await importPresetTimeSlots(selectedCampusIdx);
-        if (!timeSlotResult) {
-            window.shiguangBridge.showToast('导入时间段配置失败');
-            throw new Error('导入时间段失败');
-        }
+        if (window.location.href.includes("login") || document.querySelector('input[type="password"]')) return fail("请先登录教务系统!");
 
-        console.log('课程导入完成');
-        return true;
+        try {
+            bridge.showToast("正在获取学期列表...");
+            const { list, defaultIndex, html } = await fetchSemesterList();
+            if (list.length === 0) return fail("未获取到学期列表,请检查登录状态。");
 
-    } catch (error) {
-        console.error('导入过程出错:', error);
-        window.shiguangBridge.showToast('导入失败: ' + error.message);
-        return false;
-    }
-}
-
-/**
- * 启动导入流程并处理完成信号
- */
-async function runImportFlow() {
-    const success = await importYnufeCourseSchedule();
-    
-    // 只有成功导入时才发送完成信号
-    if (success) {
-        window.shiguangBridge.notifyTaskCompletion();
+            const selected = await window.shiguangBridgePromise.showSingleSelection(
+                "选择学期", JSON.stringify(list.map(item => item.label)), defaultIndex
+            );
+            if (selected === null || selected === -1 || !list[selected]) return fail("已取消导入");
+
+            const semesterId = list[selected].value;
+            const { startDate, totalWeeks } = await fetchSemesterInfo(semesterId);
+            const page = await fetchSchedule(semesterId, html);
+
+            // 课表页同时带两校区作息模式,安宁模式有课说明该学期在安宁校区
+            const anningMode = getTimeModeValue(page, text => text.includes("安宁校区"));
+            const onAnning = Boolean(anningMode)
+                && parseTimetable(parseDoc(await fetchSchedule(semesterId, page, anningMode))).length > 0;
+            const campusKey = onAnning ? "anning" : "longquan";
+
+            const courses = applyCrossBlockTimes(parseTimetable(parseDoc(page)), campusKey);
+            if (courses.length === 0) return fail("所选学期课表为空,请检查学期或登录状态。");
+
+            // 学期配置:总周数 + 开学日期(取不到开学日期就不写,避免覆盖已有值)
+            const config = { semesterTotalWeeks: totalWeeks || 20, firstDayOfWeek: 1, defaultClassDuration: CLASS_MINUTES };
+            if (startDate) config.semesterStartDate = startDate;
+            await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
+            await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(buildTimeSlots(campusKey)));
+            await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+
+            bridge.showToast(`成功导入 ${courses.length} 门课程!(${onAnning ? "安宁校区" : "龙泉校区"})`);
+            return true;
+        } catch (error) {
+            return fail("导入失败: " + error.message);
+        }
     }
-    
-    return success;
-}
 
-// 启动导入流程
-runImportFlow();
+    runImportFlow()
+        .then(ok => ok && window.shiguangBridge.notifyTaskCompletion())
+        .catch(error => window.shiguangBridge.showToast("导入失败: " + error.message));
+})();