瀏覽代碼

Merge pull request #830 from ShiGuangSchedule/pending

星河欲转 3 周之前
父節點
當前提交
cb8a2ed510

+ 5 - 0
index/root_index.yaml

@@ -263,6 +263,11 @@ schools:
     initial: "N"
     resource_folder: "NUAA"
 
+  - id: "USC"
+    name: "南华大学"
+    initial: "N"
+    resource_folder: "USC"
+
   - id: "XMCU"
     name: "厦门城市职业学院"
     initial: "X"

+ 6 - 6
resources/GDOU/adapters.yaml

@@ -12,14 +12,14 @@ adapters:
     adapter_name: "广东海洋大学阳江校区教务"
     category: "BACHELOR_AND_ASSOCIATE"
     asset_js_path: "gdouyj.js"
-    import_url: "https://jw.gdou.edu.cn/"
+    import_url: "https://wfw.gdou.edu.cn/https://jw.gdou.edu.cn/"
     maintainer: "Yihe-ng"
-    description: "广东海洋大学阳江校区教务适配,使用阳江校区教学楼错峰作息时间,如有误请提交issues"
-
+    description: "使用阳江校区错峰作息。登录前,请点右上角菜单,选择“电脑模式”。如果登录后页面显示UI异常,直接点击“执行导入”导入课表,确认学年学期后,再点一次“导入”保存课程。如有误请提交 issues"
+
   - adapter_id: "GDOUYJ_02"
-    adapter_name: "广东海洋大学阳江校区教务(校外免VPN)"
+    adapter_name: "广东海洋大学阳江校区教务(备用通道)"
     category: "BACHELOR_AND_ASSOCIATE"
     asset_js_path: "gdouyj2.js"
     import_url: "https://jxpj.gdou.edu.cn/wx/?universitycode=10566"
-    maintainer: "Yihe-ng"
-    description: "广东海洋大学阳江校区教务适配,使用阳江校区教学楼错峰作息时间。本通道经教学评价系统取数,校外无需VPN或校园网,部分课程可能缺失,可手动补录"
+    maintainer: "Yihe-ng,海大百事通"
+    description: "广东海洋大学阳江校区教务适配,使用阳江校区教学楼错峰作息时间。教务系统不可用时的备用通道:改从教学评价系统获取,数据可能存在滞后、部分课程可能缺失,可手动补录"

+ 151 - 9
resources/GDOU/gdouyj.js

@@ -3,7 +3,11 @@
  * @date 2026-7-30
  * @author Mccurtain (原始 GDOU 适配)
  * @adapted-by Yihe-ng (阳江校区作息与适配)
- * @version 1.1
+ * @version 1.2
+ *
+ * @note 教务系统已关闭外网直连,现行入口为学校提供的零信任地址
+ *       (https://wfw.gdou.edu.cn/https://jw.gdou.edu.cn/),校内外均可访问。
+ *       接口地址改为运行时推导,
  */
 
 (function () {
@@ -100,7 +104,7 @@ function mergeAndDistinctCourses(courses) {
 
 /**
  * 解析周次字符串,处理单双周和周次范围。
- * 兼容格式:"1-16周"、"6周"、"1-8周(单)"、"1-10周(双)"、"1-5周,9周"
+ * 兼容格式:"1-16周"、"6周"、"第6周"、"1-8周(单)"、"1-10周(双)"、"1-5周,9周"
  */
 function parseWeeks(weekStr) {
     if (!weekStr) return [];
@@ -113,7 +117,7 @@ function parseWeeks(weekStr) {
         const trimmedSet = set.trim();
 
         const rangeMatch = trimmedSet.match(/(\d+)\s*-\s*(\d+)\s*周?/);
-        const singleMatch = trimmedSet.match(/^(\d+)\s*周?/); // 匹配单个周次
+        const singleMatch = trimmedSet.match(/^(?:第\s*)?(\d+)\s*周?/); // 匹配单个周次,包括调课返回的“第N周”
 
         let start = 0;
         let end = 0;
@@ -279,7 +283,7 @@ async function promptUserToStart() {
     console.log("JS: 流程开始:显示公告。");
     return await window.shiguangBridgePromise.showAlert(
         "广东海洋大学阳江校区教务系统课表导入",
-        "请先登录广东海洋大学教务系统(jw.gdou.edu.cn),并在个人课表页选择要导入的学年学期。点击确认后按提示继续即可。",
+        "请确认页面上的学年和学期,再按提示导入课程。\n\n如果登录后页面显示不全,下次登录前可点右上角菜单,选择“电脑模式”。",
         "好的,开始导入"
     );
 }
@@ -334,6 +338,16 @@ function isOnTimetablePage() {
 }
 
 function getCurrentPageAcademicOptions() {
+    const frame = typeof document !== "undefined"
+        ? document.getElementById("gdouyj-timetable-frame")
+        : null;
+    if (frame) {
+        try {
+            return parseAcademicOptionsFromDocument(frame.contentDocument);
+        } catch (error) {
+            return null;
+        }
+    }
     if (!isOnTimetablePage() || typeof document === "undefined" || !document.querySelector) {
         return null;
     }
@@ -341,6 +355,132 @@ function getCurrentPageAcademicOptions() {
     return parseAcademicOptionsFromDocument(document);
 }
 
+/**
+ * WFW 移动门户生成的代理前缀会使页面资源跳转到登录页。
+ * 只修正本校 WFW 前缀,保留学校原页面和原查询逻辑。
+ */
+function normalizeWfwPageHtml(html, origin) {
+    const prefix = `${origin}/https://jw.gdou.edu.cn`;
+    const escapedPrefix = prefix.replace(/\//g, "\\/");
+    const escapedOrigin = origin.replace(/\//g, "\\/");
+    return html.split(prefix).join(origin).split(escapedPrefix).join(escapedOrigin);
+}
+
+async function prepareWfwTimetableHtml(html, origin) {
+    const page = new DOMParser().parseFromString(normalizeWfwPageHtml(html, origin), "text/html");
+    for (const existing of Array.from(page.querySelectorAll("base"))) existing.remove();
+    const base = page.createElement("base");
+    base.href = `${origin}/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151&layout=default`;
+    page.head.prepend(base);
+    // 顺序获取原站依赖,避免 WFW 同时加载大量资源时返回 503 后留下半成品页面。
+    for (const script of Array.from(page.querySelectorAll("script[src]"))) {
+        const url = new URL(script.getAttribute("src"), `${origin}/`);
+        if (url.origin !== origin) throw new Error("课表页包含非同源脚本,已停止打开");
+        let response;
+        for (let attempt = 0; attempt < 3; attempt++) {
+            response = await fetch(url.href, { credentials: "include", cache: "no-store" });
+            if (response.status !== 503) break;
+            if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 500));
+        }
+        if (!response.ok) throw new Error(`页面资源加载失败:${url.pathname},HTTP ${response.status}`);
+        const type = response.headers.get("content-type") || "";
+        if (!/(?:javascript|ecmascript)/i.test(type)) {
+            throw new Error("页面资源未返回脚本,请重新登录");
+        }
+        const code = normalizeWfwPageHtml(await response.text(), origin);
+        script.removeAttribute("src");
+        script.removeAttribute("async");
+        script.removeAttribute("defer");
+        script.textContent = code.replace(/<\/script/gi, "<\\/script");
+    }
+    return `<!doctype html>\n${page.documentElement.outerHTML}`;
+}
+
+async function openTimetableFromMobilePortal() {
+    if (window.location.hostname !== "wfw.gdou.edu.cn" ||
+        window.location.pathname !== "/xtgl/index_cxAllApp.html" ||
+        document.getElementById("gdouyj-timetable-frame")) {
+        return false;
+    }
+
+    const confirmed = await window.shiguangBridgePromise.showAlert(
+        "打开课表",
+        "这里暂时看不到课表。点“打开课表”后,请确认页面上的学年和学期,再点一次拾光的“导入”按钮保存课程。",
+        "打开课表"
+    );
+    if (!confirmed) return true;
+
+    try {
+        const url = buildScheduleUrl("xskbcx_cxXskbcxIndex.html", "gnmkdm=N2151&layout=default");
+        const response = await fetch(url, { credentials: "include" });
+        if (!response.ok) throw new Error(`HTTP ${response.status}`);
+        const html = await response.text();
+        const parsed = new DOMParser().parseFromString(html, "text/html");
+        if (!parseAcademicOptionsFromDocument(parsed)) {
+            throw new Error("未取得个人课表页面,请重新登录;必要时在登录前切换电脑模式");
+        }
+        window.shiguangBridge.showToast("正在加载个人课表页面,请稍候...");
+        const preparedHtml = await prepareWfwTimetableHtml(html, window.location.origin);
+
+        const panel = document.createElement("div");
+        panel.id = "gdouyj-timetable-panel";
+        panel.style.cssText = "position:fixed;inset:0;z-index:2147483647;display:flex;flex-direction:column;background:white";
+        const toolbar = document.createElement("div");
+        toolbar.style.cssText = "display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#087ebc;color:white;font:16px sans-serif";
+        const label = document.createElement("span");
+        label.textContent = "确认学年学期后,再点“导入”";
+        const close = document.createElement("button");
+        close.textContent = "返回";
+        close.type = "button";
+        const previousTitle = document.title;
+        close.onclick = () => {
+            panel.remove();
+            document.title = previousTitle;
+        };
+        toolbar.append(label, close);
+        const frame = document.createElement("iframe");
+        frame.id = "gdouyj-timetable-frame";
+        frame.title = "广东海洋大学个人课表查询";
+        frame.style.cssText = "flex:1;min-height:0;width:100%;border:0;background:white";
+        frame.srcdoc = preparedHtml;
+        panel.append(toolbar, frame);
+        document.body.appendChild(panel);
+        document.title = "个人课表查询";
+    } catch (error) {
+        window.shiguangBridge.showToast(`打开课表页面失败:${error.message}`);
+    }
+    return true;
+}
+
+/**
+ * 推导教务系统接口前缀:
+ *   直接访问:pathname 形如 /kbcx/xskbcx_cxXsgrkb.html                    → 前缀为 ""
+ *   反代·拼接态:pathname 形如 /https://jw.gdou.edu.cn/kbcx/xskbcx_...html → 前缀为 "/https://jw.gdou.edu.cn"
+ *   反代·哈希态:pathname 形如 /http/<hash>/kbcx/xskbcx_...html            → 前缀为 "/http/<hash>"
+ * 以 "/kbcx/" 为锚点即可同时覆盖以上三种情况。
+ */
+function getScheduleBase() {
+    const path = (window.location && window.location.pathname) || "";
+
+    const kbIndex = path.indexOf("/kbcx/");
+    if (kbIndex >= 0) return path.slice(0, kbIndex);
+
+    // 兜底:停留在教务系统非课表页(例如首页)时,直接识别反代前缀。
+    const proxyPrefix = path.match(/^\/(?:https?:\/\/[^/]+|(?:http|https|https-443)\/[^/]+)/i);
+    return proxyPrefix ? proxyPrefix[0] : "";
+}
+
+/**
+ * 拼接教务系统接口地址,自动带上直连/反代前缀。
+ * @param {string} modulePath 接口文件名,例如 "xskbcx_cxXsgrkb.html"
+ * @param {string} query 可选查询串,例如 "gnmkdm=N2151"
+ */
+function buildScheduleUrl(modulePath, query) {
+    const base = getScheduleBase();
+    const queryString = query ? `?${query}` : "";
+    return `${window.location.origin}${base}/kbcx/${modulePath}${queryString}`;
+}
+
 /**
  * 从正方课表页读取学年和学期选项。
  * 学期码直接使用教务系统返回的 value,例如第一学期为 3、第二学期为 12。
@@ -352,7 +492,7 @@ async function fetchAcademicOptions() {
         return currentPageOptions;
     }
 
-    const url = "https://jw.gdou.edu.cn/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151&layout=default";
+    const url = buildScheduleUrl("xskbcx_cxXskbcxIndex.html", "gnmkdm=N2151&layout=default");
 
     try {
         const response = await fetch(url, {
@@ -471,7 +611,7 @@ function findSemesterStartDate(value) {
  * 日期接口失败时返回 null,不阻断课表导入。
  */
 async function fetchSemesterStartDate(academicYear, semesterCode) {
-    const url = "https://jw.gdou.edu.cn/kbcx/xskbcxZccx_cxZcByXnxq.html?gnmkdm=N2154";
+    const url = buildScheduleUrl("xskbcxZccx_cxZcByXnxq.html", "gnmkdm=N2154");
     const requestBody = `xnm=${encodeURIComponent(academicYear)}&xqm=${encodeURIComponent(semesterCode)}`;
 
     try {
@@ -517,7 +657,7 @@ async function fetchAndParseCourses(academicYear, semesterCode) {
     const requestBody = `xnm=${encodeURIComponent(academicYear)}&xqm=${encodeURIComponent(semesterCode)}&kzlx=ck&xsdm=&kclbdm=`;
 
     // 广东海洋大学正方教务 v9 个人课表查询接口
-    const targetUrl = "https://jw.gdou.edu.cn/kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151";
+    const targetUrl = buildScheduleUrl("xskbcx_cxXsgrkb.html", "gnmkdm=N2151");
 
     try {
         // 课表和校历互不依赖,并行请求以减少导入等待时间。
@@ -564,7 +704,7 @@ async function fetchAndParseCourses(academicYear, semesterCode) {
         };
     } catch (e) {
         console.error("JS: 获取课表失败:", e);
-        window.shiguangBridge.showToast("获取课表失败,请确认已登录教务系统且网络可访问 jw.gdou.edu.cn。");
+        window.shiguangBridge.showToast("获取课表失败:请确认已在教务系统登录并停留在个人课表页,且登录状态未过期。");
         return null;
     }
 }
@@ -616,6 +756,8 @@ async function importPresetTimeSlots(timeSlots) {
 }
 
 async function runImportFlow() {
+    // 首次在异常移动门户执行时只打开查询页,保留用户核对与选择学期的机会。
+    if (await openTimetableFromMobilePortal()) return;
     const alertConfirmed = await promptUserToStart();
     if (!alertConfirmed) {
         window.shiguangBridge.showToast("用户取消了导入。");
@@ -667,4 +809,4 @@ async function runImportFlow() {
 // 脚本执行入口
 runImportFlow();
 })();
-
+

+ 7 - 4
resources/GDOU/gdouyj2.js

@@ -1,11 +1,13 @@
 /**
- * 广东海洋大学阳江校区教务适配(校外免 VPN 通道)
+ * 广东海洋大学阳江校区教务适配(备用通道)
  * @date 2026-9-12
- * @author Yihe-ng
+ * @author Yihe-ng,海大百事通
  * @version 1.0
  *
- * 数据取自教学质量综合评价系统(jxpj.gdou.edu.cn),校外可直连,无需 VPN 或校园网。
+ * 教务系统不可用时的备用通道:数据取自教学质量综合评价系统(jxpj.gdou.edu.cn),
+ * 校内外均可直连,无需 VPN 或校园网。
  * 该系统课表与教务同源同步,字段含周次、星期、节次、教室,可直接生成课表。
+ * 该方法由海大百事通与20260912发现
  *
  * 接口链路(均为同源请求,复用页面登录态):
  *   POST /apiservice/University/GetAllSemester          学期列表
@@ -50,6 +52,7 @@ const TimeSlots = [
 /**
  * 阳江校区其他场地(非慎思楼)的作息。
  * 只有第 3、4 节在校区作息表中是不同的连续时间块,使用自定义时间表示。
+ * 貌似无法动态获取时间
  */
 const OTHER_VENUE_SECTION_3_4_TIME = { startTime: "10:10", endTime: "11:40" };
 
@@ -672,7 +675,7 @@ function showToast(message) {
 
 async function promptUserToStart() {
     return await window.shiguangBridgePromise.showAlert(
-        "广东海洋大学阳江校区课表导入(校外免 VPN)",
+        "广东海洋大学阳江校区课表导入(备用通道)",
         "本适配器通过接口直接获取课表,无需停留在课表页面。\n\n"
         + "导入步骤:\n"
         + "1. 确认已登录教学质量综合评价系统(未登录请先完成统一认证)\n"

+ 32 - 27
resources/JSEI/jsei_01.js

@@ -125,35 +125,40 @@ function mergeAndDistinctCourses(courses) {
 function parseWeeks(weekStr) {
     if (!weekStr) return [];
 
-    const weekSets = weekStr.split(',');
-    let weeks = [];
-
-    for (const set of weekSets) {
-        const trimmedSet = set.trim();
-
-        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('(双)');
+    const cleaned = weekStr
+        .replace(/周数[::]/g, '')
+        .replace(/第/g, '')
+        .replace(/周/g, '')
+        .replace(/共\s*\d+\s*.*$/g, '');
+
+    const segments = cleaned.split(/[,,、;;]/);
+
+    const weeks = [];
+    const segRegex = /(\d+)(?:\s*[-~]\s*(\d+))?\s*(?:[((]?\s*([单双])\s*周?\s*[))]?)?/g;
+
+    for (const seg of segments) {
+        const s = seg.trim();
+        if (!s) continue;
+
+        segRegex.lastIndex = 0;
+        let m;
+        while ((m = segRegex.exec(s)) !== null) {
+            if (m[0] === '') {
+                segRegex.lastIndex++;
+                continue;
+            }
+
+            const start = parseInt(m[1], 10);
+            const end = m[2] ? parseInt(m[2], 10) : start;
+            const flagStr = m[3] || '';
+
+            let flag = 0;
+            if (flagStr.includes('单')) flag = 1;
+            else if (flagStr.includes('双')) flag = 2;
 
             for (let w = start; w <= end; w++) {
-                if (isSingle && w % 2 === 0) continue;
-                if (isDouble && w % 2 !== 0) continue;
+                if (flag === 1 && w % 2 === 0) continue;
+                if (flag === 2 && w % 2 !== 0) continue;
                 weeks.push(w);
             }
         }

+ 9 - 0
resources/USC/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/USC/adapters.yaml
+adapters:
+  - adapter_id: "USC_01"
+    adapter_name: "南华大学强智教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "usc_01.js"
+    import_url: "http://jwzx.usc.edu.cn:8924"
+    maintainer: "UVhyx"
+    description: "南华大学教务在线(湖南强智科技 jsxsd)适配,支持获取开学日期"

+ 210 - 0
resources/USC/usc_01.js

@@ -0,0 +1,210 @@
+// 南华大学(http://jwzx.usc.edu.cn:8924)拾光课程表适配脚本
+
+
+const BASE_URL = "http://jwzx.usc.edu.cn:8924";
+const [URL_COURSE, URL_CALENDAR] = [`${BASE_URL}/jsxsd/xskb/xskb_list.do`, `${BASE_URL}/jsxsd/jxzl/jxzl_query`];
+
+// 工具函数
+const parseSlots = arr => arr.map((s, i) => ({ number: i + 1, startTime: s.slice(0, 5), endTime: s.slice(6) }));
+const TIME_SLOTS_SUMMER = parseSlots(["08:00-08:45","08:55-09:40","10:00-10:45","10:55-11:40","15:00-15:45","15:55-16:40","17:00-17:45","17:55-18:40","20:00-20:45","20:55-21:40"]);
+const TIME_SLOTS_WINTER = parseSlots(["08:20-09:05","09:15-10:00","10:20-11:05","11:15-12:00","14:30-15:15","15:25-16:10","16:30-17:15","17:25-18:10","19:30-20:15","20:25-21:10"]);
+
+const pad2 = n => String(n).padStart(2, "0");
+const WEEK_MAP = { 一:1, 二:2, 三:3, 四:4, 五:5, 六:6, 日:7, 天:7 };
+
+const parseWeeks = str => [...new Set((str || "").split("(")[0].split(",").flatMap(s => {
+    const [st, ed] = s.split("-").map(Number);
+    return (st && ed) ? Array.from({length: ed - st + 1}, (_, i) => st + i) : [st].filter(n => !isNaN(n));
+}))].sort((a, b) => a - b);
+
+const parseSectionRange = text => {
+    const [_, s, e] = String(text || "").match(/(\d+)\s*(?:-\s*(\d+))?/) || [];
+    return s ? { start: +s, end: +(e || s) } : null;
+};
+
+function parseDayColumns(table) {
+    for (const row of table.rows) {
+        const days = [...row.children].filter(el => /^(TH|TD)$/.test(el.tagName))
+            .map(el => WEEK_MAP[el.textContent.trim().match(/^星期([一二三四五六日天])$/)?.[1]] || null);
+        if (days.filter(Boolean).length >= 5) return days.filter(d => d !== null);
+    }
+    return null;
+}
+
+function parseCellCourses(cell, selector, day, rowSec) {
+    const block = cell.querySelector(selector);
+    if (!block?.innerHTML.trim() || block.innerHTML.trim() === "&nbsp;") return [];
+
+    return block.innerHTML.split(/---------------------?/).flatMap(part => {
+        if (!part.trim()) return [];
+        const temp = document.createElement("div");
+        temp.innerHTML = part;
+
+        let name = "";
+        for (const node of temp.childNodes) {
+            if (node.tagName === "FONT") break;
+            if (node.nodeType === 3) name += node.textContent.replace(/\s+/g, " ");
+        }
+        if (!(name = name.trim())) return [];
+
+        const textOf = sel => (temp.querySelector(sel)?.textContent || "").trim();
+        const teacher = textOf("font[title='老师'], font[title='教师']").replace("任课教师:", "") || "未知教师";
+        const position = textOf("font[title='教室']") || "未知地点";
+        const weekStr = textOf("font[title='周次(节次)']");
+
+        const nums = (weekStr.match(/\[([\d\-,\s]+?)节\]/)?.[1] || "").split(/[^\d]+/).map(Number).filter(n => n > 0);
+        const startSection = nums.length ? Math.min(...nums) : (rowSec?.start || 0);
+        const endSection = nums.length ? Math.max(...nums) : (rowSec?.end || 0);
+        const weeks = parseWeeks(weekStr);
+
+        return (weeks.length && startSection) ? [{ name, teacher, position, day, startSection, endSection: Math.max(startSection, endSection), weeks }] : [];
+    });
+}
+
+// 合并函数
+function mergeAndDistinctCourses(courses) {
+    if (!courses?.length) return [];
+    const key1 = c => [c.name, c.teacher, c.position, c.day, c.weeks.join()].join("|");
+    const key2 = c => [c.name, c.teacher, c.position, c.day, c.startSection, c.endSection].join("|");
+
+    return courses.map(c => ({...c, weeks: [...c.weeks].sort((a, b) => a - b)}))
+        .sort((a, b) => key1(a).localeCompare(key1(b)) || a.startSection - b.startSection)
+        .reduce((acc, n) => { // 阶段1:合并连续/重复节次
+            const cur = acc[acc.length - 1];
+            if (cur && key1(cur) === key1(n)) {
+                if (cur.endSection + 1 === n.startSection) cur.endSection = n.endSection;
+                else if (cur.startSection !== n.startSection || cur.endSection !== n.endSection) acc.push(n);
+            } else acc.push(n);
+            return acc;
+        }, [])
+        .sort((a, b) => key2(a).localeCompare(key2(b)))
+        .reduce((acc, n) => { // 阶段2:合并相同节次的周次
+            const cur = acc[acc.length - 1];
+            if (cur && key2(cur) === key2(n)) cur.weeks = [...new Set([...cur.weeks, ...n.weeks])].sort((a,b) => a - b);
+            else acc.push(n);
+            return acc;
+        }, []);
+}
+
+const parseTimetableToModel = doc => {
+    const table = doc.getElementById("kbtable");
+    if (!table) return [];
+    const dayMap = parseDayColumns(table) || [1, 2, 3, 4, 5, 6, 7];
+    const selector = table.querySelector("div.kbcontent") ? "div.kbcontent" : "div.kbcontent1";
+
+    return mergeAndDistinctCourses([...table.rows].flatMap(row => {
+        const cells = [...row.querySelectorAll("td")];
+        const dayCells = cells.length === dayMap.length + 1 ? cells.slice(1) : cells;
+        if (dayCells.length !== dayMap.length) return [];
+        const rowSec = parseSectionRange(row.querySelector("th")?.textContent);
+        return dayCells.flatMap((cell, i) => dayMap[i] ? parseCellCourses(cell, selector, dayMap[i], rowSec) : []);
+    }));
+};
+
+// 网络请求与解析
+const parseHtml = html => new DOMParser().parseFromString(html, "text/html");
+const req = async (url, opts) => {
+    const res = await fetch(url, { credentials: "include", ...opts });
+    if (!res.ok) throw new Error(`HTTP ${res.status}`);
+    return res.text();
+};
+const formOpts = body => ({ method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body });
+
+const requestCoursePage = semId => req(URL_COURSE, formOpts(`jx0404id=&cj0701id=&zc=&demo=&xnxq01id=${semId||""}`));
+const requestCoursePageByGet = semId => req(`${URL_COURSE}?xnxq01id=${encodeURIComponent(semId||"")}&zc=&cj0701id=&demo=`, { method: "GET" });
+const requestCalendarPage = semId => req(URL_CALENDAR, formOpts(`xnxq01id=${semId}`));
+
+function parseSemesterOptions(html) {
+    const select = parseHtml(html).getElementById("xnxq01id");
+    if (!select) return { list: [], defaultIndex: -1 };
+    const all = [...select.options].map(o => ({ value: o.value, label: o.textContent.trim(), selected: o.selected })).filter(o => o.value);
+    const idx = Math.max(0, all.findIndex(o => o.selected));
+    const start = Math.max(0, idx - 3);
+    return { list: all.slice(start, idx + 4), defaultIndex: idx - start };
+}
+
+async function fetchCourses(semId) {
+    let html = await requestCoursePage(semId).catch(() => null);
+    let courses = html ? parseTimetableToModel(parseHtml(html)) : [];
+    if (!courses.length) {
+        html = await requestCoursePageByGet(semId).catch(() => null);
+        if (html) courses = parseTimetableToModel(parseHtml(html));
+    }
+    return courses;
+}
+
+async function fetchSemesterInfo(semId) {
+    try {
+        const doc = parseHtml(await requestCalendarPage(semId));
+        const dates = [...doc.querySelectorAll("td[title]")].map(td => {
+            const m = td.getAttribute("title")?.match(/(\d{4})年(\d{1,2})月(\d{1,2})日?/);
+            return m ? `${m[1]}-${pad2(m[2])}-${pad2(m[3])}` : null;
+        }).filter(Boolean).sort();
+        const totalWeeks = Math.max(0, ...[...doc.querySelectorAll("#kbtable tr td:first-child")].map(td => parseInt(td.textContent, 10)).filter(n => !isNaN(n)));
+        return { startDate: dates[0] || null, totalWeeks: totalWeeks || null };
+    } catch { return { startDate: null, totalWeeks: null }; }
+}
+
+// 全局校验
+window.validateStartDateInput = input => !input?.trim() || /^\d{4}-\d{1,2}-\d{1,2}$/.test(input.trim()) ? false : "请输入形如 2026-09-01 的日期,或留空跳过";
+
+async function promptSemesterStartDate() {
+    const input = await window.shiguangBridgePromise.showPrompt("设置开学日期", "未能自动获取开学日期...\n请输入本学期第一周的星期一日期(如 2026-09-01),留空则跳过:", "", "validateStartDateInput");
+    const m = input?.trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
+    return m ? `${m[1]}-${pad2(m[2])}-${pad2(m[3])}` : null;
+}
+
+const computeTotalWeeks = courses => {
+    const max = Math.max(0, ...courses.flatMap(c => c.weeks || []));
+    return (max > 0 && max <= 40) ? max : 20;
+};
+
+function buildComboSchedule(semId, label) {
+    const [_, y0, y1, term] = String(semId || "").match(/^(\d{4})-(\d{4})-(\d)$/) || [];
+    if (!term) return null;
+    const Y = term === "2" ? +y1 : +y0;
+    const winter = { name: "秋冬季作息", startDate: `${term === "2" ? Y - 1 : Y}-10-01`, endDate: `${term === "2" ? Y : Y + 1}-04-30`, timeSlots: [] };
+    return {
+        name: `南华大学 ${label || semId} 作息方案`,
+        publicSchedules: [ { name: "夏秋季作息", startDate: `${Y}-05-01`, endDate: `${Y}-09-30`, timeSlots: TIME_SLOTS_SUMMER }, winter ].sort((a,b) => a.startDate.localeCompare(b.startDate))
+    };
+}
+
+const saveAppConfig = (startDate, weeks) => window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({ semesterTotalWeeks: weeks || 20, firstDayOfWeek: 7, defaultClassDuration: 45, ...(startDate && { semesterStartDate: startDate }) }));
+const saveAppTimeSlots = () => window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(TIME_SLOTS_WINTER));
+
+// 主流程
+(async function runImportFlow() {
+    const bridge = window.shiguangBridge, pBridge = window.shiguangBridgePromise;
+    try {
+        if (!await pBridge.showAlert("提示", "请确保已登录教务在线,并已打开「学期理论课表」页面。\n是否开始导入?", "开始")) return;
+
+        bridge.showToast("正在获取学期列表...");
+        const { list, defaultIndex } = parseSemesterOptions(await requestCoursePage(""));
+        if (!list.length) return bridge.showToast("未获取到学期列表,请检查登录状态。");
+
+        const selIdx = await pBridge.showSingleSelection("选择学期", JSON.stringify(list.map(s => s.label)), defaultIndex);
+        if (selIdx === null) return bridge.showToast("已取消导入。");
+
+        bridge.showToast("正在请求课程数据...");
+        const sem = list[selIdx], courses = await fetchCourses(sem.value);
+        if (!courses.length) return bridge.showToast("未发现课程,请检查学期选择或登录状态。");
+
+        bridge.showToast("正在获取学期信息...");
+        const { startDate, totalWeeks } = await fetchSemesterInfo(sem.value);
+        const finalStart = startDate || await promptSemesterStartDate();
+
+        await saveAppConfig(finalStart, totalWeeks || computeTotalWeeks(courses));
+        await pBridge.saveImportedCourses(JSON.stringify(courses));
+
+        try {
+            if (await saveAppTimeSlots()) {
+                const combo = buildComboSchedule(sem.value, sem.label);
+                if (combo) await pBridge.saveComboSchedule(JSON.stringify(combo));
+            }
+        } catch { bridge.showToast("时间段或组合作息导入异常,已跳过。"); }
+
+        bridge.showToast(`成功导入 ${courses.length} 条课程`);
+        bridge.notifyTaskCompletion();
+    } catch (e) { bridge.showToast("异常: " + e.message); }
+})();

+ 31 - 26
resources/XHKJZYXY/xhkjzyxy_01.js

@@ -110,35 +110,40 @@ function mergeAndDistinctCourses(courses) {
 function parseWeeks(weekStr) {
     if (!weekStr) return [];
 
-    const weekSets = weekStr.split(',');
-    let weeks = [];
-
-    for (const set of weekSets) {
-        const trimmedSet = set.trim();
-
-        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;
-        }
+    const cleaned = weekStr
+        .replace(/周数[::]/g, '')
+        .replace(/第/g, '')
+        .replace(/周/g, '')
+        .replace(/共\s*\d+\s*.*$/g, '');
+
+    const segments = cleaned.split(/[,,、;;]/);
+
+    const weeks = [];
+    const segRegex = /(\d+)(?:\s*[-~]\s*(\d+))?\s*(?:[((]?\s*([单双])\s*周?\s*[))]?)?/g;
+
+    for (const seg of segments) {
+        const s = seg.trim();
+        if (!s) continue;
+
+        segRegex.lastIndex = 0;
+        let m;
+        while ((m = segRegex.exec(s)) !== null) {
+            if (m[0] === '') {
+                segRegex.lastIndex++;
+                continue;
+            }
+
+            const start = parseInt(m[1], 10);
+            const end = m[2] ? parseInt(m[2], 10) : start;
+            const flagStr = m[3] || '';
 
-        if (processed) {
-            const isSingle = trimmedSet.includes('(单)');
-            const isDouble = trimmedSet.includes('(双)');
+            let flag = 0;
+            if (flagStr.includes('单')) flag = 1;
+            else if (flagStr.includes('双')) flag = 2;
 
             for (let w = start; w <= end; w++) {
-                if (isSingle && w % 2 === 0) continue;
-                if (isDouble && w % 2 !== 0) continue;
+                if (flag === 1 && w % 2 === 0) continue;
+                if (flag === 2 && w % 2 !== 0) continue;
                 weeks.push(w);
             }
         }