// 湖北文理学院(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(/>([^<]+));
return match ? match[1].trim() : String(htmlStr).trim();
}
// 去掉教师名里的括号备注
function cleanTeacherName(name) {
if (!name) return '';
return name.replace(/([^)]*)/g, '').replace(/\([^)]*\)/g, '').trim();
}
// 超星的 zcstr 直接是逗号分隔的周次数字,例如 "1,2,3,10,11"
function parseWeeks(weekStr) {
if (!weekStr) return [];
return String(weekStr).split(',')
.map(w => 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