Browse Source

Merge pull request #776 from ShiGuangSchedule/pending

星河欲转 1 month ago
parent
commit
226e71e510

+ 10 - 0
index/root_index.yaml

@@ -1157,4 +1157,14 @@ schools:
     name: "东北林业大学"
     initial: "D"
     resource_folder: "NEFU"
+    
+  - id: "SCETOP"
+    name: "四川托普信息技术职业学院"
+    initial: "S"
+    resource_folder: "SCETOP"
+
+  - id: "HUNAU"
+    name: "湖南农业大学"
+    initial: "H"
+    resource_folder: "HUNAU"
     

+ 9 - 0
resources/HUNAU/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/HUNAU/adapters.yaml
+adapters:
+  - adapter_id: "HUNAU_01"
+    adapter_name: "湖南农业大学(强智教务)"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "hunau_01.js"
+    import_url: "https://webvpn.hunau.edu.cn/login?cas_login=true"
+    maintainer: "salt-fishes"
+    description: "登录湖南农业大学教务系统后即可导入课表(校外经 WebVPN 访问)"

+ 554 - 0
resources/HUNAU/hunau_01.js

@@ -0,0 +1,554 @@
+// 湖南农业大学(hunau.edu.cn)拾光课程表适配脚本
+// 教务系统:强智 jsxsd(湖南强智科技教务系统)
+// 校外经 WebVPN(webvpn.hunau.edu.cn)访问,校内可直连;脚本按当前页面自动推导 /jsxsd 前缀,两种形态通用
+// 使用流程:在软件内置浏览器登录学校统一身份认证/WebVPN,进入教务系统任意页面后执行导入
+// 数据来源:GET {前缀}/jsxsd/xskb/xskb_list.do?xnxq01id=学期  → 课表页(table#timetable)
+//           POST {前缀}/jsxsd/jxzl/jxzl_query  → 教学周历(首列为周次,第二列 title 为该周星期一日期)
+// 维护者:salt-fishes
+
+var HUNAU_KB_PATH = '/jsxsd/xskb/xskb_list.do';
+var HUNAU_CALENDAR_PATH = '/jsxsd/jxzl/jxzl_query';
+var HUNAU_CLASS_DURATION = 45;
+var HUNAU_BREAK_DURATION = 10;
+var HUNAU_DEFAULT_TOTAL_WEEKS = 20;
+var HUNAU_POST_OPTIONS = { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } };
+
+// 兜底作息:当课表页未提供节次时间时使用(按湖南农大 6 个大节、每节 45 分钟、课间 10 分钟推得)
+var HUNAU_FALLBACK_TIME_SLOTS = [
+    { number: 1, startTime: '08:00', endTime: '08:45' },
+    { number: 2, startTime: '08:55', endTime: '09:40' },
+    { number: 3, startTime: '10:05', endTime: '10:50' },
+    { number: 4, startTime: '11:00', endTime: '11:45' },
+    { number: 5, startTime: '14:30', endTime: '15:15' },
+    { number: 6, startTime: '15:25', endTime: '16:10' },
+    { number: 7, startTime: '16:35', endTime: '17:20' },
+    { number: 8, startTime: '17:30', endTime: '18:15' },
+    { number: 9, startTime: '19:30', endTime: '20:15' },
+    { number: 10, startTime: '20:25', endTime: '21:10' },
+    { number: 11, startTime: '21:20', endTime: '22:05' },
+    { number: 12, startTime: '22:15', endTime: '23:00' }
+];
+
+var HUNAU_CN_NUMBER = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10 };
+
+// ==================== 桥接封装 ====================
+
+function toast(message) {
+    if (window.shiguangBridge && typeof window.shiguangBridge.showToast === 'function') {
+        window.shiguangBridge.showToast(message);
+    } else {
+        console.log('[HUNAU] ' + message);
+    }
+}
+
+async function alertUser(title, message) {
+    if (window.shiguangBridgePromise && typeof window.shiguangBridgePromise.showAlert === 'function') {
+        const confirmed = await window.shiguangBridgePromise.showAlert(title, message, '知道了');
+        return confirmed === true || confirmed === 'true';
+    }
+    alert(title + '\n' + message);
+    return true;
+}
+
+// 原生以字符串序号或 "null" 回传,统一为数字序号,-1 表示取消
+async function selectFromList(title, items, defaultIndex) {
+    if (!(window.shiguangBridgePromise && typeof window.shiguangBridgePromise.showSingleSelection === 'function')) {
+        return defaultIndex;
+    }
+    const result = await window.shiguangBridgePromise.showSingleSelection(title, JSON.stringify(items), defaultIndex);
+    if (result === null || result === undefined || result === 'null') return -1;
+    const index = typeof result === 'number' ? result : parseInt(result, 10);
+    return isNaN(index) ? -1 : index;
+}
+
+// ==================== 请求 ====================
+
+// 兼容直连与 WebVPN 形态的地址,取当前路径中 /jsxsd/ 之前的部分作为前缀
+function jsxsdUrl(path) {
+    const currentPath = window.location.pathname || '';
+    const index = currentPath.indexOf('/jsxsd/');
+    return window.location.origin + (index < 0 ? '' : currentPath.slice(0, index)) + path;
+}
+
+async function requestText(url, options) {
+    const response = await fetch(url, Object.assign({ credentials: 'include' }, options || {}));
+    return await response.text();
+}
+
+// 不同强智版本分别使用 #timetable 与 #kbtable
+function findTimetableTable(doc) {
+    return doc.getElementById('timetable') || doc.getElementById('kbtable');
+}
+
+// 优先 GET 带学期参数;失败再退回 POST 表单
+async function fetchTimetableDoc(semesterValue) {
+    const url = jsxsdUrl(HUNAU_KB_PATH);
+    const got = new DOMParser().parseFromString(
+        await requestText(url + '?xnxq01id=' + encodeURIComponent(semesterValue)), 'text/html');
+    if (findTimetableTable(got)) return got;
+
+    const body = 'xnxq01id=' + encodeURIComponent(semesterValue) + '&sfFD=1';
+    const posted = new DOMParser().parseFromString(
+        await requestText(url, Object.assign({ method: 'POST', body: body }, HUNAU_POST_OPTIONS)), 'text/html');
+    return findTimetableTable(posted) ? posted : null;
+}
+
+// ==================== 学期 ====================
+
+// 学期列表:[{ value: "2025-2026-2", label: "2025-2026 学年第 2 学期", selected: true }]
+function parseSemesters(doc) {
+    const select = doc.querySelector('select[name="xnxq01id"], select#xnxq01id');
+    if (!select) return [];
+    const list = [];
+    select.querySelectorAll('option').forEach(function (option) {
+        const value = String(option.getAttribute('value') || '').trim();
+        if (!value) return;
+        list.push({
+            value: value,
+            label: value.replace(/^(\d{4})-(\d{4})-(\d)$/, '$1-$2 学年第 $3 学期'),
+            selected: option.hasAttribute('selected')
+        });
+    });
+    return list;
+}
+
+// ==================== 节次时间 ====================
+
+function timeToMinutes(text) {
+    const match = String(text || '').match(/(\d{1,2}):(\d{2})/);
+    return match ? parseInt(match[1], 10) * 60 + parseInt(match[2], 10) : null;
+}
+
+function minutesToTime(minutes) {
+    const value = Math.round(minutes);
+    return String(Math.floor(value / 60)).padStart(2, '0') + ':' + String(value % 60).padStart(2, '0');
+}
+
+// 从课表行标题(如“第一大节 08:00-09:40”)推导每一小节的上课时间。
+// 大节内的小节数由时长反推:n 个小节的时长 = n*45 + (n-1)*课间,
+// 大节内的剩余时间再均分为课间。这样即使某门课跨大节,也不会污染小节的节次编号。
+function parseTimeSlots(doc) {
+    const table = findTimetableTable(doc);
+    if (!table) return [];
+    const slots = [];
+    Array.from(table.querySelectorAll('tr')).forEach(function (row) {
+        const label = row.querySelector('th');
+        if (!label) return;
+        const labelText = String(label.textContent).replace(/\s+/g, ' ').trim();
+        const match = labelText.match(/第([一二三四五六七八九十]+)大节\s*(\d{1,2}:\d{2})\s*[-~—]\s*(\d{1,2}:\d{2})/);
+        if (!match) return;
+
+        const bigIndex = HUNAU_CN_NUMBER[match[1]];
+        const startMin = timeToMinutes(match[2]);
+        const endMin = timeToMinutes(match[3]);
+        if (!bigIndex || startMin === null || endMin === null || endMin <= startMin) return;
+
+        const span = endMin - startMin;
+        const unit = HUNAU_CLASS_DURATION + HUNAU_BREAK_DURATION;
+        const count = Math.max(1, Math.round((span + HUNAU_BREAK_DURATION) / unit));
+        const gap = count > 1 ? (span - count * HUNAU_CLASS_DURATION) / (count - 1) : 0;
+
+        for (let i = 0; i < count; i++) {
+            const start = startMin + i * (HUNAU_CLASS_DURATION + gap);
+            slots.push({
+                number: (bigIndex - 1) * count + i + 1,
+                startTime: minutesToTime(start),
+                endTime: minutesToTime(start + HUNAU_CLASS_DURATION)
+            });
+        }
+    });
+
+    // 去重并按节次排序;数据不足时回退到内置作息
+    const byNumber = {};
+    slots.forEach(function (slot) { if (!byNumber[slot.number]) byNumber[slot.number] = slot; });
+    const result = Object.keys(byNumber).map(function (key) { return byNumber[key]; })
+        .sort(function (a, b) { return a.number - b.number; });
+    return result.length >= 6 ? result : HUNAU_FALLBACK_TIME_SLOTS;
+}
+
+// ==================== 课表解析 ====================
+
+// 周次与节次:兼容 1-16(周)[01-02节]、1-8,10-16(周)、1-15周(单)、2-16(双) 等写法
+function parseWeeksAndSections(text) {
+    const result = { weeks: [], sections: [] };
+    const content = String(text || '').trim();
+    if (!content) return result;
+
+    const parity = /双/.test(content) ? 2 : (/单/.test(content) ? 1 : 0);
+    const bracketIndex = content.indexOf('[');
+    const cleaned = (bracketIndex >= 0 ? content.slice(0, bracketIndex) : content)
+        .replace(/第/g, '')
+        .replace(/至|到|~/g, '-')
+        .replace(/[((][^()()]*[))]/g, '')
+        .replace(/周/g, '');
+
+    cleaned.split(/[,,、;;\s]+/).forEach(function (segment) {
+        const item = segment.trim();
+        const range = item.match(/^(\d+)\s*[-–—]\s*(\d+)$/);
+        if (range) {
+            for (let week = parseInt(range[1], 10); week <= parseInt(range[2], 10); week++) result.weeks.push(week);
+        } else if (/^\d+$/.test(item)) {
+            result.weeks.push(parseInt(item, 10));
+        }
+    });
+
+    if (parity) {
+        result.weeks = result.weeks.filter(function (week) { return week % 2 === (parity === 1 ? 1 : 0); });
+    }
+    result.weeks = Array.from(new Set(result.weeks)).sort(function (a, b) { return a - b; });
+
+    const sectionMatch = content.match(/\[([^\]]*)\]/);
+    if (sectionMatch) {
+        (sectionMatch[1].match(/\d+/g) || []).forEach(function (number) {
+            const section = parseInt(number, 10);
+            if (result.sections.indexOf(section) < 0) result.sections.push(section);
+        });
+        result.sections.sort(function (a, b) { return a - b; });
+    }
+    return result;
+}
+
+// 教师姓名在教师字体里与职称连写(如“何龙姣讲师”“周小红其他”),且多人以逗号分隔,逐个去掉常见职称后缀
+function cleanTeacher(text) {
+    const raw = String(text || '').trim();
+    if (!raw) return '';
+    const titles = ['其他正高级', '其他副高级', '其他中级', '其他初级', '副教授', '教授', '副研究员', '研究员',
+        '高级实验师', '实验师', '高级工程师', '工程师', '高级技师', '技师', '讲师', '助教', '其他'];
+    const strip = function (name) {
+        const value = name.trim();
+        for (let i = 0; i < titles.length; i++) {
+            if (value.length > titles[i].length && value.slice(-titles[i].length) === titles[i]) {
+                return value.slice(0, value.length - titles[i].length).trim();
+            }
+        }
+        return value;
+    };
+    return raw.split(/[,,、;;\/]+/).map(strip).filter(function (name) { return name; }).join(',');
+}
+
+// 收集单个课表单元格内的全部课程候选。强智在格内同时输出简表与详表,
+// 简表缺少教师与节次方括号,此处一并解析,交由归并阶段择优。
+function collectCellCandidates(cellDiv, day, output, rowSections) {
+    const html = String(cellDiv.innerHTML || '').trim();
+    if (!html || html.replace(/&nbsp;/gi, '').trim() === '') return;
+
+    html.split(/[-—–]{6,}\s*(?:<br\s*\/?>)?/i).forEach(function (blockHtml) {
+        if (!blockHtml.trim()) return;
+
+        const holder = document.createElement('div');
+        holder.innerHTML = blockHtml;
+
+        let name = '';
+        for (let i = 0; i < holder.childNodes.length; i++) {
+            const node = holder.childNodes[i];
+            if (node.nodeType === 3 && /[\u4e00-\u9fa5]/.test(node.textContent)) {
+                name = node.textContent.trim();
+                break;
+            }
+        }
+        if (!name) name = String(holder.textContent || '').split('\n')[0].trim();
+        if (!name) return;
+
+        const pickText = function (titles) {
+            for (let i = 0; i < titles.length; i++) {
+                const element = holder.querySelector('font[title*="' + titles[i] + '"]');
+                if (element && element.textContent.trim()) return element.textContent.trim();
+            }
+            return '';
+        };
+
+        let timeText = pickText(['周次', '节次']);
+        if (!/\[[^\]]*\]/.test(timeText)) {
+            const bracket = String(holder.textContent || '').match(/\[[^\]]*\]/);
+            if (bracket) timeText += bracket[0];
+        }
+
+        const parsed = parseWeeksAndSections(timeText || holder.textContent);
+        if (parsed.weeks.length === 0) return;
+        if (parsed.sections.length === 0 && rowSections) {
+            for (let section = rowSections[0]; section <= rowSections[1]; section++) parsed.sections.push(section);
+        }
+
+        const teacher = cleanTeacher(pickText(['教师', '老师']));
+        const position = pickText(['教室', '地点']).replace(/\[\s*\d+\s*-\s*\d+\s*\]\s*节?$/, '').trim();
+
+        output.push({
+            name: name,
+            teacher: teacher,
+            position: position,
+            day: day,
+            weeks: parsed.weeks,
+            sections: parsed.sections,
+            score: (teacher ? 4 : 0) + (/\[[^\]]*\]/.test(timeText) ? 3 : 0) +
+                (position ? 1 : 0) + (parsed.sections.length > 1 ? 1 : 0)
+        });
+    });
+}
+
+// 按课程名、周次、教室归并候选,保留信息更完整的一条
+function mergeCellCandidates(candidates) {
+    const groups = {};
+    const order = [];
+    candidates.forEach(function (item) {
+        const key = item.name + '|' + item.weeks.join(',') + '|' + item.position;
+        if (groups[key] === undefined) {
+            groups[key] = item;
+            order.push(key);
+        } else if (item.score > groups[key].score) {
+            groups[key] = item;
+        }
+    });
+
+    const courses = [];
+    order.forEach(function (key) {
+        const item = groups[key];
+        if (item.sections.length === 0) return;
+        courses.push({
+            name: item.name,
+            teacher: item.teacher || '未知教师',
+            position: item.position || '未知地点',
+            day: item.day,
+            weeks: item.weeks,
+            startSection: item.sections[0],
+            endSection: item.sections[item.sections.length - 1]
+        });
+    });
+    return courses;
+}
+
+function parseTimetable(doc) {
+    const table = findTimetableTable(doc);
+    if (!table) return [];
+
+    const courses = [];
+    let bigIndex = 0;
+    Array.from(table.querySelectorAll('tr')).forEach(function (row) {
+        const label = row.querySelector('th');
+        const labelText = label ? String(label.textContent).replace(/\s+/g, ' ').trim() : '';
+        if (/^第[一二三四五六七八九十]+大节/.test(labelText)) bigIndex++;
+        const rowSections = bigIndex > 0 ? [2 * bigIndex - 1, 2 * bigIndex] : null;
+
+        Array.from(row.querySelectorAll('td')).forEach(function (cell, index) {
+            const blocks = cell.querySelectorAll('div.kbcontent, div.kbcontent1');
+            if (blocks.length === 0) return;
+
+            const candidates = [];
+            Array.from(blocks).forEach(function (block) {
+                collectCellCandidates(block, index + 1, candidates, rowSections);
+            });
+            mergeCellCandidates(candidates).forEach(function (course) {
+                courses.push(course);
+            });
+        });
+    });
+    return courses;
+}
+
+// ==================== 教学周历 ====================
+
+// 教学周历:首列为周次序号,第二列 title 为该周星期一日期(如 2026年08月31)
+function parseCalendar(doc) {
+    const result = { semesterStartDate: null, totalWeeks: null };
+    const table = doc.querySelector('table');
+    if (!table) return result;
+
+    const toDate = function (text) {
+        const match = String(text || '').match(/(\d{4})\s*[-年]\s*(\d{1,2})\s*[-月]\s*(\d{1,2})/);
+        return match ? match[1] + '-' + match[2].padStart(2, '0') + '-' + match[3].padStart(2, '0') : null;
+    };
+
+    Array.from(table.querySelectorAll('tr')).forEach(function (row) {
+        const cells = Array.from(row.children);
+        const first = cells.length > 0 ? String(cells[0].textContent).trim() : '';
+        if (!/^\d+$/.test(first)) return;
+
+        const week = parseInt(first, 10);
+        result.totalWeeks = result.totalWeeks === null ? week : Math.max(result.totalWeeks, week);
+        if (week === 1 && !result.semesterStartDate && cells.length > 1) {
+            result.semesterStartDate = toDate(cells[1].getAttribute('title')) || toDate(cells[1].textContent);
+        }
+    });
+    return result;
+}
+
+async function fetchCalendar(semesterValue) {
+    try {
+        const text = await requestText(jsxsdUrl(HUNAU_CALENDAR_PATH), Object.assign(
+            { method: 'POST', body: 'xnxq01id=' + encodeURIComponent(semesterValue) }, HUNAU_POST_OPTIONS));
+        return parseCalendar(new DOMParser().parseFromString(text, 'text/html'));
+    } catch (error) {
+        console.warn('[HUNAU] 教学周历读取失败:', error);
+        return { semesterStartDate: null, totalWeeks: null };
+    }
+}
+
+// ==================== 合并去重 ====================
+
+// 节次与周次合并去重函数
+// 来源:官方 Wiki《课程合并与去重函数》
+// https://github.com/XingHeYuZhuan/shiguangschedule/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) : []
+    }));
+
+    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);
+
+    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 merged = step1Merged[0];
+
+    for (let i = 1; i < step1Merged.length; i++) {
+        const next = step1Merged[i];
+
+        const isSameCourseAndSection =
+            merged.name === next.name &&
+            merged.teacher === next.teacher &&
+            merged.position === next.position &&
+            merged.day === next.day &&
+            merged.startSection === next.startSection &&
+            merged.endSection === next.endSection;
+
+        if (isSameCourseAndSection) {
+            merged.weeks = Array.from(new Set([...merged.weeks, ...next.weeks])).sort((a, b) => a - b);
+        } else {
+            step2Merged.push(merged);
+            merged = next;
+        }
+    }
+    step2Merged.push(merged);
+
+    return step2Merged;
+}
+
+// ==================== 流程编排 ====================
+
+async function runImportFlow() {
+    try {
+        if ((window.location.pathname || '').indexOf('/jsxsd/') < 0) {
+            await alertUser('请先进入教务系统', '请登录教务系统并停留在任意教务功能页面后重新执行导入。');
+            return;
+        }
+
+        toast('正在获取学期信息…');
+        const listDoc = new DOMParser().parseFromString(await requestText(jsxsdUrl(HUNAU_KB_PATH)), 'text/html');
+        const semesters = parseSemesters(listDoc);
+        if (semesters.length === 0) {
+            await alertUser('未获取到学期列表', '课表页返回异常,请确认登录状态有效后重试。');
+            return;
+        }
+
+        let defaultIndex = 0;
+        semesters.forEach(function (item, index) { if (item.selected) defaultIndex = index; });
+        const picked = semesters.length > 1
+            ? await selectFromList('选择学期', semesters.map(function (item) { return item.label; }), defaultIndex)
+            : defaultIndex;
+        if (picked < 0 || picked >= semesters.length) {
+            toast('导入已取消');
+            return;
+        }
+        const semester = semesters[picked];
+
+        toast('正在获取 ' + semester.label + ' 课表…');
+        const tableDoc = await fetchTimetableDoc(semester.value);
+        if (!tableDoc) {
+            await alertUser('未获取到课表数据', '请确认已登录教务系统后重试。');
+            return;
+        }
+
+        const courses = mergeAndDistinctCourses(parseTimetable(tableDoc));
+        if (courses.length === 0) {
+            await alertUser('未解析到课程', semester.label + ' 页面中没有课程内容,该学期可能尚未发布课表。');
+            return;
+        }
+
+        const calendar = await fetchCalendar(semester.value);
+        const weeks = courses.reduce(function (all, course) { return all.concat(course.weeks); }, []);
+        const config = {
+            semesterTotalWeeks: Math.max(HUNAU_DEFAULT_TOTAL_WEEKS, Math.max.apply(null, weeks)),
+            defaultClassDuration: HUNAU_CLASS_DURATION,
+            defaultBreakDuration: HUNAU_BREAK_DURATION
+        };
+        if (calendar.semesterStartDate) {
+            config.semesterStartDate = calendar.semesterStartDate;
+        } else {
+            toast('未读取到教学周历,开学日期可在软件内手动校准');
+        }
+
+        // 课表配置与节次时间属于可选数据,失败不影响课程导入
+        try {
+            await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
+        } catch (error) {
+            console.warn('[HUNAU] 课表配置保存失败:', error);
+        }
+        try {
+            await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(parseTimeSlots(tableDoc)));
+        } catch (error) {
+            console.warn('[HUNAU] 节次时间保存失败:', error);
+        }
+
+        if (!await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses))) {
+            toast('课程保存失败,请重试');
+            return;
+        }
+
+        toast('导入成功:' + semester.label + ' 共 ' + courses.length + ' 条课程时段');
+        if (window.shiguangBridge && typeof window.shiguangBridge.notifyTaskCompletion === 'function') {
+            window.shiguangBridge.notifyTaskCompletion();
+        }
+    } catch (error) {
+        console.error('[HUNAU] 导入流程异常:', error);
+        await alertUser('导入失败', error && error.message ? error.message : String(error));
+    }
+}
+
+// 启动导入流程
+runImportFlow();

+ 9 - 0
resources/SCETOP/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/SCETOP/adapters.yaml
+adapters:
+  - adapter_id: "SCETOP_01"
+    adapter_name: "四川托普信息技术职业学院-正方教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "scetop_zf_legacy.js"
+    import_url: "http://zf.scetop.com/default2.aspx"
+    maintainer: "psenY"
+    description: "四川托普信息技术职业学院教务系统课表导入。登录后在「信息查询」里打开「学生个人课表」或「班级课表查询」,显示出周课表即可导入。导入会同时看三个页面:必修类的课以班级课表为准(班级课表调课会及时更新),选修课以个人课表为准;班级课表里没有的课会从个人课表补上,不会漏课。上课时间按学校作息自动设置(A 教学区以外的第 3、4 节和周五下午会单独调整)。没有固定上课时间的实践、实训类课程不会导入,可以手动添加。"

+ 784 - 0
resources/SCETOP/scetop_zf_legacy.js

@@ -0,0 +1,784 @@
+// 四川托普信息技术职业学院 · 老版正方教务课表导入
+//
+// 一次导入会读取三个页面并合并:
+//   1. 班级课表查询 tjkbcx.aspx(必修类以此为准;格子格式「1-14(1,2)」)
+//   2. 学生个人课表 xskbcx.aspx(选修课以此为准;格子格式「周一第1,2节{第1-17周}」)
+//   3. 学生选课情况查询 xsxkqk.aspx(只用来读「课程性质」,区分选修/必修)
+// 合并规则:选修课以个人课表为准;必修类只要班级课表里有就以班级课表为准
+// (班级课表调课会及时更新,个人课表是学期初的快照),班级课表完全没有的必修课
+// 才用个人课表兜底。
+// 从任意一个课表页点导入都可以,脚本会自己去读另一个页面。
+//
+// 作息取自学校作息时间表:A 教学区的第 3/4 节与其它教学区不同,周五下午整体提前半小时。
+// 没有固定上课时间的课程(实践、实训类)不导入,只在提示里告知数量。
+
+(function () {
+  'use strict';
+
+  var DAY_BY_CHAR = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7, '天': 7 };
+
+  // ---------------- 文本处理 ----------------
+
+  function decodeEntities(s) {
+    return String(s)
+      .replace(/&nbsp;/gi, ' ')
+      .replace(/&amp;/gi, '&')
+      .replace(/&lt;/gi, '<')
+      .replace(/&gt;/gi, '>')
+      .replace(/&#39;/g, "'")
+      .replace(/&quot;/gi, '"')
+      .replace(/&#(\d+);/g, function (_, n) { return String.fromCodePoint(Number(n)); });
+  }
+
+  function cellText(html) {
+    return decodeEntities(String(html).replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, ''));
+  }
+
+  function normalize(raw) {
+    return String(raw)
+      .replace(/[((]/g, '(').replace(/[))]/g, ')')
+      .replace(/[,、;;]/g, ',')
+      .replace(/[-–—]/g, '-')
+      .replace(/[0-9]/g, function (d) { return String.fromCharCode(d.charCodeAt(0) - 65248); })
+      .replace(/\s+/g, '');
+  }
+
+  // ---------------- 作息时间(学校作息表) ----------------
+
+  var TIME_SLOTS_DEFAULT = [
+    { number: 1, startTime: '08:30', endTime: '09:15' },
+    { number: 2, startTime: '09:20', endTime: '10:05' },
+    { number: 3, startTime: '10:20', endTime: '11:05' },
+    { number: 4, startTime: '11:10', endTime: '11:55' },
+    { number: 5, startTime: '14:00', endTime: '14:45' },
+    { number: 6, startTime: '14:50', endTime: '15:35' },
+    { number: 7, startTime: '15:50', endTime: '16:35' },
+    { number: 8, startTime: '16:40', endTime: '17:25' },
+    { number: 9, startTime: '18:30', endTime: '19:15' },
+    { number: 10, startTime: '19:20', endTime: '20:05' }
+  ];
+
+  // A 教学区以外的第 3/4 节晚 20 分钟
+  var TIME_SLOTS_OUTSIDE_A = TIME_SLOTS_DEFAULT.map(function (slot) {
+    if (slot.number === 3) return { number: 3, startTime: '10:40', endTime: '11:25' };
+    if (slot.number === 4) return { number: 4, startTime: '11:30', endTime: '12:15' };
+    return slot;
+  });
+
+  // 周五下午整体提前半小时(学校作息表只对第 5-8 节做了说明)
+  var TIME_SLOTS_FRIDAY_PM = {
+    5: { startTime: '13:30', endTime: '14:15' },
+    6: { startTime: '14:20', endTime: '15:05' },
+    7: { startTime: '15:20', endTime: '16:05' },
+    8: { startTime: '16:10', endTime: '16:55' }
+  };
+
+  function slotOf(table, section) {
+    return table[section - 1] || null;
+  }
+
+  function isAreaA(position) {
+    var p = String(position || '').trim();
+    if (!p) return true;                       // 地点缺失时按默认作息处理
+    return /^A\d/i.test(p) || /^A[-区]/i.test(p);
+  }
+
+  // 周五只在这门课整段都落在第 5-8 节时才提前半小时;
+  // 跨到晚上(第 9、10 节)的课整体按默认作息处理,避免拼出不存在的时段。
+  function useFridayAfternoon(course) {
+    return (
+      course.day === 5 &&
+      course.startSection >= 5 &&
+      course.endSection <= 8 &&
+      Boolean(TIME_SLOTS_FRIDAY_PM[course.startSection]) &&
+      Boolean(TIME_SLOTS_FRIDAY_PM[course.endSection])
+    );
+  }
+
+  // ---------------- 周次 / 节次解析 ----------------
+
+  function parseWeekList(str) {
+    var weeks = [];
+    str.split(',').forEach(function (seg) {
+      var m = seg.match(/^(\d+)(?:[-~](\d+))?(单|双)?$/);
+      if (!m) return;
+      var from = parseInt(m[1], 10);
+      var to = m[2] ? parseInt(m[2], 10) : from;
+      var flag = m[3] || '';
+      if (!(from > 0) || to < from) return;
+      for (var w = from; w <= to; w++) {
+        if (flag === '单' && w % 2 === 0) continue;
+        if (flag === '双' && w % 2 === 1) continue;
+        if (weeks.indexOf(w) === -1) weeks.push(w);
+      }
+    });
+    return weeks.sort(function (a, b) { return a - b; });
+  }
+
+  function parseSectionList(str) {
+    var out = [];
+    str.split(',').forEach(function (seg) {
+      var m = seg.match(/^(\d+)(?:[-~](\d+))?$/);
+      if (!m) return;
+      var from = parseInt(m[1], 10);
+      var to = m[2] ? parseInt(m[2], 10) : from;
+      if (!(from > 0) || to < from) return;
+      for (var s = from; s <= to; s++) if (out.indexOf(s) === -1) out.push(s);
+    });
+    return out.sort(function (a, b) { return a - b; });
+  }
+
+  /**
+   * 解析课程格里的「周次(节次)」那一行,兼容两个页面各自的格式:
+   *   学生个人课表:周一第1,2节{第1-17周} / 周二第1,2节{第2-16周|双周}
+   *   班级课表:    1-14(1,2) / 2-14双(1,2) / 18(1,2) / 1-8,10-16(1,2)
+   * day 为 0 表示这一行没写星期几,星期由所在列决定。
+   */
+  function parseInfoLine(raw) {
+    var text = normalize(raw);
+
+    var student = text.match(/^周([一二三四五六日天])第([\d,\-~]+)节\{第([\d,\-~]+)周(?:\|(单|双)周?)?\}$/);
+    if (student) {
+      var weeks = parseWeekList(student[3] + (student[4] || ''));
+      var sections = parseSectionList(student[2]);
+      if (weeks.length && sections.length) {
+        return { day: DAY_BY_CHAR[student[1]], weeks: weeks, sections: sections };
+      }
+      return null;
+    }
+
+    var classTable = text.match(/^([\d,\-~单双]+)\(([\d,\-~]+)\)$/);
+    if (classTable) {
+      var weeks2 = parseWeekList(classTable[1]);
+      var sections2 = parseSectionList(classTable[2]);
+      if (weeks2.length && sections2.length) {
+        return { day: 0, weeks: weeks2, sections: sections2 };
+      }
+      return null;
+    }
+
+    return null;
+  }
+
+  /**
+   * 一个格子里可能排了多门课,用空行分隔(班级课表里用 <br><br><br> 分隔)。
+   * 以「周次(节次)」那一行为锚点:上一行是课程名,下面两行是教师和地点。
+   */
+  function parseCellBlocks(text, stats) {
+    var lines = String(text).split('\n')
+      .map(function (line) { return line.replace(/\s+/g, ' ').trim(); })
+      .filter(Boolean);
+
+    var blocks = [];
+    for (var i = 0; i < lines.length; i++) {
+      var info = parseInfoLine(lines[i]);
+      if (!info) continue;
+      var name = lines[i - 1] || '';
+      if (!name || parseInfoLine(name)) continue;
+      var teacher = lines[i + 1] || '';
+      var position = lines[i + 2] || '';
+      if (parseInfoLine(teacher)) teacher = '';
+      if (parseInfoLine(position)) position = '';
+      if (!teacher || !position) stats.incomplete++;
+      blocks.push({
+        name: name,
+        teacher: teacher,
+        position: position,
+        day: info.day,
+        weeks: info.weeks,
+        sections: info.sections
+      });
+    }
+
+    if (!blocks.length && lines.length) {
+      stats.unrecognized.push(lines.join(' / ').slice(0, 80));
+    }
+    return blocks;
+  }
+
+  // ---------------- 表格解析(对任意 Document 生效) ----------------
+
+  /**
+   * 把表格展开成网格,处理 rowspan / colspan,拿到每个格子的真实列号。
+   * 必须用 table.rows / row.cells:用 querySelectorAll('td') 会把嵌套表格里的
+   * 格子也算进来(页面里确实存在外层表格套课表的写法),导致列号整体错位。
+   */
+  function buildGrid(table) {
+    var rows = [].slice.call(table.rows || table.querySelectorAll('tr'));
+    var grid = [];
+    var occupied = [];
+
+    for (var r = 0; r < rows.length; r++) {
+      grid[r] = grid[r] || [];
+      occupied[r] = occupied[r] || [];
+      var cells = [].slice.call(rows[r].cells || rows[r].querySelectorAll('td,th'));
+      var column = 0;
+
+      for (var i = 0; i < cells.length; i++) {
+        while (occupied[r][column]) column++;
+        var cell = cells[i];
+        var colSpan = parseInt(cell.getAttribute('colspan') || '1', 10) || 1;
+        var rowSpan = parseInt(cell.getAttribute('rowspan') || '1', 10) || 1;
+
+        for (var rr = r; rr < r + rowSpan; rr++) {
+          occupied[rr] = occupied[rr] || [];
+          for (var cc = column; cc < column + colSpan; cc++) {
+            occupied[rr][cc] = true;
+            if (rr === r && cc === column) grid[rr][cc] = cell;   // 合并格只认左上角
+          }
+        }
+        column += colSpan;
+      }
+    }
+    return grid;
+  }
+
+  /**
+   * 找到写有「星期一…星期日」的表头行,并建立 列号 -> 星期 的对应关系。
+   * 表头不一定在第一行(上面可能有标题行),所以往下找几行。
+   */
+  function findHeader(grid) {
+    for (var r = 0; r < Math.min(grid.length, 6); r++) {
+      var dayByColumn = {};
+      for (var c = 0; c < (grid[r] || []).length; c++) {
+        var cell = grid[r][c];
+        if (!cell) continue;
+        var text = cellText(cell.innerHTML).replace(/\s+/g, '');
+        var m = text.match(/^星期([一二三四五六日天])$/);
+        if (m) dayByColumn[c] = DAY_BY_CHAR[m[1]];
+      }
+      if (Object.keys(dayByColumn).length >= 5) return { row: r, dayByColumn: dayByColumn };
+    }
+    return null;
+  }
+
+  /** 在指定文档里依次尝试候选表格,取第一个能找到周课表的 */
+  function findCourseTable(doc) {
+    var candidates = [];
+    ['Table6', 'Table1'].forEach(function (id) {
+      var el = doc.getElementById(id);
+      if (el && el.tagName && el.tagName.toLowerCase() === 'table') candidates.push(el);
+    });
+    [].slice.call(doc.querySelectorAll('table.blacktab')).forEach(function (t) {
+      if (candidates.indexOf(t) === -1) candidates.push(t);
+    });
+
+    for (var i = 0; i < candidates.length; i++) {
+      var grid = buildGrid(candidates[i]);
+      var header = findHeader(grid);
+      if (header) return { grid: grid, header: header };
+    }
+
+    var tables = [].slice.call(doc.querySelectorAll('table'));
+    for (var j = 0; j < tables.length; j++) {
+      if (candidates.indexOf(tables[j]) !== -1) continue;
+      var g = buildGrid(tables[j]);
+      var h = findHeader(g);
+      if (h) return { grid: g, header: h };
+    }
+    return null;
+  }
+
+  /** 统计「实践课(或无上课时间)」「未安排上课时间的课程」有多少条,只用于提示 */
+  function countCoursesWithoutTime(doc) {
+    var count = 0;
+    ['DataGrid1', 'Datagrid2'].forEach(function (id) {
+      var table = doc.getElementById(id);
+      if (!table || !table.rows) return;
+      for (var r = 1; r < table.rows.length; r++) {
+        if (String(table.rows[r].textContent || '').trim()) count++;
+      }
+    });
+    return count;
+  }
+
+  // ---------------- 课程性质(选修/必修)----------------
+  // 课表页里没有「课程性质」,要去「学生选课情况查询」页取。
+  var COURSE_TYPE_PATH = 'xsxkqk.aspx?gnmkdm=N121615';
+
+  function normalizeCourseName(name) {
+    return String(name || '').replace(/\s+/g, '').replace(/(/g, '(').replace(/)/g, ')');
+  }
+
+  function isElectiveType(type) {
+    return /选修/.test(String(type || ''));
+  }
+
+  function findCourseTypeTable(doc) {
+    var tables = [].slice.call(doc.querySelectorAll('table'));
+    for (var i = 0; i < tables.length; i++) {
+      var head = tables[i].rows && tables[i].rows[0];
+      if (head && /课程性质/.test(String(head.textContent || ''))) return tables[i];
+    }
+    return null;
+  }
+
+  /** 从「学生选课情况查询」页读出 课程名称 -> 课程性质 */
+  function parseCourseTypes(doc) {
+    var map = {};
+    var table = findCourseTypeTable(doc);
+    if (!table || !table.rows) return map;
+    for (var r = 0; r < table.rows.length; r++) {
+      var cells = table.rows[r].cells;
+      if (!cells || cells.length < 3) continue;
+      var name = String(cells[1].textContent || '').trim();
+      var type = String(cells[2].textContent || '').trim();
+      if (!name || !type || name === '课程名称') continue;
+      map[normalizeCourseName(name)] = type;
+    }
+    return map;
+  }
+
+  /** 读取「学生选课情况查询」页;学期与当前页不一致时按它自己的表单回发一次 */
+  async function loadCourseTypes(studentId, liveTerm) {
+    var url = String(window.location.origin || '') + '/' + COURSE_TYPE_PATH + '&xh=' + encodeURIComponent(studentId);
+    var doc = await fetchDocument(url);
+
+    var yearSelect = doc.querySelector('select[name="ddlXN"]');
+    var termSelect = doc.querySelector('select[name="ddlXQ"]');
+    if (liveTerm && yearSelect && termSelect &&
+        (yearSelect.value !== liveTerm.year || termSelect.value !== liveTerm.term)) {
+      var rerendered = await repostWithTerm(doc, url, {
+        yearField: yearSelect.name, year: liveTerm.year,
+        termField: termSelect.name, term: liveTerm.term
+      });
+      if (rerendered) doc = rerendered;
+    }
+    return parseCourseTypes(doc);
+  }
+
+  function collectCourses(doc, stats) {
+    var found = findCourseTable(doc);
+    if (!found) return [];
+    var grid = found.grid;
+    var courses = [];
+
+    for (var r = found.header.row + 1; r < grid.length; r++) {
+      for (var c = 0; c < (grid[r] || []).length; c++) {
+        var columnDay = found.header.dayByColumn[c];
+        var cell = grid[r][c];
+        if (!columnDay || !cell) continue;
+
+        parseCellBlocks(cellText(cell.innerHTML), stats).forEach(function (block) {
+          // 学生个人课表的周次行自带「周一…」,以它为准(合并格会让后面的行少几个格子);
+          // 班级课表没有写星期几,用表头列对应。
+          if (block.day && block.day !== columnDay) stats.dayMismatch++;
+          courses.push({
+            name: block.name,
+            teacher: block.teacher,
+            position: block.position,
+            day: block.day || columnDay,
+            weeks: block.weeks,
+            startSection: block.sections[0],
+            endSection: block.sections[block.sections.length - 1]
+          });
+        });
+      }
+    }
+
+    // 同一门课、同一天、同节次、同地点、同周次只保留一条
+    var seen = {};
+    return courses.filter(function (course) {
+      var key = [
+        course.name, course.day, course.teacher, course.position,
+        course.startSection, course.endSection, course.weeks.join(',')
+      ].join('|');
+      if (seen[key]) return false;
+      seen[key] = true;
+      return true;
+    });
+  }
+
+  /** 把与默认作息不同的课写成自定义时间,避免整份作息被搞乱 */
+  function applyCustomTimes(courses, stats) {
+    return courses.map(function (course) {
+      var table = isAreaA(course.position) ? TIME_SLOTS_DEFAULT : TIME_SLOTS_OUTSIDE_A;
+      var startSlot = slotOf(table, course.startSection);
+      var endSlot = slotOf(table, course.endSection);
+      var defaultStart = slotOf(TIME_SLOTS_DEFAULT, course.startSection);
+      var defaultEnd = slotOf(TIME_SLOTS_DEFAULT, course.endSection);
+
+      if (!startSlot || !endSlot || !defaultStart || !defaultEnd) {
+        stats.sectionOverflow++;
+        return course;
+      }
+
+      if (useFridayAfternoon(course)) {
+        var fridayStart = TIME_SLOTS_FRIDAY_PM[course.startSection];
+        var fridayEnd = TIME_SLOTS_FRIDAY_PM[course.endSection];
+        return Object.assign({}, course, {
+          isCustomTime: true,
+          customStartTime: fridayStart.startTime,
+          customEndTime: fridayEnd.endTime
+        });
+      }
+
+      if (startSlot.startTime === defaultStart.startTime && endSlot.endTime === defaultEnd.endTime) {
+        return course;    // 与默认作息一致,不用单独设置
+      }
+
+      return Object.assign({}, course, {
+        isCustomTime: true,
+        customStartTime: startSlot.startTime,
+        customEndTime: endSlot.endTime
+      });
+    });
+  }
+
+  // ---------------- 两个课表页的取数 ----------------
+
+  function pickStudentId(text) {
+    var m = String(text || '').match(/[?&]xh=([0-9A-Za-z]+)/);
+    return m ? m[1] : '';
+  }
+
+  /**
+   * 学号可能出现在:当前地址、页面文字(「学号:xxx」)、左侧菜单里的课表链接。
+   * 老正方的课表是框架页,脚本有可能跑在顶层框架上,那里没有课表也没有学号文字,
+   * 所以同源的其它框架也要扫一遍。
+   */
+  function getStudentId() {
+    var found = pickStudentId(window.location.search) || pickStudentId(window.location.href);
+    if (found) return found;
+
+    var windows = [window];
+    try { if (window.top && window.top !== window) windows.push(window.top); } catch (e) {}
+    try { if (window.parent && window.parent !== window) windows.push(window.parent); } catch (e) {}
+
+    for (var i = 0; i < windows.length && !found; i++) {
+      var doc = null;
+      try { doc = windows[i].document; } catch (e) { doc = null; }
+      if (!doc) continue;
+
+      var text = doc.body ? String(doc.body.textContent || '') : '';
+      var m = text.match(/学号[::]\s*([0-9A-Za-z]+)/);
+      if (m) { found = m[1]; break; }
+
+      var links = doc.querySelectorAll('a[href*="xh="]');
+      for (var j = 0; j < links.length; j++) {
+        found = pickStudentId(links[j].getAttribute('href') || '');
+        if (found) break;
+      }
+    }
+    return found || '';
+  }
+
+  /** 从页面里读出当前选中的学年 / 学期(两个页面的控件名分别是 xnd/xqd 与 xn/xq) */
+  function readTerm(doc) {
+    var yearSelect = doc.querySelector('select[name="xnd"], select[name="xn"]');
+    var termSelect = doc.querySelector('select[name="xqd"], select[name="xq"]');
+    if (!yearSelect || !termSelect) return null;
+    return {
+      yearField: yearSelect.name, year: yearSelect.value,
+      termField: termSelect.name, term: termSelect.value
+    };
+  }
+
+  /**
+   * 教务页面是 GB2312,而 response.text() 在部分 WebView 里一律按 UTF-8 解码,
+   * 中文会变成乱码、一条课程都解析不出来(表现为 HTTP 200、字节数正常、读到 0 门)。
+   * 所以自己拿字节解码:两种解都试,取含中文关键词多的那个。
+   */
+  function decodeBytes(buffer) {
+    function tryDecode(label) {
+      try { return new TextDecoder(label).decode(buffer); } catch (e) { return ''; }
+    }
+    function score(text) {
+      if (!text) return -1;
+      var hits = 0;
+      ['星期', '课程', '节', '周', '教师', '教室', '学号'].forEach(function (kw) {
+        var at = text.indexOf(kw);
+        while (at !== -1 && hits < 60) { hits++; at = text.indexOf(kw, at + 1); }
+      });
+      return hits - (text.split('\uFFFD').length - 1) * 3;   // 乱码字符要扣分
+    }
+    var gbk = tryDecode('gbk');
+    var utf8 = tryDecode('utf-8');
+    return score(gbk) >= score(utf8) ? gbk : utf8;
+  }
+
+  async function readResponseText(response) {
+    try {
+      var buffer = await response.arrayBuffer();
+      return decodeBytes(buffer);
+    } catch (error) {
+      return await response.text();     // 老 WebView 不支持 arrayBuffer 时退回原样
+    }
+  }
+
+  async function fetchDocument(url) {
+    var response = await fetch(url, { credentials: 'include' });
+    var html = await readResponseText(response);
+    var doc = new DOMParser().parseFromString(html, 'text/html');
+    doc.__fetchInfo = {
+      status: (response && response.status) || 0,
+      length: html.length,
+      title: String(doc.title || '').slice(0, 24)
+    };
+    if (response && response.ok === false) throw new Error('HTTP ' + response.status);
+    return doc;
+  }
+
+  /** 页面上的学年/学期是下拉框,改了要回发一次才会重排;这里按页面自己的表单字段回发 */
+  async function repostWithTerm(doc, url, term) {
+    var form = doc.querySelector('form');
+    if (!form) return null;
+
+    var pairs = [];
+    [].slice.call(form.querySelectorAll('input[type="hidden"]')).forEach(function (input) {
+      if (input.name) pairs.push([input.name, input.value || '']);
+    });
+    [].slice.call(form.querySelectorAll('select[name]')).forEach(function (select) {
+      pairs.push([select.name, select.value || '']);
+    });
+
+    function setPair(key, value) {
+      for (var i = 0; i < pairs.length; i++) {
+        if (pairs[i][0] === key) { pairs[i][1] = value; return; }
+      }
+      pairs.push([key, value]);
+    }
+    setPair(term.yearField, term.year);
+    setPair(term.termField, term.term);
+    setPair('__EVENTTARGET', term.yearField);
+    setPair('__EVENTARGUMENT', '');
+
+    var body = pairs.map(function (pair) {
+      return encodeURIComponent(pair[0]) + '=' + encodeURIComponent(pair[1]);
+    }).join('&');
+
+    var response = await fetch(url, {
+      method: 'POST',
+      credentials: 'include',
+      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+      body: body
+    });
+    if (response && response.ok === false) throw new Error('HTTP ' + response.status);
+    return new DOMParser().parseFromString(await readResponseText(response), 'text/html');
+  }
+
+  /** 读取一个课表页:先直接取;当前页选的学期和它不一致时再回发一次 */
+  async function loadTimetable(kind, studentId, liveTerm) {
+    var path = (kind === 'student' ? 'xskbcx.aspx' : 'tjkbcx.aspx') + '?xh=' + encodeURIComponent(studentId);
+    var url = String(window.location.origin || '') + '/' + path;
+    var doc = await fetchDocument(url);
+
+    var pageTerm = readTerm(doc);
+    if (liveTerm && pageTerm && (pageTerm.year !== liveTerm.year || pageTerm.term !== liveTerm.term)) {
+      var rerendered = await repostWithTerm(doc, url, {
+        yearField: pageTerm.yearField, year: liveTerm.year,
+        termField: pageTerm.termField, term: liveTerm.term
+      });
+      if (rerendered) doc = rerendered;
+    }
+    return doc;
+  }
+
+  /**
+   * 合并规则(2026-09-29 用户拍板):
+   *   选修课 → 以个人课表为准(班级课表里属于选修课的条目不采用)
+   *   必修类 → 只要班级课表里有这门课就以班级课表为准;只有班级课表完全没有这门课时,
+   *            才用个人课表的版本兜底
+   * 原因:班级课表调课会及时更新;个人课表是学期初的快照,调课之后不会更新。
+   */
+  function mergeCourses(personal, classCourses, stats, typeMap) {
+    function typeOf(course) { return typeMap ? (typeMap[normalizeCourseName(course.name)] || '') : ''; }
+    function elective(course) { return isElectiveType(typeOf(course)); }
+
+    var classNames = {};
+    classCourses.forEach(function (course) { classNames[normalizeCourseName(course.name)] = true; });
+
+    var result = [];
+
+    classCourses.forEach(function (course) {
+      if (elective(course)) { stats.classElectiveSkipped++; return; }
+      result.push(course);
+    });
+    stats.requiredFromClass = result.length;
+
+    personal.forEach(function (course) {
+      var key = normalizeCourseName(course.name);
+      if (elective(course)) { result.push(course); stats.electiveFromPersonal++; return; }
+      if (classNames[key]) { stats.requiredSkippedPersonal++; return; }
+      result.push(course);
+      stats.requiredFromPersonal++;
+    });
+
+    return result;
+  }
+
+  // ---------------- 主流程 ----------------
+
+  async function runImportFlow() {
+    var confirmed = await window.shiguangBridgePromise.showAlert(
+      '四川托普课表导入',
+      '请先登录教务系统,打开「学生个人课表」或「班级课表查询」中的任意一个,' +
+        '等页面上显示出周课表(能看到「星期一」到「星期日」)再点下面的按钮。\n\n' +
+        '导入会自动读取两个课表页:学生个人课表优先,班级课表只用来补齐个人课表里没有的课程;' +
+        '开学初期个人课表还没排出来时,就完全按班级课表导入。\n\n' +
+        '导入只读取课表,不会修改教务系统里的任何数据。',
+      '开始导入'
+    );
+    if (!confirmed) {
+      window.shiguangBridge.showToast('已取消导入。');
+      return;
+    }
+
+    var studentId = getStudentId();
+    if (!studentId) {
+      await window.shiguangBridgePromise.showAlert(
+        '导入失败',
+        '没有识别出学号。\n请从教务系统里的「学生个人课表」或「班级课表查询」页面点导入。\n\n' +
+          '(诊断:当前地址 ' + String(window.location.href || '').slice(0, 110) + ')',
+        '确定'
+      );
+      return;
+    }
+
+    var liveTerm = readTerm(document);
+    var stats = {
+      unrecognized: [], incomplete: 0, dayMismatch: 0, sectionOverflow: 0,
+      requiredFromClass: 0, electiveFromPersonal: 0, requiredFromPersonal: 0,
+      classElectiveSkipped: 0, requiredSkippedPersonal: 0,
+      typeMap: {}, typeLoaded: false, failedPages: []
+    };
+
+    var pageInfo = [];
+    var loadOne = async function (kind, label) {
+      try {
+        var doc = await loadTimetable(kind, studentId, liveTerm);
+        var courses = collectCourses(doc, stats);
+        var info = doc.__fetchInfo || {};
+        pageInfo.push(label + ':HTTP ' + info.status + ',' +
+          Math.round((info.length || 0) / 1024) + ' KB,读到 ' + courses.length + ' 门');
+        return courses;
+      } catch (error) {
+        stats.failedPages.push(label);
+        pageInfo.push(label + ':读取失败(' + (error && error.message ? error.message : error) + ')');
+        console.warn('[四川托普] ' + label + '读取失败:', error);
+        return [];
+      }
+    };
+
+    var personal = await loadOne('student', '学生个人课表');
+    var classCourses = await loadOne('class', '班级课表');
+
+    // 课程性质(选修/必修):课表页没有,单独去「学生选课情况查询」页取
+    try {
+      stats.typeMap = await loadCourseTypes(studentId, liveTerm);
+      stats.typeLoaded = Object.keys(stats.typeMap).length > 0;
+      pageInfo.push('课程性质:读到 ' + Object.keys(stats.typeMap).length + ' 门');
+    } catch (error) {
+      stats.typeMap = {};
+      console.warn('[四川托普] 课程性质读取失败(不影响导入,只影响选修课隔离):', error);
+      pageInfo.push('课程性质:读取失败');
+    }
+
+    var courses = applyCustomTimes(mergeCourses(personal, classCourses, stats, stats.typeMap));
+
+    if (!courses.length) {
+      window.shiguangBridge.showToast('两个课表页都还没有排课。');
+      var emptyLines = ['学生个人课表和班级课表都没有读到课程。', ''];
+      pageInfo.forEach(function (line) { emptyLines.push(line); });
+      emptyLines.push('');
+      emptyLines.push('请确认教务系统里选的是本学期的学年和学期;开学初期课表可能还没排出来,过几天再试。');
+      await window.shiguangBridgePromise.showAlert('没有读到课程', emptyLines.join('\n'), '确定');
+      return;
+    }
+
+    // 先写作息与课表设置,最后写课程:前面失败时不会白覆盖已有课表
+    var timeSlotsSaved = true;
+    try {
+      timeSlotsSaved = (await window.shiguangBridgePromise.savePresetTimeSlots(
+        JSON.stringify(TIME_SLOTS_DEFAULT)
+      )) === true;
+    } catch (error) {
+      timeSlotsSaved = false;
+      console.warn('[四川托普] 作息时间没有写入成功:', error);
+    }
+
+    try {
+      if (typeof window.shiguangBridgePromise.saveCourseConfig === 'function') {
+        var totalWeeks = courses.reduce(function (max, course) {
+          return Math.max(max, course.weeks[course.weeks.length - 1] || 0);
+        }, 0);
+        await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
+          semesterTotalWeeks: totalWeeks,
+          defaultClassDuration: 45,
+          firstDayOfWeek: 1
+        }));
+      }
+    } catch (error) {
+      console.warn('[四川托普] 课表设置没有写入成功:', error);
+    }
+
+    window.shiguangBridge.showToast('正在保存 ' + courses.length + ' 门课程...');
+    var saveResult = await window.shiguangBridgePromise.saveImportedCourses(
+      JSON.stringify(courses, null, 2)
+    );
+    if (saveResult !== true) {
+      window.shiguangBridge.showToast('课程没有保存成功,请重试。');
+      await window.shiguangBridgePromise.showAlert(
+        '导入未完成',
+        '课程没有保存成功,请重新导入一次。',
+        '确定'
+      );
+      return;
+    }
+
+    var customCount = courses.filter(function (course) { return course.isCustomTime; }).length;
+    var untimedCount = countCoursesWithoutTime(document);
+
+    var message = ['已导入 ' + courses.length + ' 门课程。'];
+    message.push('其中必修类 ' + stats.requiredFromClass + ' 门以班级课表为准(班级课表调课会及时更新)' +
+      (stats.requiredFromPersonal ? ',另有 ' + stats.requiredFromPersonal + ' 门班级课表里没有、从个人课表补上' : '') +
+      ';选修课 ' + stats.electiveFromPersonal + ' 门以个人课表为准。');
+    if (!stats.electiveFromPersonal && !stats.requiredFromPersonal) {
+      message.push('学生个人课表这次没有课程,整份课表都来自班级课表。');
+    }
+    message.push(timeSlotsSaved ? '上课时间已按学校作息设置。' : '作息时间没有写进去,请在「课表设置」里手动填写。');
+    if (customCount) {
+      message.push('其中 ' + customCount + ' 门课的上课时间与默认作息不同,已经单独设置。');
+    }
+    if (!stats.typeLoaded) {
+      message.push('这次没能读到课程性质,选修课与必修课没有区分,全部按班级课表优先处理。');
+    }
+    if (untimedCount) {
+      message.push('另有 ' + untimedCount + ' 门课程没有固定上课时间(实践、实训类),没有导入,可以自己手动添加。');
+    }
+    if (stats.failedPages.length) {
+      message.push('(' + stats.failedPages.join('、') + '这次没有读到,课表可能不完整,可以重试一次。)');
+    }
+    if (stats.unrecognized.length) {
+      message.push('有 ' + stats.unrecognized.length + ' 处内容没能识别,课表可能不完整。');
+    }
+    if (stats.sectionOverflow) {
+      message.push('有 ' + stats.sectionOverflow + ' 门课的节次超出了作息表范围,时间可能不准。');
+    }
+
+    window.shiguangBridge.showToast('导入完成,共 ' + courses.length + ' 门课程。');
+    await window.shiguangBridgePromise.showAlert('导入完成', message.join('\n'), '确定');
+    if (window.shiguangBridge && window.shiguangBridge.notifyTaskCompletion) {
+      window.shiguangBridge.notifyTaskCompletion();
+    }
+
+    console.log('[四川托普] 取数明细:' + pageInfo.join(';'));
+    console.log('[四川托普] 课程性质:' + JSON.stringify(stats.typeMap) + '(班级课表里属于选修、按个人课表处理的条目 ' + stats.classElectiveSkipped + ' 条)');
+    console.log('[四川托普] 导入完成:' + courses.length + ' 门课程(必修来自班级课表 ' + stats.requiredFromClass +
+      ',选修来自个人课表 ' + stats.electiveFromPersonal + ',个人课表兜底补上 ' + stats.requiredFromPersonal +
+      ';班级课表里属于选修的条目被丢弃 ' + stats.classElectiveSkipped + ' 条,被班级课表覆盖的个人必修 ' + stats.requiredSkippedPersonal + ' 条)');
+    if (stats.unrecognized.length) console.warn('[四川托普] 没能识别的内容:', stats.unrecognized);
+    if (stats.dayMismatch) console.warn('[四川托普] 有 ' + stats.dayMismatch + ' 条课程的星期与所在列不一致,已按格内文字处理');
+    if (stats.incomplete) console.warn('[四川托普] 有 ' + stats.incomplete + ' 条课程缺少教师或地点');
+  }
+
+  runImportFlow().catch(function (error) {
+    console.error('[四川托普]', error);
+    window.shiguangBridge.showToast('导入出错:' + (error && error.message ? error.message : error));
+    if (window.shiguangBridgePromise && window.shiguangBridgePromise.showAlert) {
+      window.shiguangBridgePromise
+        .showAlert('导入出错', '导入过程中出错了:' + (error && error.message ? error.message : error) + '\n可以重试一次。', '确定')
+        .catch(function () {});
+    }
+  });
+})();