Kaynağa Gözat

Merge pull request #871 from ShiGuangSchedule/pending

星河欲转 2 hafta önce
ebeveyn
işleme
c4a35feb06

+ 10 - 0
index/root_index.yaml

@@ -1283,7 +1283,17 @@ schools:
     initial: "S"
     resource_folder: "LIDAPOLY"
 
+  - id: "SWFU"
+    name: "西南林业大学"
+    initial: "X"
+    resource_folder: "SWFU"
+    
   - id: "BJTU"
     name: "北京交通大学"
     initial: "B"
     resource_folder: "BJTU"
+
+  - id: "NWAFU"
+    name: "西北农林科技大学"
+    initial: "X"
+    resource_folder: "NWAFU"

+ 10 - 0
resources/NWAFU/adapters.yaml

@@ -0,0 +1,10 @@
+# resources/NWAFU/adapters.yaml
+# 西北农林科技大学 · ehall 新版教务(金智 jwapp)「我的课表」
+adapters:
+  - adapter_id: "NWAFU_01"
+    adapter_name: "西北农林科技大学"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "nwafu.js"
+    import_url: "https://newehall.nwafu.edu.cn/"
+    maintainer: "123zhuzhu61"
+    description: "西北农林科技大学:登录后进入「我的本研课表」点击执行导入。接口取数,写入学期总周数与冬令/夏令作息(硬编码)"

+ 778 - 0
resources/NWAFU/nwafu.js

@@ -0,0 +1,778 @@
+// 文件: nwafu.js
+// ---------------------------------------------------------------------------
+// 学校:西北农林科技大学(NWAFU)
+// 系统:ehall 新版教务(金智 jwapp)→「我的本研课表」
+// 入口:https://newehall.nwafu.edu.cn/(登录后进入「我的本研课表」再执行导入)
+//
+// 规范:拾光课程表《适配脚本开发指南 v2》
+//   · 数据:courses / timeSlots / config / comboSchedule
+//   · 桥接:window.shiguangBridgePromise(异步)/ window.shiguangBridge(同步)
+//   · 流程:runImportFlow() 编排,成功后才 notifyTaskCompletion()
+
+// 作息:按学校作息时间表硬编码(第 1-4 节冬夏相同,第 5 节起不同)
+//   夏令:当年 05.01 — 当年 10.01;冬令:当年 10.01 — 次年 05.01
+// ---------------------------------------------------------------------------
+
+// ==================== 常量 ====================
+
+const APP_CODE = 'wdkbby';
+const URL_TERM = '/modules/jshkcb/dqxnxq.do';
+const URL_TERM_LIST = '/modules/jshkcb/xnxqcx.do';
+const URL_COURSE = '/modules/xskcb/cxxszhxqkb.do';
+const URL_CALENDAR = '/modules/xskcb/cxxljc.do';
+const URL_SECTIONS = '/modules/jshkcb/cxjcs.do';
+const ACTION_TERM = 'dqxnxq';
+const ACTION_TERM_LIST = 'xnxqcx';
+const ACTION_COURSE = 'cxxszhxqkb';
+const ACTION_CALENDAR = 'cxxljc';
+const ACTION_SECTIONS = 'cxjcs';
+const PORTAL_HINT = 'newehall.nwafu.edu.cn 新门户 →「我的本研课表」';
+
+const DEFAULT_TOTAL_WEEKS = 20;
+const MAX_TOTAL_WEEKS = 30;
+
+// ==================== 作息(学校作息时间表) ====================
+
+const SLOTS_WINTER = [
+    { number: 1, startTime: '08:00', endTime: '08:45' },
+    { number: 2, startTime: '08:55', endTime: '09:40' },
+    { number: 3, startTime: '10:10', endTime: '10:55' },
+    { number: 4, startTime: '11:05', endTime: '11:50' },
+    { number: 5, startTime: '14:00', endTime: '14:45' },
+    { number: 6, startTime: '14:55', endTime: '15:40' },
+    { number: 7, startTime: '16:00', endTime: '16:45' },
+    { number: 8, startTime: '16:55', endTime: '17:40' },
+    { number: 9, startTime: '19:00', endTime: '19:45' },
+    { number: 10, startTime: '19:50', endTime: '20:35' },
+    { number: 11, startTime: '20:40', endTime: '21:25' }
+];
+
+const SLOTS_SUMMER = [
+    { number: 1, startTime: '08:00', endTime: '08:45' },
+    { number: 2, startTime: '08:55', endTime: '09:40' },
+    { number: 3, startTime: '10:10', endTime: '10:55' },
+    { number: 4, startTime: '11:05', endTime: '11:50' },
+    { number: 5, startTime: '14:30', endTime: '15:15' },
+    { number: 6, startTime: '15:25', endTime: '16:10' },
+    { number: 7, startTime: '16:30', endTime: '17:15' },
+    { number: 8, startTime: '17:25', endTime: '18:10' },
+    { number: 9, startTime: '19:30', endTime: '20:15' },
+    { number: 10, startTime: '20:20', endTime: '21:05' },
+    { number: 11, startTime: '21:10', endTime: '21:55' }
+];
+
+// ==================== 工具 ====================
+
+function toast(message) {
+    try {
+        window.shiguangBridge.showToast(String(message));
+    } catch (e) {
+        console.warn('[NWAFU] showToast 不可用:', e && e.message);
+    }
+}
+
+function log(message) {
+    try {
+        console.log('[NWAFU 课表适配] ' + message);
+    } catch (e) { /* ignore */ }
+}
+
+function textOf(value) {
+    if (value === null || value === undefined) return '';
+    const result = String(value).replace(/\u00a0/g, ' ').trim();
+    if (result === 'null' || result === 'undefined') return '';
+    return result;
+}
+
+function uniqueSorted(numbers) {
+    return Array.from(new Set(numbers.filter((n) => typeof n === 'number' && !isNaN(n) && n > 0)))
+        .sort((a, b) => a - b);
+}
+
+/** 教师字段去重 **/
+function cleanTeacher(value) {
+    const raw = textOf(value);
+    if (!raw) return '';
+    const names = raw.split(/[,,、;;]/).map((item) => textOf(item)).filter((item) => item !== '');
+    return Array.from(new Set(names)).join(',') || raw;
+}
+
+// ==================== 教务接口 ====================
+
+function rootPath() {
+    const config = window._JW_INIT_CONFIG || {};
+    let root = config.path || config.ROOT_PATH || config.contextPath || '/jwapp';
+    root = String(root);
+    if (root.charAt(0) !== '/') root = '/' + root;
+    return root.replace(/\/+$/, '');
+}
+
+function encodeForm(data) {
+    const pairs = [];
+    Object.keys(data || {}).forEach((key) => {
+        const value = data[key];
+        if (value === null || value === undefined) return;
+        pairs.push(encodeURIComponent(key) + '=' + encodeURIComponent(String(value)));
+    });
+    return pairs.join('&');
+}
+
+/** 与教务页面自身(BH_UTILS.doSyncAjax)等价的表单 POST:同源、带 Cookie */
+async function api(modulePath, data) {
+    const response = await fetch(rootPath() + '/sys/' + APP_CODE + modulePath, {
+        method: 'POST',
+        credentials: 'include',
+        headers: {
+            'accept': 'application/json, text/javascript, */*; q=0.01',
+            'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
+            'x-requested-with': 'XMLHttpRequest'
+        },
+        body: encodeForm(data || {})
+    });
+
+    if (!response || !response.ok) throw new Error('HTTP ' + (response ? response.status : '?'));
+
+    const raw = await response.text();
+    let json;
+    try {
+        json = JSON.parse(raw);
+    } catch (e) {
+        throw new Error('返回内容不是 JSON(很可能登录已失效)');
+    }
+    if (json && json.loginURL) throw new Error('登录状态已失效,请重新登录');
+    return json;
+}
+
+/** 取出 {datas:{<action>:{rows:[...]}}} 中的 rows */
+function rowsOf(response, action) {
+    if (!response || !response.datas) return null;
+    const node = response.datas[action];
+    if (!node || !node.rows || typeof node.rows.length !== 'number') return null;
+    if (node.rows.length === 0 && node.extParams && String(node.extParams.code) !== '1') return null;
+    return node.rows;
+}
+
+function messageOf(response) {
+    if (!response || !response.datas) return '';
+    const keys = Object.keys(response.datas);
+    for (let i = 0; i < keys.length; i++) {
+        const node = response.datas[keys[i]];
+        if (node && node.extParams && node.extParams.msg) return String(node.extParams.msg);
+    }
+    return '';
+}
+
+// ==================== 学年学期 ====================
+
+/** 当前学年学期(dqxnxq.do),用于学期列表的默认选中项 */
+async function fetchTerm() {
+    const response = await api(URL_TERM, {});
+    const rows = rowsOf(response, ACTION_TERM);
+    if (!rows || !rows.length) {
+        throw new Error('未能获取当前学年学期' + (messageOf(response) ? ':' + messageOf(response) : ''));
+    }
+    const dm = textOf(rows[0].DM);
+    if (!dm) throw new Error('学年学期接口未返回学期代码');
+    return { dm: dm, mc: textOf(rows[0].MC) };
+}
+
+/** 学期列表(xnxqcx.do,按学期代码降序 = 最新在前) */
+async function fetchTermList() {
+    const response = await api(URL_TERM_LIST, { '*order': '-DM' });
+    const rows = rowsOf(response, ACTION_TERM_LIST);
+    if (!rows || !rows.length) return [];
+    return sortTermsDesc(rows);
+}
+
+/** 规整学期行并降序排列 */
+function sortTermsDesc(rows) {
+    return (rows || [])
+        .map((row) => ({ dm: textOf(row && row.DM), mc: textOf(row && row.MC) }))
+        .filter((term) => term.dm)
+        .sort((a, b) => b.dm.localeCompare(a.dm));
+}
+
+/** 学期显示文本 */
+function termText(term) {
+    const mc = textOf(term && term.mc);
+    return mc || textOf(term && term.dm);
+}
+
+/** 默认选中当前学期;列表中找不到则选第一条 */
+function defaultTermIndex(list, currentDm) {
+    if (!Array.isArray(list) || !list.length) return -1;
+    const key = textOf(currentDm);
+    if (!key) return 0;
+    const index = list.findIndex((term) => term.dm === key);
+    return index >= 0 ? index : 0;
+}
+
+/**
+ * 让用户选择要导入的学期(官方 API:showSingleSelection)。
+ * 返回 { dm, mc };用户取消返回 null。
+ */
+async function selectTerm() {
+    let list = [];
+    try {
+        list = await fetchTermList();
+    } catch (e) {
+        log('学期列表接口失败,退回当前学期: ' + e.message);
+    }
+
+    let currentDm = '';
+    try {
+        const current = await fetchTerm();
+        currentDm = current.dm;
+        if (!list.length) list = [current];
+    } catch (e) {
+        log('当前学期接口失败: ' + e.message);
+    }
+
+    if (!list.length) throw new Error('未能获取学期列表,请先登录教务并打开「我的本研课表」');
+    if (list.length === 1) return list[0];
+
+    const labels = list.map((term) => termText(term));
+    const selected = await window.shiguangBridgePromise.showSingleSelection(
+        '选择学期',
+        JSON.stringify(labels),
+        defaultTermIndex(list, currentDm)
+    );
+    if (selected === null || selected === undefined) return null;
+
+    const index = Number(selected);
+    if (!(index >= 0 && index < list.length)) return null;
+    return list[index];
+}
+
+/** "2026-2027-1" → { xn: "2026-2027", xq: "1" } */
+function termCodes(dm) {
+    const parts = textOf(dm).split('-');
+    if (parts.length < 3) return null;
+    const xn = parts[0] + '-' + parts[1];
+    const xq = parts[2];
+    if (!/^\d{4}-\d{4}$/.test(xn) || !/^\d+$/.test(xq)) return null;
+    return { xn: xn, xq: xq };
+}
+
+/**
+ * 校历:所选学期的开学日期 + 校历总周数
+ *   POST {ROOT}/sys/wdkbby/modules/xskcb/cxxljc.do  { XN, XQ }
+ *   rows[0].XQKSRQ → 开学日期;rows[0].ZZC → 校历总周数
+ * 取不到返回 null(不影响课程导入)。
+ */
+async function fetchTermCalendar(dm) {
+    const codes = termCodes(dm);
+    if (!codes) return null;
+
+    const response = await api(URL_CALENDAR, { XN: codes.xn, XQ: codes.xq });
+    const rows = rowsOf(response, ACTION_CALENDAR);
+    if (!rows || !rows.length) return null;
+
+    const rawDate = textOf(rows[0].XQKSRQ);
+    const startDate = /^\d{4}-\d{2}-\d{2}/.test(rawDate) ? rawDate.substring(0, 10) : '';
+    const totalWeeks = parseInt(textOf(rows[0].ZZC), 10);
+
+    if (!startDate && !(totalWeeks > 0)) return null;
+    return {
+        startDate: startDate,
+        totalWeeks: totalWeeks > 0 ? Math.min(totalWeeks, MAX_TOTAL_WEEKS) : 0
+    };
+}
+
+// ==================== 周次解析 ====================
+
+/** 周次位串,例如 "0000111111111111110000"(第 5-18 周)→ [5..18] */
+function parseWeekBitString(value) {
+    const raw = textOf(value);
+    if (!raw) return null;
+    const compact = raw.replace(/\s+/g, '');
+    if (!/^[01]+$/.test(compact)) return null;
+    if (compact.length < 1 || compact.length > 60) return null;
+    const weeks = [];
+    for (let i = 0; i < compact.length; i++) {
+        if (compact.charAt(i) === '1') weeks.push(i + 1);
+    }
+    return weeks;
+}
+
+// 周次表达式:6-18周 / 4周 / 7-17周(单) / 4-6周(双),8-13周 / 5-16周,18周
+const WEEK_GROUP = '\\d+\\s*(?:[-~至]\\s*\\d+)?\\s*(?:[((]\\s*[单双]\\s*[))])?\\s*周\\s*(?:[((]\\s*[单双]\\s*[))])?';
+const WEEK_EXPRESSION = '(?:' + WEEK_GROUP + '\\s*[,,、]?\\s*)+';
+
+/** 解析一段周次内容(可含逗号分隔的多组、单双周标记) */
+function parseWeekSegments(expression) {
+    const raw = textOf(expression);
+    if (!raw) return [];
+
+    const weeks = [];
+    raw.split(/[,,、]/).forEach((segment) => {
+        if (!segment) return;
+        let parity = 0; // 0=全部 1=单周 2=双周
+        if (segment.indexOf('单') !== -1) parity = 1;
+        if (segment.indexOf('双') !== -1) parity = 2;
+
+        const rangePattern = /(\d+)\s*(?:[-~至]\s*(\d+))?/g;
+        let match;
+        while ((match = rangePattern.exec(segment)) !== null) {
+            const start = parseInt(match[1], 10);
+            let end = match[2] ? parseInt(match[2], 10) : start;
+            if (isNaN(start) || start <= 0) continue;
+            if (isNaN(end) || end < start) end = start;
+            if (end - start > 60) end = start;
+            for (let week = start; week <= end; week++) {
+                if (parity === 1 && week % 2 === 0) continue;
+                if (parity === 2 && week % 2 === 1) continue;
+                weeks.push(week);
+            }
+        }
+    });
+    return uniqueSorted(weeks);
+}
+
+/** 只截取周次表达式,避免把「第3节-第4节」当周次 */
+function matchWeekExpression(raw) {
+    const match = new RegExp(WEEK_EXPRESSION).exec(raw);
+    if (!match || !match[0]) return null;
+
+    let start = match.index;
+    const back = /(?:\d+\s*(?:[-~至]\s*\d+)?\s*[,,、]\s*)+$/.exec(raw.substring(0, start));
+    if (back) start -= back[0].length;
+
+    return { index: start, text: raw.substring(start, match.index + match[0].length) };
+}
+
+/** 周次文本:"6-18周" / "4周" / "1-16周(单)" / "1,3,5周" */
+function parseWeeksDescription(value) {
+    const raw = textOf(value);
+    if (!raw) return [];
+
+    const matched = matchWeekExpression(raw);
+    if (matched) {
+        const weeks = parseWeekSegments(matched.text);
+        if (weeks.length) return weeks;
+    }
+    const head = raw.split('周')[0] || '';
+    return parseWeekSegments(head.replace(/^[^\d]*/, ''));
+}
+
+// ==================== 接口数据 → 课程 ====================
+
+function buildPosition(row) {
+    const room = textOf(row.JASMC) || textOf(row.JAS);
+    if (room) return room;
+    const building = textOf(row.JXLDM_DISPLAY) || textOf(row.JXLMC);
+    if (building) return building;
+    return textOf(row.XXXQMC);
+}
+
+function parseCourseRow(row) {
+    if (!row) return null;
+
+    const day = parseInt(textOf(row.SKXQ), 10);
+    if (!(day >= 1 && day <= 7)) return null;
+
+    const baseName = textOf(row.KCM);
+    if (!baseName) return null;
+    const alias = textOf(row.TYXMDM_DISPLAY);
+    const name = alias ? (baseName + '(' + alias + ')') : baseName;
+
+    const startSection = parseInt(textOf(row.KSJC), 10);
+    let endSection = parseInt(textOf(row.JSJC), 10);
+    if (!(startSection > 0)) return null;
+    if (isNaN(endSection) || endSection < startSection) endSection = startSection;
+
+    let weeks = parseWeekBitString(row.SKZC);
+    if (!weeks || !weeks.length) weeks = parseWeeksDescription(row.ZCMC);
+    if (!weeks || !weeks.length) return null; // 周次无法确定:不完整课程,忽略
+
+    return {
+        name: name,
+        teacher: cleanTeacher(row.SKJS),
+        position: buildPosition(row),
+        day: day,
+        startSection: startSection,
+        endSection: endSection,
+        weeks: weeks
+    };
+}
+
+async function fetchCourses(term) {
+    const response = await api(URL_COURSE, { XNXQDM: term.dm });
+    const rows = rowsOf(response, ACTION_COURSE);
+    if (!rows) {
+        const message = messageOf(response);
+        throw new Error('课表接口未返回数据' + (message ? ':' + message : ''));
+    }
+    if (!rows.length) throw new Error('课表接口返回 0 条记录(该学期可能没有排课)');
+
+    const courses = [];
+    let maxWeekBits = 0;
+    rows.forEach((row) => {
+        const bits = textOf(row.SKZC).replace(/\s+/g, '');
+        if (/^[01]+$/.test(bits) && bits.length > maxWeekBits) maxWeekBits = bits.length;
+        const course = parseCourseRow(row);
+        if (course) courses.push(course);
+    });
+
+    if (!courses.length) throw new Error('课表接口返回 ' + rows.length + ' 条记录,但均无法解析出课程');
+    log('接口返回 ' + rows.length + ' 条排课记录,解析出 ' + courses.length + ' 条(忽略 ' + (rows.length - courses.length) + ' 条不完整记录)');
+    return { courses: courses, maxWeekBits: maxWeekBits };
+}
+
+// ==================== 合并去重(官方参考实现) ====================
+
+/**
+ * 节次与周次合并去重函数
+ * 来源:拾光课程表 wiki《课程合并与去重函数》
+ */
+function mergeAndDistinctCourses(courses) {
+    if (!Array.isArray(courses) || courses.length <= 1) return courses || [];
+
+    const list = courses.map((c) => Object.assign({}, c, {
+        name: c.name || '',
+        teacher: c.teacher || '',
+        position: c.position || '',
+        weeks: Array.isArray(c.weeks) ? c.weeks.slice().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.concat(nxt.weeks))).sort((a, b) => a - b);
+        } else {
+            step2Merged.push(cur);
+            cur = nxt;
+        }
+    }
+    step2Merged.push(cur);
+
+    return step2Merged;
+}
+
+// ==================== 作息方案 ====================
+
+function pad2(value) {
+    return value < 10 ? ('0' + value) : String(value);
+}
+
+/** 生成 "YYYY-MM-DD" */
+function dateText(year, month, day) {
+    return String(year) + '-' + pad2(month) + '-' + pad2(day);
+}
+
+/**
+ * 生成「冬令 + 夏令」两段作息(区间为闭区间,结束日取下一段开始日前一天):
+ *   夏令 05-01 ~ 09-30;冬令 10-01 ~ 次年 04-30
+ * 只生成「当前时段 + 下一时段」两条规则,避免同一日期命中多条。
+ */
+function buildComboSchedule(today, winterSlots, summerSlots) {
+    const winter = winterSlots || SLOTS_WINTER;
+    const summer = summerSlots || SLOTS_SUMMER;
+    const date = today || new Date();
+    const year = date.getFullYear();
+    const month = date.getMonth() + 1;
+
+    let rules;
+    if (month >= 5 && month <= 9) {
+        rules = [
+            { name: '夏令作息', startDate: dateText(year, 5, 1), endDate: dateText(year, 9, 30), timeSlots: summer },
+            { name: '冬令作息', startDate: dateText(year, 10, 1), endDate: dateText(year + 1, 4, 30), timeSlots: winter }
+        ];
+    } else if (month >= 10) {
+        rules = [
+            { name: '冬令作息', startDate: dateText(year, 10, 1), endDate: dateText(year + 1, 4, 30), timeSlots: winter },
+            { name: '夏令作息', startDate: dateText(year + 1, 5, 1), endDate: dateText(year + 1, 9, 30), timeSlots: summer }
+        ];
+    } else {
+        rules = [
+            { name: '冬令作息', startDate: dateText(year - 1, 10, 1), endDate: dateText(year, 4, 30), timeSlots: winter },
+            { name: '夏令作息', startDate: dateText(year, 5, 1), endDate: dateText(year, 9, 30), timeSlots: summer }
+        ];
+    }
+
+    return { name: '西北农林科技大学作息(冬令/夏令)', publicSchedules: rules };
+}
+
+/** 分钟数 → "HH:mm" */
+function hhmm(minutes) {
+    const hour = Math.floor(minutes / 60) % 24;
+    return pad2(hour) + ':' + pad2(minutes % 60);
+}
+
+/**
+ * 课程节次超出作息表时补足(45 分钟上课 + 10 分钟课间)。
+ * 正常学期不会触发(作息表 11 节),只有教务临时加节时兜底,避免课程引用不存在的节次。
+ */
+function extendSlots(base, maxSection) {
+    const slots = (base || []).map((slot) => ({
+        number: slot.number,
+        startTime: slot.startTime,
+        endTime: slot.endTime
+    }));
+    if (!slots.length) return slots;
+
+    const target = Math.min(Math.max(parseInt(maxSection, 10) || slots.length, slots.length), 20);
+    while (slots.length < target) {
+        const last = slots[slots.length - 1];
+        const parts = last.endTime.split(':');
+        const startMinutes = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10) + 10;
+        slots.push({
+            number: slots.length + 1,
+            startTime: hhmm(startMinutes),
+            endTime: hhmm(startMinutes + 45)
+        });
+    }
+    return slots;
+}
+
+/** 课程里出现的最大的结束节次 */
+function maxSectionOf(courses) {
+    let max = 0;
+    (courses || []).forEach((course) => {
+        const end = parseInt(course && course.endSection, 10);
+        if (end > max) max = end;
+    });
+    return max;
+}
+
+/**
+ * 节次结构(cxjcs.do):上午 / 下午 / 晚上各几节。
+ * 仅用于日志核对硬编码作息是否过期,取不到不影响导入。
+ */
+async function fetchSectionLayout(dm) {
+    const codes = termCodes(dm);
+    if (!codes) return null;
+
+    const response = await api(URL_SECTIONS, { XN: codes.xn, XQ: codes.xq });
+    const rows = rowsOf(response, ACTION_SECTIONS);
+    if (!rows || !rows.length) return null;
+
+    const row = rows[0];
+    const morning = parseInt(textOf(row.SFJJ), 10) || 0;
+    const afternoon = parseInt(textOf(row.XFJJ), 10) || 0;
+    const evening = parseInt(textOf(row.WSJJ), 10) || 0;
+    return { morning: morning, afternoon: afternoon, evening: evening, total: morning + afternoon + evening };
+}
+
+/** 课表配置:优先用校历给的开学日期与总周数 */
+function buildCourseConfig(calendar, courseWeeks) {
+    const totalWeeks = (calendar && calendar.totalWeeks > 0) ? calendar.totalWeeks : courseWeeks;
+    const config = { semesterTotalWeeks: totalWeeks };
+    if (calendar && calendar.startDate) config.semesterStartDate = calendar.startDate;
+    return config;
+}
+
+// ==================== 保存 ====================
+
+async function saveConfig(config) {
+    try {
+        return await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
+    } catch (error) {
+        toast('保存课表配置失败: ' + error.message);
+        return false;
+    }
+}
+
+async function saveCourses(courses) {
+    try {
+        return await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+    } catch (error) {
+        toast('保存课程失败: ' + error.message);
+        return false;
+    }
+}
+
+// ==================== 主导入流程 ====================
+
+/** 前置确认 */
+async function promptUserToStart() {
+    const confirmed = await window.shiguangBridgePromise.showAlert(
+        '导入确认',
+        '将从西北农林科技大学「我的本研课表」导入课程与作息。\n'
+        + '请先登录 ' + PORTAL_HINT + ',再点击开始导入;\n'
+        + '导入时可选择要导入的学期(默认当前学期)。',
+        '开始导入'
+    );
+    if (!confirmed) {
+        toast('已取消导入');
+        return false;
+    }
+    toast('正在读取课表数据…');
+    return true;
+}
+
+function computeTotalWeeks(courses, maxWeekBits) {
+    let maxWeek = 0;
+    courses.forEach((course) => {
+        (course.weeks || []).forEach((week) => {
+            if (week > maxWeek) maxWeek = week;
+        });
+    });
+    let total = Math.max(maxWeek, maxWeekBits || 0);
+    if (!total) total = DEFAULT_TOTAL_WEEKS;
+    return Math.min(Math.max(total, 1), MAX_TOTAL_WEEKS);
+}
+
+/** 编排整个导入流程:成功才发送结束信号 */
+/**
+ * 作息保存:先基础作息(冬令),再冬令/夏令组合方案。
+ * 官方要求 savePresetTimeSlots 必须先成功,否则 saveComboSchedule 会被拒绝。
+ */
+async function saveSchedule(winterSlots, summerSlots) {
+    try {
+        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(winterSlots));
+        await window.shiguangBridgePromise.saveComboSchedule(
+            JSON.stringify(buildComboSchedule(new Date(), winterSlots, summerSlots))
+        );
+        return true;
+    } catch (e) {
+        log('作息导入失败(不影响课程): ' + e.message);
+        return false;
+    }
+}
+
+/** 校历与会话信息:失败都不影响课程导入 */
+async function loadExtras(dm) {
+    let calendar = null;
+    try {
+        calendar = await fetchTermCalendar(dm);
+    } catch (e) {
+        log('校历接口失败(不影响导入): ' + e.message);
+    }
+
+    let layout = null;
+    try {
+        layout = await fetchSectionLayout(dm);
+    } catch (e) {
+        log('节次结构接口失败: ' + e.message);
+    }
+    if (layout && layout.total > 0) {
+        log('教务节次结构:上午 ' + layout.morning + ' / 下午 ' + layout.afternoon
+            + ' / 晚上 ' + layout.evening + ' 节(合计 ' + layout.total + ')');
+    }
+    return calendar;
+}
+
+/** 编排整个导入流程(只做流程控制):成功才发送结束信号 */
+async function runImportFlow() {
+    try {
+        if (!(await promptUserToStart())) return;
+
+        // 1) 选择学期(默认当前学期;已结束且下学期已公布时可选下学期)
+        const term = await selectTerm();
+        if (!term) {
+            toast('已取消导入');
+            return;
+        }
+        log('选中学期: ' + term.dm + ' / ' + termText(term));
+
+        // 2) 该学期课程 + 校历
+        const fetched = await fetchCourses(term);
+        const courses = mergeAndDistinctCourses(fetched.courses);
+        const calendar = await loadExtras(term.dm);
+
+        // 3) 配置 → 课程
+        const config = buildCourseConfig(calendar, computeTotalWeeks(courses, fetched.maxWeekBits));
+        if (!(await saveConfig(config))) {
+            toast('课表配置保存失败,导入终止');
+            return;
+        }
+        if (!(await saveCourses(courses))) {
+            toast('课程保存失败,导入终止');
+            return;
+        }
+
+        // 4) 作息(节次超出作息表时自动补足)→ 组合作息
+        const maxSection = maxSectionOf(courses);
+        if (maxSection > SLOTS_WINTER.length) {
+            log('课程出现第 ' + maxSection + ' 节,作息自动补足到 ' + maxSection + ' 节');
+        }
+        const winterSlots = extendSlots(SLOTS_WINTER, maxSection);
+        const summerSlots = extendSlots(SLOTS_SUMMER, maxSection);
+        const scheduleImported = await saveSchedule(winterSlots, summerSlots);
+
+        // 5) 完成
+        toast(
+            '导入成功:' + courses.length + ' 门课程'
+            + (scheduleImported ? ',已写入冬令/夏令作息' : ',作息导入失败')
+            + (calendar && calendar.startDate ? ',开学 ' + calendar.startDate : '')
+            + '(' + termText(term) + ')'
+        );
+        window.shiguangBridge.notifyTaskCompletion();
+    } catch (error) {
+        await reportFailure(error);
+    }
+}
+
+/** 失败提示:优先弹窗,弹窗不可用时退回 toast */
+async function reportFailure(error) {
+    const message = '导入失败:' + (error && error.message ? error.message : String(error));
+    console.error('[NWAFU 课表适配] ' + message);
+    try {
+        await window.shiguangBridgePromise.showAlert(
+            '导入失败',
+            message + '\n\n请先登录 ' + PORTAL_HINT + ',打开课表页面后重试。',
+            '知道了'
+        );
+    } catch (e) {
+        toast(message);
+    }
+}
+// 启动导入流程
+runImportFlow();

+ 9 - 0
resources/SWFU/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/SWFU/adapters.yaml
+adapters:
+  - adapter_id: "SWFU"
+    adapter_name: "西南林业大学教务系统"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "swfu.js"
+    import_url: "https://jw.swfu.edu.cn/jwglxt/xtgl/login_slogin.html"
+    maintainer: "Haooz"
+    description: "西南林业大学课表导入,请先登录教务系统,再点击导入!非本校开发者,如有误请提交issues。"

+ 210 - 0
resources/SWFU/swfu.js

@@ -0,0 +1,210 @@
+// 西南林业大学 (swfu.edu.cn) 拾光课程表适配脚本
+// 正方教务 V9,接口:
+// 学年学期 GET /jwglxt/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151
+// 课表 POST /jwglxt/kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151  xnm=&xqm=3&kzlx=ck&xsdm=&kclbdm=&kclxdm=
+// 校历 POST /jwglxt/kbcx/xskbcxZccx_cxZcByXnxq.html?gnmkdm=N2154  xnm=&xqm=   (rq/zs 字段)
+
+const SWFU_TIME_SLOTS = [
+    { number: 1, startTime: "08:20", endTime: "09:05" },
+    { number: 2, startTime: "09:10", endTime: "09:55" },
+    { number: 3, startTime: "10:25", endTime: "11:10" },
+    { number: 4, startTime: "11:15", endTime: "12:00" },
+    { number: 5, startTime: "14:00", endTime: "14:45" },
+    { number: 6, startTime: "14:50", endTime: "15:35" },
+    { number: 7, startTime: "16:05", endTime: "16:50" },
+    { number: 8, startTime: "16:55", endTime: "17:40" },
+    { number: 9, startTime: "18:30", endTime: "19:15" },
+    { number: 10, startTime: "19:20", endTime: "20:05" },
+    { number: 11, startTime: "20:25", endTime: "21:10" },
+    { number: 12, startTime: "21:15", endTime: "22:00" }
+];
+
+//解析周次字符串,处理单双周和周次范围
+function parseWeeks(weekStr) {
+    if (!weekStr) return [];
+
+    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 (flag === 1 && w % 2 === 0) continue;
+                if (flag === 2 && w % 2 !== 0) continue;
+                weeks.push(w);
+            }
+        }
+    }
+
+    return [...new Set(weeks)].sort((a, b) => a - b);
+}
+
+// 解析 API 返回的 JSON 数据
+function parseJsonData(jsonData) {
+    if (!jsonData || !Array.isArray(jsonData.kbList)) return [];
+    const courses = [];
+    for (const c of jsonData.kbList) {
+        if (!c.kcmc || !c.xqj || !c.jcs || !c.zcd) continue;
+        const weeks = parseWeeks(c.zcd);
+        if (!weeks.length) continue;
+        const parts = String(c.jcs).split("-");
+        const startSection = Number(parts[0]);
+        const endSection = Number(parts[parts.length - 1]);
+        const day = Number(c.xqj);
+        if (isNaN(day) || isNaN(startSection) || isNaN(endSection) ||
+            day < 1 || day > 7 || startSection > endSection) continue;
+        courses.push({
+            name: String(c.kcmc).trim(),
+            teacher: String(c.xm || "").trim(),
+            position: String(c.cdmc || "").trim(),
+            day: day,
+            startSection: startSection,
+            endSection: endSection,
+            weeks: weeks
+        });
+    }
+    return courses;
+}
+
+async function promptUserToStart() {
+    return await window.shiguangBridgePromise.showAlert(
+        "教务系统课表导入", "导入前请确保您已在浏览器中成功登录西南林业大学教务系统", "好的,开始导入");
+}
+
+// 从课表查询页读取学年学期选项,默认选中当前学期
+async function fetchAcademicOptions() {
+    try {
+        const resp = await fetch("/jwglxt/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151",
+            { method: "GET", credentials: "include" });
+        if (!resp.ok) return null;
+        const doc = new DOMParser().parseFromString(await resp.text(), "text/html");
+        const read = sel => Array.from(doc.querySelectorAll(sel + " option"))
+            .filter(o => o.value !== "")
+            .map(o => ({ value: o.value, text: o.textContent.trim(), selected: o.hasAttribute("selected") }));
+        const years = read("#xnm"), semesters = read("#xqm");
+        if (!years.length || !semesters.length) return null;
+        const selIdx = years.findIndex(o => o.selected);
+        const start = Math.max(0, (selIdx === -1 ? 0 : selIdx) - 2);
+        return {
+            yearOptions: years.slice(start, Math.min(years.length, (selIdx === -1 ? 0 : selIdx) + 3)),
+            semesterOptions: semesters,
+            defaultYearIndex: selIdx === -1 ? 0 : selIdx - start,
+            defaultSemesterIndex: Math.max(0, semesters.findIndex(o => o.selected))
+        };
+    } catch (e) { return null; }
+}
+
+async function selectAcademicYearAndSemester() {
+    const data = await fetchAcademicOptions();
+    if (!data) { window.shiguangBridge.showToast("从教务系统读取学年学期失败,请确保已登录。"); return null; }
+    const { yearOptions, semesterOptions, defaultYearIndex, defaultSemesterIndex } = data;
+    const yearIdx = await window.shiguangBridgePromise.showSingleSelection(
+        "选择学年", JSON.stringify(yearOptions.map(o => o.text)), defaultYearIndex);
+    if (yearIdx === null || yearIdx === -1) return null;
+    const semIdx = await window.shiguangBridgePromise.showSingleSelection(
+        "选择学期", JSON.stringify(semesterOptions.map(o => o.text)), defaultSemesterIndex);
+    if (semIdx === null || semIdx === -1) return null;
+    return { academicYear: yearOptions[yearIdx].value, semesterCode: semesterOptions[semIdx].value };
+}
+
+// 校历:rq="2026-08-31/2026-09-06",数组长度即总周数
+async function fetchSemesterConfig(academicYear, semesterCode) {
+    try {
+        const resp = await fetch("/jwglxt/kbcx/xskbcxZccx_cxZcByXnxq.html?gnmkdm=N2154", {
+            method: "POST",
+            headers: {
+                "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
+                "x-requested-with": "XMLHttpRequest"
+            },
+            body: `xnm=${academicYear}&xqm=${semesterCode}`,
+            credentials: "include"
+        });
+        if (!resp.ok) return null;
+        const json = JSON.parse(await resp.text());
+        if (!Array.isArray(json) || !json.length) return null;
+        const first = json.find(i => Number(i.zs) === 1) || json[0];
+        const startDate = String(first.rq || "").split("/")[0].trim();
+        if (!/^\d{4}-\d{2}-\d{2}$/.test(startDate)) return null;
+        return { semesterStartDate: startDate, semesterTotalWeeks: json.length };
+    } catch (e) { return null; }
+}
+
+async function fetchAndParseCourses(academicYear, semesterCode) {
+    window.shiguangBridge.showToast("正在请求课表数据...");
+    try {
+        const resp = await fetch("/jwglxt/kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151", {
+            method: "POST",
+            headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" },
+            body: `xnm=${academicYear}&xqm=${semesterCode}&kzlx=ck&xsdm=&kclbdm=&kclxdm=`,
+            credentials: "include"
+        });
+        if (!resp.ok) throw new Error("状态码 " + resp.status);
+        const courses = parseJsonData(JSON.parse(await resp.text()));
+        if (!courses.length) throw new Error("未找到课程数据,请确认所选学年学期是否正确。");
+        return courses;
+    } catch (e) {
+        window.shiguangBridge.showToast("导入失败:" + e.message);
+        console.error("JS: Import Error", e);
+        return null;
+    }
+}
+
+async function saveCourses(courses) {
+    try {
+        await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+        return true;
+    } catch (e) {
+        window.shiguangBridge.showToast("课程保存失败: " + e.message);
+        return false;
+    }
+}
+
+async function runImportFlow() {
+    if (!await promptUserToStart()) { window.shiguangBridge.showToast("用户取消了导入。"); return; }
+
+    const selection = await selectAcademicYearAndSemester();
+    if (!selection) { window.shiguangBridge.showToast("导入已取消。"); return; }
+
+    const courses = await fetchAndParseCourses(selection.academicYear, selection.semesterCode);
+    if (courses === null) return;
+    if (!await saveCourses(courses)) return;
+
+    const config = await fetchSemesterConfig(selection.academicYear, selection.semesterCode);
+    if (config) {
+        try { await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config)); } catch (e) {}
+    } else {
+        window.shiguangBridge.showToast("未取到开学日期,已跳过课表配置,请在应用内手动设置。");
+    }
+    try { await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(SWFU_TIME_SLOTS)); } catch (e) {}
+
+    window.shiguangBridge.showToast("课程导入成功,共导入 " + courses.length + " 门课程!");
+    window.shiguangBridge.notifyTaskCompletion();
+}
+
+runImportFlow();