소스 검색

Merge pull request #695 from XingHeYuZhuan/pending

星河欲转 6 일 전
부모
커밋
208867cc58
3개의 변경된 파일1207개의 추가작업 그리고 262개의 파일을 삭제
  1. 9 1
      resources/UJS/adapters.yaml
  2. 855 0
      resources/UJS/ujs_yjs_v1.0.js
  3. 343 261
      resources/UJS/ujs_zhengfang_v9.0.js

+ 9 - 1
resources/UJS/adapters.yaml

@@ -5,4 +5,12 @@ adapters: #固定字段
     asset_js_path: "ujs_zhengfang_v9.0.js"
     import_url: "https://jwxt.ujs.edu.cn/sso/jziotlogin"
     maintainer: "洛初 Github@gongfuture"
-    description: "江苏大学教务系统适配,仅支持个人课表的导入,通过统一认证登录  ver.2"
+    description: "江苏大学教务系统适配,仅支持个人课表的导入,通过统一身份认证登录  ver.3"
+
+  - adapter_id: "UJS_02"
+    adapter_name: "江苏大学研究生管理系统"
+    category: "POSTGRADUATE"
+    asset_js_path: "ujs_yjs_v1.0.js"
+    import_url: "http://yjsgll.ujs.edu.cn/pyxx/"
+    maintainer: "洛初 Github@gongfuture"
+    description: "江苏大学研究生管理系统适配,仅支持个人课表的导入,通过统一身份认证登录  ver.1"

+ 855 - 0
resources/UJS/ujs_yjs_v1.0.js

@@ -0,0 +1,855 @@
+// 江苏大学(ujs.edu.cn)研究生管理系统 拾光课程表适配脚本
+// 基于正方研究生管理系统(研究生版 Gmis / pyxx)适配
+// 出现问题请联系作者或者提交直接pr更改,这更加快速
+
+// 作者:洛初 Github@gongfuture
+
+// 2026.09.10 第一版
+// 研究生系统没有对外直连入口,需先经统一身份认证(ehall 门户或 http://yjsgll.ujs.edu.cn/pyxx/)登录。
+// 课表从学生课表查询页 pygl/kbcx_xs.aspx 的 DataGrid 里解析:
+// 单元格文本自带「第3-18周 单双周:星期二 上3,4 星期五 上1,2」,星期、节次、周次都能直接取到。
+// 作息时间取自课表页下方的作息表(上午/下午/晚上),由服务端按当前令时渲染,脚本原样导入,
+// 因此不再像本科脚本那样询问夏令时/冬令时,也不做楼栋匹配(研究生系统全校一套作息)。(后续看十一再调整)
+// 开学日期:研究生系统没有校历接口,改用「我的课表」接口 App_Ajax/GetkcHandler.ashx 逐周回查,
+// 找到某门课第一次出现的日期,再按(起始周-1)*7+(星期-1)反推开学日期;反推不到则跳过配置保存。
+
+/**
+ * 数组过滤(原生实现)。
+ * 部分教务页面会改写 Array.prototype.filter,这里用普通循环自行实现,不依赖被改写过的数组方法。
+ */
+function arrayFilter(arr, predicate) {
+    const result = [];
+    for (let i = 0; i < arr.length; i++) {
+        if (predicate(arr[i], i, arr)) {
+            result.push(arr[i]);
+        }
+    }
+    return result;
+}
+
+/**
+ * 拼接教务系统接口地址。
+ * 校内直连时 location.origin 就是教务域名,前缀为空;
+ * 校外经 WebVPN 访问时路径带有 /http/<hex> 前缀,必须保留,否则会变成跨域请求。
+ */
+function buildApiUrl(path) {
+    const prefixMatch = window.location.pathname.match(/^\/http\/[0-9a-f]+/i);
+    const prefix = prefixMatch ? prefixMatch[0] : "";
+    return window.location.origin + prefix + path;
+}
+
+/**
+ * 当前是否在研究生管理系统内。
+ * 校内直连域名是 yjsgll.ujs.edu.cn,校外经 WebVPN 时是 webvpn.ujs.edu.cn/http/<hex>/pyxx/。
+ */
+function isGraduateSystemPage() {
+    const host = window.location.hostname;
+    if (host.indexOf("yjsgll.ujs.edu.cn") !== -1) return true;
+    return /\/http\/[0-9a-f]+\/pyxx\//i.test(window.location.pathname);
+}
+
+/**
+ * 是否停在登录页。
+ * 未登录访问会被重定向到统一身份认证 https://pass.ujs.edu.cn/cas/login?service=.../pyxx/loginCAS.aspx。
+ */
+function isLoginPage() {
+    const host = window.location.hostname;
+    if (host.indexOf("pass.ujs.edu.cn") !== -1) return true;
+    return window.location.pathname.indexOf("/cas/login") !== -1;
+}
+
+/**
+ * 解析周次文本,返回周次数组。
+ * 研究生系统的写法是「第3-18周」,也可能出现「第3周」;
+ * 单双周标记要先把「单双周」这个词本身剔掉,否则会被误判成单周。
+ */
+function parseWeeks(weekText) {
+    if (!weekText) return [];
+
+    const cleaned = String(weekText).replace(/单双周/g, "");
+    const oddOnly = cleaned.indexOf("单") !== -1;
+    const evenOnly = cleaned.indexOf("双") !== -1;
+
+    const weeks = [];
+    const rangePattern = /(\d+)\s*-\s*(\d+)\s*周|(\d+)\s*周/g;
+    let match;
+
+    while ((match = rangePattern.exec(cleaned)) !== null) {
+        const start = Number(match[1] || match[3]);
+        const end = match[2] ? Number(match[2]) : start;
+
+        for (let week = start; week <= end; week++) {
+            if (oddOnly && week % 2 === 0) continue;
+            if (evenOnly && week % 2 !== 0) continue;
+            weeks.push(week);
+        }
+    }
+
+    return Array.from(new Set(weeks)).sort((a, b) => a - b);
+}
+
+/**
+ * 节次与周次合并去重(参考官方 wiki 课程合并与去重函数)。
+ * 课表里同一门课的每个上课时段都会重复一份完整文本,
+ * 解析后会得到多条完全相同的记录,这里统一合并/去重。
+ */
+function mergeAndDistinctCourses(courses) {
+    if (!Array.isArray(courses) || courses.length <= 1) return courses;
+
+    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) {
+            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) {
+            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;
+}
+
+/**
+ * 取单元格文本。
+ * 课表每个格子里是 <br> 分行的文本,DOMParser 解析出来的文档没有 innerText,
+ * 先把 <br> 换成换行再取 textContent。
+ */
+function readCellText(cell) {
+    const clone = cell.cloneNode(true);
+    Array.from(clone.querySelectorAll("br")).forEach(br => br.replaceWith("\n"));
+    return clone.textContent.replace(/ /g, " ").replace(/\r/g, "");
+}
+
+/**
+ * 把「上3,4」「晚9,10,11」里的节次换算成全天连续节次。
+ * 研究生系统文本里的节次就是全天连续编号(晚上从 9 开始),
+ * 但个别部署可能写成段内编号(下1,2 / 晚1,2,3),这里两种写法都兼容。
+ */
+function normalizeSection(periodPrefix, number) {
+    if (periodPrefix === "下") return number <= 4 ? number + 4 : number;
+    if (periodPrefix === "晚") return number <= 3 ? number + 8 : number;
+    return number;
+}
+
+const CHINESE_WEEKDAY_MAP = {
+    "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "日": 7, "天": 7
+};
+
+/**
+ * 解析一个课表单元格的文本。
+ * 典型内容:
+ *   课程:ESP(专门用途英语)
+ *   班级:ESP(专门用途英语)5班理工
+ *   (Y206(M)                       )
+ *   第3-18周 单双周:星期二 上3,4 星期五 上1,2
+ *   主讲教师:史莹娟
+ * 一门课在同一格里会列出全部上课时段(上面的例子就是周二 + 周五两段),
+ * 所以一个单元格可能产出多条课程记录。
+ */
+function parseCourseCellText(text) {
+    const lines = String(text).split("\n").map(line => line.trim()).filter(line => line.length > 0);
+
+    let name = "";
+    let className = "";
+    let teacher = "";
+    let position = "";
+    let scheduleLine = "";
+
+    for (const line of lines) {
+        if (/^课程[::]/.test(line)) {
+            name = line.replace(/^课程[::]\s*/, "").trim();
+            continue;
+        }
+        if (/^班级[::]/.test(line)) {
+            className = line.replace(/^班级[::]\s*/, "").trim();
+            continue;
+        }
+        if (/^主讲教师[::]/.test(line)) {
+            teacher = line.replace(/^主讲教师[::]\s*/, "").trim();
+            continue;
+        }
+        if (/^[((].*[))]$/.test(line)) {
+            // 教室整行被括号包住,去掉最外层括号(教室内名可能自带括号,如 Y206(M))
+            position = line.replace(/^[((]/, "").replace(/[))]$/, "").trim();
+            continue;
+        }
+        if (line.indexOf("周") !== -1) {
+            scheduleLine = scheduleLine ? `${scheduleLine} ${line}` : line;
+        }
+    }
+
+    // 名称、周次、上课时段是排课必需的,缺一不可
+    if (!name || !scheduleLine) return [];
+
+    const weeks = parseWeeks(scheduleLine);
+    if (weeks.length === 0) return [];
+
+    const sessionPattern = /星期([一二三四五六日天])\s*([上下晚])\s*([0-9,,、\s]+)/g;
+    const records = [];
+    let match;
+
+    while ((match = sessionPattern.exec(scheduleLine)) !== null) {
+        const day = CHINESE_WEEKDAY_MAP[match[1]];
+        if (!day) continue;
+
+        const numbers = arrayFilter(
+            match[3].split(/[^0-9]+/).map(item => Number(item)),
+            (item) => !isNaN(item) && item > 0
+        );
+        if (numbers.length === 0) continue;
+
+        const sections = numbers.map(number => normalizeSection(match[2], number)).sort((a, b) => a - b);
+        const startSection = sections[0];
+        const endSection = sections[sections.length - 1];
+
+        if (startSection < 1 || endSection > 20) continue;
+
+        const remarkParts = [];
+        if (className) remarkParts.push(`班级:${className}`);
+        if (scheduleLine) remarkParts.push(scheduleLine.trim());
+
+        records.push({
+            name: name,
+            teacher: teacher,
+            position: position,
+            day: day,
+            startSection: startSection,
+            endSection: endSection,
+            weeks: weeks,
+            remark: remarkParts.join(" | ")
+        });
+    }
+
+    return records;
+}
+
+/**
+ * 解析课表页 #MainWork_DataGrid1 里的全部课程。
+ * 每个单元格自带完整文本(含星期、节次、周次),不必按表格行列还原,
+ * 重复出现的记录交给 mergeAndDistinctCourses 去重。
+ */
+function parseCoursesFromGrid(doc) {
+    const grid = doc.querySelector("#MainWork_DataGrid1");
+    if (!grid) return [];
+
+    const courses = [];
+    for (const cell of Array.from(grid.querySelectorAll("td"))) {
+        const text = readCellText(cell);
+        if (text.indexOf("课程") === -1) continue;
+        courses.push(...parseCourseCellText(text));
+    }
+
+    return mergeAndDistinctCourses(courses);
+}
+
+/**
+ * 解析课表页下方的作息表,得到按全天连续编号的预设时间段。
+ * 上午 dgListSw / 下午 dgListXw / 晚上 dgListWs 三张表,
+ * 每行形如「第一节课 8:00-8:45」;编号按上午、下午、晚上依次顺延,
+ * 与课表文本里的连续节次编号一致。
+ */
+function parseTimeSlots(doc) {
+    const groups = [
+        { id: "#MainWork_dgListSw", label: "上午" },
+        { id: "#MainWork_dgListXw", label: "下午" },
+        { id: "#MainWork_dgListWs", label: "晚上" }
+    ];
+
+    const slots = [];
+    let number = 1;
+
+    for (const group of groups) {
+        const table = doc.querySelector(group.id);
+        if (!table) continue;
+
+        for (const cell of Array.from(table.querySelectorAll("td"))) {
+            // 作息表是「单元格里再套一层表」的结构,外层单元格的 textContent 也含时间,
+            // 这里只取最内层单元格,避免同一条时间被重复计数。
+            if (cell.querySelector("table")) continue;
+
+            const match = cell.textContent.match(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/);
+            if (!match) continue;
+
+            const pad = (value) => String(value).padStart(2, "0");
+            slots.push({
+                number: number,
+                startTime: `${pad(match[1])}:${match[2]}`,
+                endTime: `${pad(match[3])}:${match[4]}`
+            });
+            number += 1;
+        }
+    }
+
+    return slots;
+}
+
+/**
+ * 读取下拉框当前选中项的值。
+ * DOMParser 解析出的文档不一定支持 select.value,这里按 selected 属性自行判断。
+ */
+function getSelectedOptionValue(select) {
+    if (!select || !select.options || select.options.length === 0) return "";
+
+    for (const option of Array.from(select.options)) {
+        if (option.hasAttribute("selected")) return option.value;
+    }
+    return select.options[0].value;
+}
+
+/**
+ * 收集表单里可提交的字段,模拟一次完整回发。
+ * ASP.NET 的 __VIEWSTATE 等方法不能少,逐个手写容易漏,这里整体搬运后按需覆盖。
+ */
+function collectPostbackFields(doc, overrides) {
+    const params = new URLSearchParams();
+    const form = doc.querySelector("#form1") || doc.querySelector("form");
+    if (!form) return params;
+
+    for (const el of Array.from(form.querySelectorAll("input[name], select[name], textarea[name]"))) {
+        const tag = el.tagName.toLowerCase();
+        const name = el.getAttribute("name");
+        if (!name) continue;
+
+        if (tag === "select") {
+            params.set(name, getSelectedOptionValue(el));
+            continue;
+        }
+
+        const type = (el.getAttribute("type") || "text").toLowerCase();
+        if (type === "submit" || type === "image" || type === "button" || type === "reset") {
+            // 图片按钮/提交按钮需要 .x / .y,或者完全不提交,这里跳过由调用方补充
+            continue;
+        }
+        if (type === "checkbox" || type === "radio") {
+            if (!el.hasAttribute("checked")) continue;
+        }
+
+        params.set(name, el.getAttribute("value") || "");
+    }
+
+    Object.keys(overrides || {}).forEach(key => params.set(key, overrides[key]));
+    return params;
+}
+
+/**
+ * 以 form 表单方式回发并返回响应 HTML。
+ */
+async function postForm(url, params) {
+    const response = await fetch(url, {
+        method: "POST",
+        headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" },
+        body: params.toString(),
+        credentials: "include"
+    });
+
+    if (!response.ok) {
+        throw new Error(`回发失败,状态码 ${response.status}`);
+    }
+    return await response.text();
+}
+
+/**
+ * 读取指定学期的课表页文档。
+ * 课表页默认渲染系统当前学期;要查历史/其它学期得按页面自身的逻辑回发两次:
+ * 先把学期下拉框回发一次(换出新的 __VIEWSTATE),再点一次「查询」按钮重新绑定课表。
+ * @param {string} semesterValue 学期下拉框的 value,为空表示用默认(当前)学期
+ */
+async function fetchTimetableDocument(semesterValue) {
+    const url = buildApiUrl("/pyxx/pygl/kbcx_xs.aspx");
+
+    const pageResponse = await fetch(url, { method: "GET", credentials: "include" });
+    const pageDoc = new DOMParser().parseFromString(await pageResponse.text(), "text/html");
+
+    const semesterSelect = pageDoc.querySelector("#MainWork_drpxq");
+    if (!semesterSelect) {
+        throw new Error("NOT_LOGGED_IN");
+    }
+
+    const currentValue = getSelectedOptionValue(semesterSelect);
+    if (!semesterValue || semesterValue === currentValue) {
+        return pageDoc;
+    }
+
+    // 第一步:学期下拉框回发,拿到与新选中项对应的 __VIEWSTATE
+    const firstParams = collectPostbackFields(pageDoc, {
+        "__EVENTTARGET": "ctl00$MainWork$drpxq",
+        "__EVENTARGUMENT": "",
+        "ctl00$MainWork$drpxq": semesterValue
+    });
+    const afterChangeDoc = new DOMParser().parseFromString(await postForm(url, firstParams), "text/html");
+
+    // 第二步:点「查询」按钮,课表才按新学期的选项重新绑定
+    const secondParams = collectPostbackFields(afterChangeDoc, {
+        "__EVENTTARGET": "",
+        "__EVENTARGUMENT": "",
+        "ctl00$MainWork$drpxq": semesterValue,
+        "ctl00$MainWork$btnSearch.x": "5",
+        "ctl00$MainWork$btnSearch.y": "5"
+    });
+    const resultDoc = new DOMParser().parseFromString(await postForm(url, secondParams), "text/html");
+
+    if (!resultDoc.querySelector("#MainWork_DataGrid1")) {
+        throw new Error("POSTBACK_FAILED");
+    }
+    return resultDoc;
+}
+
+/**
+ * 读取学期下拉框的全部选项。
+ */
+function readSemesterOptions(doc) {
+    const select = doc.querySelector("#MainWork_drpxq");
+    if (!select) return null;
+
+    const options = Array.from(select.options)
+        .map(option => ({
+            value: option.value,
+            text: option.textContent.trim(),
+            selected: option.hasAttribute("selected")
+        }))
+        .filter(option => option.text);
+
+    if (options.length === 0) return null;
+
+    let defaultIndex = options.findIndex(option => option.selected);
+    if (defaultIndex === -1) defaultIndex = 0;
+
+    return { options: options, defaultIndex: defaultIndex };
+}
+
+function formatDate(date) {
+    const pad = (value) => String(value).padStart(2, "0");
+    return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
+}
+
+function addDays(date, days) {
+    const result = new Date(date.getFullYear(), date.getMonth(), date.getDate());
+    result.setDate(result.getDate() + days);
+    return result;
+}
+
+/**
+ * 查询某天「我的课表」接口,判断目标课程当天是否上课。
+ * App_Ajax/GetkcHandler.ashx 是首页「我的课表」按日期查询用的接口,
+ * 传入日期就返回当天课程,可以用来反推开学日期。
+ */
+async function hasCourseOn(date, courseName, studentId) {
+    const url = buildApiUrl(
+        `/pyxx/App_Ajax/GetkcHandler.ashx?kcdate=${formatDate(date)}&xh=${encodeURIComponent(studentId)}`
+    );
+
+    const response = await fetch(url, { method: "GET", credentials: "include" });
+    if (!response.ok) return false;
+
+    const doc = new DOMParser().parseFromString(await response.text(), "text/html");
+    const text = (doc.body ? doc.body.textContent : "").replace(/\s+/g, "");
+    return text.indexOf(String(courseName).replace(/\s+/g, "")) !== -1;
+}
+
+/**
+ * 找到参照日期:距离今天最近的一个「目标星期」的日期(今天或之前)。
+ * 星期编号 1-7 对应周一到周日。
+ */
+function findReferenceDate(day) {
+    const today = new Date();
+    const jsDay = today.getDay(); // 0=周日
+    const todayDay = jsDay === 0 ? 7 : jsDay;
+    let diff = todayDay - day;
+    if (diff < 0) diff += 7;
+    return addDays(today, -diff);
+}
+
+/**
+ * 挑选用于探测的课程:起始周最早、周次跨度最短的那门,
+ * 跨度短意味着要核对的周次少。
+ */
+function pickProbeCourse(courses) {
+    let best = null;
+
+    for (const course of courses) {
+        if (!course.weeks || course.weeks.length === 0) continue;
+
+        const weeks = course.weeks;
+        const span = weeks[weeks.length - 1] - weeks[0];
+
+        if (!best ||
+            weeks[0] < best.weeks[0] ||
+            (weeks[0] === best.weeks[0] && span < best.span)) {
+            best = { name: course.name, day: course.day, weeks: weeks, span: span };
+        }
+    }
+
+    return best;
+}
+
+/**
+ * 反推本学期开学日期。
+ *
+ * 研究生系统没有校历接口,只能借「我的课表」接口逐周回查:
+ * 以目标课程所在星期的最近一个日期为参照,按周展开探测(区间大约覆盖前后一个学期),
+ * 再把每个命中日期当作「该课第一次上课的那天」,用课程自己的周次表去核对:
+ * 前一周必须没课,且课程周次表里的每一周都应当对得上。
+ * 核对通过后,开学日期 = 首次上课日期 - ((起始周 - 1) * 7 + (星期 - 1)),按周一为一周之始。
+ *
+ * 校验通过多个候选时取离今天最近的那个,避免把上一学年的同名课程认成本学期。
+ * 任何一步拿不到结果就返回 null,由调用方跳过配置保存。
+ */
+async function deriveSemesterStartDate(courses, studentId) {
+    if (!studentId) {
+        console.warn("JS: 未取到学号,跳过开学日期推算。");
+        return null;
+    }
+
+    const target = pickProbeCourse(courses);
+    if (!target) {
+        console.warn("JS: 没有可用于探测的课程,跳过开学日期推算。");
+        return null;
+    }
+
+    const reference = findReferenceDate(target.day);
+    const maxBackWeeks = 40;
+    const maxForwardWeeks = 12;
+
+    const offsets = [0];
+    for (let week = 1; week <= maxBackWeeks; week++) {
+        offsets.push(-week);
+        if (week <= maxForwardWeeks) offsets.push(week);
+    }
+
+    const observed = new Map();
+    const batchSize = 2;
+    let hitBatchIndex = -1;
+
+    for (let index = 0; index < offsets.length; index += batchSize) {
+        const batch = offsets.slice(index, index + batchSize);
+        const results = await Promise.all(
+            batch.map(async offset => ({
+                offset: offset,
+                hit: await hasCourseOn(addDays(reference, offset * 7), target.name, studentId)
+            }))
+        );
+
+        for (const item of results) {
+            observed.set(item.offset, item.hit);
+        }
+
+        if (hitBatchIndex === -1 && results.some(item => item.hit)) {
+            hitBatchIndex = index / batchSize;
+        }
+        // 找到命中后再多探一批,够核对课程周次就够了,不必把整个区间探完
+        if (hitBatchIndex !== -1 && index / batchSize > hitBatchIndex) {
+            break;
+        }
+
+        // 稍作等待防止风控
+        const delay = 300 + Math.floor(Math.random() * 200);
+        await new Promise(resolve => setTimeout(resolve, delay));
+    }
+
+    const hitOffsets = Array.from(observed.keys())
+        .filter(offset => observed.get(offset))
+        .sort((a, b) => Math.abs(a) - Math.abs(b) || a - b);
+
+    if (hitOffsets.length === 0) {
+        console.warn(`JS: 前后约一个学期内未探到课程「${target.name}」,跳过开学日期推算。`);
+        return null;
+    }
+
+    const firstWeek = target.weeks[0];
+    // 单双周课程每隔一周才上课,用课程自己的周次间隔做「上一次课」的核对距离,
+    // 否则会把「课程中途的某一次课」误判成起始周(相邻一周本来就该没课)。
+    const weekStep = target.weeks.length > 1 ? target.weeks[1] - target.weeks[0] : 1;
+
+    for (const candidate of hitOffsets) {
+        // 起始周之前的那一次课必须没有课,否则这个候选只是课程中途的一次课
+        if (observed.get(candidate - weekStep) === true) continue;
+
+        // 用课程自己的周次表核对:课程上课的每一周都应探到课
+        let consistent = true;
+        let checked = 0;
+
+        for (const week of target.weeks) {
+            const offset = candidate + (week - firstWeek);
+            const seen = observed.get(offset);
+            if (seen === undefined) continue;
+
+            checked += 1;
+            if (!seen) {
+                consistent = false;
+                break;
+            }
+        }
+
+        if (!consistent || checked < 1) continue;
+
+        const firstClassDate = addDays(reference, candidate * 7);
+        const startDate = addDays(firstClassDate, -((firstWeek - 1) * 7 + (target.day - 1)));
+
+        // 推算出的开学日期必须是周一,否则说明数据对不上,宁可不写
+        if (startDate.getDay() !== 1) continue;
+
+        console.log(`JS: 探测课程「${target.name}」首次上课日期 ${formatDate(firstClassDate)}(第 ${firstWeek} 周),推算开学日期 ${formatDate(startDate)}。`);
+        return formatDate(startDate);
+    }
+
+    console.warn(`JS: 课程「${target.name}」的探测结果与周次表对不上,跳过开学日期推算。`);
+    return null;
+}
+
+/**
+ * 计算本学期总周数:取所有课程周次的最大值。
+ * 研究生系统没有校历,这个值只是课表的周次上限,多算几周无空白无妨,少算会截断课程。
+ */
+function calculateTotalWeeks(courses) {
+    let maxWeek = 0;
+    for (const course of courses) {
+        for (const week of course.weeks) {
+            if (week > maxWeek) maxWeek = week;
+        }
+    }
+    return maxWeek;
+}
+
+async function promptUserToStart() {
+    return await window.shiguangBridgePromise.showAlert(
+        "研究生系统课表导入",
+        "导入前请确保您已在浏览器中登录江苏大学研究生管理系统(可从综合服务门户点「研究生管理系统」进入,或直接访问 http://yjsgll.ujs.edu.cn/pyxx/)。",
+        "好的,开始导入"
+    );
+}
+
+async function saveCourses(parsedCourses) {
+    try {
+        await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedCourses));
+        return true;
+    } catch (error) {
+        window.shiguangBridge.showToast(`课程保存失败: ${error.message}`);
+        console.error("JS: Save Courses Error:", error);
+        return false;
+    }
+}
+
+/**
+ * 只在能拿到真实开学日期时写入课表配置。
+ * 应用侧的 saveCourseConfig 是整体覆盖而非字段级合并:没有传入的字段会被写成模型默认值,
+ * 其中 semesterStartDate 的默认值是 null,会把用户已经设置好的开学日期清空。
+ * 所以拿不到开学日期时返回 null,宁可不写也不要写坏。
+ */
+async function saveCourseConfigIfPossible(config) {
+    if (!config) {
+        window.shiguangBridge.showToast("未取到本学期开学日期,已跳过课表配置,请在应用内手动设置开学日期。");
+        console.log("JS: 无可用开学日期,跳过 saveCourseConfig 以保留用户现有配置。");
+        return;
+    }
+
+    try {
+        await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
+        window.shiguangBridge.showToast(
+            `课表配置更新成功!开学日期 ${config.semesterStartDate},总周数 ${config.semesterTotalWeeks} 周。`
+        );
+    } catch (error) {
+        window.shiguangBridge.showToast(`课表配置保存失败: ${error.message}`);
+        console.error("JS: Save Config Error:", error);
+    }
+}
+
+async function importPresetTimeSlots(timeSlots) {
+    if (timeSlots.length === 0) {
+        window.shiguangBridge.showToast("警告:时间段为空,未导入时间段信息。");
+        return;
+    }
+
+    window.shiguangBridge.showToast(`正在导入 ${timeSlots.length} 个预设时间段...`);
+    try {
+        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
+    } catch (error) {
+        window.shiguangBridge.showToast("导入时间段失败: " + error.message);
+        console.error("JS: Save Time Slots Error:", error);
+    }
+}
+
+async function runImportFlow() {
+    if (!isGraduateSystemPage()) {
+        window.shiguangBridge.showToast("导入失败:请先进入江苏大学研究生管理系统!");
+        console.log("JS: 当前不在研究生管理系统页面,终止导入。");
+        return;
+    }
+
+    if (isLoginPage()) {
+        window.shiguangBridge.showToast("导入失败:请先登录研究生管理系统!");
+        console.log("JS: 检测到当前在登录页面,终止导入。");
+        return;
+    }
+
+    const alertConfirmed = await promptUserToStart();
+    if (!alertConfirmed) {
+        window.shiguangBridge.showToast("用户取消了导入。");
+        return;
+    }
+
+    window.shiguangBridge.showToast("正在读取课表数据...");
+
+    // 先取一次默认(当前)学期,用于学期列表与课表
+    let defaultDoc;
+    try {
+        defaultDoc = await fetchTimetableDocument("");
+    } catch (error) {
+        if (error.message === "NOT_LOGGED_IN") {
+            window.shiguangBridge.showToast("未登录或登录已过期,请先登录研究生管理系统。");
+        } else {
+            window.shiguangBridge.showToast(`读取课表失败: ${error.message}`);
+        }
+        console.error("JS: 读取课表页失败:", error);
+        return;
+    }
+
+    const semesterData = readSemesterOptions(defaultDoc);
+    if (!semesterData) {
+        window.shiguangBridge.showToast("未能读取学期列表,请确认已登录并停留在研究生管理系统。");
+        return;
+    }
+
+    const semesterIndex = await window.shiguangBridgePromise.showSingleSelection(
+        "选择学期",
+        JSON.stringify(semesterData.options.map(item => item.text)),
+        semesterData.defaultIndex
+    );
+    if (semesterIndex === null || semesterIndex === -1) {
+        window.shiguangBridge.showToast("导入已取消。");
+        return;
+    }
+
+    const semesterValue = semesterData.options[semesterIndex].value;
+    const semesterText = semesterData.options[semesterIndex].text;
+
+    let timetableDoc = defaultDoc;
+    if (semesterIndex !== semesterData.defaultIndex) {
+        window.shiguangBridge.showToast(`正在切换到 ${semesterText} ...`);
+        try {
+            timetableDoc = await fetchTimetableDocument(semesterValue);
+        } catch (error) {
+            window.shiguangBridge.showToast(`切换学期失败: ${error.message}`);
+            console.error("JS: 切换学期失败:", error);
+            return;
+        }
+    }
+
+    const courses = parseCoursesFromGrid(timetableDoc);
+    if (courses.length === 0) {
+        window.shiguangBridge.showToast(`${semesterText} 未找到任何课程,请确认所选学期是否正确。`);
+        console.log("JS: 课表为空,终止导入。");
+        return;
+    }
+
+    const timeSlots = parseTimeSlots(timetableDoc);
+    console.log(`JS: 解析到 ${courses.length} 门课程,${timeSlots.length} 个时间段。`);
+
+    // 学号取自课表页隐藏域,用来查「我的课表」接口
+    const studentId = (timetableDoc.querySelector("#MainWork_WUCpyjhdy_HFfilename") || {}).value || "";
+
+    window.shiguangBridge.showToast("正在推算开学日期...");
+    const semesterStartDate = await deriveSemesterStartDate(courses, studentId);
+
+    const totalWeeks = calculateTotalWeeks(courses);
+    const config = semesterStartDate
+        ? {
+            semesterStartDate: semesterStartDate,
+            semesterTotalWeeks: totalWeeks,
+            firstDayOfWeek: 1
+        }
+        : null;
+
+    const saveResult = await saveCourses(courses);
+    if (!saveResult) {
+        console.log("JS: 课程保存失败,流程终止。");
+        return;
+    }
+
+    await saveCourseConfigIfPossible(config);
+    await importPresetTimeSlots(timeSlots);
+
+    await window.shiguangBridgePromise.showAlert(
+        "导入完成",
+        `已导入 ${semesterText} 共 ${courses.length} 门课程。\n\n` +
+        `作息时间取自研究生系统课表页当前显示的作息(全天 ${timeSlots.length} 节),` +
+        "学校切换夏令时/冬令时后请重新导入,或在应用内手动修改时间段。\n\n" +
+        (semesterStartDate
+            ? `开学日期由课表数据反推得到:${semesterStartDate},请在应用内核对。`
+            : "未能反推开学日期,请在应用内手动设置。"),
+        "我知道了"
+    );
+
+    window.shiguangBridge.showToast(`课程导入成功,共导入 ${courses.length} 门课程!`);
+    console.log("JS: 整个导入流程执行完毕并成功。");
+    window.shiguangBridge.notifyTaskCompletion();
+}
+
+runImportFlow();

+ 343 - 261
resources/UJS/ujs_zhengfang_v9.0.js

@@ -18,6 +18,33 @@
 // 集中实践课(军训、毕业设计等)无星期节次无法排课,改为弹窗提示手动添加。
 // 教师与教室为空不再丢弃课程;修正京江各号楼的楼栋匹配;支持校外 WebVPN 访问。
 
+// 2026.09.07 第三版
+// 开学日期改从校历接口 xskbcxZccx_cxZcByXnxq 按所选学年学期获取,历史学期同样能取到,
+// 总周数取校历周数,日期解析兼容 zcrq / ksrq 字段兜底;取不到校历时仍跳过配置保存。
+// 作息时间不随切令时自动变化,导入后弹窗提示下次切令时日期。
+// 新增课程合并去重:连续节次合并、同课同节次的周次合并、完全重复去重。
+// 学年学期改为从教务系统读取选项并默认选中当前学期,读取失败时回退手动输入。
+// 课程请求与校历请求并行执行,课程解析与配置计算整合进同一函数,结构对齐官方参考脚本。
+// 修复:教务页面 zftal 脚本会改写 Array.prototype.filter(回调实参颠倒),
+// 导致集中实践课被静默丢弃,改用自实现的 arrayFilter 规避;清理注释残留与冗余日志。
+
+/**
+ * 数组过滤(原生实现)。
+ * 教务页面加载的 zftal 脚本会改写 Array.prototype.filter/some/every,
+ * 把回调实参换成 (index, value)(原生的 value 在前),
+ * 原生 filter 写法在这种页面上会得到错误结果(例如过滤不掉空选项),
+ * 所以这里用普通循环自行实现,不依赖任何被改写过的数组方法。
+ */
+function arrayFilter(arr, predicate) {
+    const result = [];
+    for (let i = 0; i < arr.length; i++) {
+        if (predicate(arr[i], i, arr)) {
+            result.push(arr[i]);
+        }
+    }
+    return result;
+}
+
 /**
  * 解析周次字符串,处理单双周和周次范围。
  */
@@ -63,6 +90,101 @@ function parseWeeks(weekStr) {
     return [...new Set(weeks)].sort((a, b) => a - b);
 }
 
+/**
+ * 节次与周次合并去重函数(参考官方 wiki 课程合并与去重函数)。
+ * 正方系统常把同一门课拆成多条记录:连续节次分开返回(1-2 节 + 3-4 节)、
+ * 单双周分开返回、或完全重复返回,这里统一合并/去重。
+ * @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;
+}
+
 /**
  * 拼接教务系统接口地址。
  * 校内直连时 location.origin 就是教务域名,前缀为空;
@@ -110,21 +232,22 @@ function parsePracticeCourses(jsonData) {
         return [];
     }
 
-    return jsonData.sjkList
-        .map((item) => ({
-            name: String(item.kcmc || "").trim(),
-            teacher: String(item.jsxm || "").trim(),
-            weekDesc: String(item.qsjsz || "").trim()
-        }))
-        .filter((item) => item.name);
+    return arrayFilter(
+        jsonData.sjkList
+            .map((item) => ({
+                name: String(item.kcmc || "").trim(),
+                teacher: String(item.jsxm || "").trim(),
+                weekDesc: String(item.qsjsz || "").trim()
+            })),
+        (item) => item.name
+    );
 }
 
 /**
  * 解析 API 返回的 JSON 数据。
+ * 合并去重后,按课程所在位置匹配作息,写入自定义时间。
  */
-function parseJsonData(jsonData) {
-    console.log("JS: parseJsonData 正在解析 JSON 数据...");
-
+function parseJsonData(jsonData, isSummerTime) {
     // 检查JSON结构:新的数据在 kbList 字段中
     if (!jsonData || !Array.isArray(jsonData.kbList)) {
         console.warn("JS: JSON 数据结构错误或缺少 kbList 字段。");
@@ -132,7 +255,7 @@ function parseJsonData(jsonData) {
     }
 
     const rawCourseList = jsonData.kbList;
-    const finalCourseList = [];
+    const initialCourseList = [];
 
     for (const rawCourse of rawCourseList) {
         // 关键字段检查:只有 kcmc(课名), xqj(星期), jcs(节次范围), zcd(周次描述) 是排课必需的。
@@ -158,7 +281,6 @@ function parseJsonData(jsonData) {
 
         // 数字有效性检查
         if (isNaN(day) || isNaN(startSection) || isNaN(endSection) || day < 1 || day > 7 || startSection > endSection) {
-            // console.warn(`JS: 课程 ${rawCourse.kcmc} 星期或节次数据无效,跳过。`);
             continue;
         }
 
@@ -178,111 +300,38 @@ function parseJsonData(jsonData) {
             course.remark = remark;
         }
 
-        finalCourseList.push(course);
+        initialCourseList.push(course);
     }
 
+    // 正方常把同一门课拆成多条记录(连续节次、单双周、重复),先合并去重,
+    // 再按楼栋匹配作息写入自定义时间,最后按星期节次排序。
+    const mergedCourses = mergeAndDistinctCourses(initialCourseList);
+    const finalCourseList = applyCustomTimeToCourses(mergedCourses, isSummerTime);
     finalCourseList.sort((a, b) =>
         a.day - b.day ||
         a.startSection - b.startSection ||
         a.name.localeCompare(b.name)
     );
 
-    console.log(`JS: JSON 数据解析完成,共找到 ${finalCourseList.length} 门课程。`);
+    console.log(`JS: 课程解析完成,原始 ${initialCourseList.length} 条,合并去重后 ${finalCourseList.length} 条。`);
     return finalCourseList;
 }
 
 /**
  * 检查当前是否处于夏令时作息时间段。
  * @returns true 夏令时 false 冬令时
+ *
+ * 曾尝试抓取教务处作息时间公告页(https://jwc.ujs.edu.cn/index/xl_zuo_xi_shi_jian.htm),
+ * 但跨域(CORS)无法读取,智能选择回退到预设日期。
+ * 冬令时是十一假期结束后调整,取 10 月 7 日;夏令时公告历年均为 4 月 7 日起执行。
  */
-async function whetherSummerTimeSlot() {
-
-    // // 教务处 校历/作息时间 公告页
-    // const url = "https://jwc.ujs.edu.cn/index/xl_zuo_xi_shi_jian.htm";
-    // let title = "";
-
-    // try {
-    //     const response = await fetch(url);
-    //     if (!response.ok) {
-    //         throw new Error(`网络请求失败。状态码: ${response.status} (${response.statusText})`);
-    //     }
-
-    //     const html = await response.text();
-    //     const doc = new DOMParser().parseFromString(html, "text/html");
-
-    //     // 优先按页面固定 id 读取:#line_u8_0, #line_u8_1 ...
-    //     for (let i = 0; i < 30; i++) {
-    //         const a = doc.querySelector(`#line_u8_${i} > a`);
-    //         if (!a) continue;
-    //         title = (a.getAttribute("title") || a.textContent || "").trim();
-    //         if (title.includes("作息时间表")) {
-    //             break;
-    //         }
-    //     }
-
-    //     // 若固定 id 没取到,则扫描所有链接文本
-    //     if (title.trim().length === 0) {
-    //         const links = doc.querySelectorAll("a");
-    //         for (const link of links) {
-    //             title = (link.getAttribute("title") || link.textContent || "").trim();
-    //             if (title.includes("作息时间表")) {
-    //                 break;
-    //             }
-    //         }
-    //     }
-
-    //     // 从公告中提取日期
-    //     if (title.trim().length === 0) {
-    //         throw new Error("未找到作息时间公告标题。");
-    //     }
-    //     const match = title.match(/[((]\s*(\d{4})年(\d{1,2})月(\d{1,2})日起执行\s*[))]/);
-    //     if (!match) {
-    //         throw new Error("公告标题格式不匹配,无法提取执行日期。");
-    //     }
-    //     const y = Number(match[1]);
-    //     const m = Number(match[2]);
-    //     const d = Number(match[3]);
-    //     const changeDate = new Date(y, m - 1, d);
-
-    //     const now = new Date();
-    //     if (changeDate.getMonth() === 3 && now >= changeDate ) { // 4月7日开始夏令时
-    //         return true;
-    //     } else if (changeDate.getMonth() === 9 && now < changeDate) { // 10月7日开始冬令时
-    //         return true;
-    //     } else {
-    //         return false;
-    //     }
-
-    // } catch (error) {
-    //     console.error('JS: 获取作息时间公告失败:', error);
-    //     window.shiguangBridge.showToast("无法获取作息时间公告,智能选择回退到预设时间。");
-
-    //     // 预设日期
-    //     const summerStart = new Date(new Date().getFullYear(), 3, 7); // 4月7日
-    //     const winterStart = new Date(new Date().getFullYear(), 9, 7); // 10月7日
-
-    //     const now = new Date();
-    //     if (now >= summerStart && now < winterStart) {
-    //         return true; // 夏令时
-    //     } else {
-    //         return false; // 冬令时
-    //     }
-    // }
-
-    // CORS 问题导致无法获取公告页,智能选择回退到预设时间。
-    // 冬令时是十一假期结束后调整,取 10 月 7 日;夏令时公告历年均为 4 月 7 日起执行。
-    // 参考教务处历年作息时间表公告:https://jwc.ujs.edu.cn/index/xl_zuo_xi_shi_jian.htm
-
+function whetherSummerTimeSlot() {
     // 预设日期
     const summerStart = new Date(new Date().getFullYear(), 3, 7); // 4月7日
     const winterStart = new Date(new Date().getFullYear(), 9, 7); // 10月7日
 
     const now = new Date();
-    if (now >= summerStart && now < winterStart) {
-        return true; // 夏令时
-    } else {
-        return false; // 冬令时
-    }
+    return now >= summerStart && now < winterStart;
 }
 
 /**
@@ -323,18 +372,13 @@ function isLoginPage() {
 
 
 function validateYearInput(input) {
-    console.log("JS: validateYearInput 被调用,输入: " + input);
     if (/^[0-9]{4}$/.test(input)) {
-        console.log("JS: validateYearInput 验证通过。");
         return false;
-    } else {
-        console.log("JS: validateYearInput 验证失败。");
-        return "请输入四位数字的学年!";
     }
+    return "请输入四位数字的学年!";
 }
 
 async function promptUserToStart() {
-    console.log("JS: 流程开始:显示公告。");
     return await window.shiguangBridgePromise.showAlert(
         "教务系统课表导入",
         "导入前请确保您已在浏览器中成功登录教务系统",
@@ -342,36 +386,134 @@ async function promptUserToStart() {
     );
 }
 
-async function getAcademicYear() {
+/**
+ * 从教务系统课表查询页读取学年学期选项。
+ * 学年:以系统当前选中项为中心,取前 2 年 + 后 2 年,共 5 个选项;
+ * 学期:直接取页面全部选项。
+ * 读取失败返回 null,由调用方回退到手动输入。
+ */
+async function fetchAcademicOptions() {
+    const url = buildApiUrl("/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151");
+
+    try {
+        const response = await fetch(url, {
+            method: "GET",
+            credentials: "include"
+        });
+
+        if (!response.ok) return null;
+
+        const doc = new DOMParser().parseFromString(await response.text(), "text/html");
+
+        const allYearOptions = arrayFilter(
+            Array.from(doc.querySelectorAll("#xnm option"))
+                .map((opt) => ({
+                    value: opt.value,
+                    text: opt.textContent.trim(),
+                    selected: opt.selected
+                })),
+            (opt) => opt.value !== ""
+        );
+
+        const semesterOptions = arrayFilter(
+            Array.from(doc.querySelectorAll("#xqm option"))
+                .map((opt) => ({
+                    value: opt.value,
+                    // 江大返回的文本只有「1」「2」,直接展示不友好,补成「第一学期」这类写法。
+                    text: /^\d$/.test(opt.textContent.trim())
+                        ? `第${"一二三四五六七八九十"[Number(opt.textContent.trim()) - 1] || opt.textContent.trim()}学期`
+                        : opt.textContent.trim(),
+                    selected: opt.selected
+                })),
+            (opt) => opt.value !== ""
+        );
+
+        if (allYearOptions.length === 0 || semesterOptions.length === 0) {
+            return null;
+        }
+
+        const selectedIndex = allYearOptions.findIndex((opt) => opt.selected);
+        const start = Math.max(0, selectedIndex === -1 ? 0 : selectedIndex - 2);
+        const end = Math.min(allYearOptions.length, selectedIndex === -1 ? 5 : selectedIndex + 3);
+        const yearOptions = allYearOptions.slice(start, end);
+
+        const defaultSemesterIndex = semesterOptions.findIndex((opt) => opt.selected);
+
+        return {
+            yearOptions,
+            semesterOptions,
+            defaultYearIndex: selectedIndex === -1 ? 0 : selectedIndex - start,
+            defaultSemesterIndex: defaultSemesterIndex === -1 ? 0 : defaultSemesterIndex
+        };
+    } catch (error) {
+        console.warn("JS: 读取学年学期选项失败:", error);
+        return null;
+    }
+}
+
+/**
+ * 提示用户选择学年和学期。
+ * 优先用教务系统返回的选项(自动默认选中当前学期),
+ * 读取失败时回退为手动输入学年 + 固定的两学期选择。
+ * @returns {{academicYear: string, semesterCode: string}} 或 null(取消)
+ */
+async function selectAcademicYearAndSemester() {
+    const optionsData = await fetchAcademicOptions();
+
+    if (optionsData) {
+        const { yearOptions, semesterOptions, defaultYearIndex, defaultSemesterIndex } = optionsData;
+
+        const yearIndex = await window.shiguangBridgePromise.showSingleSelection(
+            "选择学年",
+            JSON.stringify(yearOptions.map((item) => item.text)),
+            defaultYearIndex
+        );
+        if (yearIndex === null || yearIndex === -1) return null;
+
+        const semesterIndex = await window.shiguangBridgePromise.showSingleSelection(
+            "选择学期",
+            JSON.stringify(semesterOptions.map((item) => item.text)),
+            defaultSemesterIndex
+        );
+        if (semesterIndex === null || semesterIndex === -1) return null;
+
+        return {
+            academicYear: yearOptions[yearIndex].value,
+            semesterCode: semesterOptions[semesterIndex].value
+        };
+    }
+
+    // 回退:手动输入学年。
     const currentYear = new Date().getFullYear().toString();
     const currentMonth = new Date().getMonth() + 1; // 月份从0开始,所以加1
-    // 如果当前月份在8月或之后,默认学年是当前年份-下一年份,否则是上一年份-当前年份
-    const defaultYear = currentMonth >= 8 ? currentYear : (Number(currentYear) - 1).toString(); 
-    console.log("JS: 提示用户输入学年。");
-    return await window.shiguangBridgePromise.showPrompt(
+    // 如果当前月份在8月或之后,默认学年是当前年份,否则是上一年份
+    const defaultYear = currentMonth >= 8 ? currentYear : (Number(currentYear) - 1).toString();
+
+    const academicYear = await window.shiguangBridgePromise.showPrompt(
         "选择学年",
-        "请输入要导入课程的起始学年(如2025-2026 应该填2025):",
+        "未能从教务系统读取学年学期,手动输入要导入课程的起始学年(如2025-2026 应该填2025):",
         defaultYear,
         "validateYearInput"
     );
-}
+    if (academicYear === null) return null;
 
-async function selectSemester() {
     const semesters = ["第一学期", "第二学期"];
-    const currentMonth = new Date().getMonth() + 1; // 月份从0开始,所以加1
-    const defaultSemesterIndex = currentMonth >= 8 ? 0 : 1; // 如果当前月份在8月或之后,默认选择第一学期,否则选择第二学期
-    console.log("JS: 提示用户选择学期。");
+    const defaultSemesterIndex = currentMonth >= 8 ? 0 : 1;
     const semesterIndex = await window.shiguangBridgePromise.showSingleSelection(
         "选择学期",
         JSON.stringify(semesters),
         defaultSemesterIndex
     );
-    return semesterIndex;
+    if (semesterIndex === null || semesterIndex === -1) return null;
+
+    return {
+        academicYear,
+        semesterCode: getSemesterCode(semesterIndex)
+    };
 }
 
 async function selectTimeSlot() {
     const timeSlots = ["智能选择" ,"夏令时", "冬令时"];
-    console.log("JS: 提示用户选择作息类型。");
     const timeSlotIndex = await window.shiguangBridgePromise.showSingleSelection(
         "选择作息时间",
         JSON.stringify(timeSlots),
@@ -407,21 +549,25 @@ function getSemesterCode(semesterIndex) {
 
 
 /**
- * 获取教务系统当前学期的起止日期。
- * 首页日历区块的标题形如 "2026-2027学年1学期(2026-08-31至2027-02-21)",
- * 其中起始日期就是第 1 周周一,正是 semesterStartDate 需要的值。
- * 注意:该接口忽略 xnm/xqm 参数,只返回当前学期,
- * 因此只有用户选择的学年学期与返回值一致时才能使用。
+ * 获取指定学年学期的校历周次。
+ *
+ * 接口按周次顺序返回数组,每个元素代表一周:
+ * rq 形如 "2026-08-31/2026-09-06",zs 是周次序号。
+ * 第 1 周的起始日(周一)就是 semesterStartDate 需要的开学日期,
+ * 数组长度就是教务排的总周数。
+ * 与首页日历区块不同,这个接口尊重 xnm/xqm 参数,历史学期同样能取到;
+ * 尚未排出校历的学期返回空数组。
  */
-async function fetchCurrentSemesterRange() {
-    const url = buildApiUrl("/xtgl/index_cxAreaFive.html?localeKey=zh_CN&gnmkdm=index");
+async function fetchSemesterWeeks(academicYear, semesterCode) {
+    const url = buildApiUrl("/kbcx/xskbcxZccx_cxZcByXnxq.html?gnmkdm=N2154");
 
     try {
         const response = await fetch(url, {
             "headers": {
                 "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
+                "x-requested-with": "XMLHttpRequest"
             },
-            "body": "",
+            "body": `xnm=${academicYear}&xqm=${semesterCode}`,
             "method": "POST",
             "credentials": "include"
         });
@@ -430,25 +576,33 @@ async function fetchCurrentSemesterRange() {
             throw new Error(`状态码 ${response.status}`);
         }
 
-        const html = await response.text();
-        const match = html.match(/(\d{4})-\d{4}学年(\d)学期\s*[((](\d{4}-\d{2}-\d{2})至(\d{4}-\d{2}-\d{2})[))]/);
+        const weekList = JSON.parse(await response.text());
 
-        if (!match) {
-            console.warn("JS: 未能从日历区块解析出学期起止日期。");
+        if (!Array.isArray(weekList) || weekList.length === 0) {
+            console.warn("JS: 校历接口未返回周次数据,该学期可能尚未排出校历。");
             return null;
         }
 
-        const range = {
-            academicYear: match[1],
-            semesterIndex: Number(match[2]) - 1,
-            startDate: match[3],
-            endDate: match[4]
-        };
-        console.log("JS: 教务系统当前学期:", range);
+        // 正常情况下数组已按周次排好,仍按 zs 找一次第 1 周,避免顺序变化时取错日期。
+        const firstWeek = weekList.find((item) => Number(item.zs) === 1) || weekList[0];
+
+        // rq 形如 "2026-08-31/2026-09-06",取斜杠前的周一日期;
+        // 部分正方部署改用 zcrq / ksrq 字段,一并兜底。
+        const rqDate = String(firstWeek.rq || "").split("/")[0].trim();
+        const fallbackMatch = String(firstWeek.zcrq || firstWeek.ksrq || "").match(/(\d{4}-\d{2}-\d{2})/);
+        const startDate = /^\d{4}-\d{2}-\d{2}$/.test(rqDate) ? rqDate : (fallbackMatch ? fallbackMatch[1] : "");
+
+        if (!startDate) {
+            console.warn("JS: 校历接口返回的日期无法识别:", firstWeek);
+            return null;
+        }
+
+        const range = { startDate: startDate, totalWeeks: weekList.length };
+        console.log("JS: 校历周次:", range);
         return range;
 
     } catch (error) {
-        console.warn("JS: 获取学期起止日期失败:", error);
+        console.warn("JS: 获取校历周次失败:", error);
         return null;
     }
 }
@@ -463,80 +617,66 @@ async function fetchCurrentSemesterRange() {
  * defaultClassDuration / defaultBreakDuration 不显式传入,会被重置为应用默认的 45 / 10 分钟,
  * 与江大「45 分钟一节、课间 10 分钟」一致,因此没有副作用。
  */
-function buildCourseConfig(courses, semesterRange, firstDayOfWeek) {
+function buildCourseConfig(semesterRange, firstDayOfWeek) {
     if (!semesterRange) {
         return null;
     }
 
-    let maxWeek = 0;
-    for (const course of courses) {
-        for (const week of course.weeks) {
-            if (week > maxWeek) {
-                maxWeek = week;
-            }
-        }
-    }
-
     return {
         semesterStartDate: semesterRange.startDate,
-        // 只增不减:默认 20 周,课表里出现更大的周次时才扩展。
-        semesterTotalWeeks: Math.max(maxWeek, 20),
+        // 直接采用校历周数。校历含考试周与假期周,比实际上课周多几周,
+        // 但这个值只是课表的周次上限:多几周空白无害,少了会截断课程。
+        semesterTotalWeeks: semesterRange.totalWeeks,
         firstDayOfWeek: firstDayOfWeek
     };
 }
 
 
 /**
- * 请求和解析课程数据
+ * 请求和解析课程数据。
+ * 课程请求与校历请求并行执行,课表配置一并算好返回,
+ * 由调用方决定是否保存(拿不到开学日期时 config 为 null)。
  */
-async function fetchAndParseCourses(academicYear, semesterIndex) {
+async function fetchAndParseCourses(academicYear, semesterCode, isSummerTime) {
     window.shiguangBridge.showToast("正在请求课表数据...");
 
-    const semesterCode = getSemesterCode(semesterIndex);
-
-    // API URL 和请求体
     const xnmXqmBody = `xnm=${academicYear}&xqm=${semesterCode}&kzlx=ck&xsdm=&kclbdm=`;
     const url = buildApiUrl("/kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151");
 
-    console.log(`JS: 发送请求到 ${url}, body: ${xnmXqmBody}`);
-
-    const requestOptions = {
-        "headers": {
-            "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
-        },
-        "body": xnmXqmBody,
-        "method": "POST",
-        "credentials": "include"
-    };
+    // 并行获取课程数据和校历周次
+    const [courseResponse, semesterRange] = await Promise.all([
+        fetch(url, {
+            method: "POST",
+            headers: {
+                "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
+            },
+            body: xnmXqmBody,
+            credentials: "include"
+        }),
+        fetchSemesterWeeks(academicYear, semesterCode)
+    ]);
 
     try {
-        const response = await fetch(url, requestOptions);
-
-        if (!response.ok) {
-            throw new Error(`网络请求失败。状态码: ${response.status} (${response.statusText})`);
+        if (!courseResponse.ok) {
+            throw new Error(`网络请求失败。状态码: ${courseResponse.status} (${courseResponse.statusText})`);
         }
 
-        const jsonText = await response.text();
         let jsonData;
         try {
-            jsonData = JSON.parse(jsonText);
+            jsonData = JSON.parse(await courseResponse.text());
         } catch (e) {
             console.error('JS: JSON 解析失败,可能是会话过期:', e);
             window.shiguangBridge.showToast("数据返回格式错误,可能是您未成功登录或会话已过期。");
             return null;
         }
 
-        const courses = parseJsonData(jsonData);
+        const courses = parseJsonData(jsonData, isSummerTime);
 
         if (courses.length === 0) {
             window.shiguangBridge.showToast("未找到任何课程数据,请检查所选学年学期是否正确或本学期无课,或教务系统需要二次登录。");
             return null;
         }
 
-        console.log(`JS: 课程数据解析成功,共找到 ${courses.length} 门课程。`);
-
-        console.log("JS: 课程列表预览:", courses.slice(0, 5)); // 预览前5门课程
-
         // 集中实践课(军训、形势与政策等)没有星期和节次,无法排进周课表,单独取出用于提示。
         const practiceCourses = parsePracticeCourses(jsonData);
         if (practiceCourses.length > 0) {
@@ -550,7 +690,7 @@ async function fetchAndParseCourses(academicYear, semesterIndex) {
         return {
             courses: courses,
             practiceCourses: practiceCourses,
-            firstDayOfWeek: firstDayOfWeek
+            config: buildCourseConfig(semesterRange, firstDayOfWeek)
         };
 
     } catch (error) {
@@ -561,11 +701,8 @@ async function fetchAndParseCourses(academicYear, semesterIndex) {
 }
 
 async function saveCourses(parsedCourses) {
-    window.shiguangBridge.showToast(`正在保存 ${parsedCourses.length} 门课程...`);
-    console.log(`JS: 尝试保存 ${parsedCourses.length} 门课程...`);
     try {
-        await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedCourses, null, 2));
-        console.log("JS: 课程保存成功!");
+        await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedCourses));
         return true;
     } catch (error) {
         window.shiguangBridge.showToast(`课程保存失败: ${error.message}`);
@@ -579,26 +716,7 @@ async function saveCourses(parsedCourses) {
  * 拿不到就完全不调用 saveCourseConfig —— 应用侧是整体覆盖,
  * 传入不含 semesterStartDate 的配置会把用户已设置的开学日期清空。
  */
-async function saveCourseConfigIfPossible(courses, academicYear, semesterIndex, firstDayOfWeek) {
-    const semesterRange = await fetchCurrentSemesterRange();
-
-    let usableRange = null;
-    if (semesterRange) {
-        const sameYear = semesterRange.academicYear === String(academicYear);
-        const sameSemester = semesterRange.semesterIndex === semesterIndex;
-
-        if (sameYear && sameSemester) {
-            usableRange = semesterRange;
-        } else {
-            console.log(
-                `JS: 所选学年学期(${academicYear}/第${semesterIndex + 1}学期)` +
-                `不是教务系统当前学期(${semesterRange.academicYear}/第${semesterRange.semesterIndex + 1}学期),跳过开学日期写入。`
-            );
-        }
-    }
-
-    const config = buildCourseConfig(courses, usableRange, firstDayOfWeek);
-
+async function saveCourseConfigIfPossible(config) {
     if (!config) {
         window.shiguangBridge.showToast("未取到本学期开学日期,已跳过课表配置,请在应用内手动设置开学日期。");
         console.log("JS: 无可用开学日期,跳过 saveCourseConfig 以保留用户现有配置。");
@@ -641,7 +759,7 @@ const CMorningTimeSlots = [
     { number: 4, startTime: "11:15", endTime: "12:00" },
 ];
 
-// 夏令时 
+// 夏令时
 // 下午作息时间
 // 北固
 const DSummerAfternoonTimeSlots = [
@@ -702,20 +820,11 @@ function getCampusTypeFromPosition(position) {
     const normalized = String(position || "").replace(/\s+/g, " ").trim();
     if (!normalized) return null;
 
-    // const firstPart = normalized.split(" ")[0] || "";
-    // if (firstPart.includes("北固")) return "D";
-    // if (firstPart.includes("本部")) return "E";
-
-    // // 兜底:有些数据可能不按空格分段,补充全文匹配。
-    // if (normalized.includes("北固")) return "D";
-    // if (normalized.includes("本部")) return "E";
-
-    // api好像不返回前缀了,我也不确定北固是怎么样的格式,只能这么写了()
+    // api 不再返回「本部」这类校区前缀,北固的返回格式也未确认,只能先这么写:
+    // 带「北固」的按北固校区处理,其余一律按本部处理。
     const firstPart = normalized.split(" ")[0] || "";
     if (firstPart.includes("北固") || normalized.includes("北固")) return "D";
-    return "E"; // 其他默认本部
-
-    // return null;
+    return "E";
 }
 
 function getMorningTypeFromPosition(position) {
@@ -790,21 +899,18 @@ function applyCustomTimeToCourses(courses, isSummerTime) {
 
 
 async function importPresetTimeSlots(timeSlots) {
-    console.log(`JS: 准备导入 ${timeSlots.length} 个预设时间段。`);
-
-    if (timeSlots.length > 0) {
-        window.shiguangBridge.showToast(`正在导入 ${timeSlots.length} 个预设时间段...`);
-        try {
-            await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
-            window.shiguangBridge.showToast("预设时间段导入成功!");
-            console.log("JS: 预设时间段导入成功。");
-        } catch (error) {
-            window.shiguangBridge.showToast("导入时间段失败: " + error.message);
-            console.error('JS: Save Time Slots Error:', error);
-        }
-    } else {
+    if (timeSlots.length === 0) {
         window.shiguangBridge.showToast("警告:时间段为空,未导入时间段信息。");
-        console.warn("JS: 警告:传入时间段为空,未导入时间段信息。");
+        return;
+    }
+
+    window.shiguangBridge.showToast(`正在导入 ${timeSlots.length} 个预设时间段...`);
+    try {
+        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
+        window.shiguangBridge.showToast("预设时间段导入成功!");
+    } catch (error) {
+        window.shiguangBridge.showToast("导入时间段失败: " + error.message);
+        console.error('JS: Save Time Slots Error:', error);
     }
 }
 
@@ -819,67 +925,43 @@ async function runImportFlow() {
     const alertConfirmed = await promptUserToStart();
     if (!alertConfirmed) {
         window.shiguangBridge.showToast("用户取消了导入。");
-        console.log("JS: 用户取消了导入流程。");
         return;
     }
 
-    // // 与后续流程并发执行,提前缓存智能选择结果。
-    // const smartTimeSlotPromise = whetherSummerTimeSlot();
-    // console.log("JS: 智能作息判定已并发启动。");
-
-    const academicYear = await getAcademicYear();
-    if (academicYear === null) {
+    const selection = await selectAcademicYearAndSemester();
+    if (selection === null) {
         window.shiguangBridge.showToast("导入已取消。");
-        console.log("JS: 获取学年失败/取消,流程终止。");
         return;
     }
-    console.log(`JS: 已选择学年: ${academicYear}`);
-
-
-    const semesterIndex = await selectSemester();
-    if (semesterIndex === null || semesterIndex === -1) {
-        window.shiguangBridge.showToast("导入已取消。");
-        console.log("JS: 选择学期失败/取消,流程终止。");
-        return;
-    }
-    console.log(`JS: 已选择学期索引: ${semesterIndex}`);
+    const { academicYear, semesterCode } = selection;
+    console.log(`JS: 已选择学年学期: ${academicYear} / ${semesterCode}`);
 
     const timeSlotIndex = await selectTimeSlot();
     if (timeSlotIndex === null || timeSlotIndex === -1) {
         window.shiguangBridge.showToast("导入已取消。");
-        console.log("JS: 选择作息类型失败/取消,流程终止。");
         return;
     }
 
-    let isSummerTime = false;
+    let isSummerTime;
     if (timeSlotIndex === 1) {
         isSummerTime = true;
     } else if (timeSlotIndex === 2) {
         isSummerTime = false;
     } else {
-        // try {
-        //     isSummerTime = await smartTimeSlotPromise;
-        // } catch (error) {
-        //     console.error("JS: 智能作息判定异常,回退重新判定:", error);
-        //     isSummerTime = await whetherSummerTimeSlot();
-        // }
-        isSummerTime = await whetherSummerTimeSlot();
+        isSummerTime = whetherSummerTimeSlot();
         const shouldReselect = await reselectTimeSlot(isSummerTime);
         if (shouldReselect) {
             isSummerTime = !isSummerTime;
         }
-
     }
     console.log(`JS: 作息类型: ${isSummerTime ? "夏令时" : "冬令时"}`);
 
-    const result = await fetchAndParseCourses(academicYear, semesterIndex);
+    const result = await fetchAndParseCourses(academicYear, semesterCode, isSummerTime);
     if (result === null) {
         console.log("JS: 课程获取或解析失败,流程终止。");
         return;
     }
-    const { courses, practiceCourses, firstDayOfWeek } = result;
-
-    const coursesWithCustomTime = applyCustomTimeToCourses(courses, isSummerTime);
+    const { courses, practiceCourses, config } = result;
 
     // 作息时间在导入时一次性写入,不会自动跟随切令时变化,需要明确告知用户。
     // 同时说明只有部分教学楼收录了独立作息,其余楼栋使用默认作息时间。
@@ -915,18 +997,18 @@ async function runImportFlow() {
         );
     }
 
-    const saveResult = await saveCourses(coursesWithCustomTime);
+    const saveResult = await saveCourses(courses);
     if (!saveResult) {
         console.log("JS: 课程保存失败,流程终止。");
         return;
     }
 
-    await saveCourseConfigIfPossible(coursesWithCustomTime, academicYear, semesterIndex, firstDayOfWeek);
+    await saveCourseConfigIfPossible(config);
 
     await importPresetTimeSlots(isSummerTime ? SummerTimeSlots : WinterTimeSlots);
 
 
-    window.shiguangBridge.showToast(`课程导入成功,共导入 ${coursesWithCustomTime.length} 门课程!`);
+    window.shiguangBridge.showToast(`课程导入成功,共导入 ${courses.length} 门课程!`);
     console.log("JS: 整个导入流程执行完毕并成功。");
     window.shiguangBridge.notifyTaskCompletion();
 }