// 湖北文理学院(hbuas.edu.cn) 拾光课程表适配脚本 // 教务系统:超星教务(hbuas.jw.chaoxing.com,dbname = hbuas) // 使用流程:登录教务系统(账密登录或学习通扫码均可)→ 停留在任意教务页面 → 点击“执行导入” // // 接口说明(2026-2027-1 学期实测,返回 ret 均为 0): // GET /admin/pkgl/xskb/queryKbForXsd 课表页 HTML(内含 #xhid / #xqdm / #xnxq1) // GET /admin/api/jcsj/xqsj/getXqList 校区列表(item.id = 校区代码,item.xqmc = 名称) // GET /admin/api/getZclistByXnxq?xnxq=<学年-学期>&xqid=<校区代码> 周次表 data.zclist + 节次时间 data.jcsjszList // GET /admin/xsd/pkgl/xskb/sdpkkbList?xnxq=&xhid=&xqdm=&xskbxslx=0 课程数据 JSON(data[]) // // 细节说明: // 1. 课程数据按“单节”返回(djc = 第几节),脚本先逐节转成课程块, // 再交给 wiki 教程《课程合并与去重函数》中的合并去重函数处理: // 连续节次合并(1-2 + 3-4 → 1-4)、单双周合并(1,3,5 + 2,4,6 → 1-6)、完全重复去重。 // 实测 60 条原始记录 → 26 条,节次×周次单元 472 → 472,无数据丢失。 // 2. kcmc / tmc / croommc 都是 包裹的 HTML 片段,需要抽文本。 // 3. 教务系统的 #xnxq1 会把“当前学期”标为 selected,脚本把它作为学期单选列表的默认项: // 常用场景一键确认;学期未开始时也可改选下学期提前导入。校区则默认沿用页面校区、不弹窗。 // 4. 作息时间与开学日期都从教务接口读取,不写死;仅在接口异常时回退到内置的北校区作息表。 // // 维护者:salt-fishes // 学期选择:默认【弹出单选列表】,并把教务系统标记的“当前学期”作为默认选中项。 // 常用场景直接确认即可;学期尚未开始时,也可在这里改选下学期,提前导入课表。 // 若希望完全不弹窗、始终使用当前学期,改为 false。 const ASK_SEMESTER = true; // 校区选择:默认沿用当前页面校区、不弹窗(同一学生通常只在一个校区上课)。 // 若希望每次手动选择校区,改为 true。 const ASK_CAMPUS = false; // 接口异常时的兜底作息(2026-2027-1 北校区实测值),正常情况不会用到 const 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:50", endTime: "11:40" }, { number: 5, startTime: "14:00", endTime: "14:45" }, { number: 6, startTime: "14:55", endTime: "15:40" }, { number: 7, startTime: "16:00", endTime: "16:45" }, { number: 8, startTime: "16:45", endTime: "17:30" }, { number: 9, startTime: "18:30", endTime: "19:15" }, { number: 10, startTime: "19:15", endTime: "20:00" } ]; // 兜底总周数(接口返回 zclist 长度时以接口为准) const FALLBACK_TOTAL_WEEKS = 20; // 出错时附在弹窗里的诊断信息(便于用户反馈时定位) let diagnoseInfo = ''; // ========================================================================= // 桥接封装 // ========================================================================= function toast(message) { if (window.shiguangBridge && typeof window.shiguangBridge.showToast === 'function') { window.shiguangBridge.showToast(message); } else { console.log('[HBUAS] ' + message); } } async function alertUser(title, content) { if (window.shiguangBridgePromise && typeof window.shiguangBridgePromise.showAlert === 'function') { return await window.shiguangBridgePromise.showAlert(title, content, "确定"); } console.warn('[HBUAS] ' + title + ' - ' + content); return true; } // ========================================================================= // 文本与周次解析 // ========================================================================= // 超星返回的字段带有 标签,取出其中的纯文本 function extractAnchorText(htmlStr) { if (!htmlStr) return ''; const match = String(htmlStr).match(/>([^<]+) Number(String(w).trim())) .filter(w => !isNaN(w) && w > 0) .sort((a, b) => a - b); } function formatTime(timeStr) { if (typeof timeStr !== 'string') return null; const match = timeStr.match(/^(\d{1,2}):(\d{1,2})(?::\d{1,2})?$/); if (!match) return null; const hour = Number(match[1]); const minute = Number(match[2]); return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; } // ========================================================================= // 网络请求封装 // ========================================================================= async function getJson(url) { const response = await fetch(url, { method: 'GET', credentials: 'include', headers: { 'Accept': 'application/json, text/plain, */*', 'X-Requested-With': 'XMLHttpRequest' } }); if (!response.ok) throw new Error(`网络请求失败,状态码 ${response.status}(${url.split('?')[0]})`); const json = await response.json(); if (json.ret !== 0) { // 带上接口名与 ret,便于用户反馈时快速定位(例如 xhid 无效时 sdpkkbList 会返回 ret=-1) throw new Error(`${url.split('?')[0]} 返回 ret=${json.ret}:${json.msg || '未知错误'}`); } return json; } // ========================================================================= // 页面参数提取 // ========================================================================= // 课表页地址。其服务端渲染的 #xhid(86 字符加密串)/ #xqdm / #xnxq1 才是正确参数。 // 注意:教务首页 /admin 里也存在一个 id="xhid" 的元素,但值是用户 ID(32 字符)。 // 若误用它调 sdpkkbList,服务端会返回 ret=-1「功能暂时停用,请联系管理员」。 // 因此这里【始终优先采用课表页里的值】,只有当课表页抓取失败时才退回当前页面 DOM。 const KB_PAGE_URL = '/admin/pkgl/xskb/queryKbForXsd'; function pickKbParams(doc) { return { xhid: doc.querySelector('#xhid')?.value || '', xqdm: doc.querySelector('#xqdm')?.value || '', xnxqSelectHtml: doc.querySelector('#xnxq1')?.outerHTML || null }; } // 提取 #xhid(学号加密串)/ #xqdm(校区代码)/ #xnxq1(学期下拉) async function extractPageParams() { let pageHtml = null; try { const response = await fetch(KB_PAGE_URL, { method: 'GET', credentials: 'include', headers: { 'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8' } }); if (response.ok) { pageHtml = await response.text(); } else { console.warn(`JS: 抓取课表页失败,状态码 ${response.status}`); } } catch (error) { console.warn('JS: 抓取课表页异常:', error.message); } let params = { xhid: '', xqdm: '', xnxqSelectHtml: null }; let source = ''; if (pageHtml) { const fromPage = pickKbParams(new DOMParser().parseFromString(pageHtml, 'text/html')); if (fromPage.xhid && fromPage.xqdm) { params = fromPage; source = '课表页'; } } if (!params.xhid || !params.xqdm) { const fromDom = pickKbParams(document); if (fromDom.xhid && fromDom.xqdm) { params = fromDom; source = '当前页面'; } } if (!params.xhid || !params.xqdm) { throw new Error('无法获取课表页参数(#xhid / #xqdm),请确认已登录教务系统'); } console.log(`JS: 参数来源=${source},xhid=${params.xhid.length} 字符,xqdm=${params.xqdm.length} 字符`); return params; } // 解析 #xnxq1 下拉:labels / values / 当前选中索引 function parseSemesterOptions(selectHtml) { if (!selectHtml) return null; const doc = new DOMParser().parseFromString(selectHtml, 'text/html'); const select = doc.querySelector('#xnxq1') || doc.querySelector('select[name="xnxq"]'); if (!select) return null; const options = Array.from(select.querySelectorAll('option')).filter(o => (o.value || '').trim() !== ''); if (options.length === 0) return null; const labels = options.map(o => (o.textContent || o.value || '').trim()); const values = options.map(o => (o.value || '').trim()); // 当前选中项(教务系统会把当前学期标为 selected) let selectedIndex = options.findIndex(o => o.selected); if (selectedIndex < 0) selectedIndex = -1; return { labels, values, selectedIndex: selectedIndex, defaultIndex: selectedIndex >= 0 ? selectedIndex : 0 }; } // ========================================================================= // 学期 / 校区选择 // ========================================================================= async function chooseSemester(selectHtml) { const options = parseSemesterOptions(selectHtml); if (!options) { throw new Error('未能读取学年学期下拉,请确认已登录教务系统'); } // 默认静默采用教务系统标记的当前学期(可改为始终询问,见 ASK_SEMESTER) if (!ASK_SEMESTER && options.selectedIndex >= 0) { const picked = options.values[options.selectedIndex]; console.log(`JS: 自动使用当前学期 ${picked}`); return picked; } // 弹出单选列表,并把“当前学期”作为默认选中项: // 常用场景直接确认即可;学期未开始时,也可在此改选下学期提前导入课表 const index = await window.shiguangBridgePromise.showSingleSelection( "选择学年学期", JSON.stringify(options.labels), options.defaultIndex ); if (index === null || index === -1) return null; return options.values[index]; } async function chooseCampus(pageXqdm) { let campusList = null; try { const json = await getJson('/admin/api/jcsj/xqsj/getXqList'); campusList = (json.data || []) .map(item => ({ xqdm: String(item.id || '').trim(), xqmc: String(item.xqmc || item.id || '').trim() })) .filter(item => item.xqdm && item.xqmc); } catch (error) { console.warn('JS: 获取校区列表失败:', error.message); } if (!campusList || campusList.length === 0) { toast("未获取到校区列表,使用当前页面校区继续导入。"); return { xqdm: pageXqdm, xqmc: "" }; } const matched = campusList.findIndex(item => item.xqdm === pageXqdm); if (!ASK_CAMPUS && matched >= 0) { console.log(`JS: 自动使用页面校区 ${campusList[matched].xqmc}`); return campusList[matched]; } const index = await window.shiguangBridgePromise.showSingleSelection( "选择校区", JSON.stringify(campusList.map(item => item.xqmc)), matched >= 0 ? matched : 0 ); if (index === null || index === -1) return null; return campusList[index]; } // ========================================================================= // 作息时间与开学日期 // ========================================================================= // 返回 { timeSlots, semesterStartDate, semesterTotalWeeks } async function fetchTimeAndWeekData(xnxq, xqdm) { const json = await getJson(`/admin/api/getZclistByXnxq?xnxq=${encodeURIComponent(xnxq)}&xqid=${encodeURIComponent(xqdm)}`); const data = json.data || {}; // 节次时间 let timeSlots = null; if (Array.isArray(data.jcsjszList) && data.jcsjszList.length > 0) { timeSlots = data.jcsjszList .map(item => ({ number: Number(item.jc), startTime: formatTime(item.kssj), endTime: formatTime(item.jssj) })) .filter(item => item.number > 0 && item.startTime && item.endTime) .sort((a, b) => a.number - b.number); if (timeSlots.length === 0) timeSlots = null; } // 开学日期 = 第 1 周的周一 let semesterStartDate = null; if (Array.isArray(data.zclist)) { const firstWeek = data.zclist.find(z => Number(z.zc) === 1); if (firstWeek && firstWeek.minrq) { semesterStartDate = String(firstWeek.minrq).split(' ')[0]; } } const semesterTotalWeeks = Array.isArray(data.zclist) && data.zclist.length > 0 ? data.zclist.length : FALLBACK_TOTAL_WEEKS; return { timeSlots, semesterStartDate, semesterTotalWeeks }; } // ========================================================================= // 课程数据 // ========================================================================= /** * 节次与周次合并去重函数 * 采用 wiki 教程《课程合并与去重函数》的参考实现,未做修改 * @param {Array} courses 原始解析课程数组 * @returns {Array} 合并去重后的课程数组 */ function mergeAndDistinctCourses(courses) { if (!Array.isArray(courses) || courses.length <= 1) return courses; // 1. 深拷贝并规范周次数据,过滤无效项 const list = courses.map(c => ({ ...c, name: c.name || '', teacher: c.teacher || '', position: c.position || '', weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : [] })); // 阶段 1:合并连续节次与完全重复记录(前提:名称、教师、地点、星期、周次一致) list.sort((a, b) => { return a.name.localeCompare(b.name) || a.teacher.localeCompare(b.teacher) || a.position.localeCompare(b.position) || (a.day || 0) - (b.day || 0) || a.weeks.join(',').localeCompare(b.weeks.join(',')) || (a.startSection || 0) - (b.startSection || 0); }); const step1Merged = []; let current = list[0]; for (let i = 1; i < list.length; i++) { const next = list[i]; const isSameCourseAndWeeks = current.name === next.name && current.teacher === next.teacher && current.position === next.position && current.day === next.day && current.weeks.join(',') === next.weeks.join(','); const isContinuous = current.endSection + 1 === next.startSection; const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection; if (isSameCourseAndWeeks && isContinuous) { // 节次连续:延长结束节次 (如 1-2 节 + 3-4 节 -> 1-4 节) current.endSection = next.endSection; } else if (isSameCourseAndWeeks && isDuplicate) { // 完全重复:跳过 continue; } else { step1Merged.push(current); current = next; } } step1Merged.push(current); // 阶段 2:合并同节次的周次(前提:名称、教师、地点、星期、开始/结束节次一致) step1Merged.sort((a, b) => { return a.name.localeCompare(b.name) || a.teacher.localeCompare(b.teacher) || a.position.localeCompare(b.position) || (a.day || 0) - (b.day || 0) || (a.startSection || 0) - (b.startSection || 0) || (a.endSection || 0) - (b.endSection || 0); }); const step2Merged = []; let cur = step1Merged[0]; for (let i = 1; i < step1Merged.length; i++) { const nxt = step1Merged[i]; const isSameCourseAndSection = cur.name === nxt.name && cur.teacher === nxt.teacher && cur.position === nxt.position && cur.day === nxt.day && cur.startSection === nxt.startSection && cur.endSection === nxt.endSection; if (isSameCourseAndSection) { // 周次合并去重 (如 1-8 周 + 9-16 周 -> 1-16 周) cur.weeks = Array.from(new Set([...cur.weeks, ...nxt.weeks])).sort((a, b) => a - b); } else { step2Merged.push(cur); cur = nxt; } } step2Merged.push(cur); return step2Merged; } function parseCourseData(jsonData) { if (!jsonData || !Array.isArray(jsonData.data)) return []; // 超星按“单节”返回(djc = 第几节):先逐节转成课程块(startSection = endSection = 节次), // 再交给 mergeAndDistinctCourses 处理连续节次、单双周与重复记录 const rawCourses = []; for (const raw of jsonData.data) { const name = extractAnchorText(raw.kcmc); const teacher = cleanTeacherName(extractAnchorText(raw.tmc)); const position = extractAnchorText(raw.croommc) || '待定'; const day = Number(raw.xingqi); const section = Number(raw.djc); const weeks = parseWeeks(raw.zcstr); if (!name || isNaN(day) || isNaN(section) || day < 1 || day > 7 || section < 1 || weeks.length === 0) { continue; } rawCourses.push({ name: name, teacher: teacher, position: position, day: day, startSection: section, endSection: section, weeks: weeks }); } return mergeAndDistinctCourses(rawCourses); } async function fetchCourses(xnxq, xhid, xqdm) { const url = `/admin/xsd/pkgl/xskb/sdpkkbList?xnxq=${encodeURIComponent(xnxq)}&xhid=${encodeURIComponent(xhid)}&xqdm=${encodeURIComponent(xqdm)}&xskbxslx=0`; const json = await getJson(url); return parseCourseData(json); } // ========================================================================= // 保存 // ========================================================================= async function saveCourses(courses) { await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses)); } async function saveTimeSlots(timeSlots) { await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots)); } async function saveConfig(semesterStartDate, semesterTotalWeeks) { const config = { semesterTotalWeeks: semesterTotalWeeks }; if (semesterStartDate) config.semesterStartDate = semesterStartDate; await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config)); } // ========================================================================= // 主流程 // ========================================================================= function isLoginPage() { return window.location.href.includes('/admin/login') || window.location.href.includes('slogin'); } async function runImportFlow() { try { if (isLoginPage()) { await alertUser("导入失败", "当前是登录页面,请先完成登录并进入教务系统内部页面后再执行导入。"); return; } const confirmed = await alertUser( "湖北文理学院教务系统课表导入", "将自动读取当前学期、校区、作息时间与开学日期。\n导入前请确认已成功登录教务系统。" ); if (confirmed !== true && confirmed !== "true") { toast("用户取消了导入。"); return; } // 1. 参数(来自课表页而非当前页面,见 extractPageParams 注释) const { xhid, xqdm: pageXqdm, xnxqSelectHtml } = await extractPageParams(); diagnoseInfo = `xhid ${xhid.length} 字符 / xqdm ${pageXqdm.length} 字符`; // 2. 学期(默认取教务系统标记的当前学期) const xnxq = await chooseSemester(xnxqSelectHtml); if (xnxq === null) { toast("导入已取消:未选择学年学期。"); return; } // 3. 校区(默认取当前页面校区) const campus = await chooseCampus(pageXqdm); if (campus === null) { toast("导入已取消:未选择校区。"); return; } toast(`正在导入 ${xnxq}${campus.xqmc ? ' · ' + campus.xqmc : ''} ...`); // 4. 课程 const courses = await fetchCourses(xnxq, xhid, campus.xqdm); if (courses.length === 0) { await alertUser("未找到课程", `${xnxq} 学期未获取到任何课程数据,请确认学期选择是否正确,或该学期暂无排课。`); return; } await saveCourses(courses); console.log(`JS: 课程保存成功,共 ${courses.length} 条。`); // 5. 作息时间与开学日期(失败不阻塞课程导入) try { const { timeSlots, semesterStartDate, semesterTotalWeeks } = await fetchTimeAndWeekData(xnxq, campus.xqdm); await saveConfig(semesterStartDate, semesterTotalWeeks); if (timeSlots && timeSlots.length > 0) { await saveTimeSlots(timeSlots); } else { await saveTimeSlots(FALLBACK_TIME_SLOTS); toast("未获取到教务作息,已使用内置作息表。"); } console.log(`JS: 配置导入完成,开学日期 ${semesterStartDate},总周数 ${semesterTotalWeeks}。`); } catch (error) { console.warn('JS: 作息/开学日期获取失败:', error.message); toast("作息时间与开学日期获取失败,课程已导入,可稍后重试。"); } toast(`导入成功:${courses.length} 条课程、${xnxq}${campus.xqmc ? ' · ' + campus.xqmc : ''}。`); window.shiguangBridge.notifyTaskCompletion(); } catch (error) { console.error('JS: 导入流程异常:', error); const tail = diagnoseInfo ? `\n\n[诊断] ${diagnoseInfo}` : ''; await alertUser("导入失败", `${error.message}\n\n请确认:\n1. 已在教务系统中成功登录;\n2. 网络可以正常访问 hbuas.jw.chaoxing.com。${tail}`); } } runImportFlow();