| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397 |
- // 闽南科技学院(mku.edu.cn) 轻屿课表适配脚本
- // 教务平台:强智教务系统(新版 layui UI)
- // 数据来源:个人课表 /jsxsd/xskb/xskb_list.do(默认渲染当前学期,可用 xnxq01id 切换学期)
- // 解析逻辑参考本仓库 HYNU / ZHKU 两个强智适配的实现,按 MKU 新版 DOM 重写
- // 非该校在校开发者适配,出现问题请提交 issue 或 PR
- // ===== 基础常量 =====
- const MKU_BASE_URL = "https://jwgl.mku.edu.cn";
- const MKU_TIMETABLE_URL = MKU_BASE_URL + "/jsxsd/xskb/xskb_list.do";
- const MKU_WEEK_AJAX_URL = MKU_BASE_URL + "/jsxsd/xskb/jxzlzc_xnxq_ajax";
- const MKU_HOME_URL = MKU_BASE_URL + "/jsxsd/framework/xsMainV.htmlx";
- // 默认总周数;实际值优先取自教务周次接口 jxzlzc_xnxq_ajax
- const MKU_DEFAULT_TOTAL_WEEKS = 20;
- // 每小节课 45 分钟、课间 10 分钟(与课表页大节时间 08:00~09:40 等标注一致)
- const MKU_SECTION_MINUTES = 45;
- const MKU_BREAK_MINUTES = 10;
- // 兜底作息(按 2026-2027-1 学期课表页标注的大节时间推导)
- const MKU_FALLBACK_TIME_SLOTS = [
- { number: 1, startTime: "08:00", endTime: "08:45" },
- { number: 2, startTime: "08:55", endTime: "09:40" },
- { number: 3, startTime: "10:00", endTime: "10:45" },
- { number: 4, startTime: "10:55", endTime: "11:40" },
- { number: 5, startTime: "14:00", endTime: "14:45" },
- { number: 6, startTime: "14:55", endTime: "15:40" },
- { number: 7, startTime: "15:50", endTime: "16:35" },
- { number: 8, startTime: "16:45", endTime: "17:30" },
- { number: 9, startTime: "18:30", endTime: "19:15" },
- { number: 10, startTime: "19:25", endTime: "20:10" },
- { number: 11, startTime: "20:20", endTime: "21:05" },
- { number: 12, startTime: "21:15", endTime: "22:00" }
- ];
- // ===== 通用工具 =====
- // HH:mm 转当天分钟数
- function mkuTimeToMinutes(hhmm) {
- const parts = hhmm.split(":");
- return Number(parts[0]) * 60 + Number(parts[1]);
- }
- // 当天分钟数转 HH:mm
- function mkuMinutesToTime(total) {
- const h = Math.floor(total / 60);
- const m = total % 60;
- return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0");
- }
- // Date 转 YYYY-MM-DD
- function mkuFormatDate(date) {
- const y = date.getFullYear();
- const m = String(date.getMonth() + 1).padStart(2, "0");
- const d = String(date.getDate()).padStart(2, "0");
- return y + "-" + m + "-" + d;
- }
- // ===== 周次与节次解析 =====
- // 将周次片段("1,3,5" / "1-16" / "1-8,10-16")展开为去重排序的数字数组
- function mkuExpandWeeks(weekPart) {
- const weeks = [];
- (weekPart.match(/\d+(?:\s*[-~]\s*\d+)?/g) || []).forEach((seg) => {
- const m = seg.match(/(\d+)\s*(?:[-~]\s*(\d+))?/);
- const start = Number(m[1]);
- const end = m[2] ? Number(m[2]) : start;
- for (let w = start; w <= end; w++) weeks.push(w);
- });
- return [...new Set(weeks)].sort((a, b) => a - b);
- }
- // 解析"时间"字段,如 "1,3,5,7,9,11,13,15周[1-2节]"
- function mkuParseTimeText(timeText) {
- if (!timeText) return null;
- const weekMatch = timeText.match(/^([^周]+)周/);
- const secMatch = timeText.match(/\[\s*(\d+)\s*(?:[-~]\s*(\d+))?\s*节\s*\]/);
- if (!weekMatch && !secMatch) return null;
- let weeks = weekMatch ? mkuExpandWeeks(weekMatch[1]) : null;
- if (weeks) {
- // 兼容"单周/双周"标注
- if (/单\s*周/.test(timeText)) weeks = weeks.filter((w) => w % 2 === 1);
- if (/双\s*周/.test(timeText)) weeks = weeks.filter((w) => w % 2 === 0);
- }
- return {
- weeks,
- startSection: secMatch ? Number(secMatch[1]) : null,
- endSection: secMatch ? (secMatch[2] ? Number(secMatch[2]) : Number(secMatch[1])) : null
- };
- }
- // ===== 课表解析 =====
- // 将表格展开为 grid[row][col],正确处理 rowspan / colspan
- function mkuBuildGrid(table) {
- const grid = [];
- Array.from(table.rows).forEach((tr, r) => {
- if (!grid[r]) grid[r] = [];
- let col = 0;
- Array.from(tr.cells).forEach((td) => {
- while (grid[r][col]) col++;
- const rowSpan = td.rowSpan || 1;
- const colSpan = td.colSpan || 1;
- for (let dr = 0; dr < rowSpan; dr++) {
- for (let dc = 0; dc < colSpan; dc++) {
- if (!grid[r + dr]) grid[r + dr] = [];
- grid[r + dr][col + dc] = td;
- }
- }
- col += colSpan;
- });
- });
- return grid;
- }
- // 解析行标签(第 0 列):大节范围与时间,如 "1-2" + "08:00~09:40"
- function mkuParseRowInfos(table) {
- const rowInfos = new Map();
- Array.from(table.rows).forEach((tr, r) => {
- const labelCell = tr.cells[0];
- if (!labelCell) return;
- const titleText = labelCell.querySelector(".index-title")
- ? labelCell.querySelector(".index-title").textContent.trim()
- : "";
- const range = titleText.match(/^(\d+)(?:\s*[-~]\s*(\d+))?$/);
- if (!range) return;
- const timeMatch = labelCell.textContent.match(/(\d{1,2}:\d{2})\s*~\s*(\d{1,2}:\d{2})/);
- rowInfos.set(r, {
- startSection: Number(range[1]),
- endSection: range[2] ? Number(range[2]) : Number(range[1]),
- blockStart: timeMatch ? mkuTimeToMinutes(timeMatch[1]) : null,
- blockEnd: timeMatch ? mkuTimeToMinutes(timeMatch[2]) : null
- });
- });
- return rowInfos;
- }
- // 从"老师:xx;时间:..周[..节];地点:yy"详情串中按标签取字段
- function mkuParseInfoField(text, label) {
- const m = text.match(new RegExp(label + "[::]\\s*([^;;]*)"));
- return m ? m[1].trim() : "";
- }
- // 解析课表文档中的全部课程(同一单元格可能含多条课程,逐 li 解析)
- function mkuParseCourses(doc) {
- const sampleTd = doc.querySelector('td[name="kbDataTd"]');
- if (!sampleTd) return { courses: [], rowInfos: new Map() };
- const table = sampleTd.closest("table");
- const grid = mkuBuildGrid(table);
- const rowInfos = mkuParseRowInfos(table);
- const courses = [];
- const seenTds = new Set();
- for (const [row, rowInfo] of rowInfos) {
- for (let day = 1; day <= 7; day++) {
- const td = grid[row] && grid[row][day];
- // rowspan 跨行的大格只在首行解析一次
- if (!td || seenTds.has(td)) continue;
- seenTds.add(td);
- Array.from(td.querySelectorAll("li")).forEach((li) => {
- const nameEl = li.querySelector(".qz-hasCourse-title");
- const infoEl = li.querySelector(".qz-hasCourse-abbrinfo");
- if (!nameEl || !infoEl) return;
- const name = nameEl.textContent.trim();
- const infoText = infoEl.textContent.trim();
- if (!name || !infoText) return;
- const teacher = mkuParseInfoField(infoText, "老师") || "未知教师";
- const position = mkuParseInfoField(infoText, "地点") || "未知地点";
- const timeInfo = mkuParseTimeText(mkuParseInfoField(infoText, "时间"));
- // 节次以"时间"字段为准,缺失时回退到大节行标签
- const startSection = timeInfo && timeInfo.startSection
- ? timeInfo.startSection
- : rowInfo.startSection;
- const endSection = timeInfo && timeInfo.endSection
- ? timeInfo.endSection
- : rowInfo.endSection;
- const weeks = timeInfo && timeInfo.weeks && timeInfo.weeks.length
- ? timeInfo.weeks
- : null;
- // 无周次信息则跳过,避免导入错误数据
- if (!weeks) return;
- courses.push({
- name,
- teacher,
- position,
- day,
- startSection,
- endSection,
- weeks
- });
- });
- }
- }
- // 完全重复的条目去重(同名/同师/同地/同天/同节次/同周次)
- const deduped = [];
- const seenKeys = new Set();
- for (const course of courses) {
- const key = [
- course.name, course.teacher, course.position, course.day,
- course.startSection, course.endSection, course.weeks.join(",")
- ].join("|");
- if (seenKeys.has(key)) continue;
- seenKeys.add(key);
- deduped.push(course);
- }
- return { courses: deduped, rowInfos };
- }
- // 由课表页大节时间推导小节作息;推导失败时回退兜底配置
- function mkuBuildTimeSlots(rowInfos) {
- const slots = [];
- const rows = [...rowInfos.values()].sort((a, b) => a.startSection - b.startSection);
- for (const info of rows) {
- if (info.blockStart == null || info.blockEnd == null) return MKU_FALLBACK_TIME_SLOTS;
- const count = info.endSection - info.startSection + 1;
- const span = info.blockEnd - info.blockStart;
- if (span !== MKU_SECTION_MINUTES * count + MKU_BREAK_MINUTES * (count - 1)) {
- return MKU_FALLBACK_TIME_SLOTS;
- }
- for (let i = 0; i < count; i++) {
- const start = info.blockStart + i * (MKU_SECTION_MINUTES + MKU_BREAK_MINUTES);
- slots.push({
- number: info.startSection + i,
- startTime: mkuMinutesToTime(start),
- endTime: mkuMinutesToTime(start + MKU_SECTION_MINUTES)
- });
- }
- }
- return slots.length ? slots : MKU_FALLBACK_TIME_SLOTS;
- }
- // ===== 教务数据获取 =====
- // 拉取个人课表页面(不传学期则渲染教务当前学期)
- async function mkuFetchTimetableDoc(semesterId) {
- let url = MKU_TIMETABLE_URL + "?viweType=0&zc=&cj0701id=";
- if (semesterId) url += "&xnxq01id=" + encodeURIComponent(semesterId);
- const resp = await fetch(url, { credentials: "include" });
- if (!resp.ok) throw new Error("获取课表页面失败:HTTP " + resp.status);
- const html = await resp.text();
- const doc = new DOMParser().parseFromString(html, "text/html");
- if (!doc.querySelector("#xnxq01id") && !doc.querySelector('td[name="kbDataTd"]')) {
- throw new Error("未获取到课表数据,请先在浏览器登录教务系统");
- }
- return doc;
- }
- // 学期下拉选项(value 与显示文本一致,如 2026-2027-1)
- function mkuGetSemesterOptions(doc) {
- const select = doc.querySelector("#xnxq01id");
- if (!select) return [];
- return Array.from(select.options)
- .filter((o) => o.value)
- .map((o) => ({ value: o.value, text: o.text.trim(), selected: o.selected }));
- }
- // 学期总周数(接口返回如 [{"qszc":1,"jszc":20}])
- async function mkuFetchTotalWeeks(semesterId) {
- try {
- const resp = await fetch(MKU_WEEK_AJAX_URL + "?xnxq01id=" + encodeURIComponent(semesterId), {
- credentials: "include"
- });
- const data = await resp.json();
- if (Array.isArray(data) && data.length > 0 && Number(data[0].jszc) > 0) {
- return Number(data[0].jszc);
- }
- } catch (error) {
- console.warn("获取学期总周数失败,使用默认值:", error);
- }
- return MKU_DEFAULT_TOTAL_WEEKS;
- }
- // 学期第一周周一日期:仅当所选学期是教务"当前学期"时可信
- // 首页 #headerShowTime 形如 "2026-2027-1 第1周",结合本地今天日期反推开学日
- async function mkuFetchSemesterStartDate(semesterId) {
- try {
- const resp = await fetch(MKU_HOME_URL, { credentials: "include" });
- if (!resp.ok) return null;
- const html = await resp.text();
- const m = html.match(/id="headerShowTime"[^>]*>\s*([^<]+?)\s*</);
- if (!m) return null;
- const currentSemester = m[1].split(/\s+/)[0];
- const weekMatch = m[1].match(/第\s*(\d+)\s*周/);
- if (currentSemester !== semesterId || !weekMatch) return null;
- const currentWeek = Number(weekMatch[1]);
- if (!(currentWeek >= 1)) return null;
- const now = new Date();
- const mondayOffset = (now.getDay() + 6) % 7; // 周一记 0
- const start = new Date(
- now.getFullYear(),
- now.getMonth(),
- now.getDate() - mondayOffset - (currentWeek - 1) * 7
- );
- return mkuFormatDate(start);
- } catch (error) {
- console.warn("获取学期开始日期失败:", error);
- return null;
- }
- }
- // ===== 主流程 =====
- async function mkuRunImportFlow() {
- const bridge = window.shiguangBridge;
- const bridgePromise = window.shiguangBridgePromise;
- const confirmed = await bridgePromise.showAlert(
- "导入说明",
- "将读取闽南科技学院教务系统\"个人课表\"数据并导入轻屿课表。\n请确保已在当前浏览器登录教务系统(jwgl.mku.edu.cn)。\n是否继续?",
- "确认已登录"
- );
- if (!confirmed) {
- bridge.showToast("导入已取消");
- return;
- }
- bridge.showToast("正在读取课表页面...");
- const firstDoc = await mkuFetchTimetableDoc();
- // 让用户选择学期(默认为教务当前学期)
- const options = mkuGetSemesterOptions(firstDoc);
- const defaultOption = options.find((o) => o.selected) || null;
- let semesterId = defaultOption ? defaultOption.value : null;
- if (options.length > 0) {
- const defaultIndex = Math.max(0, options.findIndex((o) => o.selected));
- const labels = options.map((o) => o.text + (o.selected ? "(当前学期)" : ""));
- const index = await bridgePromise.showSingleSelection(
- "选择要导入的学期",
- JSON.stringify(labels),
- defaultIndex
- );
- if (index == null || index < 0 || index >= options.length) {
- bridge.showToast("导入已取消");
- return;
- }
- semesterId = options[index].value;
- }
- // 所选学期与默认渲染学期一致时直接复用首次页面
- const doc = defaultOption && semesterId === defaultOption.value
- ? firstDoc
- : await mkuFetchTimetableDoc(semesterId);
- const { courses, rowInfos } = mkuParseCourses(doc);
- if (courses.length === 0) {
- bridge.showToast("未解析到课程,请确认所选学期有课且已登录");
- return;
- }
- bridge.showToast("正在获取学期配置...");
- const totalWeeks = await mkuFetchTotalWeeks(semesterId);
- const startDate = await mkuFetchSemesterStartDate(semesterId);
- const timeSlots = mkuBuildTimeSlots(rowInfos);
- const config = {
- defaultClassDuration: MKU_SECTION_MINUTES,
- defaultBreakDuration: MKU_BREAK_MINUTES,
- semesterTotalWeeks: totalWeeks,
- firstDayOfWeek: 1
- };
- // 仅当前学期能可靠推算开学日期,历史学期留给 App 端处理
- if (startDate) config.semesterStartDate = startDate;
- await bridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
- await bridgePromise.saveCourseConfig(JSON.stringify(config));
- await bridgePromise.saveImportedCourses(JSON.stringify(courses));
- bridge.showToast("导入成功:共 " + courses.length + " 条课程");
- bridge.notifyTaskCompletion();
- }
- // 启动导入流程
- (async () => {
- try {
- await mkuRunImportFlow();
- } catch (error) {
- console.error("课表导入失败:", error);
- try {
- window.shiguangBridge.showToast(
- "导入失败:" + (error && error.message ? error.message : error)
- );
- } catch (e) {
- /* 桥接不可用时仅保留控制台日志 */
- }
- }
- })();
|