// 适配目标:武汉商学院-超星综合教学管理系统 // // 逻辑概览: // 0) 询问导出学期:可选学期来自页面下拉框 #xnxq1(服务端渲染);学期很多时分页展示 // (每页 4 个 + “更多…”翻页 + “上一页”返回);选非当前学期时用页面已有的 // xhid/xqdm 直接请求该学期 sdpkkbList JSON 并解析,无需跳转 // 1) 统一走接口:sdpkkbList 拉课程 JSON,getZclistByXnxq 取时间段/开学日期; // 不在课表页(首页/选课页等)时先抓课表页 HTML 拿 xhid/xqdm/学期列表,任意页面都能导 // 2) 课程 JSON 复刻页面逻辑:同格同课程合并周次 -> 相邻节次同内容合并(rowspan) // 3) 接口失败且当前在课表页时,回退解析页面已渲染的 DOM(td.cell + 链接) // 4) 教师姓名带工号(如“王老师(20240999)”)时,弹窗询问是否去除,默认保留 // 5) 按 课程/教师/教室/星期/周次 合并连续节次,经 AndroidBridgePromise 保存课程与时间段 // 6) 从 /admin/api/getZclistByXnxq 取周次列表:导出开学日期(第 1 周开始日)与总周数到 config // 7) 任意弹窗步骤用户点“取消”:立即终止,不解析、不保存、不触发下载 // 8) 检测重复课程(同课程/同教室/同节次/同周次):完全相同→弹“只保留一门”, // 多教师→弹“合并教师”(选项与标题随类型变化),取消即终止 (function () { function toast(message) { if (window.AndroidBridge && typeof window.AndroidBridge.showToast === "function") { window.AndroidBridge.showToast(message); } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } // 用户点击“取消”时的统一返回值:任意流程取消即终止整个导入 const CANCELED = "CANCELED"; // 桥接端是否支持单选弹窗 function canShowSingleSelection() { return !!( window.AndroidBridgePromise && typeof window.AndroidBridgePromise.showSingleSelection === "function" ); } async function getTargetDocument() { if (location.href.includes("queryKbForXsd")) { return document; } const iframe = document.querySelector("iframe[src*='queryKbForXsd']"); if (!iframe) return null; for (let i = 0; i < 20; i += 1) { try { const doc = iframe.contentDocument || (iframe.contentWindow && iframe.contentWindow.document); if (doc && doc.readyState && doc.readyState !== "loading") { return doc; } } catch (e) { // ignore } await sleep(500); } return null; } async function waitForScheduleData(doc, timeoutMs = 15000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { const cells = Array.from(doc.querySelectorAll("td.cell, td[id^='Cell']")); const filled = cells.filter((cell) => { const text = (cell.innerText || cell.textContent || "").trim(); return text.length > 0 && /周/.test(text); }); if (filled.length > 0) { return true; } await sleep(500); } return false; } function uniqueSortedNumbers(nums) { const set = new Set(nums.filter((n) => Number.isFinite(n))); return Array.from(set).sort((a, b) => a - b); } function parseWeekText(weekText) { if (!weekText) return []; let text = String(weekText).trim(); if (!text) return []; let oddOnly = false; let evenOnly = false; if (text.includes("单")) oddOnly = true; if (text.includes("双")) evenOnly = true; text = text.replace(/周/g, ""); text = text.replace(/\s+/g, ""); text = text.replace(/\(.*?\)/g, ""); text = text.replace(/(.*?)/g, ""); // 兼容接口 zc 的写法:1-19(单)/2-18(双)/裸“单”“双”/“改”后缀 text = text.replace(/[((]?单[))]?/g, ""); text = text.replace(/[((]?双[))]?/g, ""); text = text.replace(/改$/g, ""); const weeks = []; const segments = text.split(",").map((s) => s.trim()).filter(Boolean); segments.forEach((seg) => { if (!seg) return; const rangeMatch = seg.match(/^(\d+)-(\d+)$/); if (rangeMatch) { const start = parseInt(rangeMatch[1], 10); const end = parseInt(rangeMatch[2], 10); if (!Number.isFinite(start) || !Number.isFinite(end)) return; for (let w = start; w <= end; w += 1) { weeks.push(w); } return; } const single = parseInt(seg, 10); if (Number.isFinite(single)) weeks.push(single); }); let filtered = weeks; if (oddOnly && !evenOnly) { filtered = weeks.filter((w) => w % 2 === 1); } else if (evenOnly && !oddOnly) { filtered = weeks.filter((w) => w % 2 === 0); } return uniqueSortedNumbers(filtered); } // 接口行周次:优先 zcstr(逗号列表),回退 zc(parseWeekText 已兼容范围/单双/改) function parseWeeksFromRow(row) { const zcstr = String(row.zcstr || "").trim(); if (zcstr) { const weeks = parseWeekText(zcstr + "周"); if (weeks.length) return weeks; } return parseWeekText(String(row.zc || "") + "周"); } function stripHtmlText(text) { return String(text || "").replace(/<[^>]+>/g, "").trim(); } function splitCourseBlocks(cellText) { const text = cellText.replace(/\r/g, "").trim(); if (!text) return []; return text .split(/\n{2,}/) .map((block) => block.trim()) .filter(Boolean); } function extractWeeksTextFromLine(line) { if (!line) return { weeksText: "", rest: line || "" }; const match = line.match(/(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*)\s*(?:\((单|双)\))?\s*周/); if (!match) return { weeksText: "", rest: line }; const weeksCore = match[1]; const oddEven = match[2] ? `(${match[2]})` : ""; const weeksText = `${weeksCore}${oddEven}周`; const rest = line.replace(match[0], "").trim(); return { weeksText, rest }; } // 从单元格里的 链接按语义提取文本(武汉商学院课表把课程名/教师/教室渲染成链接) function extractAnchorText(cell, onclickKeyword, blockText) { if (!cell) return ""; const anchors = Array.from( cell.querySelectorAll('a[onclick*="' + onclickKeyword + '"]') ).sort((a, b) => (b.textContent || "").length - (a.textContent || "").length); for (const anchor of anchors) { const text = (anchor.textContent || "").trim(); if (text && blockText.includes(text)) { return text; } } return ""; } function parseCourseBlock(cell, block) { const lines = block .split(/\n+/) .map((l) => l.trim()) .filter(Boolean); if (!lines.length) return null; // 优先用单元格内的链接解析,避免把“校区名”当成课程名、把“课程名”当成教师。 // 武商院课表单元格文本顺序为:校区 -> 课程名 -> 教师 周次 -> 教室。 const anchorName = extractAnchorText(cell, "openKckb", block); const anchorTeacher = extractAnchorText(cell, "openJskb", block); const anchorPosition = extractAnchorText(cell, "openCrkb", block); const name = anchorName || lines[0] || ""; let teacher = ""; let weeksText = ""; let position = ""; const weekLineIndex = lines.findIndex((l) => /周/.test(l)); if (weekLineIndex >= 0) { const { weeksText: extractedWeeks, rest } = extractWeeksTextFromLine( lines[weekLineIndex] ); weeksText = extractedWeeks; if (weekLineIndex === 1) { teacher = rest || lines[1]; } } if (!teacher && lines.length > 1) { teacher = lines[1]; const { weeksText: extractedWeeks, rest } = extractWeeksTextFromLine(teacher); if (extractedWeeks) { weeksText = weeksText || extractedWeeks; teacher = rest; } } if (!weeksText) { for (const line of lines) { const { weeksText: extractedWeeks } = extractWeeksTextFromLine(line); if (extractedWeeks) { weeksText = extractedWeeks; break; } } } if (!position) { if (weekLineIndex >= 0 && weekLineIndex + 1 < lines.length) { position = lines[weekLineIndex + 1]; } if (!position) { position = lines.find((l) => l !== name && l !== teacher && !/周/.test(l)) || ""; } } return { name: anchorName || name || "未知课程", teacher: anchorTeacher || teacher || "", weeksText, position: anchorPosition || position || "", }; } function padTime(value) { const text = String(value || "").trim(); const match = text.match(/^(\d{1,2}):(\d{1,2})$/); if (!match) return text; const h = match[1].padStart(2, "0"); const m = match[2].padStart(2, "0"); return `${h}:${m}`; } function randomColor() { return Math.floor(Math.random() * 12) + 1; } function createId() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } return `id-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; } function mergeCourses(courses) { const byKey = new Map(); courses.forEach((course) => { const weeksKey = (course.weeks || []).join(","); const key = [course.name, course.teacher, course.position, course.day, weeksKey].join("|"); if (!byKey.has(key)) byKey.set(key, []); byKey.get(key).push({ ...course }); }); const merged = []; byKey.forEach((items) => { items.sort((a, b) => a.startSection - b.startSection); let current = null; items.forEach((item) => { if (!current) { current = { ...item }; return; } if (item.startSection === current.endSection + 1) { current.endSection = Math.max(current.endSection, item.endSection); } else { merged.push(current); current = { ...item }; } }); if (current) merged.push(current); }); return merged; } function parseScheduleFromDocument(doc) { const cells = Array.from(doc.querySelectorAll("td.cell")); const fallbackCells = cells.length ? [] : Array.from(doc.querySelectorAll("td[id^='Cell']")); const targetCells = cells.length ? cells : fallbackCells; const courses = []; const seen = new Set(); targetCells.forEach((cell) => { const id = cell.getAttribute("id") || ""; const match = id.match(/^Cell(\d)(\d{1,2})$/); if (!match) return; const day = parseInt(match[1], 10); const startSection = parseInt(match[2], 10); const rowspan = parseInt(cell.getAttribute("rowspan") || "1", 10); const endSection = startSection + Math.max(rowspan, 1) - 1; const blocks = splitCourseBlocks(cell.innerText || ""); blocks.forEach((blockText) => { const parsed = parseCourseBlock(cell, blockText); if (!parsed) return; const weeks = parseWeekText(parsed.weeksText); if (!weeks.length) return; const key = [ parsed.name, parsed.teacher, parsed.position, day, startSection, endSection, weeks.join(","), ].join("|"); if (seen.has(key)) return; seen.add(key); courses.push({ id: createId(), name: parsed.name, teacher: parsed.teacher, position: parsed.position, day, startSection, endSection, color: randomColor(), weeks, }); }); }); return mergeCourses(courses); } function parseTimeSlots(doc) { const slots = []; const seenNumbers = new Set(); const timeRegex = /(\d{1,2}:\d{2})/g; const timeCells = Array.from( doc.querySelectorAll("td[data-jcindex], td[data-jcIndex]") ); timeCells.forEach((cell) => { const text = (cell.innerText || cell.textContent || "").trim(); if (!text) return; const indexAttr = cell.getAttribute("data-jcindex") || cell.getAttribute("data-jcIndex"); const numberMatch = text.match(/^(\d{1,2})/); const number = parseInt(indexAttr || (numberMatch && numberMatch[1]) || "", 10); if (!Number.isFinite(number)) return; const times = text.match(timeRegex) || []; if (times.length < 2) return; if (seenNumbers.has(number)) return; seenNumbers.add(number); slots.push({ number, startTime: padTime(times[0]), endTime: padTime(times[1]), }); }); return slots.sort((a, b) => a.number - b.number); } // ===== 教师工号处理 ===== // 匹配“姓名(工号)”/“姓名(工号)”结尾的教师名 const TEACHER_ID_RE = /^(.+?)\s*[((](\d{4,})[))]$/; function hasTeacherId(teacher) { return TEACHER_ID_RE.test(teacher || ""); } function stripTeacherId(teacher) { const match = TEACHER_ID_RE.exec(teacher || ""); return match ? match[1].trim() : teacher; } // 若检测到教师姓名带工号,弹窗询问是否保留(参考 school.bak.js 的单选弹窗用法, // 选项里同时展示“保留”和“去除”后的样子,方便核对脚本解析是否正确) async function askKeepTeacherId(courses) { const withId = courses.filter((c) => hasTeacherId(c.teacher)); if (!withId.length) return "keep"; if (!canShowSingleSelection()) { console.warn("AndroidBridgePromise.showSingleSelection 不可用,教师工号默认保留"); return "keep"; } const example = withId[0].teacher; const stripped = stripTeacherId(example); const options = [ `保留工号(如:${example})`, `去除工号(如:${stripped})`, ]; console.log(`检测到 ${withId.length} 门课的教师姓名带工号,即将弹出询问...`); try { const selectedIndex = await window.AndroidBridgePromise.showSingleSelection( `教师姓名带工号(共 ${withId.length} 门课)`, JSON.stringify(options), 0 ); if (selectedIndex === null) { console.log("用户取消工号询问,终止导入"); return CANCELED; } if (selectedIndex === 1) { console.log("用户选择去除工号,例如:" + example + " -> " + stripped); return "strip"; } console.log("用户选择保留工号,保持原样"); return "keep"; } catch (error) { console.error("教师工号询问失败,默认保留工号:", error); return "keep"; } } // ===== 重复/冲突课程处理 ===== // 系统有时会把同一门课拆成多条(同名/同教室/同周次/同节次): // - 完全相同:教师也相同 -> 只需“只保留一门” // - 多教师:教师不同(如两位老师同上一门课)-> 用“合并教师”并列成一条 function findDuplicateCourseGroups(courses) { const groups = new Map(); courses.forEach((c) => { const key = [ c.name, c.position, c.day, c.startSection, c.endSection, (c.weeks || []).join(","), ].join("|"); if (!groups.has(key)) groups.set(key, []); groups.get(key).push(c); }); return Array.from(groups.values()) .filter((g) => g.length > 1) .map((g) => ({ courses: g, type: new Set(g.map((c) => c.teacher).filter(Boolean)).size > 1 ? "multiTeacher" : "identical", })); } // 弹窗询问重复课程处理。选项随重复类型变化: // 完全相同 -> 显示“只保留一门”;多教师 -> 显示“合并教师”;两类都有则都显示。 // 取消返回 CANCELED 终止导入。 async function askHandleDuplicateCourses(courses) { const groups = findDuplicateCourseGroups(courses); if (!groups.length) return "keep"; if (!canShowSingleSelection()) { console.warn("AndroidBridgePromise.showSingleSelection 不可用,重复课程默认全部保留"); return "keep"; } const total = groups.reduce((n, g) => n + g.courses.length, 0); const hasIdentical = groups.some((g) => g.type === "identical"); const hasMulti = groups.some((g) => g.type === "multiTeacher"); const exampleGroup = groups.find((g) => g.type === "multiTeacher") || groups[0]; const example = exampleGroup.courses[0]; const teacherText = Array.from( new Set(exampleGroup.courses.map((c) => c.teacher).filter(Boolean)) ).join("、"); const options = [`全部保留(${total} 门)`]; let dedupeIndex = -1; let mergeIndex = -1; if (hasIdentical) { dedupeIndex = options.length; options.push(`只保留一门(去重:如 ${example.name} 只留 1 门)`); } if (hasMulti) { mergeIndex = options.length; options.push(`合并教师(如:${example.name} -> ${teacherText})`); } let title; if (hasIdentical && hasMulti) { title = `重复课程处理(${groups.length} 组 / ${total} 门,含相同课程与多教师)`; } else if (hasMulti) { title = `多教师重复课程处理(${groups.length} 组 / ${total} 门)`; } else { title = `完全相同的重复课程处理(${groups.length} 组 / ${total} 门)`; } console.log( "检测到重复课程:", groups.map((g) => g.type + "x" + g.courses.length).join(", "), "即将弹出询问..." ); try { const selectedIndex = await window.AndroidBridgePromise.showSingleSelection( title, JSON.stringify(options), 0 ); if (selectedIndex === null) { console.log("用户取消重复课程处理,终止导入"); return CANCELED; } if (selectedIndex === dedupeIndex) { // 只保留每组第一门(去重) groups.forEach((g) => { g.courses.slice(1).forEach((c) => { const idx = courses.indexOf(c); if (idx >= 0) courses.splice(idx, 1); }); }); console.log("已去重,剩余课程:", courses.length); return "dedupe"; } if (selectedIndex === mergeIndex) { // 合并:每组保留一门;多教师组教师并列 const removeIds = new Set(); groups.forEach((g) => { const first = g.courses[0]; if (g.type === "multiTeacher") { first.teacher = Array.from( new Set(g.courses.map((c) => c.teacher).filter(Boolean)) ).join("、"); } g.courses.slice(1).forEach((c) => removeIds.add(c.id)); }); for (let i = courses.length - 1; i >= 0; i -= 1) { if (removeIds.has(courses[i].id)) courses.splice(i, 1); } console.log("已合并,剩余课程:", courses.length); return "merge"; } console.log("用户选择全部保留"); return "keep"; } catch (error) { console.error("重复课程处理失败,默认全部保留:", error); return "keep"; } } // ===== 学期选择与跨学期数据获取 ===== // 页面下拉框 #xnxq1 的