cjlu.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. // 中国计量大学(cjlu.edu.cn) 拾光课程表适配脚本
  2. // 教务系统:正方教务(新版 UI zftal-ui-v5),部署在根路径 /xtgl/
  3. // 登录:正方统一身份认证 https://authserver.cjlu.edu.cn/authserver/login?service=https://jwxt.cjlu.edu.cn/sso/jziotlogin
  4. // 数据全部通过接口获取,不读取当前页面 DOM,登录后停留在任意教务页面即可导入
  5. // 参考:《常见教务系统适配案例参考》正方教务 v9 方案(resources/HAUE、resources/JSEI)
  6. // 原适配脚本作者:ying-ck 维护者:salt-fishes
  7. var CJLU_OPTIONS_URL = 'kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151&layout=default';
  8. // 正方模块目录:模块根地址 = 当前路径中最早出现的模块目录之前的部分
  9. // 兼容根路径(/xtgl)、/jwglxt 子路径与 WebVPN(/http/<hex>/...) 形态
  10. var CJLU_MODULE_MARKERS = ['/xtgl/', '/kbcx/', '/jzgl/'];
  11. // 返回以斜杠结尾的模块根地址,便于拼接 'kbcx/xxx.html'
  12. function getBaseUrl() {
  13. const path = window.location.pathname || '';
  14. let cut = -1;
  15. CJLU_MODULE_MARKERS.forEach(function (marker) {
  16. const index = path.indexOf(marker);
  17. if (index >= 0 && (cut < 0 || index < cut)) cut = index;
  18. });
  19. return window.location.origin + (cut >= 0 ? path.slice(0, cut) : '') + '/';
  20. }
  21. // ==================== 文本 / 时间 ====================
  22. function normalizeText(value) {
  23. return String(value == null ? '' : value)
  24. .replace(/[!-~]/g, function (c) { return String.fromCharCode(c.charCodeAt(0) - 65248); })
  25. .replace(/\u3000/g, ' ')
  26. .replace(/[~~—–]/g, '-');
  27. }
  28. function normalizeTime(value) {
  29. const match = normalizeText(value).trim().match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
  30. if (!match) throw new Error('无法识别时间:' + value);
  31. return String(match[1]).padStart(2, '0') + ':' + match[2];
  32. }
  33. // ==================== 桥接封装 ====================
  34. function toast(message) {
  35. if (window.shiguangBridge && typeof window.shiguangBridge.showToast === 'function') {
  36. window.shiguangBridge.showToast(message);
  37. }
  38. }
  39. async function alertUser(title, message, confirmText) {
  40. if (window.shiguangBridgePromise && typeof window.shiguangBridgePromise.showAlert === 'function') {
  41. const confirmed = await window.shiguangBridgePromise.showAlert(title, message, confirmText || '知道了');
  42. return confirmed === true || confirmed === 'true';
  43. }
  44. alert(title + '\n' + message);
  45. return true;
  46. }
  47. // ==================== 请求 ====================
  48. async function requestHaue(path, params, moduleId) {
  49. const response = await fetch(getBaseUrl() + path + '?gnmkdm=' + encodeURIComponent(moduleId), {
  50. method: 'POST',
  51. credentials: 'same-origin',
  52. headers: {
  53. 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
  54. 'Accept': 'application/json',
  55. 'X-Requested-With': 'XMLHttpRequest'
  56. },
  57. body: new URLSearchParams(params).toString()
  58. });
  59. if (!response.ok) throw new Error('教务请求失败(HTTP ' + response.status + ')');
  60. const text = await response.text();
  61. try {
  62. return JSON.parse(text);
  63. } catch (e) {
  64. if (/login_slogin|用户登录|统一身份认证/i.test(text)) {
  65. throw new Error('登录已过期,请重新登录教务系统后再导入');
  66. }
  67. throw new Error('教务未返回 JSON 数据,请重新登录后重试');
  68. }
  69. }
  70. // ==================== 校历:开学日期 + 总周数 ====================
  71. function parseSemesterConfig(rows) {
  72. const config = {};
  73. if (!Array.isArray(rows) || rows.length === 0) return config;
  74. let first = null, max = 0;
  75. const weeks = new Set();
  76. rows.forEach(function (row) {
  77. const match = normalizeText(row.zs == null ? row.zsmc : row.zs).trim().match(/^(?:第)?(\d+)(?:周)?$/);
  78. if (!match || +match[1] < 1) return;
  79. const week = +match[1];
  80. weeks.add(week);
  81. max = Math.max(max, week);
  82. if (week === 1) first = row;
  83. });
  84. const sorted = Array.from(weeks).sort(function (a, b) { return a - b; });
  85. let continuous = sorted.length > 0;
  86. for (let i = 1; i < sorted.length; i++) {
  87. if (sorted[i] - sorted[i - 1] !== 1) { continuous = false; break; }
  88. }
  89. // 仅接受连续且最大周次在 10~60 的校历,避免把整年周历当成学期
  90. if (continuous && max >= 10 && max <= 60) config.semesterTotalWeeks = max;
  91. if (first) {
  92. // 注意:正方部分页面重写了 Array.prototype.filter/some/every,这里一律用普通循环
  93. const keys = ['rq', 'zcrq', 'ksrq'];
  94. for (let i = 0; i < keys.length; i++) {
  95. const match = normalizeText(first[keys[i]]).match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
  96. if (match) {
  97. config.semesterStartDate = match[1] + '-' + String(match[2]).padStart(2, '0') + '-' + String(match[3]).padStart(2, '0');
  98. break;
  99. }
  100. }
  101. }
  102. return config;
  103. }
  104. async function fetchSemesterConfig(year, term, warnings) {
  105. try {
  106. return parseSemesterConfig(await requestHaue('kbcx/xskbcxZccx_cxZcByXnxq.html', { xnm: year, xqm: term }, 'N2154'));
  107. } catch (error) {
  108. warnings.push('校历获取失败:' + error.message);
  109. return {};
  110. }
  111. }
  112. // ==================== 作息 ====================
  113. function parseTimeSlots(data) {
  114. if (!Array.isArray(data) || data.length === 0) throw new Error('未收到有效的作息列表');
  115. const byNumber = new Map();
  116. data.forEach(function (row) {
  117. const section = normalizeText(row.jcmc).trim();
  118. const number = +section;
  119. if (!/^\d+$/.test(section) || number < 1 || number > 30) return;
  120. const startTime = normalizeTime(row.qssj);
  121. const endTime = normalizeTime(row.jssj);
  122. const existing = byNumber.get(number);
  123. if (existing) {
  124. if (existing.startTime === startTime && existing.endTime === endTime) return;
  125. throw new Error('第 ' + number + ' 节返回不同时间:' + existing.startTime + '-' + existing.endTime + ' 与 ' + startTime + '-' + endTime);
  126. }
  127. byNumber.set(number, { number: number, startTime: startTime, endTime: endTime });
  128. });
  129. const slots = Array.from(byNumber.values()).sort(function (a, b) { return a.number - b.number; });
  130. if (slots.length === 0) throw new Error('作息列表无法解析');
  131. return slots;
  132. }
  133. async function fetchTimeSlots(year, term, campusId, warnings) {
  134. try {
  135. const params = { xnm: year, xqm: term };
  136. if (campusId) params.xqh_id = campusId;
  137. return parseTimeSlots(await requestHaue('jzgl/skxxMobile_cxRsdjc.html', params, 'N2154'));
  138. } catch (error) {
  139. warnings.push('教务作息获取失败:' + error.message);
  140. return null;
  141. }
  142. }
  143. // ==================== 周次 / 课程解析 ====================
  144. function parseWeeks(value) {
  145. const weeks = new Set();
  146. const text = normalizeText(value).replace(/(/g, '(').replace(/)/g, ')').replace(/周/g, '').replace(/[,、]/g, ',');
  147. text.split(',').forEach(function (part) {
  148. const match = part.trim().match(/^(\d+)\s*(?:-\s*(\d+))?\s*(?:\(([单双])\))?$/);
  149. if (!match) return;
  150. const start = +match[1], end = +(match[2] || match[1]);
  151. if (start < 1 || end < start || end > 60) return;
  152. for (let week = start; week <= end; week++) {
  153. if (match[3] === '单' && week % 2 === 0) continue;
  154. if (match[3] === '双' && week % 2 === 1) continue;
  155. weeks.add(week);
  156. }
  157. });
  158. return Array.from(weeks).sort(function (a, b) { return a - b; });
  159. }
  160. // 节次与周次合并去重函数(官方 wiki 实现,逐字未改)
  161. function mergeAndDistinctCourses(courses) {
  162. if (!Array.isArray(courses) || courses.length <= 1) return courses;
  163. const list = courses.map(c => ({
  164. ...c,
  165. name: c.name || '',
  166. teacher: c.teacher || '',
  167. position: c.position || '',
  168. weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : []
  169. }));
  170. list.sort((a, b) => {
  171. return a.name.localeCompare(b.name) ||
  172. a.teacher.localeCompare(b.teacher) ||
  173. a.position.localeCompare(b.position) ||
  174. (a.day || 0) - (b.day || 0) ||
  175. a.weeks.join(',').localeCompare(b.weeks.join(',')) ||
  176. (a.startSection || 0) - (b.startSection || 0);
  177. });
  178. const step1Merged = [];
  179. let current = list[0];
  180. for (let i = 1; i < list.length; i++) {
  181. const next = list[i];
  182. const isSameCourseAndWeeks =
  183. current.name === next.name &&
  184. current.teacher === next.teacher &&
  185. current.position === next.position &&
  186. current.day === next.day &&
  187. current.weeks.join(',') === next.weeks.join(',');
  188. const isContinuous = current.endSection + 1 === next.startSection;
  189. const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
  190. if (isSameCourseAndWeeks && isContinuous) {
  191. current.endSection = next.endSection;
  192. } else if (isSameCourseAndWeeks && isDuplicate) {
  193. continue;
  194. } else {
  195. step1Merged.push(current);
  196. current = next;
  197. }
  198. }
  199. step1Merged.push(current);
  200. step1Merged.sort((a, b) => {
  201. return a.name.localeCompare(b.name) ||
  202. a.teacher.localeCompare(b.teacher) ||
  203. a.position.localeCompare(b.position) ||
  204. (a.day || 0) - (b.day || 0) ||
  205. (a.startSection || 0) - (b.startSection || 0) ||
  206. (a.endSection || 0) - (b.endSection || 0);
  207. });
  208. const step2Merged = [];
  209. let merged = step1Merged[0];
  210. for (let i = 1; i < step1Merged.length; i++) {
  211. const next = step1Merged[i];
  212. const isSameCourseAndSection =
  213. merged.name === next.name &&
  214. merged.teacher === next.teacher &&
  215. merged.position === next.position &&
  216. merged.day === next.day &&
  217. merged.startSection === next.startSection &&
  218. merged.endSection === next.endSection;
  219. if (isSameCourseAndSection) {
  220. merged.weeks = Array.from(new Set([...merged.weeks, ...next.weeks])).sort((a, b) => a - b);
  221. } else {
  222. step2Merged.push(merged);
  223. merged = next;
  224. }
  225. }
  226. step2Merged.push(merged);
  227. return step2Merged;
  228. }
  229. function parseCourses(data, warnings) {
  230. if (!data || !Array.isArray(data.kbList)) throw new Error('未收到课表数据,请确认登录状态');
  231. const courses = [];
  232. data.kbList.forEach(function (row) {
  233. try {
  234. const name = String(row.kcmc || '').trim();
  235. const section = normalizeText(row.jcs || row.jc).match(/^(\d+)\s*(?:-\s*(\d+))?\s*节?$/);
  236. const day = +normalizeText(row.xqj);
  237. if (!name || !section || !/^[1-7]$/.test(String(day))) throw new Error('排课信息异常');
  238. const weeks = parseWeeks(row.zcd);
  239. if (weeks.length === 0) throw new Error('周次为空');
  240. const startSection = +section[1], endSection = +(section[2] || section[1]);
  241. if (startSection < 1 || endSection < startSection) throw new Error('节次异常');
  242. courses.push({
  243. name: name,
  244. teacher: String(row.xm || '').trim(),
  245. position: String(row.cdmc || '').trim(),
  246. day: day,
  247. startSection: startSection,
  248. endSection: endSection,
  249. weeks: weeks
  250. });
  251. } catch (error) {
  252. warnings.push('跳过课程 ' + String(row && row.kcmc || '未命名') + ':' + error.message);
  253. }
  254. });
  255. return mergeAndDistinctCourses(courses);
  256. }
  257. // ==================== 学年 / 学期选择 ====================
  258. function getDefaultOptionIndex(options, key) {
  259. const match = function (value) {
  260. const text = String(value == null ? '' : value).trim();
  261. if (!text) return -1;
  262. for (let i = 0; i < options.length; i++) if (options[i].value === text) return i;
  263. return -1;
  264. };
  265. let index = match(window[key]);
  266. if (index >= 0) return index;
  267. try {
  268. index = match(new URL(window.location.href).searchParams.get(key));
  269. if (index >= 0) return index;
  270. } catch (e) { /* ignore */ }
  271. const selected = options.findIndex(function (option) { return option.selected; });
  272. return selected >= 0 ? selected : 0;
  273. }
  274. function parseAcademicOptions(doc) {
  275. const read = function (selector) {
  276. const list = [];
  277. const nodes = doc.querySelectorAll(selector);
  278. for (let i = 0; i < nodes.length; i++) {
  279. const value = String(nodes[i].value).trim();
  280. if (nodes[i].disabled || value === '') continue;
  281. list.push({ value: value, text: nodes[i].textContent.trim(), selected: nodes[i].selected });
  282. }
  283. return list;
  284. };
  285. const years = read('#xnm option');
  286. const terms = read('#xqm option');
  287. if (years.length === 0 || terms.length === 0) throw new Error('未获取到学年学期选项,请确认已登录教务系统后重试');
  288. const selectedYear = getDefaultOptionIndex(years, 'xnm');
  289. const start = Math.max(0, selectedYear - 2);
  290. const yearOptions = years.slice(start, selectedYear + 3);
  291. return {
  292. yearOptions: yearOptions,
  293. termOptions: terms,
  294. defaultYearIndex: selectedYear - start,
  295. defaultTermIndex: getDefaultOptionIndex(terms, 'xqm')
  296. };
  297. }
  298. async function fetchAcademicOptions() {
  299. const response = await fetch(getBaseUrl() + CJLU_OPTIONS_URL, { method: 'GET', credentials: 'same-origin' });
  300. if (!response.ok) throw new Error('获取学年学期失败(HTTP ' + response.status + ')');
  301. const html = await response.text();
  302. return parseAcademicOptions(new DOMParser().parseFromString(html, 'text/html'));
  303. }
  304. async function selectAcademicYearAndSemester(bridge) {
  305. const options = await fetchAcademicOptions();
  306. const select = async function (title, entries, defaultIndex) {
  307. const result = await bridge.showSingleSelection(title, JSON.stringify(entries.map(function (item) { return item.text; })), defaultIndex);
  308. if (result === null || result === undefined || String(result).trim() === 'null') return null;
  309. const index = parseInt(result, 10);
  310. return entries[index] || null;
  311. };
  312. const make = function (year, term) { return { year: year.value, term: term.value, label: year.text + ' / ' + term.text }; };
  313. const defaultSelection = make(options.yearOptions[options.defaultYearIndex], options.termOptions[options.defaultTermIndex]);
  314. const action = await select('选择导入学期', [
  315. { value: 'default', text: '导入默认学期:' + defaultSelection.label },
  316. { value: 'change', text: '更换学年、学期' }
  317. ], 0);
  318. if (!action) return null;
  319. if (action.value === 'default') return defaultSelection;
  320. const year = await select('选择学年', options.yearOptions, options.defaultYearIndex);
  321. if (!year) return null;
  322. const term = await select('选择学期', options.termOptions, options.defaultTermIndex);
  323. if (!term) return null;
  324. return make(year, term);
  325. }
  326. // ==================== 保存 ====================
  327. async function saveWithConfirmation(method, data, label) {
  328. const result = await window.shiguangBridgePromise[method](JSON.stringify(data));
  329. if (result !== true && result !== 'true') throw new Error(label + '保存未成功,请重试');
  330. }
  331. // 兜底作息(接口失败时使用;来自教务作息接口实测)
  332. var CJLU_FALLBACK_TIME_SLOTS = [
  333. { number: 1, startTime: '08:00', endTime: '08:45' },
  334. { number: 2, startTime: '08:50', endTime: '09:35' },
  335. { number: 3, startTime: '09:55', endTime: '10:40' },
  336. { number: 4, startTime: '10:45', endTime: '11:30' },
  337. { number: 5, startTime: '11:35', endTime: '12:20' },
  338. { number: 6, startTime: '13:30', endTime: '14:15' },
  339. { number: 7, startTime: '14:20', endTime: '15:05' },
  340. { number: 8, startTime: '15:15', endTime: '16:00' },
  341. { number: 9, startTime: '16:05', endTime: '16:50' },
  342. { number: 10, startTime: '18:00', endTime: '18:45' },
  343. { number: 11, startTime: '18:50', endTime: '19:35' },
  344. { number: 12, startTime: '19:40', endTime: '20:25' }
  345. ];
  346. // ==================== 流程编排 ====================
  347. async function runImportFlow() {
  348. const bridge = window.shiguangBridgePromise;
  349. let coursesSaved = false;
  350. let saveStage = '课程';
  351. try {
  352. if (!/cjlu\.edu\.cn$/i.test(window.location.hostname)) {
  353. await alertUser('请先进入教务系统', '请登录中国计量大学教务系统后,停留在任意教务页面再执行导入。');
  354. return;
  355. }
  356. const startAlert = await alertUser('中国计量大学课表导入', '导入前请确保已登录教务系统。', '开始导入');
  357. if (!startAlert) { toast('导入已取消'); return; }
  358. const selection = await selectAcademicYearAndSemester(bridge);
  359. if (!selection) { toast('导入已取消'); return; }
  360. const warnings = [];
  361. toast('正在获取课程、校历和作息…');
  362. const kbData = await requestHaue('kbcx/xskbcx_cxXsgrkb.html', {
  363. xnm: selection.year, xqm: selection.term, kzlx: 'ck', xsdm: '', kclbdm: '', kclxdm: ''
  364. }, 'N2151');
  365. let campusId = '';
  366. const kbList = Array.isArray(kbData.kbList) ? kbData.kbList : [];
  367. for (let i = 0; i < kbList.length; i++) {
  368. if (kbList[i].xqh_id) { campusId = kbList[i].xqh_id; break; }
  369. }
  370. const configResult = await Promise.all([
  371. fetchSemesterConfig(selection.year, selection.term, warnings),
  372. fetchTimeSlots(selection.year, selection.term, campusId, warnings)
  373. ]);
  374. const config = configResult[0];
  375. const slots = configResult[1] || CJLU_FALLBACK_TIME_SLOTS;
  376. if (!configResult[1]) warnings.push('已使用内置作息,如需请核对');
  377. let courses = parseCourses(kbData, warnings);
  378. // 课程的节次必须在作息里有对应节次,否则跳过并提示(用普通循环,避开被改写的 filter)
  379. const numbers = new Set();
  380. for (let i = 0; i < slots.length; i++) numbers.add(slots[i].number);
  381. const kept = [];
  382. for (let i = 0; i < courses.length; i++) {
  383. const course = courses[i];
  384. let ok = true;
  385. for (let n = course.startSection; n <= course.endSection; n++) {
  386. if (!numbers.has(n)) { warnings.push('跳过课程 ' + course.name + ':第 ' + n + ' 节缺少作息'); ok = false; break; }
  387. }
  388. if (ok) kept.push(course);
  389. }
  390. courses = kept;
  391. if (courses.length === 0) throw new Error('没有可导入的有效课程,请核对学期。' + (warnings.length ? '\n' + warnings.join('\n') : ''));
  392. // 总周数不能小于课程最大周次
  393. if (config.semesterTotalWeeks) {
  394. const maxWeek = Math.max.apply(null, courses.reduce(function (all, c) { return all.concat(c.weeks); }, [0]));
  395. if (maxWeek > config.semesterTotalWeeks) {
  396. warnings.push('校历总周数小于课程周次,未保存总周数');
  397. delete config.semesterTotalWeeks;
  398. }
  399. }
  400. const practices = Array.isArray(kbData.sjkList) ? kbData.sjkList : [];
  401. const practiceNote = practices.length
  402. ? '\n\n以下实践课无固定星期/节次,本次不导入:\n' + practices.map(function (row) { return String(row.kcmc || '未命名') + '(' + String(row.qsjsz || '周次未定') + ')'; }).join('\n')
  403. : '';
  404. const message = selection.label + '\n共 ' + courses.length + ' 条课程时段。\n\n作息:\n' +
  405. slots.map(function (s) { return '第' + s.number + '节 ' + s.startTime + '-' + s.endTime; }).join('\n') +
  406. '\n\n开学日期:' + (config.semesterStartDate || '需手动设置') +
  407. '\n学期总周数:' + (config.semesterTotalWeeks || '需手动设置') +
  408. practiceNote + (warnings.length ? '\n\n注意:\n' + warnings.join('\n') : '');
  409. const confirmed = await bridge.showAlert('核对课程与作息', message, '确认并保存');
  410. if (confirmed !== true && confirmed !== 'true') { toast('导入已取消'); return; }
  411. await saveWithConfirmation('saveImportedCourses', courses, '课程');
  412. coursesSaved = true;
  413. saveStage = '作息';
  414. await saveWithConfirmation('savePresetTimeSlots', slots, '作息');
  415. if (config.semesterStartDate || config.semesterTotalWeeks) {
  416. saveStage = '学期配置';
  417. await saveWithConfirmation('saveCourseConfig', Object.assign({ firstDayOfWeek: 1 }, config), '学期配置');
  418. }
  419. toast('已导入 ' + courses.length + ' 条课程时段及 ' + slots.length + ' 节作息');
  420. if (window.shiguangBridge && typeof window.shiguangBridge.notifyTaskCompletion === 'function') {
  421. window.shiguangBridge.notifyTaskCompletion();
  422. }
  423. } catch (error) {
  424. await alertUser('导入未完成', (coursesSaved ? '课程已保存,但' + saveStage + '未保存成功,请重试。\n' : '') + (error && error.message ? error.message : String(error)), '确定');
  425. }
  426. }
  427. runImportFlow();