// 四川工商职业技术学院(sctbc.edu.cn) 拾光课表适配脚本 // // 教务系统:正方教务 V9(前端 zftal-ui-v5-1.0.2),http://jw.sctbc.edu.cn 根路径部署 // 登录方式:正方直登 /xtgl/login_slogin.html,或统一身份认证 cas.sctbc.edu.cn // // 全部数据通过接口获取,登录后停在任意教务页面即可导入,无需进入课表查询页。 // 以下接口均为本校实测通过: // GET kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151 学年学期选项 // POST kbcx/xskbcx_cxXsKb.html?gnmkdm=N2151 课程数据(kbList) // POST kbcx/xskbcxZccx_cxZcByXnxq.html?gnmkdm=N2154 校历(开学日期 / 总周数) // POST kbcx/xskbcx_cxRjc.html?gnmkdm=N2151 节次作息 // // 字段映射:kcmc=课程名 xm=教师 cdmc=地点 xqj/xqjmc=星期 jcs=节次 zcd=周次 xf=学分 const BASE = "http://jw.sctbc.edu.cn"; const GNMKDM_KB = "N2151"; // 课表相关模块号 const GNMKDM_XL = "N2154"; // 校历模块号 /** 接口地址 */ function apiUrl(path, gnmkdm) { return `${BASE}/${path}?gnmkdm=${gnmkdm}`; } /** * 带登录态发起 POST 请求并解析 JSON。 * 登录态由 App 内置浏览器复用,脚本不处理登录。 */ async function postJson(path, gnmkdm, params) { const response = await fetch(apiUrl(path, gnmkdm), { method: "POST", credentials: "include", referrer: apiUrl("kbcx/xskbcx_cxXskbcxIndex.html", gnmkdm), headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", "X-Requested-With": "XMLHttpRequest", Accept: "application/json, text/javascript, */*; q=0.01" }, body: new URLSearchParams(params).toString() }); if (!response.ok) { throw new Error(`教务接口请求失败(HTTP ${response.status})`); } const text = await response.text(); try { return JSON.parse(text); } catch (error) { throw new Error("登录状态可能已失效,请重新登录教务系统后再试"); } } // ==================== 数据解析 ==================== /** 全角数字转半角(正方个别页面会返回全角字符) */ function halfWidth(text) { return String(text || "").replace(/[\uff10-\uff19]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 65248)); } /** 周次解析:"1-6周,9-18周" / "1-16周(单)" / "1,3,5" → [1,2,3...] */ function toWeeks(text) { const weeks = new Set(); for (const seg of halfWidth(text).replace(/第|周/g, "").split(/[,,、]/)) { const range = seg.match(/(\d+)\s*-\s*(\d+)/); const single = seg.match(/\d+/); if (!range && !single) continue; const start = Number(range ? range[1] : single[0]); const end = Number(range ? range[2] : single[0]); const odd = seg.includes("单"); const even = seg.includes("双"); for (let week = start; week <= end; week++) { if (odd && week % 2 === 0) continue; if (even && week % 2 !== 0) continue; weeks.add(week); } } return [...weeks].sort((a, b) => a - b); } /** 节次解析:"1-2" / "3" / "第1-2节" → { start, end };无法识别返回 null */ function toSections(text) { const numbers = (halfWidth(text).match(/\d+/g) || []).map(Number).filter((n) => n > 0); if (!numbers.length) return null; return { start: Math.min(...numbers), end: Math.max(...numbers) }; } /** 星期解析:优先数字 xqj,退回中文 xqjmc(两者都查,避免只认一个来源导致丢课) */ function toDay(xqj, xqjmc) { const day = Number(xqj); if (day >= 1 && day <= 7) return day; const names = ["一", "二", "三", "四", "五", "六", "日", "天"]; const text = `${xqjmc || ""}${xqj || ""}`; const index = names.findIndex((name) => text.includes(name)); return index < 0 ? 0 : (index === 7 ? 7 : index + 1); } /** 课表 JSON → 课程数组。集中实践类课程没有星期/节次,无法落到格子里,直接跳过 */ function toCourses(kbList) { if (!Array.isArray(kbList)) return []; const courses = []; for (const item of kbList || []) { if (!item || typeof item !== "object") continue; const sections = toSections(item.jcs); const weeks = toWeeks(item.zcd); const day = toDay(item.xqj, item.xqjmc); const name = String(item.kcmc || "").trim(); if (!name || !sections || !weeks.length || !day) continue; const course = { name: name, teacher: String(item.xm || "").trim(), position: String(item.cdmc || "").trim(), day: day, startSection: sections.start, endSection: sections.end, weeks: weeks }; const credit = Number(item.xf); if (credit > 0) course.credit = credit; courses.push(course); } return mergeAndDistinctCourses(courses); } /** * 节次与周次合并去重(取自官方 wiki《课程合并与去重函数》) * 连续节次 1-2 + 3-4 → 1-4;同节次单周 + 双周 → 全周;完全重复的记录去重。 */ function mergeAndDistinctCourses(courses) { if (!Array.isArray(courses) || courses.length <= 1) return courses || []; 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) => 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 step1 = []; let current = list[0]; for (let i = 1; i < list.length; i++) { const next = list[i]; const sameWeeks = current.name === next.name && current.teacher === next.teacher && current.position === next.position && current.day === next.day && current.weeks.join(",") === next.weeks.join(","); if (sameWeeks && current.endSection + 1 === next.startSection) { current.endSection = next.endSection; // 连续节次,延长 } else if (sameWeeks && current.startSection === next.startSection && current.endSection === next.endSection) { continue; // 完全重复,丢弃 } else { step1.push(current); current = next; } } step1.push(current); // 阶段 2:合并同一节次上的不同周次(如单周 + 双周 → 全周) step1.sort((a, b) => 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 merged = []; let kept = step1[0]; for (let i = 1; i < step1.length; i++) { const next = step1[i]; const sameSection = kept.name === next.name && kept.teacher === next.teacher && kept.position === next.position && kept.day === next.day && kept.startSection === next.startSection && kept.endSection === next.endSection; if (sameSection) { kept.weeks = [...new Set([...kept.weeks, ...next.weeks])].sort((a, b) => a - b); } else { merged.push(kept); kept = next; } } merged.push(kept); return merged; } // ==================== 学年学期 / 作息 / 校历 ==================== /** 读取教务系统的学年学期选项(学年学期由教务提供,必须问系统要) */ async function fetchSemesterOptions() { const response = await fetch(apiUrl("kbcx/xskbcx_cxXskbcxIndex.html", GNMKDM_KB), { credentials: "include" }); if (!response.ok) return null; const dom = new DOMParser().parseFromString(await response.text(), "text/html"); const readSelect = (id) => { const select = dom.getElementById(id); const options = []; for (const option of (select ? select.options : [])) { if (option.value) { options.push({ value: option.value, text: option.text.trim(), selected: option.selected }); } } return options; }; const years = readSelect("xnm"); const terms = readSelect("xqm"); return years.length && terms.length ? { years: years, terms: terms } : null; } /** 默认选中项:教务当前选中的那个,没有就用第一项 */ function selectedIndexOf(options) { const index = options.findIndex((option) => option.selected); return index < 0 ? 0 : index; } /** 节次作息;接口不可用时返回空数组,不影响课程导入 */ async function fetchTimeSlots(xnm, xqm) { const data = await postJson("kbcx/xskbcx_cxRjc.html", GNMKDM_KB, { xnm: xnm, xqm: xqm, kzlx: "ck", xsdm: "", kclbdm: "", kclxdm: "" }); const rows = Array.isArray(data) ? data : (data && data.data) || []; const slots = []; for (const item of rows) { const number = Number(item.jcdm || item.jcmc); const startTime = String(item.qssj || "").slice(0, 5); const endTime = String(item.jssj || "").slice(0, 5); if (number > 0 && startTime && endTime) { slots.push({ number: number, startTime: startTime, endTime: endTime }); } } return slots.sort((a, b) => a.number - b.number); } /** 校历:开学日期与学期总周数;取不到就返回空对象 */ async function fetchConfig(xnm, xqm) { const rows = await postJson("kbcx/xskbcxZccx_cxZcByXnxq.html", GNMKDM_XL, { xnm: xnm, xqm: xqm }); if (!Array.isArray(rows)) return {}; const config = {}; let maxWeek = 0; let firstWeek = null; for (const row of rows) { const week = Number(String(row.zs || row.zsmc || "").replace(/\D/g, "")); if (!(week >= 1)) continue; if (week > maxWeek) maxWeek = week; if (week === 1) firstWeek = row; } // 仅接受 10~30 周,避免把整年校历当成一个学期 if (maxWeek >= 10 && maxWeek <= 30) config.semesterTotalWeeks = maxWeek; const row = firstWeek || {}; const date = String(row.rq || row.zcrq || row.ksrq || "").match(/\d{4}-\d{1,2}-\d{1,2}/); if (date) config.semesterStartDate = date[0]; return config; } // ==================== 主流程 ==================== async function runImportFlow() { const confirmed = await window.shiguangBridgePromise.showAlert( "四川工商职业技术学院课表导入", "请确认已登录教务系统。登录后停在任意教务页面即可开始导入,脚本会自动读取学年学期、课表、开学日期与节次作息。", "开始导入" ); if (!confirmed) return; const options = await fetchSemesterOptions(); if (!options) throw new Error("未能读取学年学期,请确认已登录教务系统"); const yearIndex = await window.shiguangBridgePromise.showSingleSelection( "选择学年", JSON.stringify(options.years.map((o) => o.text)), selectedIndexOf(options.years)); if (yearIndex === null || yearIndex < 0) return; const termIndex = await window.shiguangBridgePromise.showSingleSelection( "选择学期", JSON.stringify(options.terms.map((o) => o.text)), selectedIndexOf(options.terms)); if (termIndex === null || termIndex < 0) return; const xnm = options.years[yearIndex].value; const xqm = options.terms[termIndex].value; window.shiguangBridge.showToast("正在获取课表数据..."); const data = await postJson("kbcx/xskbcx_cxXsKb.html", GNMKDM_KB, { xnm: xnm, xqm: xqm, kzlx: "ck" }); const courses = toCourses(data.kbList); if (!courses.length) throw new Error("所选学期没有解析到课程,请确认学期是否正确"); await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses)); // 校历与作息属于附加信息,失败不影响课程导入 const config = await fetchConfig(xnm, xqm).catch(() => ({})); const maxWeek = Math.max(...courses.map((course) => Math.max(...course.weeks))); if (config.semesterTotalWeeks && maxWeek > config.semesterTotalWeeks) { config.semesterTotalWeeks = maxWeek; // 总周数不得小于课程实际周次,否则课程会被截断 } if (Object.keys(config).length) { await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config)).catch(() => {}); } const slots = await fetchTimeSlots(xnm, xqm).catch(() => []); if (slots.length) { await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(slots)).catch(() => {}); } window.shiguangBridge.showToast(`导入成功,共 ${courses.length} 条课程安排`); window.shiguangBridge.notifyTaskCompletion(); } runImportFlow().catch((error) => { console.error("SCTBC 导入失败", error); window.shiguangBridgePromise.showAlert("导入失败", error.message, "确定"); });