Przeglądaj źródła

Merge pull request #784 from ShiGuangSchedule/pending

星河欲转 1 miesiąc temu
rodzic
commit
9a296a711c

+ 13 - 2
index/root_index.yaml

@@ -1167,8 +1167,19 @@ schools:
     name: "湖南农业大学"
     initial: "H"
     resource_folder: "HUNAU"
-    
+  
   - id: "YSSDUFE"
     name: "山东财经大学燕山学院"
     initial: "S"
-    resource_folder: "YSSDUFE"    
+    resource_folder: "YSSDUFE"
+
+  - id: "NJCIT"
+    name: "南京信息职业技术学院"
+    initial: "N"
+    resource_folder: "NJCIT"
+
+  - id: "QVTU"
+    name: "泉州职业技术大学"
+    initial: "Q"
+    resource_folder: "QVTU"
+    

+ 86 - 5
resources/CSUFT/csuft.js

@@ -12,9 +12,12 @@
 //   5. 2026-09 按官方《适配脚本开发指南 v2》迁移桥接 API:
 //      window.shiguangBridgePromise(异步)+ window.shiguangBridge(同步)
 //      notifyTaskCompletion 仅在整条流程成功后调用
+//   6. 2026-09-30 通过 /jsxsd/jxzl/jxzl_query(教学周历)自动获取开学日期,
+//      传入 saveCourseConfig 的 semesterStartDate;失败时静默降级
 
 var JWXT_BASE = "https://http-jwxt-csuft-edu-cn-80.webvpn.csuft.edu.cn/jsxsd";
 var KB_URL = JWXT_BASE + "/xskb/xskb_list.do";
+var JXZL_URL = JWXT_BASE + "/jxzl/jxzl_query"; // 教学周历(校历),用于推算开学日期
 
 var WEEK_DAY_MAP = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7, '天': 7 };
 
@@ -194,6 +197,81 @@ function parseSchedule(doc) {
     return mergeCourses(raw);
 }
 
+// 从教学周历页(jxzl_query)解析开学日期与总周数,返回 {startDate, totalWeeks}。
+// 新版强智(qz-)校历每行形如 "第1周 | 07 | 08 | 09 | 10 | 11 | 09月12日 | 09月13日":
+// 周一~周五只显示日号,周末才有"月日",所以取最早的带月份单元格作锚点,
+// 用 (锚点日期 - 距第1周周一的天数) 反推第一周周一;年份由学期号推断。
+function parseTermInfo(html, semId) {
+    var doc = new DOMParser().parseFromString(html, "text/html");
+    var pad = function (n) { return (n < 10 ? '0' : '') + n; };
+
+    var fullRe = /(\d{4})\s*[-/.年]\s*(\d{1,2})\s*[-/.月]\s*(\d{1,2})/;
+    var mdRe = /(\d{1,2})\s*月\s*(\d{1,2})/;
+    var anchors = [], maxWeek = 0;
+    var table = null, tables = doc.getElementsByTagName('table');
+    for (var i = 0; i < tables.length; i++) {
+        if (/第\s*1\s*周/.test(tables[i].textContent)) { table = tables[i]; break; }
+    }
+    if (!table) return null;
+
+    var rows = table.getElementsByTagName('tr');
+    for (var r = 0; r < rows.length; r++) {
+        var cells = rows[r].getElementsByTagName('td');
+        if (cells.length < 2) continue;
+        var wm = trimText(cells[0].textContent).match(/第\s*(\d+)\s*周/);
+        if (!wm) continue;
+        var w = parseInt(wm[1], 10);
+        if (w > maxWeek) maxWeek = w;
+        for (var d = 0; d < 7 && d + 1 < cells.length; d++) {
+            var t = trimText(cells[d + 1].textContent);
+            var m = t.match(fullRe) || t.match(mdRe);
+            if (!m) continue;
+            anchors.push({
+                idx: (w - 1) * 7 + d,
+                year: m.length === 4 ? parseInt(m[1], 10) : null,
+                month: parseInt(m[m.length - 2], 10),
+                day: parseInt(m[m.length - 1], 10)
+            });
+        }
+    }
+    if (!anchors.length) return null;
+
+    anchors.sort(function (a, b) { return a.idx - b.idx; });
+    var a0 = anchors[0];
+    var y0 = parseInt((String(semId).match(/^(\d{4})/) || [])[1], 10);
+    var year = a0.year ||
+        (a0.month >= 8 ? (y0 || new Date().getFullYear())
+                        : (y0 ? y0 + 1 : new Date().getFullYear()));
+    var start = new Date(year, a0.month - 1, a0.day - a0.idx); // Date 自动处理跨月/负数日
+    if (isNaN(start.getTime())) return null;
+    return {
+        startDate: start.getFullYear() + '-' + pad(start.getMonth() + 1) + '-' + pad(start.getDate()),
+        totalWeeks: maxWeek || null
+    };
+}
+
+// 按 YSSDUFE 标准姿势获取学期配置:POST 带 xnxq01id 请求所选学期的校历,
+// 失败则退回 GET 带学期参数(ZHKU 姿势)→ GET 不带参数(仅当前学期)。
+async function fetchTermInfo(semesterId) {
+    var postUrl = JXZL_URL;
+    var getUrl = JXZL_URL + (semesterId ? "?xnxq01id=" + encodeURIComponent(semesterId) : "");
+    var attempts = [
+        { url: postUrl, options: { method: "POST",
+            headers: { "Content-Type": "application/x-www-form-urlencoded" },
+            body: "xnxq01id=" + encodeURIComponent(semesterId || "") } },
+        { url: getUrl, options: { method: "GET" } }
+    ];
+    for (var i = 0; i < attempts.length; i++) {
+        try {
+            var resp = await fetch(attempts[i].url, Object.assign({ credentials: "include" }, attempts[i].options));
+            if (!resp.ok) continue;
+            var info = parseTermInfo(await resp.text(), semesterId);
+            if (info && info.startDate) return info;
+        } catch (e) { /* 尝试下一种方式 */ }
+    }
+    return null;
+}
+
 function mergeCourses(courses) {
     if (courses.length <= 1) return courses;
     courses.sort(function (a, b) {
@@ -333,10 +411,11 @@ async function fetchAndParseCourses(term) {
     return courses;
 }
 
-async function saveAll(courses) {
+async function saveAll(courses, termInfo) {
     try {
-        await window.shiguangBridgePromise.saveCourseConfig(
-            JSON.stringify({ semesterTotalWeeks: 20, firstDayOfWeek: 1 }));
+        var config = { semesterTotalWeeks: (termInfo && termInfo.totalWeeks) || 20, firstDayOfWeek: 1 };
+        if (termInfo && termInfo.startDate) config.semesterStartDate = termInfo.startDate;
+        await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
         await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(TIME_SLOTS));
         await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
         return true;
@@ -362,10 +441,12 @@ async function runImportFlow() {
     const courses = await fetchAndParseCourses(term);
     if (courses === null) return; // 失败原因已在 fetchAndParseCourses 中提示
 
-    if (!(await saveAll(courses))) return;
+    const termInfo = await fetchTermInfo(term.semId);
+    if (!(await saveAll(courses, termInfo))) return;
 
     // 整条流程成功后才发送结束信号
-    window.shiguangBridge.showToast("成功导入 " + courses.length + " 门课程");
+    window.shiguangBridge.showToast("成功导入 " + courses.length + " 门课程" +
+        (termInfo && termInfo.startDate ? ",开学日期 " + termInfo.startDate : ""));
     window.shiguangBridge.notifyTaskCompletion();
 }
 

+ 242 - 161
resources/HBGUHX/hbguhx_01.js

@@ -1,34 +1,58 @@
 // 河北地质大学华信学院强智教务 (61.182.88.214:8090) 拾光课程表适配脚本
-// 非该大学开发者适配,开发者无法及时发现问题
-// 出现问题请联系开发者或者提交 pr 更改,这更加快速
+// 本校 25 届开发者适配,无法覆盖所有边界情况
+// 出现问题请联系 4831884790@qq.com 或提交 pr,pr 更加快速
+
+const BASE_URL = "http://61.182.88.214:8090";
+
+/**
+ * 带错误检查的 fetch,返回响应文本
+ */
+async function fetchText(url, options = {}) {
+    const response = await fetch(url, { credentials: "include", ...options });
+    if (!response.ok) {
+        throw new Error(`HTTP error! status: ${response.status}`);
+    }
+    return response.text();
+}
 
 /**
  * 解析周次字符串为数组
+ * 支持 "1-17"、"1-17(周)"、"(单周)"/"(双周)" 标记(按段生效)、中英文逗号与顿号分隔
  */
 function parseWeeks(weekStr) {
     const weeks = [];
     if (!weekStr) return weeks;
 
-    // 移除"周"字、括号和节次信息
-    const pureWeekData = weekStr.replace(/周|\(.*?\)|\[\d+-\d+ 节\]/g, '').trim();
+    // 移除节次信息(防御性),保留周次段
+    const pureWeekData = weekStr.replace(/\[.*?节\]/g, '').trim();
     if (!pureWeekData) return weeks;
 
     // 分割并处理每个段
-    const segments = pureWeekData.split(',');
+    const segments = pureWeekData.split(/[,,、]/);
     segments.forEach(seg => {
         seg = seg.trim();
         if (!seg) return;
-        
+
+        // 检测本段的单双周标记(如 "1-17(单周)"、"6(双)"),需在剥离标记前记录
+        const onlyOdd = /单/.test(seg);
+        const onlyEven = /双/.test(seg);
+        seg = seg.replace(/单|双|周|\(.*?\)/g, '').trim();
+        if (!seg) return;
+
         if (seg.includes('-')) {
             const [start, end] = seg.split('-').map(Number);
             if (!isNaN(start) && !isNaN(end)) {
                 for (let i = start; i <= end; i++) {
+                    if (onlyOdd && i % 2 === 0) continue;
+                    if (onlyEven && i % 2 === 1) continue;
                     weeks.push(i);
                 }
             }
         } else {
-            const w = parseInt(seg);
+            const w = parseInt(seg, 10);
             if (!isNaN(w)) {
+                if (onlyOdd && w % 2 === 0) return;
+                if (onlyEven && w % 2 === 1) return;
                 weeks.push(w);
             }
         }
@@ -42,7 +66,7 @@ function parseWeeks(weekStr) {
  */
 function mergeAndDistinctCourses(courses) {
     if (courses.length <= 1) return courses;
-    
+
     // 排序以便合并
     courses.sort((a, b) => {
         if (a.name !== b.name) return a.name.localeCompare(b.name);
@@ -74,6 +98,7 @@ function mergeAndDistinctCourses(courses) {
             // 合并连续节次
             current.endSection = next.endSection;
         } else if (!(isSameCourse && current.startSection === next.startSection && current.endSection === next.endSection)) {
+            // 完全重复的条目直接丢弃,其余推入结果
             merged.push(current);
             current = next;
         }
@@ -82,6 +107,31 @@ function mergeAndDistinctCourses(courses) {
     return merged;
 }
 
+/**
+ * 从课程块中提取周次和节次
+ * 匹配 "1-17(周)[01-02节]" / "1-17(周)[01-02 节]" 格式,空格均可选
+ */
+function extractWeekAndSection(weekFull) {
+    const match = weekFull.match(/(.+?)\(周\)\s*\[\s*(\d+)-(\d+)\s*节\s*\]/);
+    if (match) {
+        return {
+            weekStr: match[1],
+            startSection: parseInt(match[2], 10),
+            endSection: parseInt(match[3], 10)
+        };
+    }
+    // 兜底:只匹配周次范围,节次按 1-2 节假设
+    const altMatch = weekFull.match(/(\d+)-(\d+)/);
+    if (altMatch) {
+        return {
+            weekStr: altMatch[0],
+            startSection: 1,
+            endSection: 2
+        };
+    }
+    return null;
+}
+
 /**
  * 将 HTML 源码解析为课程模型
  */
@@ -92,77 +142,57 @@ function parseTimetableToModel(htmlString) {
         return [];
     }
 
-    let rawCourses = [];
+    const rawCourses = [];
+    // 行 = 大节,列 = 星期;只处理含 td 的行(跳过表头),备注/中午行会因缺少周次标记被自然过滤
     const rows = Array.from(timetable.querySelectorAll('tr')).filter(r => r.querySelector('td'));
 
     rows.forEach((row) => {
         const cells = row.querySelectorAll('td');
-        
+
         cells.forEach((cell, dayIndex) => {
             const day = dayIndex + 1; // 星期几(1-7)
-            
+
             // 获取所有课程详情 div,包括所有状态的
             const detailDivs = Array.from(cell.querySelectorAll('div.kbcontent'));
-            
+
             detailDivs.forEach((detailDiv) => {
                 const rawHtml = detailDiv.innerHTML.trim();
                 const innerText = detailDiv.innerText.trim();
-                
+
                 if (!rawHtml || rawHtml === "&nbsp;" || innerText.length < 2) return;
 
-                // 分割同一个格子内的多门课程
-                const blocks = rawHtml.split(/---------------------|----------------------/);
+                // 分割同一个格子内的多门课程(10 个以上连字符即为分隔线)
+                const blocks = rawHtml.split(/-{10,}/);
 
                 blocks.forEach((block) => {
                     if (!block.trim()) return;
-                    
+
                     const tempDiv = document.createElement('div');
                     tempDiv.innerHTML = block;
 
                     // 1. 提取课程名(跳过开头的空行,处理 <br> 开头的分隔块)
+                    // 注意:innerHTML 中的 &nbsp; 等实体不会自动解码,需手动处理
                     let name = "";
                     const htmlLines = tempDiv.innerHTML.split('<br>');
                     for (const line of htmlLines) {
-                        const text = line.replace(/<[^>]*>/g, '').trim();
+                        const text = line
+                            .replace(/<[^>]*>/g, '')
+                            .replace(/&nbsp;/gi, ' ')
+                            .replace(/\s+/g, ' ')
+                            .trim();
                         if (text) {
                             name = text;
                             break;
                         }
                     }
-                    
+
                     if (!name) return;
 
                     // 2. 提取周次和节次信息
                     const weekFont = tempDiv.querySelector('font[title="周次(节次)"]');
                     const weekFull = weekFont?.innerText || "";
-                    let startSection = 0;
-                    let endSection = 0;
-                    let weekStr = "";
-
-                    // 匹配 "1-17(周)[01-02节]" 格式
-                    const weekSectionMatch = weekFull.match(/(.+?)\(周\)\[(\d+)-(\d+)节\]/);
-                    if (weekSectionMatch) {
-                        weekStr = weekSectionMatch[1]; // "1-17"
-                        startSection = parseInt(weekSectionMatch[2], 10);
-                        endSection = parseInt(weekSectionMatch[3], 10);
-                    } else {
-                        // 尝试匹配 "1-17(周)[01-02 节]" 格式(带空格)
-                        const weekSectionMatchWithSpace = weekFull.match(/(.+?)\(周\)\[(\d+)-(\d+) 节\]/);
-                        if (weekSectionMatchWithSpace) {
-                            weekStr = weekSectionMatchWithSpace[1];
-                            startSection = parseInt(weekSectionMatchWithSpace[2], 10);
-                            endSection = parseInt(weekSectionMatchWithSpace[3], 10);
-                        } else {
-                            // 尝试匹配其他格式
-                            const altMatch = weekFull.match(/(\d+)-(\d+)/);
-                            if (altMatch) {
-                                weekStr = altMatch[0];
-                                // 假设是第1-2节
-                                startSection = 1;
-                                endSection = 2;
-                            }
-                        }
-                    }
+                    const weekSection = extractWeekAndSection(weekFull);
+                    if (!weekSection) return;
 
                     // 3. 提取教师信息
                     const teacher = tempDiv.querySelector('font[title="老师"]')?.innerText.trim() || "未知教师";
@@ -170,18 +200,15 @@ function parseTimetableToModel(htmlString) {
                     // 4. 提取教室地点
                     const position = tempDiv.querySelector('font[title="教室"]')?.innerText.trim() || "未知地点";
 
-                    if (name && startSection > 0) {
-                        const course = {
-                            "name": name,
-                            "teacher": teacher,
-                            "weeks": parseWeeks(weekStr),
-                            "position": position,
-                            "day": day,
-                            "startSection": startSection,
-                            "endSection": endSection
-                        };
-                        rawCourses.push(course);
-                    }
+                    rawCourses.push({
+                        "name": name,
+                        "teacher": teacher,
+                        "weeks": parseWeeks(weekSection.weekStr),
+                        "position": position,
+                        "day": day,
+                        "startSection": weekSection.startSection,
+                        "endSection": weekSection.endSection
+                    });
                 });
             });
         });
@@ -199,66 +226,78 @@ function extractSemesterOptions(htmlString) {
     if (!semesterSelect) {
         return [];
     }
-    
-    const options = Array.from(semesterSelect.querySelectorAll('option')).map(opt => ({
-        value: opt.value,
-        text: opt.text
-    }));
-    
-    return options;
+
+    return Array.from(semesterSelect.querySelectorAll('option'))
+        .filter(opt => opt.value)
+        .map(opt => ({
+            value: opt.value,
+            text: opt.text
+        }));
+}
+
+/**
+ * 时间字符串加分钟数,"08:30" + 45 → "09:15"
+ */
+function addMinutes(hhmm, minutes) {
+    const parts = hhmm.split(':').map(Number);
+    const total = parts[0] * 60 + parts[1] + minutes;
+    const h = Math.floor(total / 60) % 24;
+    const m = total % 60;
+    return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
 }
 
 /**
  * 从网页中提取作息时间
+ * 课表页 #kbtable 中每个大节行头 th 形如 "第一大节\n08:30-10:05"
+ * 拆分规则:第 1 小节 = 开始 ~ 开始+45 分钟;第 2 小节 = 开始+55 分钟 ~ 大节结束
  */
 function extractTimeSlots(htmlString) {
     const doc = new DOMParser().parseFromString(htmlString, "text/html");
     const timetable = doc.getElementById('kbtable');
     if (!timetable) return null;
-    
+
     const timeSlots = [];
     const rows = Array.from(timetable.querySelectorAll('tr'));
-    
+
     rows.forEach((row) => {
         const th = row.querySelector('th');
         if (!th) return;
-        
+
         // 提取时间范围,如 "08:30-10:05"
         const timeText = th.innerText.trim();
         const timeMatch = timeText.match(/(\d{2}:\d{2})-(\d{2}:\d{2})/);
-        
-        if (timeMatch) {
-            const startTime = timeMatch[1];
-            const endTime = timeMatch[2];
-            
-            // 判断是否是有效的时间段(排除"中午"等)
-            const sectionName = timeText.split('\n')[0].trim();
-            if (startTime && endTime && sectionName !== '中午') {
-                // 为每个大节创建两个时间段
-                const sections = [
-                    {
-                        number: timeSlots.length + 1,
-                        startTime: startTime,
-                        endTime: `${startTime.split(':')[0]}:45`
-                    },
-                    {
-                        number: timeSlots.length + 2,
-                        startTime: `${startTime.split(':')[0]}:55`,
-                        endTime: endTime
-                    }
-                ];
-                timeSlots.push(...sections);
+        if (!timeMatch) return;
+
+        const startTime = timeMatch[1];
+        const endTime = timeMatch[2];
+
+        // 判断是否是有效的时间段(排除"中午"等)
+        const sectionName = timeText.split('\n')[0].trim();
+        if (sectionName === '中午') return;
+
+        // 为每个大节创建两个小节(时间加法,勿用分钟硬替换)
+        timeSlots.push(
+            {
+                number: timeSlots.length + 1,
+                startTime: startTime,
+                endTime: addMinutes(startTime, 45)
+            },
+            {
+                number: timeSlots.length + 2,
+                startTime: addMinutes(startTime, 55),
+                endTime: endTime
             }
-        }
+        );
     });
-    
+
     return timeSlots.length > 0 ? timeSlots : null;
 }
 
 /**
- * 根据学期字符串获取学期第一周周一日期
+ * 根据学期字符串估算学期第一周周一日期
  * 学期格式如 "2025-2026-2"
  * 春季学期:3月第二个周一,秋季学期:9月1日所在周的周一
+ * 仅作兜底,优先使用教学周历解析出的真实日期
  */
 function getSemesterStartMonday(semesterId) {
     if (!semesterId) return null;
@@ -287,6 +326,56 @@ function getSemesterStartMonday(semesterId) {
     return monday;
 }
 
+/**
+ * 从教学周历页面 HTML 中解析第一周周一的日期
+ * 表格结构:<tr height='28'><td>1</td><td title='2026年08月31'>31</td>...
+ * 第 1 个 td 为周次,第 2 个 td 为星期一,title 为完整日期
+ */
+function extractWeekCalendarStartDate(htmlString) {
+    const doc = new DOMParser().parseFromString(htmlString, "text/html");
+    const timetable = doc.getElementById('kbtable');
+    if (!timetable) return null;
+
+    const rows = Array.from(timetable.querySelectorAll('tr'));
+    for (const row of rows) {
+        const tds = row.querySelectorAll('td');
+        if (tds.length < 2) continue;
+        if (tds[0].innerText.trim() !== '1') continue; // 只看第 1 周
+
+        const title = tds[1].getAttribute('title') || '';
+        const match = title.match(/(\d{4})年(\d{1,2})月(\d{1,2})/);
+        if (match) {
+            const date = new Date(
+                parseInt(match[1], 10),
+                parseInt(match[2], 10) - 1,
+                parseInt(match[3], 10)
+            );
+            if (!isNaN(date.getTime())) return date;
+        }
+        return null; // 找到第 1 周行但解析失败,不再继续扫
+    }
+    return null;
+}
+
+/**
+ * 请求教学周历页面,获取准确的学期第一周周一日期
+ * 失败时返回 null,由调用方回退到估算
+ */
+async function fetchSemesterStartMonday(semesterId) {
+    try {
+        // 注意:必须使用完整 URL 或前导 "/",否则相对路径会被教务框架错误拼接
+        const html = await fetchText(`${BASE_URL}/jsxsd/jxzl/jxzl_query`, {
+            method: "POST",
+            headers: { "Content-Type": "application/x-www-form-urlencoded" },
+            body: `xnxq01id=${encodeURIComponent(semesterId)}`
+        });
+        return extractWeekCalendarStartDate(html);
+    } catch (error) {
+        console.error('获取教学周历失败,将回退到估算学期起点:', error);
+        return null;
+    }
+}
+
 /**
  * 格式化日期为 YYYY-MM-DD
  */
@@ -299,9 +388,10 @@ function formatDate(date) {
 
 /**
  * 根据学期字符串计算当前周数
+ * 优先使用从教学周历解析到的真实起始日期,否则回退到估算
  */
-function calculateCurrentWeek(semesterId) {
-    const monday = getSemesterStartMonday(semesterId);
+function calculateCurrentWeek(semesterId, startMondayOverride) {
+    const monday = startMondayOverride || getSemesterStartMonday(semesterId);
     if (!monday) return 1;
 
     const now = new Date();
@@ -316,7 +406,7 @@ function calculateCurrentWeek(semesterId) {
 async function showWelcomeAlert() {
     return await window.shiguangBridgePromise.showAlert(
         "导入提示",
-        "请确保已在学期理论课表页面,(首页课表是本周课表不可导入,请进入学期理论课表页面)",
+        "请先进入学期理论课表页面,首页周课表无法导入",
         "开始导入"
     );
 }
@@ -329,59 +419,56 @@ async function getSemesterParamsFromUser(semesterOptions) {
         window.shiguangBridge.showToast("未获取到学期列表");
         return null;
     }
-    
+
     // 直接显示所有学期选项,让用户一次性选择
     const semesterLabels = semesterOptions.map(opt => opt.text);
-    
+
     const semesterIndex = await window.shiguangBridgePromise.showSingleSelection(
         "选择学期",
         JSON.stringify(semesterLabels),
         0 // 默认选择第一个(最新学期)
     );
-    
-    if (semesterIndex === null) return null;
-    
+
+    if (semesterIndex == null) return null; // 兼容 null / undefined(用户取消)
+
     return semesterOptions[semesterIndex].value;
 }
 
 /**
- * 请求课表 HTML 数据
+ * 请求课表页面 HTML
  */
 async function fetchCourseHtml() {
-    try {
-        const response = await fetch("http://61.182.88.214:8090/jsxsd/xskb/xskb_list.do", {
-            method: "GET",
-            credentials: "include",
-            headers: {
-                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
-            }
-        });
-        
-        if (!response.ok) {
-            throw new Error(`HTTP error! status: ${response.status}`);
-        }
-        
-        const text = await response.text();
-        return text;
-    } catch (error) {
-        console.error('获取课表页面失败:', error);
-        throw error;
-    }
+    // 浏览器禁止通过 fetch 设置 User-Agent,无需伪装请求头,Cookie 跟随即可
+    return fetchText(`${BASE_URL}/jsxsd/xskb/xskb_list.do`);
+}
+
+/**
+ * 根据选择的学期请求课表页面 HTML
+ */
+async function fetchCourseHtmlBySemester(semesterId) {
+    return fetchText(`${BASE_URL}/jsxsd/xskb/xskb_list.do`, {
+        method: "POST",
+        headers: { "Content-Type": "application/x-www-form-urlencoded" },
+        body: `cj0701id=&zc=&demo=&xnxq01id=${encodeURIComponent(semesterId)}`
+    });
 }
 
 /**
  * 保存课程数据到 App
  */
-async function saveCourseDataToApp(courses, timeSlots, semesterId) {
-    // 计算学期开始日期和当前周数
-    const startMonday = getSemesterStartMonday(semesterId);
-    const currentWeek = calculateCurrentWeek(semesterId);
+async function saveCourseDataToApp(courses, timeSlots, semesterId, startMondayOverride) {
+    // 计算学期开始日期和当前周数(优先使用教学周历解析出的真实日期)
+    const startMonday = startMondayOverride || getSemesterStartMonday(semesterId);
+    const currentWeek = calculateCurrentWeek(semesterId, startMondayOverride);
+
+    // 学期总周数:从课程数据中的最大周次推导,最小 20 周
+    const maxCourseWeek = courses.reduce((max, c) => Math.max(max, ...(c.weeks || [])), 0);
 
     // 保存学期配置
     await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
         "semesterStartDate": startMonday ? formatDate(startMonday) : null,
         "currentWeek": currentWeek,
-        "semesterTotalWeeks": 20,
+        "semesterTotalWeeks": Math.max(20, maxCourseWeek),
         "firstDayOfWeek": 1
     }));
 
@@ -405,60 +492,54 @@ async function runImportFlow() {
 
         // 2. 获取课表 HTML(包含学期选项和作息时间)
         const html = await fetchCourseHtml();
-        
+
         // 3. 从网页中提取学期选项
         const semesterOptions = extractSemesterOptions(html);
-        
-        // 4. 从网页中提取作息时间
-        let timeSlots = extractTimeSlots(html);
-        if (!timeSlots || timeSlots.length === 0) {
-            // 设置默认作息时间
-            timeSlots = [
-                { number: 1, startTime: "08:30", endTime: "09:15" },
-                { number: 2, startTime: "09:25", endTime: "10:10" },
-                { number: 3, startTime: "10:15", endTime: "11:00" },
-                { number: 4, startTime: "11:10", endTime: "11:55" },
-                { number: 5, startTime: "14:30", endTime: "15:15" },
-                { number: 6, startTime: "15:25", endTime: "16:10" },
-                { number: 7, startTime: "16:15", endTime: "17:00" },
-                { number: 8, startTime: "17:10", endTime: "17:55" },
-                { number: 9, startTime: "19:00", endTime: "19:45" },
-                { number: 10, startTime: "19:55", endTime: "20:40" }
-            ];
-        }
+
+        // 4. 从网页中提取作息时间,失败则回退到内置默认值(按本校实际作息)
+        const timeSlots = extractTimeSlots(html) || [
+            { number: 1, startTime: "08:30", endTime: "09:15" },
+            { number: 2, startTime: "09:25", endTime: "10:05" },
+            { number: 3, startTime: "10:15", endTime: "11:00" },
+            { number: 4, startTime: "11:10", endTime: "11:50" },
+            { number: 5, startTime: "14:30", endTime: "15:15" },
+            { number: 6, startTime: "15:25", endTime: "16:05" },
+            { number: 7, startTime: "16:15", endTime: "17:00" },
+            { number: 8, startTime: "17:10", endTime: "17:50" },
+            { number: 9, startTime: "19:00", endTime: "19:45" },
+            { number: 10, startTime: "19:55", endTime: "20:40" }
+        ];
 
         // 5. 让用户选择学期
         const semesterId = await getSemesterParamsFromUser(semesterOptions);
         if (!semesterId) return;
 
         // 6. 根据选择的学期重新请求课表数据
-        const response = await fetch("http://61.182.88.214:8090/jsxsd/xskb/xskb_list.do", {
-            method: "POST",
-            headers: { "Content-Type": "application/x-www-form-urlencoded" },
-            body: `cj0701id=&zc=&demo=&xnxq01id=${semesterId}`,
-            credentials: "include"
-        });
-        const courseHtml = await response.text();
+        const courseHtml = await fetchCourseHtmlBySemester(semesterId);
+
+        // 7. 从教学周历获取准确的学期第一周周一日期(失败则自动回退到估算)
+        const realStartMonday = await fetchSemesterStartMonday(semesterId);
 
-        // 7. 解析课程数据
+        // 8. 解析课程数据
         const finalCourses = parseTimetableToModel(courseHtml);
 
         if (finalCourses.length === 0) {
-            window.shiguangBridge.showToast("未发现课程,请检查学期选择或登录状态。");
-            // 尝试直接从初始 HTML 中解析课程
+            // 兜底:直接从初始页面解析,成功不额外提示,仅在失败时提示
             const initialCourses = parseTimetableToModel(html);
             if (initialCourses.length > 0) {
-                await saveCourseDataToApp(initialCourses, timeSlots, semesterId);
-                window.shiguangBridge.showToast(`成功导入 ${initialCourses.length} 门课程`);
+                await saveCourseDataToApp(initialCourses, timeSlots, semesterId, realStartMonday);
                 window.shiguangBridge.notifyTaskCompletion();
+            } else {
+                window.shiguangBridge.showToast("未获取到课程,请确认已登录且所选学期有课");
             }
             return;
         }
 
-        // 8. 保存课程数据
-        await saveCourseDataToApp(finalCourses, timeSlots, semesterId);
+        // 9. 保存课程数据
+        await saveCourseDataToApp(finalCourses, timeSlots, semesterId, realStartMonday);
 
-        window.shiguangBridge.showToast(`成功导入 ${finalCourses.length} 门课程(当前第 ${calculateCurrentWeek(semesterId)} 周)`);
+        const currentWeek = calculateCurrentWeek(semesterId, realStartMonday);
+        window.shiguangBridge.showToast(`成功导入 ${finalCourses.length} 门课程(当前第 ${currentWeek} 周)`);
         window.shiguangBridge.notifyTaskCompletion();
 
     } catch (error) {
@@ -468,4 +549,4 @@ async function runImportFlow() {
 }
 
 // 启动执行
-runImportFlow();
+runImportFlow();

+ 9 - 0
resources/NJCIT/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/NJCIT/adapters.yaml
+adapters:
+  - adapter_id: "NJCIT"
+    adapter_name: "南京信息职业技术学院"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "njcit.js"
+    import_url: "https://jw.njcit.cn/sso/lyiotlogin"
+    maintainer: "astraumbra"
+    description: "南京信息职业技术学院教务适配,登录后可直接导入"

+ 484 - 0
resources/NJCIT/njcit.js

@@ -0,0 +1,484 @@
+// 南京信息职业技术学院教务适配
+// 注意:本校教务页面加载的 zftal-ui 的 jquery.extends.contact-min.js
+// 会污染以下原型方法,本文件一律不使用:
+//   Array.prototype.filter / some —— 回调实参变成 (下标, 元素, 数组)
+//   Array.prototype.every        —— 恒为 true
+//   String.prototype.trim        —— 删除所有空白而非仅首尾
+// 参考 resources/SCNU/scnu.js、resources/UJS/ujs_zhengfang_v9.0.js 的同类处理。
+
+const text = value => String(value == null ? '': value).replace(/^\s+|\s+$/g, '');
+
+async function promptUserToStart() {
+    return await window.shiguangBridgePromise.showAlert(
+        "教务系统课表导入",
+        "导入前请确保您已在浏览器中成功登录教务系统(无需打开课表页面),点击开始后将弹窗选择学期。",
+        "好的,开始导入"
+    );
+}
+
+async function fetchAcademicOptions() {
+    const url = "/jwglxt/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151&layout=default"
+
+    try {
+        const response = await fetch(url, {
+            method : "GET",
+            credentials: "include"
+        }) 
+
+        if (!response.ok) {
+            return null;
+        }
+
+        const htmlText = await response.text();
+        const doc = new DOMParser().parseFromString(htmlText, "text/html");
+
+        const parseOptions = (selector) => {
+            const list = [];
+            let defaultIdx = 0;
+
+            for (const opt of doc.querySelectorAll(`${selector} option`)) {
+                const value = opt.getAttribute("value") || opt.value;
+                if (!value) continue;
+
+                const selected = opt.hasAttribute("selected") || opt.selected;
+                if (selected) defaultIdx = list.length;
+                
+                list.push({value, text: text(opt.textContent), selected});
+            }
+
+            return {list, defaultIdx};
+        }
+        const {list: allYears, defaultIdx: yearIdx} = parseOptions("#xnm");
+        const {list: semesterOptions, defaultIdx: defaultSemesterIndex } = parseOptions("#xqm");
+
+        if (allYears.length == 0 || semesterOptions.length == 0) return null;
+
+        const start = Math.max(0, yearIdx - 2);
+        const end = Math.min(allYears.length, yearIdx + 3);
+
+        const yearOptions = allYears.slice(start, end);
+
+        return {
+            yearOptions,
+            semesterOptions,
+            defaultYearIndex: yearIdx - start,
+            defaultSemesterIndex
+        }
+    } catch(error) {
+        console.error(error.message);
+        return null;
+    }
+}
+
+
+async function selectAcademicYearAndSemester()  {
+    const optionsData = await fetchAcademicOptions();
+        
+    if (!optionsData) {
+        window.shiguangBridge.showToast("从教务系统读取学年学期失败,请确保登录状态。");
+        return null;
+    }
+
+    const { yearOptions, semesterOptions, defaultYearIndex, defaultSemesterIndex } = optionsData;
+    
+    const yearTexts = yearOptions.map(item => item.text);
+    const yearIndex = await window.shiguangBridgePromise.showSingleSelection(
+        "选择学年",
+        JSON.stringify(yearTexts),
+        defaultYearIndex
+    );
+
+    if (yearIndex === null || yearIndex === -1) {
+        return null;
+    }
+    const selectedYearCode = yearOptions[yearIndex].value;
+
+    const semesterTexts = semesterOptions.map(item => item.text); 
+    const semesterIndex = await window.shiguangBridgePromise.showSingleSelection(
+        "选择学期",
+        JSON.stringify(semesterTexts),
+        defaultSemesterIndex
+    );
+
+    if (semesterIndex === null || semesterIndex === -1) return null;
+    const selectedSemesterCode = semesterOptions[semesterIndex].value;
+
+    return {
+        academicYear: selectedYearCode,
+        semesterCode: selectedSemesterCode
+    };
+}
+
+async function fetchSemesterStartDate(academicYear, semesterCode) {
+    const url = "/jwglxt/kbcx/xskbcxZccx_cxZcByXnxq.html?gnmkdm=N2154";
+    const requestBody = `xnm=${academicYear}&xqm=${semesterCode}`;
+    try {
+        const response = await fetch(url, {
+            method : "POST",
+            headers : {
+                "Content-Type" :"application/x-www-form-urlencoded;charset=UTF-8",
+                "x-requested-with": "XMLHttpRequest"
+            },
+            body: requestBody,
+            credentials: "include"
+        });
+
+        if (!response.ok) {
+            return null;
+        }
+
+        const json = await response.json();
+        if (Array.isArray(json) &&  json.length > 0) {
+            const firstWeekObj = json.find(item => String(item.zs) == "1" || String(item.zsmc) == "1") || json[0];
+            const totalWeeksInt = json.length;
+
+            if (firstWeekObj.rq) {
+                const startDateStr = firstWeekObj.rq.split('/')[0];
+                if (/^\d{4}-\d{2}-\d{2}$/.test(startDateStr)) {
+                    return {
+                        semesterStartDate : startDateStr,
+                        semesterTotalWeeks: totalWeeksInt
+                    }
+                }
+            }
+        }
+
+    } catch(e) {
+        console.error(e);
+    }
+    return null;
+}
+
+async function fetchTimesSlots(academicYear, semesterCode) {
+    const url = "/jwglxt/kbcx/xskbcx_cxRjc.html?gnmkdm=N2151";
+    const requestBody = `xnm=${academicYear}&xqm=${semesterCode}&xqh_id=01`;
+    try {
+        const response = await fetch(url, {
+            method : "POST",
+            headers : {
+                "Content-Type" :"application/x-www-form-urlencoded;charset=UTF-8",
+                "x-requested-with": "XMLHttpRequest"
+            },
+            body: requestBody,
+            credentials: "include"
+        });
+
+        if (!response.ok) {
+            return null;
+        }
+
+        const json = await response.json();
+        if (!Array.isArray(json)) {
+            return null;
+        }
+        
+        const timeSlots = [];
+        for (const item of json) {
+            if (!item.jcmc || !item.qssj || !item.jssj) {
+                continue;
+            }
+
+            timeSlots.push({
+                number: Number(item.jcmc),
+                startTime: item.qssj.substring(0, 5),
+                endTime: item.jssj.substring(0, 5)
+            })
+        }
+        timeSlots.sort((a, b) => a.number - b.number);
+            
+        return timeSlots.length > 0 ? timeSlots : null;
+    } catch (e) {
+        console.error(e);
+        return null;
+    }
+}
+
+/**
+ * 节次与周次合并去重函数
+ * @param {Array<Object>} courses 原始解析课程数组
+ * @returns {Array<Object>} 合并去重后的课程数组
+ */
+function mergeAndDistinctCourses(courses) {
+    if (!Array.isArray(courses) || courses.length <= 1) return courses;
+
+    // 1. 深拷贝并规范周次数据,过滤无效项
+    const list = courses.map(c => ({
+        ...c,
+        name: c.name || '',
+        teacher: c.teacher || '',
+        position: c.position || '',
+        weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : []
+    }));
+
+    // 阶段 1:合并连续节次与完全重复记录(前提:名称、教师、地点、星期、周次一致)
+    list.sort((a, b) => {
+        return 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 step1Merged = [];
+    let current = list[0];
+
+    for (let i = 1; i < list.length; i++) {
+        const next = list[i];
+
+        const isSameCourseAndWeeks =
+            current.name === next.name &&
+            current.teacher === next.teacher &&
+            current.position === next.position &&
+            current.day === next.day &&
+            current.weeks.join(',') === next.weeks.join(',');
+
+        const isContinuous = current.endSection + 1 === next.startSection;
+        const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
+
+        if (isSameCourseAndWeeks && isContinuous) {
+            // 节次连续:延长结束节次 (如 1-2 节 + 3-4 节 -> 1-4 节)
+            current.endSection = next.endSection;
+        } else if (isSameCourseAndWeeks && isDuplicate) {
+            // 完全重复:跳过
+            continue;
+        } else {
+            step1Merged.push(current);
+            current = next;
+        }
+    }
+    step1Merged.push(current);
+
+    // 阶段 2:合并同节次的周次(前提:名称、教师、地点、星期、开始/结束节次一致)
+    step1Merged.sort((a, b) => {
+        return 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 step2Merged = [];
+    let cur = step1Merged[0];
+
+    for (let i = 1; i < step1Merged.length; i++) {
+        const nxt = step1Merged[i];
+
+        const isSameCourseAndSection =
+            cur.name === nxt.name &&
+            cur.teacher === nxt.teacher &&
+            cur.position === nxt.position &&
+            cur.day === nxt.day &&
+            cur.startSection === nxt.startSection &&
+            cur.endSection === nxt.endSection;
+
+        if (isSameCourseAndSection) {
+            // 周次合并去重 (如 1-8 周 + 9-16 周 -> 1-16 周)
+            cur.weeks = Array.from(new Set([...cur.weeks, ...nxt.weeks])).sort((a, b) => a - b);
+        } else {
+            step2Merged.push(cur);
+            cur = nxt;
+        }
+    }
+    step2Merged.push(cur);
+
+    return step2Merged;
+}
+
+function parseWeeks(weekStr) {
+    if (!weekStr) return [];
+
+    const weekSets = weekStr.split(',');
+    let weeks = [];
+
+    for (const set of weekSets) {
+        const trimmedSet = text(set);
+
+        const rangeMatch = trimmedSet.match(/(\d+)-(\d+)周/);
+        const singleMatch = trimmedSet.match(/^(\d+)周/);
+
+        let start = 0;
+        let end = 0;
+        let processed = false;
+
+        if (rangeMatch) {
+            start = Number(rangeMatch[1]);
+            end = Number(rangeMatch[2]);
+            processed = true;
+        } else if (singleMatch) {
+            start = end = Number(singleMatch[1]);
+            processed = true;
+        }
+        
+        if (processed) {
+            const isSingle = trimmedSet.includes('(单)');
+            const isDouble = trimmedSet.includes('(双)');
+
+            for (let w = start; w <= end; w++) {
+                if (isSingle && w % 2 === 0) continue;
+                if (isDouble && w % 2 !== 0) continue;
+                weeks.push(w);
+            }
+        }
+    }
+
+    return [...new Set(weeks)].sort((a, b) => a - b);
+}
+
+function parseCourseJsonData(jsonData) {
+    if (!jsonData || !Array.isArray(jsonData.kbList)) {
+        return [];
+    }
+
+    const rawCourseList = jsonData.kbList;
+    let initalCourseList = [];
+
+    for (const rawCourse of rawCourseList) {
+        if (!rawCourse.kcmc || !rawCourse.xqj || !rawCourse.jcs || !rawCourse.zcd) {
+            continue;
+        }
+
+        
+        const sectionParts = rawCourse.jcs.split('-');
+        const startSection = Number(sectionParts[0]);
+        const endSection = Number(sectionParts[sectionParts.length - 1]);
+        const day = Number(rawCourse.xqj);
+        const weeks = parseWeeks(rawCourse.zcd);
+
+        initalCourseList.push({
+            name: text(rawCourse.kcmc),
+            teacher: text(rawCourse.xm),
+            position: text(rawCourse.cdmc || "未排地点"),
+            day: day,
+            startSection: startSection,
+            endSection: endSection,
+            weeks: weeks
+        })
+    }
+
+    return mergeAndDistinctCourses(initalCourseList);
+}
+
+
+async function fetchAndParseCourses(academicYear, semesterCode) {
+    const url = "/jwglxt/kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151";
+    const requestBody = `xnm=${academicYear}&xqm=${semesterCode}&kzlx=ck`;
+    
+    try {
+        const [courseResponse, semesterStartDate, fetchedTimeSlots] = await Promise.all(
+            [
+                fetch(url, {
+                method : "POST",
+                headers : {
+                    "Content-Type" :"application/x-www-form-urlencoded;charset=UTF-8",
+                    "x-requested-with": "XMLHttpRequest"
+                },
+                body: requestBody,
+                credentials: "include"
+                }),
+                fetchSemesterStartDate(academicYear, semesterCode),
+                fetchTimesSlots(academicYear, semesterCode)
+            ]
+        );
+
+        if (courseResponse.ok) {
+            const json = await courseResponse.json();
+            const courseData = parseCourseJsonData(json);
+
+            return {
+                courses: courseData,
+                config: semesterStartDate,
+                timeSlots: fetchedTimeSlots
+            }
+        }
+    } catch(e) {
+        console.error(e);
+    }
+
+    return null;
+}
+
+
+
+// 课程是必须的,保存失败就没有导入的意义,抛异常交给导入流程中断
+async function saveCourses(courses) {
+    try {
+        await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+    } catch (error) {
+        throw new Error("课程保存失败:" + error.message);
+    }
+}
+
+// 作息和学期配置不是必须的,失败只返回 false,由导入流程汇总提示
+async function saveTimeSlots(timeSlots) {
+    try {
+        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
+        return true;
+    } catch (error) {
+        console.error(error);
+        return false;
+    }
+}
+
+async function saveConfig(config) {
+    try {
+        await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
+        return true;
+    } catch (error) {
+        console.error(error);
+        return false;
+    }
+}
+
+(async function runImportFlow() {
+
+    const confirmed = await promptUserToStart();
+    if (!confirmed) {
+        window.shiguangBridge.showToast("用户取消了导入。");
+        return;
+    }
+
+    const selection = await selectAcademicYearAndSemester();
+    if (!selection) {
+        window.shiguangBridge.showToast("未选择学年学期,导入流程终止。");
+        return;
+    }
+
+    const { academicYear, semesterCode } = selection;
+
+    const result = await fetchAndParseCourses(academicYear, semesterCode);
+    if (!result || !result.courses || result.courses.length === 0) {
+        window.shiguangBridge.showToast("未获取到有效课程,导入终止!");
+        return;
+    }
+    const { courses, config, timeSlots } = result;
+
+    try {
+        await saveCourses(courses);
+    } catch(error) {
+        window.shiguangBridge.showToast(error.message);
+        return;
+    }
+
+    // 记下没保存成功的部分,最后一起提示,避免提示互相覆盖
+    const failed = [];
+
+    if (config) {
+        const saved = await saveConfig(config);
+        if (!saved) failed.push("学期配置");
+    }
+
+    if (timeSlots) {
+        const saved = await saveTimeSlots(timeSlots);
+        if (!saved) failed.push("作息");
+    }
+
+    window.shiguangBridge.showToast(
+        failed.length
+            ? `成功导入 ${courses.length} 条排课记录,但${failed.join("、")}未保存成功`
+            : `成功导入 ${courses.length} 条排课记录!`
+    );
+
+    window.shiguangBridge.notifyTaskCompletion();
+})();

+ 9 - 0
resources/QVTU/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/QVTU/adapters.yaml
+adapters:
+  - adapter_id: "QVTU"
+    adapter_name: "泉州职业技术大学强智教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "qvtu.js"
+    import_url: "https://jw.qvtu.edu.cn/jsxsd/"
+    maintainer: "suiren0219"
+    description: "泉州职业技术大学强智教务。登录教务系统后直接导入即可,可选择学期;开学日期与总周数取自教学周历。有问题请通过 GitHub 联系开发者。"

+ 379 - 0
resources/QVTU/qvtu.js

@@ -0,0 +1,379 @@
+// ============================================================
+// 泉州职业技术大学(jw.qvtu.edu.cn/jsxsd,强智教务)
+// 拾光课程表适配脚本
+//
+// 参照官方强智适配案例(YSSDUFE_01,见 shiguangschedule Wiki
+// "常见教务系统适配案例参考")编写:
+//   1. 教学周历 jxzl_query 提供学期列表 / 开学日期 / 总周数;
+//   2. 学期理论课表 xskb_list.do 提供课程数据(#kbtable / div.kbcontent 官方结构)。
+// 若站点周历页结构与案例不同,自动降级:跳过选学期、不提交开学日期,
+// 任何情况都不会阻塞导入。
+// 流程遵循官方开发文档约定:学期配置 → 课程 → 作息时间(可选,失败不阻止完成);
+// notifyTaskCompletion 只在流程成功后调用。
+// 出现问题请联系开发者或提交 PR 更改。
+// ============================================================
+
+// 整体包一层 IIFE:本脚本可能被反复注入同一页面(测试器每次点击都会
+// 重新注入且不刷新页面),顶层 const/function 会与上一次注入冲突
+// (Identifier has already been declared),也会覆盖教务页面自己的全局函数。
+(function () {
+
+// ================= 配置 =================
+
+// 作息时间:来自教务"大节"时段,小节按"45 分钟上课 + 15 分钟休息"拆分。
+// 注:站点里"第七大节(13,14小节) 12:00-13:59"与晚间节次时间重叠,
+//     会被拾光"时间段不可重叠"校验拒绝,故不提供 13、14 节时间(当前无课程使用)。
+const QVTU_TIME_SLOTS = [
+  { number: 1, startTime: "08:10", endTime: "08:55" },
+  { number: 2, startTime: "09:10", endTime: "09:55" },
+  { number: 3, startTime: "10:10", endTime: "10:55" },
+  { number: 4, startTime: "11:10", endTime: "11:55" },
+  { number: 5, startTime: "14:10", endTime: "14:55" },
+  { number: 6, startTime: "15:10", endTime: "15:55" },
+  { number: 7, startTime: "16:10", endTime: "16:55" },
+  { number: 8, startTime: "17:10", endTime: "17:55" },
+  { number: 9, startTime: "19:10", endTime: "19:55" },
+  { number: 10, startTime: "20:10", endTime: "20:55" },
+  { number: 11, startTime: "21:10", endTime: "21:55" },
+  { number: 12, startTime: "22:10", endTime: "22:55" }
+];
+
+// 教务系统地址与接口
+const QVTU_ORIGIN_CHECK = /jw\.qvtu\.edu\.cn$/i; // 只在教务站点上运行
+const QVTU_JXZL_URL = "/jsxsd/jxzl/jxzl_query"; // 教学周历(学期列表 + 开学日期 + 总周数)
+const QVTU_XSKB_URL = "/jsxsd/xskb/xskb_list.do"; // 学期理论课表
+
+const QVTU_REQUEST_TIMEOUT = 8000; // 单请求超时(毫秒)
+
+// ================= 工具函数 =================
+
+function toast(message) {
+  window.shiguangBridge.showToast(message);
+}
+
+// 生命周期结束信号:只在流程成功后调用(官方约定)
+function notifyDone() {
+  window.shiguangBridge.notifyTaskCompletion();
+}
+
+// 带超时保护的 fetch(门户切换页面时站点脚本会中断进行中的请求,失败重试一次)
+async function fetchText(url, options, tag) {
+  const opts = options || { method: "GET", credentials: "include" };
+  let lastErr = null;
+  for (let attempt = 1; attempt <= 2; attempt++) {
+    try {
+      const p = fetch(url, opts).then(r => r.text());
+      if (typeof setTimeout !== "function") return await p;
+      return await Promise.race([p, new Promise((_, reject) => {
+        setTimeout(() => reject(new Error("请求超时:" + tag)), QVTU_REQUEST_TIMEOUT);
+      })]);
+    } catch (e) {
+      lastErr = e;
+    }
+  }
+  throw new Error("请求失败(" + tag + "):" + (lastErr && lastErr.message ? lastErr.message : "网络错误"));
+}
+
+// 解析 HTML 文本为文档对象
+function parseHtml(html) {
+  return new DOMParser().parseFromString(String(html || ""), "text/html");
+}
+
+// 只认可解析得了的课表(登录页/错误页不含这些标记)。
+// 不同强智版本课表格子用 class="kbcontent" 或 class="kb_table",本站另有 <p title=...> 格式。
+function hasCourse(text) {
+  return /课程名称:|kbcontent|kb_table/.test(String(text || ""));
+}
+
+// ================= 核心解析逻辑(一):教学周历 =================
+
+// GET 周历页:解析学期下拉框,以当前选中学期为原点上下各取 3 个。
+// 站点若无 xnxq01id 下拉框则返回空列表(流程自动跳过选学期)。
+async function fetchSemesterList() {
+  const doc = parseHtml(await fetchText(location.origin + QVTU_JXZL_URL, null, "教学周历页"));
+  const select = doc.getElementById("xnxq01id");
+  if (!select) return { list: [], defaultIndex: -1 };
+
+  const all = Array.from(select.querySelectorAll("option")).map(opt => ({
+    value: opt.value,
+    label: opt.textContent.trim(),
+    selected: opt.hasAttribute("selected")
+  }));
+  let idx = all.findIndex(o => o.selected);
+  if (idx === -1) idx = 0;
+  const start = Math.max(0, idx - 3);
+  return {
+    list: all.slice(start, Math.min(all.length, idx + 4)),
+    defaultIndex: idx - start
+  };
+}
+
+// POST 周历页:解析指定学期的开学日期(首行 td[title])与总周数(首列最大整数)。
+// 结构不符时返回 { startDate: null, totalWeeks: null },不阻塞流程。
+async function fetchSemesterInfo(semesterId) {
+  const html = await fetchText(location.origin + QVTU_JXZL_URL, {
+    method: "POST",
+    headers: { "Content-Type": "application/x-www-form-urlencoded" },
+    body: "xnxq01id=" + encodeURIComponent(semesterId),
+    credentials: "include"
+  }, "教学周历详情");
+  const doc = parseHtml(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) return { startDate: null, totalWeeks: null };
+
+  // 开学日期:第一行首个带 title 的 td,如 "2025年9月1日"(兼容不补零)
+  const title = (rows[0].querySelector("td[title]") || {}).getAttribute
+    ? rows[0].querySelector("td[title]").getAttribute("title") : "";
+  const m = String(title).match(/^(\d{4})年(\d{1,2})月(\d{1,2})日$/);
+  const pad = n => (n < 10 ? "0" + n : "" + n);
+  const startDate = m ? m[1] + "-" + pad(+m[2]) + "-" + pad(+m[3]) : null;
+
+  // 总周数:所有行首列能解析为整数的最大值
+  let totalWeeks = null;
+  for (const row of rows) {
+    const td = row.querySelector("td");
+    if (!td) continue;
+    const n = parseInt(td.textContent.trim(), 10);
+    if (!isNaN(n) && n > 0 && (totalWeeks === null || n > totalWeeks)) totalWeeks = n;
+  }
+  return { startDate, totalWeeks };
+}
+
+// ================= 核心解析逻辑(二):课程 =================
+
+// "第3周"、"第1-16周"、"第1,3,5-8周"、"第1-15周(单周)" → [3] / [1..16] / 奇数周…
+function parseWeeks(text) {
+  let t = String(text || "").replace(/\s/g, "");
+  const parity = t.indexOf("单周") >= 0 ? 1 : t.indexOf("双周") >= 0 ? 2 : 0;
+  t = t.replace(/[((][^))]*[))]/g, "");
+  const set = {};
+  const rangeRe = /(\d+)\s*[-–—~]\s*(\d+)/g;
+  let m;
+  while ((m = rangeRe.exec(t)) !== null) {
+    for (let w = Math.min(+m[1], +m[2]); w <= Math.max(+m[1], +m[2]); w++) set[w] = 1;
+  }
+  for (const s of t.replace(rangeRe, " ").match(/\d+/g) || []) set[+s] = 1;
+  const weeks = [];
+  for (const k in set) {
+    const n = +k;
+    if (n >= 1 && n <= 30 && !(parity === 1 && n % 2 === 0) && !(parity === 2 && n % 2 === 1)) weeks.push(n);
+  }
+  return weeks.sort((a, b) => a - b);
+}
+
+// 把解析结果合并进总表(同名同天同节次区间同地点 → 合并周次)
+function mergeCourse(map, c) {
+  if (!c.name || !c.day || !c.weeks.length || !c.startSection) return;
+  const key = [c.name, c.day, c.startSection + "-" + c.endSection, c.position].join("|");
+  if (!map[key]) {
+    map[key] = c;
+  } else {
+    for (const w of c.weeks) if (!map[key].weeks.includes(w)) map[key].weeks.push(w);
+    map[key].weeks.sort((a, b) => a - b);
+  }
+}
+
+// 解析路径 A:官方案例的 DOM 结构(#kbtable 内 div.kbcontent,多课程以长横线分隔)
+function parseKbcontent(doc) {
+  const timetable = doc.getElementById("kbtable");
+  if (!timetable) return [];
+  const map = {};
+  const rows = Array.from(timetable.querySelectorAll("tr")).filter(r => r.querySelector("td"));
+  rows.forEach(row => {
+    const cells = row.querySelectorAll("td");
+    cells.forEach((cell, dayIndex) => {
+      const day = dayIndex + 1;
+      cell.querySelectorAll("div.kbcontent").forEach(div => {
+        const raw = div.innerHTML.trim();
+        if (!raw || raw === "&nbsp;" || div.innerText.trim().length < 2) return;
+        raw.split(/-{10,}/).forEach(block => {
+          if (!block.trim()) return;
+          const box = document.createElement("div");
+          box.innerHTML = block;
+
+          let name = "";
+          for (const node of box.childNodes) {
+            if (node.nodeType === 3 && node.textContent.trim() !== "") { name = node.textContent.trim(); break; }
+          }
+          const teacher = (box.querySelector('font[title="老师"], font[title="教师"]') || {}).innerText || "";
+          const position = (box.querySelector('font[title="教室"]') || {}).innerText || "未知地点";
+          const weekStr = ((box.querySelector('font[title="周次(节次)"]') || {}).innerText) || "";
+
+          let startSection = 0, endSection = 0;
+          const sec = weekStr.match(/\[(.*?)节\]/);
+          if (sec && sec[1]) {
+            const nums = sec[1].split("-").map(Number).filter(n => !isNaN(n));
+            if (nums.length) { startSection = nums[0]; endSection = nums[nums.length - 1]; }
+          }
+          if (name && startSection > 0) {
+            mergeCourse(map, {
+              name, teacher: teacher.replace(/任课教师[::]/, "").trim() || "未知教师",
+              position, day, weeks: parseWeeks(weekStr),
+              startSection, endSection
+            });
+          }
+        });
+      });
+    });
+  });
+  return Object.values(map);
+}
+
+// ================= 数据抓取与编排 =================
+
+async function fetchAndParseCourses() {
+  if (!QVTU_ORIGIN_CHECK.test(location.hostname)) {
+    throw new Error("请先在浏览器/WebView 打开教务网站(https://jw.qvtu.edu.cn/jsxsd)并登录,再运行。当前页面:" + location.href);
+  }
+
+  // 1) 学期列表 + 用户选择(站点无学期下拉框时自动跳过,导入当前学期)
+  let semesterId = null;
+  const { list, defaultIndex } = await fetchSemesterList();
+  if (list.length) {
+    const picked = await window.shiguangBridgePromise.showSingleSelection(
+      "选择学期", JSON.stringify(list.map(s => s.label)), defaultIndex
+    );
+    if (picked === null) { toast("已取消导入。"); return null; }
+    semesterId = list[picked].value;
+  }
+
+  // 2) 教学周历 → 开学日期 + 总周数(结构不符则降级为 null)
+  let startDate = null, totalWeeks = null;
+  if (semesterId) {
+    const info = await fetchSemesterInfo(semesterId);
+    startDate = info.startDate;
+    totalWeeks = info.totalWeeks;
+  }
+
+  // 3) 课表:选了学期用 POST 指定学期,否则 GET 当前学期
+  const html = await fetchText(location.origin + QVTU_XSKB_URL, semesterId ? {
+    method: "POST",
+    headers: { "Content-Type": "application/x-www-form-urlencoded" },
+    body: "jx0404id=&cj0701id=&zc=&demo=&xnxq01id=" + encodeURIComponent(semesterId),
+    credentials: "include"
+  } : null, "学期课表页");
+
+  if (!hasCourse(html)) {
+    if (/用户登录|请登录|loginForm|passwd/i.test(String(html))) {
+      throw new Error("教务会话已过期或尚未登录(接口返回登录页)。请重新登录教务系统后运行。");
+    }
+    console.warn("[泉州职业技术大学适配器] 学期课表页返回了意外内容(前 300 字符):",
+      String(html || "").replace(/\s+/g, " ").slice(0, 300));
+    throw new Error("学期课表页返回了无法识别的内容,请按 F12 打开控制台,把警告信息发给开发者核对。");
+  }
+
+  // 4) 解析(官方案例 DOM 结构)
+  const courses = parseKbcontent(parseHtml(html));
+  if (!courses.length) {
+    throw new Error("教务页面里没有解析到课程。请确认所选学期已有排课,或把错误信息发给我核对。");
+  }
+
+  // 5) 周历未给出总周数时,取课程最大周次兜底
+  if (!totalWeeks) {
+    for (const c of courses) for (const w of c.weeks) if (w > totalWeeks) totalWeeks = w;
+  }
+  return { courses, startDate, totalWeeks };
+}
+
+// ================= 保存步骤(顺序参照官方开发文档) =================
+
+// 保存学期配置(开学日期/总周数来自教学周历,没拿到就不提交/用课程最大周兜底)
+// defaultClassDuration/defaultBreakDuration 与作息表一致(45 分钟一节、小节间休息 15 分钟)
+async function saveCourseConfig(startDate, totalWeeks) {
+  try {
+    const config = {
+      firstDayOfWeek: 1,
+      defaultClassDuration: 45,
+      defaultBreakDuration: 15,
+      semesterTotalWeeks: totalWeeks > 0 ? totalWeeks : 20
+    };
+    if (startDate) config.semesterStartDate = startDate;
+    const ok = await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
+    if (!ok) { toast("学期配置保存失败。"); return false; }
+    return true;
+  } catch (e) {
+    toast("学期配置保存失败:" + e.message);
+    return false;
+  }
+}
+
+// 保存课程数据
+async function saveCourses(courses) {
+  try {
+    const ok = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+    if (!ok) { toast("课程数据保存失败。"); return false; }
+    return true;
+  } catch (e) {
+    toast("课程数据保存失败:" + e.message);
+    return false;
+  }
+}
+
+// 保存作息时间表(官方文档:可选步骤,失败不阻止导入完成)
+async function savePresetTimeSlots() {
+  try {
+    const ok = await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(QVTU_TIME_SLOTS));
+    if (!ok) toast("作息时间保存失败。");
+    return !!ok;
+  } catch (e) {
+    toast("作息时间保存失败:" + e.message);
+    return false;
+  }
+}
+
+// ================= 流程编排 =================
+
+/**
+ * 编排整个课程导入流程(顺序与约定参照官方开发文档)。
+ * 用户取消或关键步骤保存失败时立即结束;作息时间为可选步骤,失败不阻止完成;
+ * notifyTaskCompletion 只在流程成功后调用(官方约定)。
+ */
+async function runImportFlow() {
+  try {
+    // 1. 公告和前置检查
+    const confirmed = await window.shiguangBridgePromise.showAlert(
+      "提示",
+      "请确保已成功登录泉州职业技术大学教务系统。是否开始导入?",
+      "开始"
+    );
+    if (!confirmed) {
+      toast("已取消导入。");
+      return;
+    }
+
+    // 2. 网络请求和数据解析(内部含选学期;取消时返回 null)
+    toast("正在获取学期课表…");
+    const result = await fetchAndParseCourses();
+    if (!result) return;
+    const { courses, startDate, totalWeeks } = result;
+
+    // 3. 保存学期配置
+    if (!await saveCourseConfig(startDate, totalWeeks)) return;
+
+    // 4. 保存课程数据
+    toast("正在保存 " + courses.length + " 条课程安排…");
+    if (!await saveCourses(courses)) return;
+
+    // 5. 导入作息时间表(可选步骤,失败不阻止导入完成)
+    if (!await savePresetTimeSlots()) {
+      toast("提示:作息时间未导入,课程已保存,可在 App 里手动设置或稍后重试。");
+    }
+
+    // 6. 流程成功,发送结束信号
+    toast(`成功导入 ${courses.length} 条课程安排!` +
+      (startDate ? `学期 ${startDate} 起,共 ${totalWeeks} 周。` : "开学日期未能自动获取,可在 App 里手动设置。"));
+    notifyDone();
+  } catch (error) {
+    // 任何一步失败:记录并提示用户,与官方适配一致不向外抛出,也不发送完成信号
+    console.error("[泉州职业技术大学适配器]", error);
+    toast("导入失败:" + (error && error.message ? error.message : error));
+  }
+}
+
+// 启动导入流程
+runImportFlow();
+
+})();