Răsfoiți Sursa

add:南开大学研究生院教务适配 (#728)

Cure2004 2 zile în urmă
părinte
comite
8502764715
3 a modificat fișierele cu 555 adăugiri și 1 ștergeri
  1. 6 1
      index/root_index.yaml
  2. 9 0
      resources/NANKAI/adapters.yaml
  3. 540 0
      resources/NANKAI/nankai.js

+ 6 - 1
index/root_index.yaml

@@ -1081,4 +1081,9 @@ schools:
   - id: "HUUC"
   - id: "HUUC"
     name: "河南城建学院"
     name: "河南城建学院"
     initial: "H"
     initial: "H"
-    resource_folder: "HUUC"
+    resource_folder: "HUUC"
+
+  - id: "NANKAI"
+    name: "南开大学"
+    initial: "N"
+    resource_folder: "NANKAI"

+ 9 - 0
resources/NANKAI/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/NKU/adapters.yaml
+adapters:
+  - adapter_id: "NANKAI"
+    adapter_name: "南开大学研究生教育综合管理系统"
+    category: "POSTGRADUATE"
+    asset_js_path: "nankai.js"
+    import_url: "https://yjs.nankai.edu.cn/allogene/page/home.htm"
+    maintainer: "Cure"
+    description: "南开大学研究生院(yjs.nankai.edu.cn)适配, 需校园网环境,登录后即可直接导入, "

+ 540 - 0
resources/NANKAI/nankai.js

@@ -0,0 +1,540 @@
+// 南开大学(研究生)教育综合管理系统 拾光课程表适配脚本
+// 适用系统: https://yjs.nankai.edu.cn/   入口: 培养 → 个人课表 (py/page/student/grkcb.htm)
+//
+// 要点:
+//   1. 服务端渲染(JSP)输出表格, 无 JSON 接口, 只能解析 DOM;
+//   2. 表格按「周次」分周渲染, 同一门课只在它真正上课的那一周出现 —— 逐周抓取第 1 ~ N 周, 每周抓
+//      到什么就记一条 weeks=[N] 的原子记录; 跨周合并 / 连续节次合并 / 去重交给官方函数处理;
+//   3. 只抓一次不带参数的课表页: 学年 / 学期 / 周次三个 <select>(选中项即学校默认学期)与作息
+//      说明都在这一份响应里; 学年学期不给用户挑, 确认框列出学年 / 学期 / 开学日期后直接导入。
+//
+// 维护者: Cure   |   出现问题请提 issues 或提交 PR
+
+/* 包在 IIFE 里: 不污染教务页面全局, 重复注入时顶层 const 也不会重复声明(函数体不再缩进一级)。 */
+(function () {
+'use strict';
+
+/* ============================ 常量 ============================ */
+
+// 课表页路径(系统内所有页面均为 /<模块>/page/<角色>/<页面>.htm 结构)
+const NKU_KB_PAGE = '/py/page/student/grkcb.htm';
+
+// 逐周抓取的并发数(同一 JSP 会话并发过高会被串行化, 3 比较稳)
+const NKU_FETCH_CONCURRENCY = 3;
+
+// 兜底作息: 正常从课表页正文解析, 这里只作解析失败(页面改版)时的保底; 下标 + 1 即节次
+const NKU_FALLBACK_TIME_SLOTS = [
+    '08:00-08:45', '08:55-09:40', '10:00-10:45', '10:55-11:40', '12:00-12:45',
+    '12:55-13:40', '14:00-14:45', '14:55-15:40', '16:00-16:45', '16:55-17:40',
+    '18:30-19:15', '19:25-20:10', '20:20-21:05', '21:15-22:00'
+].map((range, index) => {
+    const [startTime, endTime] = range.split('-');
+    return { number: index + 1, startTime, endTime };
+});
+
+// 开学日期(第 1 周周一)推算基准 = [月, 日, 年偏移]: 取基准日当天或之后的第一个周一(系统无校历)
+const NKU_TERM_BASE = {
+    '11': [9, 1, 0],  // 第一学期: 当年 9 月 1 日
+    '12': [2, 20, 1], // 第二学期: 次年 2 月 20 日
+    '13': [7, 1, 0]   // 短学期:   当年 7 月 1 日
+};
+
+/* ============================ 通用工具 ============================ */
+
+const CN_DIGITS = { 零: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
+
+/** 中文数字 -> 整数(支持 一 ~ 九十九); 无法解析返回 NaN */
+function cnToInt(text) {
+    const s = String(text || '').replace(/\s/g, '');
+    if (!s) return NaN;
+    if (s === '十') return 10;
+    if (s.length === 1) return Object.prototype.hasOwnProperty.call(CN_DIGITS, s) ? CN_DIGITS[s] : NaN;
+    const idx = s.indexOf('十');
+    if (idx === -1) return NaN;
+    const high = idx === 0 ? 1 : CN_DIGITS[s[idx - 1]];
+    const low = idx === s.length - 1 ? 0 : CN_DIGITS[s[idx + 1]];
+    return (high === undefined || low === undefined) ? NaN : high * 10 + low;
+}
+
+/** 规整空白与全角空格 */
+function normalizeText(text) {
+    return String(text == null ? '' : text).replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
+}
+
+/** "8:00" / "08:00:00" -> "08:00"; 非法返回 null */
+function formatTime(value) {
+    const match = typeof value === 'string' ? value.match(/^(\d{1,2}):(\d{1,2})(?::\d{1,2})?$/) : null;
+    if (!match) return null;
+    const hour = Number(match[1]);
+    const minute = Number(match[2]);
+    if (hour > 23 || minute > 59) return null;
+    return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
+}
+
+/** "08:45" -> 525; 非法返回 -1 */
+function timeToMinutes(value) {
+    const t = formatTime(value);
+    return t ? Number(t.slice(0, 2)) * 60 + Number(t.slice(3)) : -1;
+}
+
+/** 日期 -> "YYYY-MM-DD" */
+function formatDate(date) {
+    const pad = n => String(n).padStart(2, '0');
+    return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
+}
+
+/* ============================ 桥接层 ============================ */
+
+function showToast(message) {
+    try { window.shiguangBridge.showToast(message); } catch { /* 提示失败不打断导入 */ }
+}
+
+function notifyTaskCompletion() {
+    try { window.shiguangBridge.notifyTaskCompletion(); } catch { /* 结束信号交给宿主处理 */ }
+}
+
+const bridgeSupports = method =>
+    !!(window.shiguangBridgePromise && typeof window.shiguangBridgePromise[method] === 'function');
+
+/* ============================ 页面解析 ============================ */
+
+/** 把表格展开成二维网格, 正确处理 rowspan / colspan(课表用 rowspan 跨节次) */
+function buildTableGrid(table) {
+    const grid = [];
+    const occupied = [];
+
+    Array.from(table.querySelectorAll('tr')).forEach((tr, rowIndex) => {
+        let col = 0;
+        Array.from(tr.children).forEach(cell => {
+            const tag = (cell.tagName || '').toLowerCase();
+            if (tag !== 'td' && tag !== 'th') return;
+
+            while (occupied[rowIndex] && occupied[rowIndex][col]) col++;
+            const colspan = Math.max(parseInt(cell.getAttribute('colspan') || '1', 10) || 1, 1);
+            const rowspan = Math.max(parseInt(cell.getAttribute('rowspan') || '1', 10) || 1, 1);
+
+            for (let i = 0; i < rowspan; i++) {
+                const r = rowIndex + i;
+                grid[r] = grid[r] || [];
+                occupied[r] = occupied[r] || [];
+                for (let j = 0; j < colspan; j++) {
+                    if (!i && !j) grid[r][col] = cell;
+                    occupied[r][col + j] = true;
+                }
+            }
+            col += colspan;
+        });
+    });
+
+    return grid;
+}
+
+/** 表头形如 <th colspan="2">时间</th><th>星期一</th>… -> { 列下标: 星期(1~7) } */
+function findDayColumns(grid) {
+    const dayChars = '一二三四五六日';
+    const map = {};
+    (grid[0] || []).forEach((cell, col) => {
+        if (!cell) return;
+        const match = normalizeText(cell.textContent).replace(/\s/g, '').match(/^星期([一二三四五六日天])$/);
+        if (!match) return;
+        const day = dayChars.indexOf(match[1] === '天' ? '日' : match[1]) + 1;
+        if (day >= 1 && day <= 7) map[col] = day;
+    });
+    return map;
+}
+
+/** 课表主表格 */
+function findTimetable(doc) {
+    const direct = doc.querySelector('table.table-course');
+    if (direct && /星期/.test(direct.textContent || '')) return direct;
+
+    return Array.from(doc.querySelectorAll('table')).find(table =>
+        /星期一/.test(table.textContent || '') && /第\s*\d+\s*节/.test(table.textContent || '')) || null;
+}
+
+/** 单元格里的 <a> -> 按行切分的文本数组(<br> 视为换行): 课程名 / 周次说明 / 第X节 -- 第Y节 / 教师 / 地点 */
+function anchorToLines(anchor) {
+    const clone = anchor.cloneNode(true);
+    Array.from(clone.querySelectorAll('br')).forEach(br => {
+        br.parentNode.replaceChild(document.createTextNode('\n'), br);
+    });
+    return String(clone.textContent || '').replace(/\u00a0/g, ' ').split('\n')
+        .map(line => normalizeText(line)).filter(Boolean);
+}
+
+/** 解析一个课程链接 -> { name, teacher, position, day, startSection, endSection }
+ *  周次不在这里判断: 抓取哪一周, 这条记录就属于哪一周(见 crawlAllWeeks 的原子化) */
+function parseCourseAnchor(anchor, day) {
+    const lines = anchorToLines(anchor);
+    const strong = anchor.querySelector('strong');
+    let name = normalizeText(strong ? strong.textContent : '') || normalizeText(lines[0] || '');
+    name = name.replace(/^[|\s]+/, '');
+    if (!name) return null;
+
+    // 「第X节 -- 第Y节」所在行
+    const sectionIdx = lines.findIndex(line => /第\s*\d+\s*节/.test(line));
+    if (sectionIdx < 0) return null;
+
+    const numbers = (lines[sectionIdx].match(/第\s*(\d+)\s*节/g) || [])
+        .map(token => Number(token.replace(/\D/g, ''))).filter(num => num > 0);
+    if (!numbers.length) return null;
+
+    // 教师 / 地点紧随节次行; 有些课程没有独立教师行, 此时第一行其实是地点
+    let teacher = normalizeText(lines[sectionIdx + 1] || '');
+    let position = normalizeText(lines[sectionIdx + 2] || '');
+    if (!position && /楼|室|馆|场地|校区|通知|待定/.test(teacher)) {
+        position = teacher;
+        teacher = '';
+    }
+
+    if (!(day >= 1 && day <= 7)) return null;
+    return {
+        name,
+        teacher,
+        position: position || '待定',
+        day,
+        startSection: Math.min.apply(null, numbers),
+        endSection: Math.max.apply(null, numbers)
+    };
+}
+
+/** 解析某一周课表页面里的所有课程; null 表示页面里找不到课表(可能登录已失效) */
+function parseTimetableDoc(doc) {
+    const table = findTimetable(doc);
+    if (!table) return null;
+
+    const grid = buildTableGrid(table);
+    const dayColumns = findDayColumns(grid);
+    const colKeys = Object.keys(dayColumns);
+    if (colKeys.length === 0) return null;
+
+    const courses = [];
+    for (let row = 1; row < grid.length; row++) {
+        colKeys.forEach(key => {
+            const cell = grid[row] ? grid[row][Number(key)] : null;
+            if (!cell) return;
+            // 同一格可能塞了多门课(冲突课程), 逐个解析
+            Array.from(cell.querySelectorAll('a'))
+                .filter(anchor => /第\s*\d+\s*节/.test(anchor.textContent || ''))
+                .forEach(anchor => {
+                    const course = parseCourseAnchor(anchor, dayColumns[key]);
+                    if (course) courses.push(course);
+                });
+        });
+    }
+    return courses;
+}
+
+/** 从页面底部说明文字解析作息时间, 形如 "上午:第一节 8:00-8:45 第二节8:55-9:40 …"
+ *  时间段必须从 1 开始且编号连续, 否则返回 null 交给兜底表 */
+function parseTimeSlotsFromDoc(doc) {
+    const bodyText = doc && doc.body ? doc.body.textContent || '' : '';
+    if (!bodyText) return null;
+
+    const normalized = bodyText
+        .replace(/[:]/g, ':')
+        .replace(/[–—~~至]/g, '-')
+        .replace(/\s+/g, ' ');
+    const pattern = /第([一二三四五六七八九十]+)节?:?\s*(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})/g;
+    const collected = new Map();
+    let match;
+
+    while ((match = pattern.exec(normalized)) !== null) {
+        const number = cnToInt(match[1]);
+        const startTime = formatTime(match[2]);
+        const endTime = formatTime(match[3]);
+        if (!number || !startTime || !endTime) continue;
+        if (timeToMinutes(startTime) >= timeToMinutes(endTime)) continue;
+        collected.set(number, { number, startTime, endTime });
+    }
+
+    const slots = Array.from(collected.values()).sort((a, b) => a.number - b.number);
+    if (!slots.length) return null;
+    return slots.every((slot, index) => slot.number === index + 1) ? slots : null;
+}
+
+/* ============================ 合并与去重(官方参考实现)============================ */
+
+/** 拾光官方《课程合并与去重函数》的参考实现, 照搬(只精简了注释):
+ *  https://github.com/XingHeYuZhuan/shiguangschedule/wiki/课程合并与去重函数
+ *  输入 / 输出都是原子课程 { name, teacher, position, day, startSection, endSection, weeks } */
+function mergeAndDistinctCourses(courses) {
+    if (!Array.isArray(courses) || courses.length <= 1) return courses;
+
+    const list = courses.map(course => Object.assign({}, course, {
+        name: course.name || '',
+        teacher: course.teacher || '',
+        position: course.position || '',
+        weeks: Array.isArray(course.weeks) ? [].concat(course.weeks).sort((a, b) => a - b) : []
+    }));
+
+    // 阶段 1: 合并连续节次与完全重复的记录(前提: 名称 / 教师 / 地点 / 星期 / 周次一致)
+    list.sort((a, b) =>
+        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 withSections = [];
+    let current = list[0];
+    for (let i = 1; i < list.length; i++) {
+        const next = list[i];
+        const sameWeeks = current.name === next.name
+            && current.teacher === next.teacher
+            && current.position === next.position
+            && current.day === next.day
+            && current.weeks.join(',') === next.weeks.join(',');
+        if (sameWeeks && current.endSection + 1 === next.startSection) {
+            current.endSection = next.endSection;
+        } else if (sameWeeks && current.startSection === next.startSection && current.endSection === next.endSection) {
+            continue;
+        } else {
+            withSections.push(current);
+            current = next;
+        }
+    }
+    withSections.push(current);
+
+    // 阶段 2: 合并同节次的周次(前提: 名称 / 教师 / 地点 / 星期 / 起止节次一致)
+    withSections.sort((a, b) =>
+        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 merged = [];
+    let head = withSections[0];
+    for (let i = 1; i < withSections.length; i++) {
+        const next = withSections[i];
+        const sameSection = head.name === next.name
+            && head.teacher === next.teacher
+            && head.position === next.position
+            && head.day === next.day
+            && head.startSection === next.startSection
+            && head.endSection === next.endSection;
+        if (sameSection) {
+            head.weeks = Array.from(new Set([].concat(head.weeks, next.weeks))).sort((a, b) => a - b);
+        } else {
+            merged.push(head);
+            head = next;
+        }
+    }
+    merged.push(head);
+
+    return merged;
+}
+
+/* ============================ 列表读取与抓取 ============================ */
+
+/** <select> 的选项 -> [{ value, label, selected }] */
+function readSelectOptions(selectEl) {
+    if (!selectEl) return [];
+    return Array.from(selectEl.querySelectorAll('option'))
+        .map(option => ({
+            value: normalizeText(option.value),
+            label: normalizeText(option.textContent || option.value),
+            selected: option.selected === true || option.hasAttribute('selected')
+        }))
+        .filter(option => option.value !== '');
+}
+
+/** 页面已选中的选项值; 没有选中项时返回空串 */
+function selectedValue(options) {
+    const selected = options.find(option => option.selected);
+    return selected ? selected.value : '';
+}
+
+/** 文档是否为本系统的课表页 —— 用来区分「这一周没课」和「登录失效 / 请求被拦」 */
+const looksLikeCoursePage = doc =>
+    !!(doc && (doc.querySelector('#kcbForm') || doc.querySelector('#zc') || doc.querySelector('#xn')));
+
+/** 课表页地址; 不带任何参数即为系统默认的当前学期 */
+const kbPageUrl = (xn, xj, zc) =>
+    `${NKU_KB_PAGE}?xn=${encodeURIComponent(xn)}&xj=${encodeURIComponent(xj)}&zc=${encodeURIComponent(zc)}`;
+
+/** 抓一个页面并解析成 DOM。请求失败 / 状态异常一律抛出, 由调用方统一按「没抓到」处理 */
+async function fetchCourseDoc(url) {
+    const response = await fetch(url, { method: 'GET', credentials: 'include' });
+    if (!response.ok) throw new Error('页面请求失败');
+    return new DOMParser().parseFromString(await response.text(), 'text/html');
+}
+
+/** 简单的并发池 */
+async function runWithConcurrency(tasks, limit) {
+    const results = new Array(tasks.length);
+    let cursor = 0;
+    const worker = async () => {
+        while (cursor < tasks.length) {
+            const index = cursor++;
+            results[index] = await tasks[index]();
+        }
+    };
+    await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
+    return results;
+}
+
+/** 逐周抓取: 第 N 周抓到的每门课都记成一条原子记录(weeks = [N]), 不做任何归并。
+ *  返回 { courses, okWeeks }; okWeeks = 成功取到课表页的周次数(0 表示整轮都没抓到)。 */
+async function crawlAllWeeks(xn, xj, weeks) {
+    const tasks = weeks.map(week => async () => {
+        try {
+            const doc = await fetchCourseDoc(kbPageUrl(xn, xj, week));
+            const list = parseTimetableDoc(doc);
+            if (list) return { week, list };
+            // 是本系统的课表页却没有表格 —— 多为超出本学期周次范围, 按当周无课处理
+            return looksLikeCoursePage(doc) ? { week, list: [] } : null;
+        } catch {
+            return null;
+        }
+    });
+
+    const courses = [];
+    let okWeeks = 0;
+
+    (await runWithConcurrency(tasks, NKU_FETCH_CONCURRENCY)).forEach(result => {
+        if (!result) return;
+        okWeeks++;
+        result.list.forEach(course => courses.push(Object.assign({}, course, { weeks: [result.week] })));
+    });
+
+    return { courses, okWeeks };
+}
+
+/* ============================ 页面读取与确认 ============================ */
+
+/** 取某个值在选项里的显示文本(学年形如 2026-2027, 学期形如 第一学期); 找不到就用原始值 */
+const labelOf = (options, value) => (options.find(option => option.value === value) || {}).label || value;
+
+/** 第 1 周周一 = 基准日当天或之后的第一个周一; 学期代码认不出来时按第一学期 */
+function guessStartDate(xn, xj) {
+    const [month, day, offset] = NKU_TERM_BASE[String(xj)] || NKU_TERM_BASE['11'];
+    const date = new Date((Number(xn) || new Date().getFullYear()) + offset, month - 1, day);
+    date.setDate(date.getDate() + (8 - date.getDay()) % 7); // 当天就是周一则不动
+    return formatDate(date);
+}
+
+/** 抓一次不带参数的课表页, 一次读齐: 学年与学期的值及显示文本(页面选中项 = 学校当前学期)、
+ *  周次列表、作息时间、按学期规则推算的开学日期。读不到下拉框(登录失效 / 页面改版)返回 null */
+async function loadCoursePage() {
+    let doc;
+    try {
+        doc = await fetchCourseDoc(NKU_KB_PAGE);
+    } catch {
+        return null;
+    }
+
+    const xnOptions = readSelectOptions(doc.querySelector('#xn'));
+    const xjOptions = readSelectOptions(doc.querySelector('#xj'));
+    const weeks = readSelectOptions(doc.querySelector('#zc'))
+        .map(option => Number(option.value)).filter(value => value > 0);
+    const xn = selectedValue(xnOptions);
+    const xj = selectedValue(xjOptions);
+    if (!xn || !xj || !weeks.length) return null;
+
+    return {
+        xn,
+        xj,
+        xnLabel: labelOf(xnOptions, xn),
+        xjLabel: labelOf(xjOptions, xj),
+        startDate: guessStartDate(xn, xj),
+        weeks,
+        timeSlots: parseTimeSlotsFromDoc(doc)
+    };
+}
+
+/** 导入信息确认框: 列出学年 / 学期 / 开学日期, 只能确认或关掉(关掉 = 放弃导入) */
+async function confirmTerm(page) {
+    if (!bridgeSupports('showAlert')) return true;
+
+    const content = '即将导入以下内容,请确认:\n\n'
+        + ` 学年   ${page.xnLabel}\n`
+        + ` 学期   ${page.xjLabel}\n`
+        + ` 开学日期 ${page.startDate}(周一)\n\n`
+        + '学年与学期取自教务系统的默认学期,开学日期按学期规则推算。;
+    try {
+        return await window.shiguangBridgePromise.showAlert('确认导入信息', content, '开始导入') === true;
+    } catch {
+        return true;
+    }
+}
+
+/* ============================ 保存 ============================ */
+
+/** 调用桥接层的保存接口; 失败时给一句笼统提示(不带原始异常)并返回 false */
+async function saveVia(method, payload, failMessage) {
+    try {
+        await window.shiguangBridgePromise[method](JSON.stringify(payload));
+        return true;
+    } catch {
+        showToast(failMessage);
+        return false;
+    }
+}
+
+/** 从作息时间推算单节课与课间时长, 用于课表配置 */
+function deriveDurations(timeSlots) {
+    const fallback = { classDuration: 45, breakDuration: 10 };
+    if (!Array.isArray(timeSlots) || timeSlots.length < 2) return fallback;
+
+    const classDuration = timeToMinutes(timeSlots[0].endTime) - timeToMinutes(timeSlots[0].startTime);
+    const breakDuration = timeToMinutes(timeSlots[1].startTime) - timeToMinutes(timeSlots[0].endTime);
+    return (classDuration > 0 && breakDuration >= 0) ? { classDuration, breakDuration } : fallback;
+}
+
+/* ============================ 主流程 ============================ */
+
+async function importFlow() {
+    // 1. 抓一次无参课表页: 学年学期默认值、周次列表、作息时间都出在这一份响应里; 抓不到即中止
+    const page = await loadCoursePage();
+    if (!page) {
+        showToast('导入失败:未获取到课表数据,请重新登录后重试。');
+        return;
+    }
+
+    // 2. 确认框: 列出学年 / 学期 / 开学日期, 只能确认或关掉
+    if (!await confirmTerm(page)) {
+        showToast('已取消导入。');
+        return;
+    }
+
+    // 3. 逐周抓取 -> 原子课程 -> 交给官方函数合并去重
+    const crawl = await crawlAllWeeks(page.xn, page.xj, page.weeks);
+    if (!crawl.okWeeks) {
+        showToast('导入失败:未获取到课表数据,请重新登录后重试。');
+        return;
+    }
+    if (!crawl.courses.length) {
+        showToast('未查询到课程,请确认所选学年学期是否正确。');
+        return;
+    }
+    const courses = mergeAndDistinctCourses(crawl.courses);
+
+    // 4. 保存: 课程 -> 作息时间 -> 课表配置
+    const timeSlots = page.timeSlots || NKU_FALLBACK_TIME_SLOTS;
+    if (!await saveVia('saveImportedCourses', courses, '课程保存失败')) return;
+    await saveVia('savePresetTimeSlots', timeSlots, '导入作息时间失败');
+
+    // 开学日期用推算出来的第 1 周周一
+    const durations = deriveDurations(timeSlots);
+    await saveVia('saveCourseConfig', {
+        semesterStartDate: page.startDate,
+        semesterTotalWeeks: courses.reduce((max, course) => Math.max(max, ...course.weeks), 1),
+        defaultClassDuration: durations.classDuration,
+        defaultBreakDuration: durations.breakDuration,
+        firstDayOfWeek: 1
+    }, '保存课表配置失败');
+
+    // 5. 完成
+    notifyTaskCompletion();
+}
+
+/* ============================ 入口 ============================ */
+
+// 宿主在用户点击「开始导入」后注入本脚本, 所以顶层直接启动: 抓一次课表页 -> 确认框 -> 逐周抓取
+importFlow();
+
+})();