// 适配目标:武汉商学院-超星综合教学管理系统
//
// 逻辑概览:
// 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 的