Ver código fonte

Merge pull request #764 from ShiGuangSchedule/pending

星河欲转 1 mês atrás
pai
commit
78ddba45dc

+ 10 - 0
index/root_index.yaml

@@ -1137,4 +1137,14 @@ schools:
     name: "上海电力大学"
     initial: "S"
     resource_folder: "SHIEP"
+
+  - id: "SHNU"
+    name: "上海师范大学"
+    initial: "S"
+    resource_folder: "SHNU"
+
+  - id: "NEFU"
+    name: "东北林业大学"
+    initial: "D"
+    resource_folder: "NEFU"
     

+ 8 - 0
resources/NEFU/adapters.yaml

@@ -0,0 +1,8 @@
+adapters:
+  - adapter_id: "NEFU_01"
+    adapter_name: "东北林业大学本部本科生院"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "nefu.js"
+    import_url: "https://jwxt.nefu.edu.cn/jsxsd/"
+    maintainer: "Chr0n0stasis"
+    description: "本部本科个人课表。登录后选择学期导入,保留原有作息和学期设置。"

+ 255 - 0
resources/NEFU/nefu.js

@@ -0,0 +1,255 @@
+/* 东北林业大学本部本科生院:强智个人课表导入。
+ * 维护者:Chr0n0stasis。沿用已登录会话,不处理登录或保存凭据。
+ * 桥接及合并规则参考拾光官方 Wiki,测试见 tests/NEFU。
+ */
+(function (factory) {
+  const api = factory();
+  if (typeof module === 'object' && module.exports) module.exports = api;
+  else void api.runImportFlow(window);
+})(function () {
+  'use strict';
+  const ORIGIN = 'https://jwxt.nefu.edu.cn';
+  const PATH = '/jsxsd/xskb/xskb_list.do';
+  function fail(code, message) {
+    const error = new Error(message); error.code = code; throw error;
+  }
+  const text = element => (element?.textContent || '').replace(/\s+/g, ' ').trim();
+  function numbers(value, maximum, label) {
+    const source = String(value).replace(/\s/g, '').replace(/[,、]/g, ',').replace(/[-–—~~]/g, '-');
+    if (!/^\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*$/.test(source)) fail('INVALID_RANGE', `${label}格式不支持:${value}`);
+    const result = new Set();
+    for (const part of source.split(',')) {
+      const [a, b = a] = part.split('-').map(Number);
+      if (a < 1 || b < a || b > maximum) fail('INVALID_RANGE', `${label}越界或倒序:${value}`);
+      for (let n = a; n <= b; n++) result.add(n);
+    }
+    return [...result].sort((a, b) => a - b);
+  }
+  function parseTime(value) {
+    // 本校已观察到 1-5,7-8周[1-2节];单双周分支只在 HTML 明确写出时采用。
+    const match = String(value).replace(/\s/g, '').match(/^([\d,,、\--–—~~]+)(单|双)?周(?:[((](单|双)周?[))])?\[([\d,,、\--–—~~]+)节\]$/);
+    if (!match) fail('INVALID_TIME', `课程时间格式不支持:${value}`);
+    let weeks = numbers(match[1], 60, '周次');
+    if (match[2] && match[3] && match[2] !== match[3]) fail('INVALID_TIME', '单双周标记相互矛盾');
+    const parity = match[2] || match[3];
+    if (parity) weeks = weeks.filter(w => w % 2 === (parity === '单' ? 1 : 0));
+    if (!weeks.length) fail('INVALID_TIME', '单双周过滤后没有有效周次');
+    const sections = numbers(match[4], 48, '节次');
+    const ranges = [];
+    for (const section of sections) {
+      const last = ranges[ranges.length - 1];
+      if (last && section === last[1] + 1) last[1] = section;
+      else ranges.push([section, section]);
+    }
+    return { weeks, ranges };
+  }
+  function metadata(doc) {
+    if (doc.querySelector('#userAccount, #userPassword') || /登录/.test(doc.title || '')) fail('LOGIN_REQUIRED', '请先在教务系统中登录;登录页不是空课表');
+    const options = id => {
+      const select = doc.querySelector(`#${id}`);
+      if (!select) fail('NOT_TIMETABLE', `缺少 ${id},可能是外层框架、登录页或错误页面`);
+      return { selected: select.value, items: [...select.options].map(o => ({ value: o.value, label: text(o) })) };
+    };
+    return { semesters: options('xnxq01id'), modes: options('kbjcmsid'), weeks: options('zc') };
+  }
+  function headerMap(table) {
+    const headers = [...(table.tHead?.rows[0]?.cells || [])];
+    const digits = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 日: 7, 天: 7 };
+    const days = headers.map(cell => {
+      const m = text(cell).match(/^(?:星期|周)([一二三四五六日天])$/);
+      return m ? digits[m[1]] : null;
+    });
+    if (headers.length !== 8 || days[0] !== null || new Set(days.slice(1)).size !== 7 || days.slice(1).some(d => !d)) fail('BAD_HEADER', '课表必须能明确识别星期一至星期日');
+    if (headers.some(h => h.colSpan !== 1 || h.rowSpan !== 1)) fail('BAD_HEADER', '暂不支持合并表头');
+    return days;
+  }
+  function parseDocument(doc, expected = {}) {
+    const meta = metadata(doc);
+    for (const [key, actual] of [['semesterId', meta.semesters.selected], ['modeId', meta.modes.selected], ['week', meta.weeks.selected]]) {
+      if (expected[key] !== undefined && String(expected[key]) !== actual) fail('QUERY_MISMATCH', `服务器返回的 ${key} 与请求不一致`);
+    }
+    const tables = [...doc.querySelectorAll('table.qz-weeklyTable')];
+    if (tables.length !== 1) fail('NOT_TIMETABLE', '未发现唯一的正式周课表,不能按空课表处理');
+    const table = tables[0], days = headerMap(table);
+    const rows = [...table.tBodies].flatMap(body => [...body.rows]);
+    if (!rows.length) fail('BAD_GRID', '缺少节次行');
+    const grid = rows.map(() => Array(8));
+    const teachingBlocks = [], allSections = new Set(), cells = [];
+    let cardCount = 0;
+    rows.forEach((row, rowIndex) => {
+      let column = 0;
+      // row.cells 只含当前 tr 的实际子单元格;rowspan 占位仅写入 grid。
+      // 下方行跳过占位,不会再次遍历或统计同一个 td。
+      for (const cell of row.cells) {
+        while (column < 8 && grid[rowIndex][column]) column++;
+        const height = cell.rowSpan, width = cell.colSpan;
+        if (height < 1 || rowIndex + height > rows.length || column + width > 8) fail('BAD_GRID', '课表单元格跨行或跨列越界');
+        for (let r = rowIndex; r < rowIndex + height; r++) for (let c = column; c < column + width; c++) {
+          if (grid[r][c]) fail('BAD_GRID', '课表合并单元格重叠');
+          grid[r][c] = cell;
+        }
+        if (column === 0) {
+          if (cell.getAttribute('name') !== 'timeTd' || width !== 1 || height !== 1) fail('BAD_GRID', '节次标签结构已变化');
+          const label = text(cell), sectionMatch = label.match(/[((]([\d、,,\s]+)小节[))]/);
+          if (!sectionMatch) fail('BAD_GRID', '无法读取行标签中的小节编号');
+          const sections = numbers(sectionMatch[1], 48, '节次行');
+          sections.forEach(s => allSections.add(s));
+          const clock = label.match(/(\d{2}:\d{2})\s*[~~\-]\s*(\d{2}:\d{2})/);
+          teachingBlocks.push({ sections, startTime: clock?.[1] || null, endTime: clock?.[2] || null });
+        } else {
+          if (cell.getAttribute('name') !== 'kbDataTd' || width !== 1) fail('BAD_GRID', '课程单元格类型或跨列发生变化');
+          const lists = [...cell.querySelectorAll('ul[name="kbdataUl"]')];
+          if (lists.length !== 1) fail('BAD_GRID', '缺少唯一课程列表');
+          const items = [...lists[0].children];
+          if (items.some(e => !e.matches('li.courselists-item'))) fail('BAD_CARD', '出现未知课程卡片结构');
+          const declaredText = lists[0].getAttribute('kbdatasize');
+          if (declaredText !== null && (!/^\d+$/.test(declaredText) || Number(declaredText) !== items.length)) fail('BAD_CARD', '页面课程计数与卡片数不一致');
+          cardCount += items.length;
+          cells.push({ day: days[column], items });
+        }
+        column += width;
+      }
+      if (grid[rowIndex].filter(Boolean).length !== 8) fail('BAD_GRID', '课表行不完整,禁止猜测星期');
+    });
+    const courses = [], seen = new Set();
+    for (const { day, items } of cells) for (const item of items) {
+      const name = text(item.querySelector('.qz-hasCourse-title'));
+      const detail = text(item.querySelector('.qz-hasCourse-abbrinfo'));
+      const fields = detail.match(/^老师[::](.*?)[;;]时间[::](.*?)[;;]地点[::](.*)$/);
+      if (!name || !fields) fail('BAD_CARD', '课程名称或老师/时间/地点字段结构不完整');
+      const { weeks, ranges } = parseTime(fields[2]);
+      for (const [startSection, endSection] of ranges) {
+        for (let s = startSection; s <= endSection; s++) if (!allSections.has(s)) fail('BAD_CARD', '课程节次不在所选作息模式中');
+        const course = { name, teacher: fields[1].trim(), position: fields[3].trim(), day, weeks: [...weeks], startSection, endSection };
+        const key = JSON.stringify(course);
+        if (!seen.has(key)) { seen.add(key); courses.push(course); }
+      }
+    }
+    const remarks = [...table.querySelectorAll('tfoot .qz-weeklyTable-detailtext')].map(text).filter(Boolean);
+    return {
+      status: courses.length ? 'ok' : 'scheduled-empty',
+      semesterId: meta.semesters.selected, modeId: meta.modes.selected, week: meta.weeks.selected,
+      metadata: meta, courses, remarks, teachingBlocks,
+      statistics: { sourceCards: cardCount, courseRecords: courses.length, distinctCourseNames: new Set(courses.map(c => c.name)).size },
+      warnings: [
+        ...(remarks.length ? ['存在未排具体星期/节次的备注项目;未伪造课程时间,请另行核对。'] : []),
+        '仅导入课程,保留原有作息和学期设置。'
+      ]
+    };
+  }
+  function parseHTML(html, expected = {}) {
+    if (typeof DOMParser === 'undefined') fail('NO_DOM', 'HTML 解析需要浏览器 DOMParser');
+    return parseDocument(new DOMParser().parseFromString(html, 'text/html'), expected);
+  }
+  // 按周合并同名、同教师、同地点、同星期的连续节次,再汇总相同时段的周次。
+  // 先按周拆分,避免把部分周次的调课扩展到整个学期。
+  function mergeCourses(courses) {
+    const groups = new Map();
+    for (const course of courses) {
+      const key = JSON.stringify([course.name, course.teacher, course.position, course.day]);
+      if (!groups.has(key)) groups.set(key, { course, weeks: new Map() });
+      const group = groups.get(key);
+      for (const week of new Set(course.weeks)) {
+        if (!group.weeks.has(week)) group.weeks.set(week, []);
+        group.weeks.get(week).push([course.startSection, course.endSection]);
+      }
+    }
+    const result = [];
+    for (const { course, weeks } of groups.values()) {
+      const ranges = new Map();
+      for (const [week, intervals] of [...weeks].sort((a, b) => a[0] - b[0])) {
+        intervals.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
+        const merged = [];
+        for (const [start, end] of intervals) {
+          const last = merged[merged.length - 1];
+          if (last && start <= last[1] + 1) last[1] = Math.max(last[1], end);
+          else merged.push([start, end]);
+        }
+        for (const [startSection, endSection] of merged) {
+          const key = startSection + ':' + endSection;
+          if (!ranges.has(key)) ranges.set(key, {
+            name: course.name, teacher: course.teacher, position: course.position,
+            day: course.day, startSection, endSection, weeks: []
+          });
+          ranges.get(key).weeks.push(week);
+        }
+      }
+      result.push(...ranges.values());
+    }
+    return result.sort((a, b) => a.day - b.day || a.startSection - b.startSection ||
+      a.endSection - b.endSection || a.name.localeCompare(b.name) ||
+      a.teacher.localeCompare(b.teacher) || a.position.localeCompare(b.position) ||
+      a.weeks[0] - b.weeks[0]);
+  }
+
+  function createClient(environment) {
+    const env = environment || globalThis;
+    if (env.location?.origin !== ORIGIN) fail('WRONG_ORIGIN', '请在东北林业大学教务系统中执行导入');
+    async function getHTML(params) {
+      const controller = new AbortController();
+      const timer = setTimeout(() => controller.abort(), 20000);
+      try {
+        const response = await env.fetch(PATH + '?' + new URLSearchParams(params), {
+          method: 'GET', credentials: 'same-origin', signal: controller.signal
+        });
+        if (!response.ok) fail('HTTP_ERROR', '教务响应 HTTP ' + response.status);
+        if (new URL(response.url, ORIGIN).origin !== ORIGIN) fail('LOGIN_REQUIRED', '登录已失效,请重新登录');
+        const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
+        metadata(doc);
+        return doc;
+      } catch (error) {
+        if (error.name === 'AbortError') fail('TIMEOUT', '教务请求超时,请稍后重试');
+        throw error;
+      } finally {
+        clearTimeout(timer);
+      }
+    }
+    return Object.freeze({
+      async options() { return metadata(await getHTML({ viweType: '0' })); },
+      async load({ semesterId, modeId, week = '' } = {}, available) {
+        const meta = available || await this.options();
+        const semester = semesterId ?? meta.semesters.selected;
+        const mode = modeId ?? meta.modes.items.find(o => o.label === '本部')?.value ?? meta.modes.selected;
+        if (!meta.semesters.items.some(o => o.value === semester)) fail('BAD_SELECTION', '学期不在教务返回的选项内');
+        if (!meta.modes.items.some(o => o.value === mode)) fail('BAD_SELECTION', '作息模式不在教务返回的选项内');
+        if (String(week) !== '' && !/^(?:[1-9]|[1-5]\d|60)$/.test(String(week))) fail('BAD_SELECTION', '周次必须为空或 1 到 60 的整数');
+        const doc = await getHTML({ viweType: '0', xnxq01id: semester, kbjcmsid: mode, zc: String(week) });
+        return parseDocument(doc, { semesterId: semester, modeId: mode, week: String(week) });
+      }
+    });
+  }
+
+  async function runImportFlow(env) {
+    const bridge = env.shiguangBridgePromise;
+    const ui = env.shiguangBridge;
+    try {
+      const client = createClient(env);
+      const meta = await client.options();
+      const index = await bridge.showSingleSelection(
+        '选择学期(本部本科生院)',
+        JSON.stringify(meta.semesters.items.map(item => item.label)),
+        meta.semesters.items.findIndex(item => item.value === meta.semesters.selected)
+      );
+      if (index === null) return { status: 'cancelled' };
+      if (!Number.isInteger(index) || index < 0 || index >= meta.semesters.items.length) {
+        fail('BAD_SELECTION', '学期选择无效');
+      }
+      ui.showToast('正在读取课程;本次保留原有作息和学期设置。');
+      const result = await client.load({ semesterId: meta.semesters.items[index].value }, meta);
+      const courses = mergeCourses(result.courses);
+      if (result.remarks.length) ui.showToast('教务另有未排具体时间的备注项目,请在教务课表中查看。');
+      // 沿用 App 的目标课表选择和覆盖流程。正常空课表也按完整查询结果提交。
+      if (await bridge.saveImportedCourses(JSON.stringify(courses)) !== true) {
+        fail('SAVE_FAILED', '课程保存失败,请重试');
+      }
+      ui.showToast(courses.length ? '已导入 ' + courses.length + ' 条排课记录。' : '该学期没有已排时间的课程,已导入空课表。');
+      ui.notifyTaskCompletion();
+      return { status: 'saved', count: courses.length };
+    } catch (error) {
+      const message = error.code ? error.message : '网络或导入操作失败,请检查登录状态后重试';
+      if (ui) ui.showToast('导入失败:' + message);
+      return { status: 'error', code: error.code || 'IMPORT_FAILED' };
+    }
+  }
+  return Object.freeze({ numbers, parseTime, metadata, parseDocument, parseHTML, mergeCourses, createClient, runImportFlow });
+});

+ 9 - 0
resources/SHNU/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/SHNU/adapters.yaml
+adapters:
+  - adapter_id: "SHNU_01"
+    adapter_name: "上海师范大学树维教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "shnu.js"
+    import_url: "https://course.shnu.edu.cn/"
+    maintainer: "FaQxD233"
+    description: "上海师范大学本科生教务系统(course.shnu.edu.cn/eams)适配,支持选择学期。登录之后直接导入不用点网页,加载不出来不重要。"

+ 388 - 0
resources/SHNU/shnu.js

@@ -0,0 +1,388 @@
+// 上海师范大学(course.shnu.edu.cn) 时光课程表适配脚本
+// 适配对象: 树维EAMS经典版 (courseTableForStd)
+// 登录方式: 统一身份认证(cas.shnu.edu.cn), 登录成功回到教务系统页面后点击导入
+// 非该大学开发者适配,开发者无法及时发现问题
+// 出现问题请提issues或者提交pr更改,这更加快速
+
+const BASE_URL = "https://course.shnu.edu.cn/eams";
+
+// 上师大每天 14 节(EAMS unitCount 默认 14), 作息时间参考上师大教务处公开作息
+const DEFAULT_UNIT_COUNT = 14;
+
+// 节次 → 时间 (第 N 节), 与教务系统课表一致
+const SHNU_TIME_SLOTS = [
+    { "number": 1, "startTime": "08:00", "endTime": "08:45" },
+    { "number": 2, "startTime": "08:50", "endTime": "09:30" },
+    { "number": 3, "startTime": "09:45", "endTime": "10:30" },
+    { "number": 4, "startTime": "10:35", "endTime": "11:15" },
+    { "number": 5, "startTime": "11:25", "endTime": "12:10" },
+    { "number": 6, "startTime": "13:00", "endTime": "13:45" },
+    { "number": 7, "startTime": "13:50", "endTime": "14:30" },
+    { "number": 8, "startTime": "14:45", "endTime": "15:30" },
+    { "number": 9, "startTime": "15:35", "endTime": "16:15" },
+    { "number": 10, "startTime": "16:25", "endTime": "17:10" },
+    { "number": 11, "startTime": "18:00", "endTime": "18:45" },
+    { "number": 12, "startTime": "18:50", "endTime": "19:30" },
+    { "number": 13, "startTime": "19:40", "endTime": "20:25" },
+    { "number": 14, "startTime": "20:30", "endTime": "21:10" }
+];
+
+/**
+ * 统一网络请求(携带登录 Cookie)
+ */
+async function request(url, options = {}) {
+    const res = await fetch(url, { credentials: "include", ...options });
+    if (!res.ok) throw new Error(`网络请求失败: ${res.status}`);
+    return await res.text();
+}
+
+/**
+ * 获取课表初始化页面, 优先走 AJAX 接口(无冗余布局), 失败则回退完整页面
+ */
+async function fetchCourseTablePage() {
+    try {
+        return await request(`${BASE_URL}/courseTableForStd.action?sf_request_type=ajax`);
+    } catch (e) {
+        return await request(`${BASE_URL}/courseTableForStd.action`);
+    }
+}
+
+/**
+ * 从课表初始化页面提取关键参数
+ * ids: 个人课表id(第一个匹配为 std 个人课表, 第二个为 class 班级课表)
+ * tagId: 学期选择条 id, 用于 dataQuery 查询全部学期
+ * currentSemesterId: 页面隐藏域中的当前学期 id
+ */
+function detectParameters(html) {
+    const idsMatch = html.match(/bg\.form\.addInput\(form,\s*"ids"\s*,\s*"(\d+)"\)/);
+    if (!idsMatch) return null;
+
+    const tagIdMatch = html.match(/id="(semesterBar\d+Semester)"/);
+    const semMatch = html.match(/name="semester\.id"\s+value="(\d+)"/);
+
+    return {
+        ids: idsMatch[1],
+        tagId: tagIdMatch ? tagIdMatch[1] : null,
+        currentSemesterId: semMatch ? semMatch[1] : null
+    };
+}
+
+/**
+ * 通过 dataQuery 接口获取所有学期, 解析失败时返回 null
+ */
+function parseSemesterCalendar(raw) {
+    // 树维返回的是不带引号 key 的 JS 字面量, 用 Function 求值
+    try {
+        const data = Function("return (" + raw + ");")();
+        if (data && data.semesters) return data.semesters;
+    } catch (e) { /* 忽略, 走正则兜底 */ }
+
+    const m = raw.match(/semesters\s*:\s*(\{[\s\S]*\})/);
+    if (m) {
+        try {
+            const data = Function("return ({semesters: " + m[1] + "});")();
+            if (data && data.semesters) return data.semesters;
+        } catch (e) { /* 兜底也失败 */ }
+    }
+    return null;
+}
+
+/**
+ * 获取学期列表并让用户选择
+ * 默认选中当前学期(id 与页面隐藏域一致), 其次为 id 最大的(最新)学期
+ */
+async function getSelectedSemester(tagId, currentSemesterId) {
+    let list = [];
+
+    if (tagId) {
+        try {
+            const raw = await request(`${BASE_URL}/dataQuery.action?sf_request_type=ajax`, {
+                method: "POST",
+                headers: { "Content-Type": "application/x-www-form-urlencoded" },
+                body: `tagId=${encodeURIComponent(tagId)}&dataType=semesterCalendar&empty=false`
+            });
+            const semesters = parseSemesterCalendar(raw);
+            if (semesters) {
+                const seen = {};
+                for (const key in semesters) {
+                    const arr = semesters[key];
+                    if (!Array.isArray(arr)) continue;
+                    arr.forEach(s => {
+                        if (!s || s.id == null || seen[s.id]) return;
+                        seen[s.id] = true;
+                        list.push({ id: String(s.id), name: `${s.schoolYear} ${s.name}学期` });
+                    });
+                }
+                // id 越大学期越新, 按新→旧排列
+                list.sort((a, b) => (parseInt(b.id) || 0) - (parseInt(a.id) || 0));
+            }
+        } catch (e) { /* dataQuery 失败, 走兜底 */ }
+    }
+
+    // 兜底: 仅使用页面自带的当前学期
+    if (list.length === 0) {
+        if (!currentSemesterId) {
+            throw new Error("无法获取学期列表, 请确认已登录教务系统");
+        }
+        list = [{ id: currentSemesterId, name: "当前学期" }];
+    }
+
+    // 计算默认选中项: 当前学期优先
+    let defaultIndex = 0;
+    if (currentSemesterId) {
+        const idx = list.findIndex(s => s.id === currentSemesterId);
+        if (idx >= 0) defaultIndex = idx;
+    }
+
+    const idx = await window.shiguangBridgePromise.showSingleSelection(
+        "选择学期", JSON.stringify(list.map(s => s.name)), defaultIndex
+    );
+    return idx !== null ? list[idx] : null;
+}
+
+/**
+ * 拉取指定学期的完整课表页面(包含全部周次的 TaskActivity 数据)
+ */
+async function fetchCourseTableHtml(semesterId, ids) {
+    return await request(`${BASE_URL}/courseTableForStd!courseTable.action?sf_request_type=ajax`, {
+        method: "POST",
+        headers: { "Content-Type": "application/x-www-form-urlencoded" },
+        body: `ignoreHead=1&setting.kind=std&startWeek=&project.id=1&semester.id=${encodeURIComponent(semesterId)}&ids=${encodeURIComponent(ids)}`
+    });
+}
+
+/**
+ * 解析课表 HTML 中的 TaskActivity JS 数据 (上师大版本)
+ *
+ * 树维 EAMS 页面结构:
+ *   var teachers = new Array();
+ *   var actTeachers = new Array();
+ *   teachers[0] = "张三";
+ *   actTeachers[0] = {id: 123, name: "张三"};
+ *   activity = new TaskActivity(参数中引号字段依次为: [0]编号? [1]课程名 [2]? [3]地点 [4]周次bitmap [5]...);
+ *   index = day * unitCount + slot;   // day: 0=周一, slot: 0-based 节次
+ *
+ * 周次 bitmap: 第 i 位为 '1' 表示第 i 周有课(第 0 位恒为 '0')
+ */
+function parseTaskActivities(html) {
+    const rawResults = [];
+
+    const unitCountMatch = html.match(/unitCount\s*=\s*(\d+)/);
+    const unitCount = unitCountMatch ? parseInt(unitCountMatch[1]) : DEFAULT_UNIT_COUNT;
+
+    const blocks = html.split(/activity\s*=\s*new\s+TaskActivity\s*\(/);
+
+    for (let i = 1; i < blocks.length; i++) {
+        const block = blocks[i];
+
+        // 教师来自上一个分块末尾的 actTeachers 赋值(即本课程对应的教师数组)
+        let teacher = "";
+        const prevBlock = blocks[i - 1];
+        // 风格一: 数组字面量 actTeachers = [{id:.., name:"张三"}, ...]
+        const teacherAssigns = prevBlock.match(/actTeachers\s*=\s*\[([\s\S]*?)\]/g);
+        if (teacherAssigns && teacherAssigns.length > 0) {
+            const names = teacherAssigns[teacherAssigns.length - 1].match(/name\s*:\s*"([^"]*)"/g);
+            if (names) {
+                teacher = names.map(n => n.replace(/name\s*:\s*"/, "").replace(/"$/, "")).join(",");
+            }
+        }
+        // 风格二: 元素赋值 actTeachers[0] = {id:.., name:"张三"};
+        if (!teacher) {
+            const elemAssigns = prevBlock.match(/actTeachers\[\d+\]\s*=\s*\{[^}]*\}/g);
+            if (elemAssigns && elemAssigns.length > 0) {
+                const names = elemAssigns.join(",").match(/name\s*:\s*"([^"]*)"/g);
+                if (names) {
+                    teacher = names.map(n => n.replace(/name\s*:\s*"/, "").replace(/"$/, "")).join(",");
+                }
+            }
+        }
+
+        // 提取 TaskActivity(...) 参数 (截到第一个 ");")
+        const argsMatch = block.match(/^([\s\S]*?)\)\s*;/);
+        if (!argsMatch) continue;
+        const argsStr = argsMatch[1];
+
+        const quoted = [];
+        const quotedMatches = argsStr.match(/"([^"]*)"/g);
+        if (quotedMatches) {
+            quotedMatches.forEach(q => quoted.push(q.substring(1, q.length - 1)));
+        }
+        if (quoted.length < 5) continue;
+
+        // 课程名: 去掉末尾的 "(xx)" 班级号后缀
+        let courseName = (quoted[1] || "").trim();
+        courseName = courseName.replace(/\([^)]*\)\s*$/, "").trim();
+        if (!courseName) courseName = "未知课程";
+
+        // 周次 bitmap: 优先取第 5 个引号字段, 不符时回退为首个形如 0/1 串的字段
+        let weeksBitmap = quoted[4];
+        if (!/^[01]+$/.test(weeksBitmap)) {
+            const bitmapIdx = quoted.findIndex(q => /^[01]{6,}$/.test(q));
+            if (bitmapIdx < 0) continue;
+            weeksBitmap = quoted[bitmapIdx];
+        }
+
+        // 地点: bitmap 前一个引号字段 (标准结构), 兜底取第 4 个
+        let position = quoted[3] || "";
+        const finalBitmapIdx = quoted.indexOf(weeksBitmap);
+        if (finalBitmapIdx > 0 && finalBitmapIdx !== 4) {
+            position = quoted[finalBitmapIdx - 1] || position;
+        }
+        position = position.replace(/\(.*?\)/g, "").trim() || "未知地点";
+
+        // 周次: bitmap 第 i 位 = 第 i 周
+        const weeks = [];
+        for (let j = 0; j < weeksBitmap.length; j++) {
+            if (weeksBitmap.charAt(j) === "1") weeks.push(j);
+        }
+        if (weeks.length === 0) continue;
+
+        // 上课时间: index = day * unitCount + slot
+        const idxRegex = /index\s*=\s*(\d+)\s*\*\s*unitCount\s*\+\s*(\d+)\s*;/g;
+        let m;
+        while ((m = idxRegex.exec(block)) !== null) {
+            rawResults.push({
+                name: courseName,
+                teacher: teacher || "未知教师",
+                position: position,
+                day: parseInt(m[1]) + 1,          // 1=周一 ... 7=周日
+                startSection: parseInt(m[2]) + 1, // 1-based 节次
+                endSection: parseInt(m[2]) + 1,
+                weeks: weeks
+            });
+        }
+    }
+
+    return mergeContinuousLessons(rawResults);
+}
+
+/**
+ * 全局课程合并逻辑
+ */
+function mergeContinuousLessons(lessons) {
+    if (!lessons || lessons.length === 0) return [];
+    // 1. 建立基于 (课程名|教师|地点|星期几) 的分组
+    const groups = {};
+    lessons.forEach(l => {
+        const key = `${l.name}|${l.teacher}|${l.position}|${l.day}`;
+        if (!groups[key]) {
+            groups[key] = {
+                name: l.name,
+                teacher: l.teacher,
+                position: l.position,
+                day: l.day,
+                // 假设大学最多 50 周,构建一个:第 N 周对应哪些节次的矩阵
+                weeksMatrix: Array.from({ length: 50 }, () => new Set())
+            };
+        }
+        // 将系统传来的凌乱数据彻底打散,按“周”填入对应的“节”中,Set自动去重
+        if (l.weeks && Array.isArray(l.weeks)) {
+            l.weeks.forEach(w => {
+                if (w >= 0 && w < 50) {
+                    for (let s = l.startSection; s <= l.endSection; s++) {
+                        groups[key].weeksMatrix[w].add(s);
+                    }
+                }
+            });
+        }
+    });
+    const merged = [];
+    // 2. 根据矩阵重新组装绝对精确的课程块
+    for (const key in groups) {
+        const group = groups[key];
+        const matrix = group.weeksMatrix;
+
+        // 用于记录相同的“连续节次块”分布在哪些周次
+        // 例如 blockMap["1-2"] = [1, 2, 3, 4, 5, 6, 7, 8, 9]
+        // 例如 blockMap["2-2"] = [10]
+        const blockMap = {};
+        for (let w = 0; w < matrix.length; w++) {
+            const sections = Array.from(matrix[w]).sort((a, b) => a - b);
+            if (sections.length === 0) continue;
+            // 寻找当前周的连续节次块
+            let start = sections[0];
+            let prev = sections[0];
+            for (let i = 1; i < sections.length; i++) {
+                const curr = sections[i];
+                if (curr === prev + 1) {
+                    prev = curr; // 节次连续,继续延伸
+                } else {
+                    // 节次断开,结算上一个块
+                    const blockKey = `${start}-${prev}`;
+                    if (!blockMap[blockKey]) blockMap[blockKey] = [];
+                    blockMap[blockKey].push(w);
+
+                    // 开启新块
+                    start = curr;
+                    prev = curr;
+                }
+            }
+            // 结算每周最后一个块
+            const blockKey = `${start}-${prev}`;
+            if (!blockMap[blockKey]) blockMap[blockKey] = [];
+            blockMap[blockKey].push(w);
+        }
+        // 3. 将聚合好的 blockMap 转换为最终的 JSON 对象
+        for (const blockKey in blockMap) {
+            const [startSec, endSec] = blockKey.split('-').map(Number);
+            merged.push({
+                name: group.name,
+                teacher: group.teacher,
+                position: group.position,
+                day: group.day,
+                startSection: startSec,
+                endSection: endSec,
+                weeks: blockMap[blockKey]
+            });
+        }
+    }
+    // 4. 排序以便输出整洁美观
+    merged.sort((a, b) => {
+        if (a.day !== b.day) return a.day - b.day;
+        if (a.startSection !== b.startSection) return a.startSection - b.startSection;
+        return a.name.localeCompare(b.name);
+    });
+    return merged;
+}
+
+/**
+ * 导入上师大预设作息时间
+ */
+async function applyTimeSlots() {
+    return await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(SHNU_TIME_SLOTS));
+}
+
+/**
+ * 编排整个课程导入流程。
+ * 在任何一步用户取消或发生错误时,都会立即退出,notifyTaskCompletion()只在成功后调用
+ */
+async function runImportFlow() {
+    try {
+        window.shiguangBridge.showToast("开始探测教务参数...");
+        const html = await fetchCourseTablePage();
+        const params = detectParameters(html);
+        if (!params) throw new Error("未能识别教务参数, 请先登录教务系统再点击导入");
+
+        window.shiguangBridge.showToast("正在获取学期列表...");
+        const semester = await getSelectedSemester(params.tagId, params.currentSemesterId);
+        if (!semester) return; // 用户取消
+
+        window.shiguangBridge.showToast(`正在同步 ${semester.name} 课表...`);
+        const tableHtml = await fetchCourseTableHtml(semester.id, params.ids);
+        const courses = parseTaskActivities(tableHtml);
+        if (!courses || courses.length === 0) throw new Error("未解析到课程数据, 可能为该学期无课或页面结构变化");
+
+        await applyTimeSlots();
+        const saveResult = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+
+        if (saveResult) {
+            window.shiguangBridge.showToast(`成功导入 ${courses.length} 个课程条目`);
+            window.shiguangBridge.notifyTaskCompletion();
+        }
+    } catch (e) {
+        console.error(`[SHNU适配器异常] ${e.message}`);
+        window.shiguangBridge.showToast(e.message);
+    }
+}
+
+runImportFlow();