sctbc.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. // 四川工商职业技术学院(sctbc.edu.cn) 拾光课表适配脚本
  2. //
  3. // 教务系统:正方教务 V9(前端 zftal-ui-v5-1.0.2),http://jw.sctbc.edu.cn 根路径部署
  4. // 登录方式:正方直登 /xtgl/login_slogin.html,或统一身份认证 cas.sctbc.edu.cn
  5. //
  6. // 全部数据通过接口获取,登录后停在任意教务页面即可导入,无需进入课表查询页。
  7. // 以下接口均为本校实测通过:
  8. // GET kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151 学年学期选项
  9. // POST kbcx/xskbcx_cxXsKb.html?gnmkdm=N2151 课程数据(kbList)
  10. // POST kbcx/xskbcxZccx_cxZcByXnxq.html?gnmkdm=N2154 校历(开学日期 / 总周数)
  11. // POST kbcx/xskbcx_cxRjc.html?gnmkdm=N2151 节次作息
  12. //
  13. // 字段映射:kcmc=课程名 xm=教师 cdmc=地点 xqj/xqjmc=星期 jcs=节次 zcd=周次 xf=学分
  14. const BASE = "http://jw.sctbc.edu.cn";
  15. const GNMKDM_KB = "N2151"; // 课表相关模块号
  16. const GNMKDM_XL = "N2154"; // 校历模块号
  17. /** 接口地址 */
  18. function apiUrl(path, gnmkdm) {
  19. return `${BASE}/${path}?gnmkdm=${gnmkdm}`;
  20. }
  21. /**
  22. * 带登录态发起 POST 请求并解析 JSON。
  23. * 登录态由 App 内置浏览器复用,脚本不处理登录。
  24. */
  25. async function postJson(path, gnmkdm, params) {
  26. const response = await fetch(apiUrl(path, gnmkdm), {
  27. method: "POST",
  28. credentials: "include",
  29. referrer: apiUrl("kbcx/xskbcx_cxXskbcxIndex.html", gnmkdm),
  30. headers: {
  31. "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
  32. "X-Requested-With": "XMLHttpRequest",
  33. Accept: "application/json, text/javascript, */*; q=0.01"
  34. },
  35. body: new URLSearchParams(params).toString()
  36. });
  37. if (!response.ok) {
  38. throw new Error(`教务接口请求失败(HTTP ${response.status})`);
  39. }
  40. const text = await response.text();
  41. try {
  42. return JSON.parse(text);
  43. } catch (error) {
  44. throw new Error("登录状态可能已失效,请重新登录教务系统后再试");
  45. }
  46. }
  47. // ==================== 数据解析 ====================
  48. /** 全角数字转半角(正方个别页面会返回全角字符) */
  49. function halfWidth(text) {
  50. return String(text || "").replace(/[\uff10-\uff19]/g, (c) =>
  51. String.fromCharCode(c.charCodeAt(0) - 65248));
  52. }
  53. /** 周次解析:"1-6周,9-18周" / "1-16周(单)" / "1,3,5" → [1,2,3...] */
  54. function toWeeks(text) {
  55. const weeks = new Set();
  56. for (const seg of halfWidth(text).replace(/第|周/g, "").split(/[,,、]/)) {
  57. const range = seg.match(/(\d+)\s*-\s*(\d+)/);
  58. const single = seg.match(/\d+/);
  59. if (!range && !single) continue;
  60. const start = Number(range ? range[1] : single[0]);
  61. const end = Number(range ? range[2] : single[0]);
  62. const odd = seg.includes("单");
  63. const even = seg.includes("双");
  64. for (let week = start; week <= end; week++) {
  65. if (odd && week % 2 === 0) continue;
  66. if (even && week % 2 !== 0) continue;
  67. weeks.add(week);
  68. }
  69. }
  70. return [...weeks].sort((a, b) => a - b);
  71. }
  72. /** 节次解析:"1-2" / "3" / "第1-2节" → { start, end };无法识别返回 null */
  73. function toSections(text) {
  74. const numbers = (halfWidth(text).match(/\d+/g) || []).map(Number).filter((n) => n > 0);
  75. if (!numbers.length) return null;
  76. return { start: Math.min(...numbers), end: Math.max(...numbers) };
  77. }
  78. /** 星期解析:优先数字 xqj,退回中文 xqjmc(两者都查,避免只认一个来源导致丢课) */
  79. function toDay(xqj, xqjmc) {
  80. const day = Number(xqj);
  81. if (day >= 1 && day <= 7) return day;
  82. const names = ["一", "二", "三", "四", "五", "六", "日", "天"];
  83. const text = `${xqjmc || ""}${xqj || ""}`;
  84. const index = names.findIndex((name) => text.includes(name));
  85. return index < 0 ? 0 : (index === 7 ? 7 : index + 1);
  86. }
  87. /** 课表 JSON → 课程数组。集中实践类课程没有星期/节次,无法落到格子里,直接跳过 */
  88. function toCourses(kbList) {
  89. if (!Array.isArray(kbList)) return [];
  90. const courses = [];
  91. for (const item of kbList || []) {
  92. if (!item || typeof item !== "object") continue;
  93. const sections = toSections(item.jcs);
  94. const weeks = toWeeks(item.zcd);
  95. const day = toDay(item.xqj, item.xqjmc);
  96. const name = String(item.kcmc || "").trim();
  97. if (!name || !sections || !weeks.length || !day) continue;
  98. const course = {
  99. name: name,
  100. teacher: String(item.xm || "").trim(),
  101. position: String(item.cdmc || "").trim(),
  102. day: day,
  103. startSection: sections.start,
  104. endSection: sections.end,
  105. weeks: weeks
  106. };
  107. const credit = Number(item.xf);
  108. if (credit > 0) course.credit = credit;
  109. courses.push(course);
  110. }
  111. return mergeAndDistinctCourses(courses);
  112. }
  113. /**
  114. * 节次与周次合并去重(取自官方 wiki《课程合并与去重函数》)
  115. * 连续节次 1-2 + 3-4 → 1-4;同节次单周 + 双周 → 全周;完全重复的记录去重。
  116. */
  117. function mergeAndDistinctCourses(courses) {
  118. if (!Array.isArray(courses) || courses.length <= 1) return courses || [];
  119. const list = courses.map((c) => ({
  120. ...c,
  121. name: c.name || "",
  122. teacher: c.teacher || "",
  123. position: c.position || "",
  124. weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : []
  125. }));
  126. // 阶段 1:合并连续节次与完全重复记录
  127. list.sort((a, b) =>
  128. a.name.localeCompare(b.name) ||
  129. a.teacher.localeCompare(b.teacher) ||
  130. a.position.localeCompare(b.position) ||
  131. (a.day || 0) - (b.day || 0) ||
  132. a.weeks.join(",").localeCompare(b.weeks.join(",")) ||
  133. (a.startSection || 0) - (b.startSection || 0));
  134. const step1 = [];
  135. let current = list[0];
  136. for (let i = 1; i < list.length; i++) {
  137. const next = list[i];
  138. const sameWeeks = current.name === next.name &&
  139. current.teacher === next.teacher &&
  140. current.position === next.position &&
  141. current.day === next.day &&
  142. current.weeks.join(",") === next.weeks.join(",");
  143. if (sameWeeks && current.endSection + 1 === next.startSection) {
  144. current.endSection = next.endSection; // 连续节次,延长
  145. } else if (sameWeeks && current.startSection === next.startSection &&
  146. current.endSection === next.endSection) {
  147. continue; // 完全重复,丢弃
  148. } else {
  149. step1.push(current);
  150. current = next;
  151. }
  152. }
  153. step1.push(current);
  154. // 阶段 2:合并同一节次上的不同周次(如单周 + 双周 → 全周)
  155. step1.sort((a, b) =>
  156. a.name.localeCompare(b.name) ||
  157. a.teacher.localeCompare(b.teacher) ||
  158. a.position.localeCompare(b.position) ||
  159. (a.day || 0) - (b.day || 0) ||
  160. (a.startSection || 0) - (b.startSection || 0) ||
  161. (a.endSection || 0) - (b.endSection || 0));
  162. const merged = [];
  163. let kept = step1[0];
  164. for (let i = 1; i < step1.length; i++) {
  165. const next = step1[i];
  166. const sameSection = kept.name === next.name &&
  167. kept.teacher === next.teacher &&
  168. kept.position === next.position &&
  169. kept.day === next.day &&
  170. kept.startSection === next.startSection &&
  171. kept.endSection === next.endSection;
  172. if (sameSection) {
  173. kept.weeks = [...new Set([...kept.weeks, ...next.weeks])].sort((a, b) => a - b);
  174. } else {
  175. merged.push(kept);
  176. kept = next;
  177. }
  178. }
  179. merged.push(kept);
  180. return merged;
  181. }
  182. // ==================== 学年学期 / 作息 / 校历 ====================
  183. /** 读取教务系统的学年学期选项(学年学期由教务提供,必须问系统要) */
  184. async function fetchSemesterOptions() {
  185. const response = await fetch(apiUrl("kbcx/xskbcx_cxXskbcxIndex.html", GNMKDM_KB), {
  186. credentials: "include"
  187. });
  188. if (!response.ok) return null;
  189. const dom = new DOMParser().parseFromString(await response.text(), "text/html");
  190. const readSelect = (id) => {
  191. const select = dom.getElementById(id);
  192. const options = [];
  193. for (const option of (select ? select.options : [])) {
  194. if (option.value) {
  195. options.push({ value: option.value, text: option.text.trim(), selected: option.selected });
  196. }
  197. }
  198. return options;
  199. };
  200. const years = readSelect("xnm");
  201. const terms = readSelect("xqm");
  202. return years.length && terms.length ? { years: years, terms: terms } : null;
  203. }
  204. /** 默认选中项:教务当前选中的那个,没有就用第一项 */
  205. function selectedIndexOf(options) {
  206. const index = options.findIndex((option) => option.selected);
  207. return index < 0 ? 0 : index;
  208. }
  209. /** 节次作息;接口不可用时返回空数组,不影响课程导入 */
  210. async function fetchTimeSlots(xnm, xqm) {
  211. const data = await postJson("kbcx/xskbcx_cxRjc.html", GNMKDM_KB, {
  212. xnm: xnm, xqm: xqm, kzlx: "ck", xsdm: "", kclbdm: "", kclxdm: ""
  213. });
  214. const rows = Array.isArray(data) ? data : (data && data.data) || [];
  215. const slots = [];
  216. for (const item of rows) {
  217. const number = Number(item.jcdm || item.jcmc);
  218. const startTime = String(item.qssj || "").slice(0, 5);
  219. const endTime = String(item.jssj || "").slice(0, 5);
  220. if (number > 0 && startTime && endTime) {
  221. slots.push({ number: number, startTime: startTime, endTime: endTime });
  222. }
  223. }
  224. return slots.sort((a, b) => a.number - b.number);
  225. }
  226. /** 校历:开学日期与学期总周数;取不到就返回空对象 */
  227. async function fetchConfig(xnm, xqm) {
  228. const rows = await postJson("kbcx/xskbcxZccx_cxZcByXnxq.html", GNMKDM_XL, { xnm: xnm, xqm: xqm });
  229. if (!Array.isArray(rows)) return {};
  230. const config = {};
  231. let maxWeek = 0;
  232. let firstWeek = null;
  233. for (const row of rows) {
  234. const week = Number(String(row.zs || row.zsmc || "").replace(/\D/g, ""));
  235. if (!(week >= 1)) continue;
  236. if (week > maxWeek) maxWeek = week;
  237. if (week === 1) firstWeek = row;
  238. }
  239. // 仅接受 10~30 周,避免把整年校历当成一个学期
  240. if (maxWeek >= 10 && maxWeek <= 30) config.semesterTotalWeeks = maxWeek;
  241. const row = firstWeek || {};
  242. const date = String(row.rq || row.zcrq || row.ksrq || "").match(/\d{4}-\d{1,2}-\d{1,2}/);
  243. if (date) config.semesterStartDate = date[0];
  244. return config;
  245. }
  246. // ==================== 主流程 ====================
  247. async function runImportFlow() {
  248. const confirmed = await window.shiguangBridgePromise.showAlert(
  249. "四川工商职业技术学院课表导入",
  250. "请确认已登录教务系统。登录后停在任意教务页面即可开始导入,脚本会自动读取学年学期、课表、开学日期与节次作息。",
  251. "开始导入"
  252. );
  253. if (!confirmed) return;
  254. const options = await fetchSemesterOptions();
  255. if (!options) throw new Error("未能读取学年学期,请确认已登录教务系统");
  256. const yearIndex = await window.shiguangBridgePromise.showSingleSelection(
  257. "选择学年", JSON.stringify(options.years.map((o) => o.text)), selectedIndexOf(options.years));
  258. if (yearIndex === null || yearIndex < 0) return;
  259. const termIndex = await window.shiguangBridgePromise.showSingleSelection(
  260. "选择学期", JSON.stringify(options.terms.map((o) => o.text)), selectedIndexOf(options.terms));
  261. if (termIndex === null || termIndex < 0) return;
  262. const xnm = options.years[yearIndex].value;
  263. const xqm = options.terms[termIndex].value;
  264. window.shiguangBridge.showToast("正在获取课表数据...");
  265. const data = await postJson("kbcx/xskbcx_cxXsKb.html", GNMKDM_KB, { xnm: xnm, xqm: xqm, kzlx: "ck" });
  266. const courses = toCourses(data.kbList);
  267. if (!courses.length) throw new Error("所选学期没有解析到课程,请确认学期是否正确");
  268. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  269. // 校历与作息属于附加信息,失败不影响课程导入
  270. const config = await fetchConfig(xnm, xqm).catch(() => ({}));
  271. const maxWeek = Math.max(...courses.map((course) => Math.max(...course.weeks)));
  272. if (config.semesterTotalWeeks && maxWeek > config.semesterTotalWeeks) {
  273. config.semesterTotalWeeks = maxWeek; // 总周数不得小于课程实际周次,否则课程会被截断
  274. }
  275. if (Object.keys(config).length) {
  276. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config)).catch(() => {});
  277. }
  278. const slots = await fetchTimeSlots(xnm, xqm).catch(() => []);
  279. if (slots.length) {
  280. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(slots)).catch(() => {});
  281. }
  282. window.shiguangBridge.showToast(`导入成功,共 ${courses.length} 条课程安排`);
  283. window.shiguangBridge.notifyTaskCompletion();
  284. }
  285. runImportFlow().catch((error) => {
  286. console.error("SCTBC 导入失败", error);
  287. window.shiguangBridgePromise.showAlert("导入失败", error.message, "确定");
  288. });