| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486 |
- // 仲恺农业工程学院拾光课程表适配脚本
- // https://edu-admin.zhku.edu.cn/
- // 教务平台:强智教务
- // 适配开发者:lc6464
- const PRESET_TIME_CONFIG = {
- campuses: {
- haizhu: {
- startTimes: {
- morning: "08:00",
- noon: "11:30",
- afternoon: "14:30",
- evening: "19:30"
- }
- },
- baiyun: {
- startTimes: {
- morning: "08:40",
- noon: "12:20",
- afternoon: "13:30",
- evening: "19:00"
- }
- }
- },
- common: {
- sectionCounts: {
- morning: 4,
- noon: 1,
- afternoon: 4,
- evening: 3
- },
- durations: {
- classMinutes: 40,
- shortBreakMinutes: 10,
- longBreakMinutes: 20
- },
- longBreakAfter: {
- morning: 2,
- noon: 0, // 午间课程无大课间
- afternoon: 2,
- evening: 0 // 晚间课程无大课间
- }
- }
- };
- const CAMPUS_OPTIONS = [
- { id: "haizhu", label: "海珠校区" },
- { id: "baiyun", label: "白云校区" }
- ];
- // 统一做文本清洗,避免 DOM 中换行与多空格干扰匹配
- function cleanText(value) {
- return (value ?? "").replace(/\s+/g, " ").trim();
- }
- // HH:mm -> 当天分钟数
- function parseTimeToMinutes(hhmm) {
- const [h, m] = hhmm.split(":").map(Number);
- return h * 60 + m;
- }
- // 当天分钟数 -> HH:mm
- function formatMinutesToTime(totalMinutes) {
- const h = Math.floor(totalMinutes / 60);
- const m = totalMinutes % 60;
- return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
- }
- // 将“2026年03月09”这类中文日期转换为“2026-03-09”
- function normalizeCnDateToIso(cnDateText) {
- const match = (cnDateText ?? "").match(/(\d{4})年(\d{1,2})月(\d{1,2})/);
- if (match == null) {
- throw new Error(`无法解析日期:${cnDateText}`);
- }
- // 这里使用 Number 而不是 parseInt
- // 输入来自正则捕获组,已是纯数字,不需要 parseInt 的截断语义
- const y = Number(match[1]);
- const m = Number(match[2]);
- const d = Number(match[3]);
- return `${String(y).padStart(4, "0")}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
- }
- // 通过首页时间模式标签识别校区
- // 规则:name="kbjcmsid" 的 li 中,带 layui-this 的若是第一个则海珠,第二个则白云
- async function detectCampusFromMainPage() {
- const url = "https://edu-admin.zhku.edu.cn/jsxsd/framework/xsMain_new.htmlx";
- const response = await fetch(url, {
- method: "GET",
- credentials: "include"
- });
- if (!response.ok) {
- throw new Error(`获取首页时间模式失败:HTTP ${response.status}`);
- }
- const html = await response.text();
- const parser = new DOMParser();
- const doc = parser.parseFromString(html, "text/html");
- const nodes = Array.from(doc.querySelectorAll('li[name="kbjcmsid"]'));
- if (nodes.length < 2) {
- return null;
- }
- const activeIndex = nodes.findIndex((node) => {
- return node.classList.contains("layui-this");
- });
- if (activeIndex === 0) {
- return "haizhu";
- }
- if (activeIndex === 1) {
- return "baiyun";
- }
- // 兜底:若索引异常,按文本再次判断
- const activeNode = activeIndex >= 0 ? nodes[activeIndex] : null;
- const activeText = cleanText(activeNode?.textContent ?? "");
- if (activeText.includes("白云")) {
- return "baiyun";
- }
- if (activeText.includes("默认")) {
- return "haizhu";
- }
- return null;
- }
- // 获取最终校区
- // 先尝试自动识别,识别失败再让用户选择
- async function chooseCampus() {
- // 按 xsMain_new.htmlx 的时间模式标签判断
- try {
- const campusFromMain = await detectCampusFromMainPage();
- if (campusFromMain != null) {
- console.log("通过首页时间模式识别到校区:", campusFromMain);
- return campusFromMain;
- }
- } catch (error) {
- console.warn("通过首页时间模式识别校区失败,将回退到页面文本识别:", error);
- }
- const labels = CAMPUS_OPTIONS.map((item) => item.label);
- const defaultIndex = 1; // 默认白云校区
- const selectedIndex = await window.AndroidBridgePromise.showSingleSelection(
- "请选择校区",
- JSON.stringify(labels),
- defaultIndex
- );
- if (selectedIndex == null || selectedIndex < 0 || selectedIndex >= CAMPUS_OPTIONS.length) {
- return "baiyun";
- }
- return CAMPUS_OPTIONS[selectedIndex].id;
- }
- // 按规则动态生成节次时间
- // 这样后续学校调整作息时,只需要改 PRESET_TIME_CONFIG
- function buildPresetTimeSlots(campusId) {
- const campus = PRESET_TIME_CONFIG.campuses[campusId] ?? PRESET_TIME_CONFIG.campuses.baiyun;
- const common = PRESET_TIME_CONFIG.common;
- const segments = ["morning", "noon", "afternoon", "evening"];
- const slots = [];
- let sectionNumber = 1;
- for (const segment of segments) {
- // 每个时段从配置中的起始时间开始滚动推导
- let cursor = parseTimeToMinutes(campus.startTimes[segment]);
- const count = common.sectionCounts[segment];
- const longBreakAfter = common.longBreakAfter[segment] ?? 0;
- for (let i = 1; i <= count; i += 1) {
- const start = cursor;
- const end = start + common.durations.classMinutes;
- slots.push({
- number: sectionNumber,
- startTime: formatMinutesToTime(start),
- endTime: formatMinutesToTime(end)
- });
- sectionNumber += 1;
- cursor = end;
- if (i < count) {
- // 当 longBreakAfter 为 0 时,该时段不会触发大课间
- const longBreakApplies = longBreakAfter > 0 && i === longBreakAfter;
- cursor += longBreakApplies
- ? common.durations.longBreakMinutes
- : common.durations.shortBreakMinutes;
- }
- }
- }
- return slots;
- }
- // 解析周次与节次
- // 示例:"3-4,6-8(周)[01-02节]"、"1-16(单周)[03-04节]"
- function parseWeeksAndSections(rawText) {
- const text = cleanText(rawText);
- const match = text.match(/^(.*?)\(([^)]*周)\)\[(.*?)节\]$/);
- if (match == null) {
- throw new Error(`无法解析课程时间:${text}`);
- }
- const weeksPart = match[1];
- const weekFlag = match[2];
- const sectionsPart = match[3];
- // 先把周次范围展开成完整数组
- const weeks = [];
- const weekRanges = weeksPart.match(/\d+(?:-\d+)?/g) ?? [];
- for (const rangeText of weekRanges) {
- if (rangeText.includes("-")) {
- const [start, end] = rangeText.split("-").map(Number);
- for (let w = start; w <= end; w += 1) {
- weeks.push(w);
- }
- } else {
- weeks.push(Number(rangeText));
- }
- }
- // 去重并排序后,再根据单双周标记过滤
- let normalizedWeeks = [...new Set(weeks)].sort((a, b) => a - b);
- if (weekFlag.includes("单")) {
- normalizedWeeks = normalizedWeeks.filter((w) => w % 2 === 1);
- }
- if (weekFlag.includes("双")) {
- normalizedWeeks = normalizedWeeks.filter((w) => w % 2 === 0);
- }
- const sections = (sectionsPart.match(/\d+/g) ?? []).map(Number).sort((a, b) => a - b);
- if (sections.length === 0) {
- throw new Error(`无法解析节次:${text}`);
- }
- return {
- weeks: normalizedWeeks,
- startSection: sections[0],
- endSection: sections[sections.length - 1]
- };
- }
- // 从当前位置向前查找满足条件的 font 节点
- function findPreviousFont(fonts, startIndex, predicate) {
- for (let i = startIndex - 1; i >= 0; i -= 1) {
- if (predicate(fonts[i])) {
- return fonts[i];
- }
- }
- return null;
- }
- // 从当前位置向后查找满足条件的 font 节点
- function findNextFont(fonts, startIndex, predicate) {
- for (let i = startIndex + 1; i < fonts.length; i += 1) {
- if (predicate(fonts[i])) {
- return fonts[i];
- }
- }
- return null;
- }
- // 教务页面会用 display:none 隐藏辅助节点,这里只保留可见信息
- function isVisibleFont(font) {
- const styleText = (font.getAttribute("style") ?? "").replace(/\s+/g, "").toLowerCase();
- return !styleText.includes("display:none");
- }
- // 从课表 iframe 中解析课程
- // 输出为扁平数组,不做同名课程合并
- function parseCoursesFromIframeDocument(iframeDoc) {
- const courses = [];
- const cells = iframeDoc.querySelectorAll(".kbcontent[id$='2']");
- cells.forEach((cell) => {
- // id 形如 xxxxx-<day>-2,day 为 1~7
- const idParts = (cell.id ?? "").split("-");
- const day = Number(idParts[idParts.length - 2]);
- if (!Number.isInteger(day) || day < 1 || day > 7) {
- return;
- }
- // 同一个 cell 里可能存在多个课程,因此要逐个锚点拆解
- const fonts = Array.from(cell.querySelectorAll("font"));
- fonts.forEach((font, idx) => {
- const title = cleanText(font.getAttribute("title") ?? "");
- if (!title.includes("周次")) {
- return;
- }
- if (!isVisibleFont(font)) {
- return;
- }
- const weekText = cleanText(font.textContent);
- if (weekText === "") {
- return;
- }
- // 以“周次(节次)”行为锚点,向前找教师和课程名,向后找教室
- const teacherFont = findPreviousFont(fonts, idx, (candidate) => {
- const candidateTitle = cleanText(candidate.getAttribute("title") ?? "");
- return candidateTitle.includes("教师") && isVisibleFont(candidate);
- });
- const teacherIndex = teacherFont == null ? idx : fonts.indexOf(teacherFont);
- const nameFont = findPreviousFont(fonts, teacherIndex, (candidate) => {
- const candidateTitle = cleanText(candidate.getAttribute("title") ?? "");
- const candidateNameAttr = cleanText(candidate.getAttribute("name") ?? "");
- const text = cleanText(candidate.textContent);
- return (
- candidateTitle === "" &&
- candidateNameAttr === "" &&
- isVisibleFont(candidate) &&
- text !== ""
- );
- });
- const locationFont = findNextFont(fonts, idx, (candidate) => {
- const candidateTitle = cleanText(candidate.getAttribute("title") ?? "");
- return candidateTitle.includes("教室") && isVisibleFont(candidate);
- });
- const courseName = cleanText(nameFont?.textContent ?? "");
- const teacher = cleanText(teacherFont?.textContent ?? "");
- let position = cleanText(locationFont?.textContent ?? "");
- // 移除教室中的“(白)”“(白云)”“(白)实”等字样,该信息对于学生而言无意义
- position = position.replace(/[((]白云?[))]实?/g, "");
- // 过滤空课程名、网络课和不存在的虚拟位置
- if (courseName === ""
- || position.includes("网络学时,不排时间教室")
- || position.includes("经典研读")
- || /^(?网络课)?/.test(courseName)) {
- return;
- }
- const parsed = parseWeeksAndSections(weekText);
- // 查重
- const existingCourse = courses.find((c) => c.name === courseName
- && c.teacher === teacher
- && c.position === position
- && c.day === day
- && c.startSection === parsed.startSection
- && c.endSection === parsed.endSection
- && JSON.stringify(c.weeks) === JSON.stringify(parsed.weeks));
- if (existingCourse != null) {
- return;
- }
- courses.push({
- name: courseName,
- teacher,
- position,
- day,
- startSection: parsed.startSection,
- endSection: parsed.endSection,
- weeks: parsed.weeks
- });
- });
- });
- return courses;
- }
- // 获取课表 iframe 的文档对象
- function getScheduleIframeDocument() {
- const iframe = document.querySelector("iframe[src*='/jsxsd/xskb/xskb_list.do']");
- if (iframe == null || iframe.contentDocument == null) {
- throw new Error("未找到课表 iframe,或 iframe 内容尚未加载完成");
- }
- return iframe.contentDocument;
- }
- // 获取当前学年学期 ID,例如 2025-2026-2
- function getSemesterId(iframeDoc) {
- const select = iframeDoc.querySelector("#xnxq01id");
- if (select == null) {
- throw new Error("未找到学年学期选择框 #xnxq01id");
- }
- // 优先读取 option[selected],读取失败再回退到 select.value
- const selectedOption = select.querySelector("option[selected]");
- return cleanText(selectedOption?.value ?? select.value);
- }
- // 拉取教学周历并提取开学日期与总周数
- async function fetchSemesterCalendarInfo(semesterId) {
- const url = `https://edu-admin.zhku.edu.cn/jsxsd/jxzl/jxzl_query?xnxq01id=${encodeURIComponent(semesterId)}`;
- const response = await fetch(url, {
- method: "GET",
- credentials: "include"
- });
- if (!response.ok) {
- throw new Error(`获取教学周历失败:HTTP ${response.status}`);
- }
- const html = await response.text();
- const parser = new DOMParser();
- const doc = parser.parseFromString(html, "text/html");
- const rows = Array.from(doc.querySelectorAll("#kbtable tr"));
- // 周次行特征:第一列是纯数字
- const weekRows = rows.filter((row) => {
- const firstCell = row.querySelector("td");
- return /^\d+$/.test(cleanText(firstCell?.textContent ?? ""));
- });
- if (weekRows.length === 0) {
- throw new Error("教学周历中未找到周次行");
- }
- // 学期起始日按“第一周周一”计算
- const firstWeekRow = weekRows[0];
- const mondayCell = firstWeekRow.querySelectorAll("td")[1];
- const mondayTitle = mondayCell?.getAttribute("title") ?? "";
- if (mondayTitle === "") {
- throw new Error("教学周历中未找到第一周周一日期");
- }
- return {
- semesterStartDate: normalizeCnDateToIso(mondayTitle),
- semesterTotalWeeks: weekRows.length
- };
- }
- // 主流程:读取课表 -> 选择校区 -> 拉周历 -> 生成节次 -> 调桥接导入
- async function importSchedule() {
- AndroidBridge.showToast("开始读取教务课表……");
- // 读取 iframe 并获取当前学年学期 ID
- const iframeDoc = getScheduleIframeDocument();
- const semesterId = getSemesterId(iframeDoc);
- // 解析课程信息
- const courses = parseCoursesFromIframeDocument(iframeDoc);
- if (courses.length === 0) {
- throw new Error("未解析到任何课程,请确认当前课表页面已加载完成");
- }
- // 拉取周历信息,获取开学日期与总周数
- const calendarInfo = await fetchSemesterCalendarInfo(semesterId);
- // 选择校区并生成预设上课时间配置
- const campusId = await chooseCampus();
- const campusLabel = CAMPUS_OPTIONS.find((item) => item.id === campusId)?.label ?? "白云校区";
- const presetTimeSlots = buildPresetTimeSlots(campusId);
- // 构建上课预设时间配置
- const config = {
- semesterStartDate: calendarInfo.semesterStartDate,
- semesterTotalWeeks: calendarInfo.semesterTotalWeeks,
- defaultClassDuration: PRESET_TIME_CONFIG.common.durations.classMinutes,
- defaultBreakDuration: PRESET_TIME_CONFIG.common.durations.shortBreakMinutes,
- // 每周按周一起始计算,因此固定为 1
- firstDayOfWeek: 1
- };
- // 通知课表软件进行导入,传递课程与预设时间配置
- await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
- await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(presetTimeSlots));
- await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify(config));
- AndroidBridge.showToast(`导入成功:${campusLabel},课程 ${courses.length} 条`);
- AndroidBridge.notifyTaskCompletion();
- }
- // 自执行入口
- (async () => {
- try {
- await importSchedule();
- } catch (error) {
- console.error("课表导入失败:", error);
- // 失败原因直接提示给用户,便于在移动端快速定位问题
- AndroidBridge.showToast(`导入失败:${error.message}`);
- }
- })();
|