school.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. // 北京师范大学珠海校区(金智/青果 KINGOSOFT 教务)拾光课程表适配脚本
  2. //
  3. // 方案参考:wiki《常见教务系统适配案例参考》青果教务案例(resources/WZZY),
  4. // 全部数据通过带会话的网络请求获取,不依赖课表页面 DOM,支持开学日期导入。
  5. //
  6. // 本校实测接口:
  7. // 学期列表 POST /frame/droplist/getDropLists.action
  8. // body: comboBoxName=Ms_KBBP_FBXKJGXNXQ&...(参数见请求体)
  9. // → JSON [{"code":"2026,0","name":"2026-2027学年秋季学期"},...]
  10. // 注:本校 code 以逗号分隔(案例为 "-"),解析时已区分
  11. // 课表数据 GET /wsxk/xkjg.ckdgxsxdkchj_data10319.jsp?params=<base64(xn=..&xq=..)>
  12. // → GBK 编码 HTML 列表表格(网络数据源,非课表页面)
  13. // 注:案例使用 student/wsxk.xskcb10319.jsp(周×节矩阵视图),
  14. // 本校同族接口的列表视图字段更稳定,故用本接口,数据同源
  15. // 开学日期 POST /frame/desk/showLessonScheduleInfosV14.action?xn=..&xq=..&jxz=1
  16. // → 第一周课表 HTML,表头形如 <label>一</label><br>09-07,
  17. // 正则与青果案例一致;年份按学期推算(春季学期为 xn+1)
  18. // 作息时间 GET /public/SchoolTimetable.show.jsp → 制表符分隔节次表
  19. // (案例为写死作息,本校教务提供公布接口,优先动态获取)
  20. // 当前学期 POST /jw/common/showYearTerm.action(getDropLists 失败时的回退默认值)
  21. //
  22. // 学年学期编码:xn=学年前年份(2026 即 2026-2027 学年),xq=0 秋季 / 1 春季
  23. const HOST = 'https://jwxt.bnuzh.edu.cn';
  24. const DROPLIST_URL = HOST + '/frame/droplist/getDropLists.action';
  25. const YEARTERM_URL = HOST + '/jw/common/showYearTerm.action';
  26. const KB_DATA_URL = HOST + '/wsxk/xkjg.ckdgxsxdkchj_data10319.jsp';
  27. const LESSON_INFO_URL = HOST + '/frame/desk/showLessonScheduleInfosV14.action';
  28. const TIMETABLE_URL = HOST + '/public/SchoolTimetable.show.jsp';
  29. const DROPLIST_BODY =
  30. 'comboBoxName=Ms_KBBP_FBXKJGXNXQ&paramValue=&isYXB=0&isCDDW=0&isPYCC=0' +
  31. '&isPYCCNJ=0&zyPyccFiled=&isKBLB=0&isXQ=0&isBJ=0&isSXJD=0&isDJKSLB=0';
  32. const DAY_MAP = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7, '天': 7 };
  33. // 教务未公布作息时的兜底(2026-2027 学年秋季学期)
  34. const FALLBACK_TIME_SLOTS = [
  35. { number: 1, startTime: '08:00', endTime: '08:45' },
  36. { number: 2, startTime: '08:55', endTime: '09:40' },
  37. { number: 3, startTime: '10:00', endTime: '10:45' },
  38. { number: 4, startTime: '10:55', endTime: '11:40' },
  39. { number: 5, startTime: '13:30', endTime: '14:15' },
  40. { number: 6, startTime: '14:25', endTime: '15:10' },
  41. { number: 7, startTime: '15:30', endTime: '16:15' },
  42. { number: 8, startTime: '16:25', endTime: '17:10' },
  43. { number: 9, startTime: '18:00', endTime: '18:45' },
  44. { number: 10, startTime: '18:55', endTime: '19:40' },
  45. { number: 11, startTime: '19:50', endTime: '20:35' },
  46. { number: 12, startTime: '20:45', endTime: '21:30' }
  47. ];
  48. function validateYearInput(input) {
  49. if (/^\d{4}$/.test(String(input).trim())) return false;
  50. return '请输入四位数字学年,如 2026';
  51. }
  52. function validateDateInput(input) {
  53. if (/^\d{4}-\d{2}-\d{2}$/.test(String(input).trim())) return false;
  54. return '请输入正确格式的日期,如 2026-09-07';
  55. }
  56. async function fetchGbk(url, options) {
  57. const resp = await fetch(url, options);
  58. return new TextDecoder('gbk').decode(await resp.arrayBuffer());
  59. }
  60. // ---------- 学期 ----------
  61. // 案例同款:网络请求获取学期列表(含默认值逻辑,与课表页面解耦)
  62. async function fetchSemesterList() {
  63. try {
  64. const resp = await fetch(DROPLIST_URL, {
  65. method: 'POST',
  66. headers: {
  67. 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
  68. 'X-Requested-With': 'XMLHttpRequest'
  69. },
  70. body: DROPLIST_BODY,
  71. credentials: 'include'
  72. });
  73. const list = await resp.json();
  74. if (Array.isArray(list) && list.length > 0 && list[0].code) return list;
  75. } catch (e) { /* 回退到默认值方案 */ }
  76. return null;
  77. }
  78. // getDropLists 不可用时:以教务返回的当前学期为默认值生成候选列表
  79. function buildSemesterList(current) {
  80. const list = [];
  81. let xn = parseInt(current.xn, 10);
  82. let xq = current.xq === '1' ? 1 : 0;
  83. for (let i = 0; i < 8; i++) {
  84. list.push({
  85. code: xn + ',' + xq,
  86. name: xn + '-' + (xn + 1) + '学年' + (xq === 0 ? '秋季' : '春季') + '学期'
  87. });
  88. if (xq === 0) { xn -= 1; xq = 1; } else { xq = 0; }
  89. }
  90. return list;
  91. }
  92. async function selectSemester() {
  93. // 当前学期作为默认选中值(案例 dqxnxq.do 的默认值逻辑,本校由 showYearTerm 提供)
  94. let current = null;
  95. try {
  96. const resp = await fetch(YEARTERM_URL, { method: 'POST' });
  97. const data = JSON.parse(await resp.text());
  98. if (data && data.xn) current = { xn: data.xn, xq: String(data.xqM) };
  99. } catch (e) { /* 列表仍可用时不影响主流程 */ }
  100. let list = await fetchSemesterList();
  101. if (!list && current) list = buildSemesterList(current);
  102. if (!list) {
  103. // 列表与当前学期均不可得时,手动输入
  104. const year = await window.shiguangBridgePromise.showPrompt(
  105. '选择学年', '请输入学年的前年份,如 2026 表示 2026-2027 学年:', '2026', 'validateYearInput'
  106. );
  107. if (year === null) return null;
  108. const xq = await window.shiguangBridgePromise.showSingleSelection(
  109. '选择学期', JSON.stringify(['秋季学期', '春季学期']), 0
  110. );
  111. if (xq === null) return null;
  112. return { xn: String(year).trim(), xq: String(xq) };
  113. }
  114. let defaultIndex = 0;
  115. if (current) {
  116. const idx = list.findIndex(
  117. item => String(item.code).replace(/\s/g, '') === current.xn + ',' + current.xq
  118. );
  119. if (idx >= 0) defaultIndex = idx;
  120. }
  121. const names = list.slice(0, 10).map(item => item.name);
  122. const index = await window.shiguangBridgePromise.showSingleSelection(
  123. '选择导入学期', JSON.stringify(names), defaultIndex
  124. );
  125. if (index === null || index < 0 || index >= list.length) return null;
  126. // 本校 code 形如 "2026,0"(逗号),案例为 "2026-0",两种分隔符都兼容
  127. const parts = String(list[index].code).split(/[,\-]/);
  128. const xn = parts[0].trim();
  129. const xq = (parts[1] || '0').trim();
  130. if (!/^\d{4}$/.test(xn)) return null;
  131. return { xn, xq };
  132. }
  133. // ---------- 课表数据 ----------
  134. async function fetchCourseHtml(xn, xq) {
  135. const params = btoa('xn=' + xn + '&xq=' + xq);
  136. const html = await fetchGbk(KB_DATA_URL + '?params=' + params, { credentials: 'include' });
  137. if (!html || html.includes('凭证已失效')) return null;
  138. return html;
  139. }
  140. function expandWeeks(start, end, parity) {
  141. const weeks = [];
  142. for (let w = start; w <= end; w++) {
  143. if (parity === '单' && w % 2 === 0) continue;
  144. if (parity === '双' && w % 2 === 1) continue;
  145. weeks.push(w);
  146. }
  147. return weeks;
  148. }
  149. // 单个时间段,如 "1-16周 三[7-8] 丽泽楼C202(140)"
  150. function parseTimeBlock(block) {
  151. const m = block.match(
  152. /^(\d+)\s*[-—~-]\s*(\d+)周(?:\((单|双)\))?\s*([一二三四五六日天])\s*\[\s*(\d{1,2})(?:\s*[-—~-]\s*(\d{1,2}))?\s*\]\s*(.+)$/
  153. );
  154. if (!m) return null;
  155. const day = DAY_MAP[m[4]];
  156. if (!day) return null;
  157. const startSection = parseInt(m[5], 10);
  158. const endSection = m[6] ? parseInt(m[6], 10) : startSection;
  159. const weeks = expandWeeks(parseInt(m[1], 10), parseInt(m[2], 10), m[3]);
  160. if (weeks.length === 0 || endSection < startSection) return null;
  161. // 去掉地点末尾的教室容量,如 "丽泽楼C202(140)" → "丽泽楼C202"
  162. const position = m[7].trim().replace(/\(\d+\)$/, '');
  163. return { day, startSection, endSection, weeks, position };
  164. }
  165. function parseCourses(html) {
  166. const courses = [];
  167. const doc = new DOMParser().parseFromString(html, 'text/html');
  168. const rows = doc.querySelectorAll('tbody tr');
  169. for (const row of rows) {
  170. const cells = row.querySelectorAll('td');
  171. if (cells.length < 6) continue;
  172. const nameMatch = cells[0].textContent.trim().match(/^\[[^\]]+\]\s*(.+)$/);
  173. if (!nameMatch) continue;
  174. const name = nameMatch[1].trim();
  175. const teacher = cells[4].textContent.trim().replace(/;/g, '、');
  176. const timeText = cells[5].textContent.trim();
  177. if (!timeText) continue;
  178. for (const block of timeText.split(',')) {
  179. const parsed = parseTimeBlock(block.trim());
  180. if (!parsed) continue;
  181. const course = {
  182. name: name,
  183. teacher: teacher,
  184. position: parsed.position,
  185. day: parsed.day,
  186. startSection: parsed.startSection,
  187. endSection: parsed.endSection,
  188. weeks: parsed.weeks
  189. };
  190. courses.push(course);
  191. }
  192. }
  193. return courses;
  194. }
  195. // 节次与周次两阶段合并去重(与青果案例 mergeAndDistinctCourses 同逻辑):
  196. // 阶段 1 合并同课程同周次的连续节次、去除完全重复记录;
  197. // 阶段 2 合并同课程同节次的周次(如 1-8 周 + 9-16 周 → 1-16 周)
  198. function mergeAndDistinctCourses(courses) {
  199. if (!Array.isArray(courses) || courses.length <= 1) return courses;
  200. const list = courses.map(c => ({
  201. ...c,
  202. name: c.name || '',
  203. teacher: c.teacher || '',
  204. position: c.position || '',
  205. weeks: Array.isArray(c.weeks) ? [...new Set(c.weeks)].sort((a, b) => a - b) : []
  206. }));
  207. const byKey = (a, b) =>
  208. a.name.localeCompare(b.name) ||
  209. a.teacher.localeCompare(b.teacher) ||
  210. a.position.localeCompare(b.position) ||
  211. (a.day || 0) - (b.day || 0);
  212. list.sort((a, b) =>
  213. byKey(a, b) ||
  214. a.weeks.join(',').localeCompare(b.weeks.join(',')) ||
  215. (a.startSection || 0) - (b.startSection || 0));
  216. const step1 = [];
  217. let current = list[0];
  218. for (let i = 1; i < list.length; i++) {
  219. const next = list[i];
  220. const sameGroup =
  221. current.name === next.name &&
  222. current.teacher === next.teacher &&
  223. current.position === next.position &&
  224. current.day === next.day &&
  225. current.weeks.join(',') === next.weeks.join(',');
  226. const isContinuous = current.endSection + 1 === next.startSection;
  227. const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
  228. if (sameGroup && isContinuous) {
  229. current.endSection = next.endSection;
  230. } else if (sameGroup && isDuplicate) {
  231. continue;
  232. } else {
  233. step1.push(current);
  234. current = next;
  235. }
  236. }
  237. step1.push(current);
  238. step1.sort((a, b) =>
  239. byKey(a, b) ||
  240. (a.startSection || 0) - (b.startSection || 0) ||
  241. (a.endSection || 0) - (b.endSection || 0));
  242. const step2 = [];
  243. let cur = step1[0];
  244. for (let i = 1; i < step1.length; i++) {
  245. const nxt = step1[i];
  246. const sameGroup =
  247. cur.name === nxt.name &&
  248. cur.teacher === nxt.teacher &&
  249. cur.position === nxt.position &&
  250. cur.day === nxt.day &&
  251. cur.startSection === nxt.startSection &&
  252. cur.endSection === nxt.endSection;
  253. if (sameGroup) {
  254. cur.weeks = [...new Set([...cur.weeks, ...nxt.weeks])].sort((a, b) => a - b);
  255. } else {
  256. step2.push(cur);
  257. cur = nxt;
  258. }
  259. }
  260. step2.push(cur);
  261. return step2;
  262. }
  263. // ---------- 开学日期 ----------
  264. // 取第一周课表表头的 MM-DD(形如 <label>一</label><br>09-07),推算完整日期
  265. async function fetchSemesterStartDate(xn, xq) {
  266. try {
  267. const html = await fetchGbk(
  268. LESSON_INFO_URL + '?xn=' + xn + '&xq=' + xq + '&jxz=1',
  269. {
  270. method: 'POST',
  271. headers: { 'X-Requested-With': 'XMLHttpRequest' },
  272. credentials: 'include'
  273. }
  274. );
  275. const match = html.match(/<br\s*\/?>\s*(\d{2})-(\d{2})/);
  276. if (match) {
  277. const year = xq === '1' ? String(parseInt(xn, 10) + 1) : xn;
  278. return year + '-' + match[1] + '-' + match[2];
  279. }
  280. } catch (e) { /* 返回 null 由用户手动填写 */ }
  281. return null;
  282. }
  283. // ---------- 作息时间 ----------
  284. async function fetchTimeSlots() {
  285. try {
  286. const text = await fetchGbk(TIMETABLE_URL, { credentials: 'include' });
  287. const slots = [];
  288. const re = /(\d{1,2})\t(\d{2}:\d{2})\t(\d{2}:\d{2})/g;
  289. let m;
  290. while ((m = re.exec(text)) !== null) {
  291. const number = parseInt(m[1], 10);
  292. if (number >= 1) {
  293. slots.push({ number: number, startTime: m[2], endTime: m[3] });
  294. }
  295. }
  296. slots.sort((a, b) => a.number - b.number);
  297. if (slots.length > 0 && slots[0].number === 1) return slots;
  298. } catch (e) { /* 回退内置 */ }
  299. return FALLBACK_TIME_SLOTS;
  300. }
  301. // ---------- 导入流程 ----------
  302. async function runImportFlow() {
  303. // 脚本必须在教务站点域内执行,登录会话才会随请求携带
  304. if (!window.location.hostname.endsWith('bnuzh.edu.cn')) {
  305. await window.shiguangBridgePromise.showAlert(
  306. '无法导入',
  307. '当前不在教务系统页面内,请先完成教务系统登录再执行导入。',
  308. '我知道了'
  309. );
  310. return;
  311. }
  312. const confirmed = await window.shiguangBridgePromise.showAlert(
  313. '导入课表',
  314. '即将从教务系统获取课表、作息与开学日期。请确保当前页面已登录教务系统。',
  315. '开始导入'
  316. );
  317. if (!confirmed) {
  318. window.shiguangBridge.showToast('已取消导入。');
  319. return;
  320. }
  321. // 1. 选择学期(列表来自网络请求,默认选中最新学期)
  322. const semester = await selectSemester();
  323. if (!semester) {
  324. window.shiguangBridge.showToast('已取消导入。');
  325. return;
  326. }
  327. const { xn, xq } = semester;
  328. // 2. 网络请求获取课表数据
  329. window.shiguangBridge.showToast('正在获取课表数据...');
  330. let html = null;
  331. try {
  332. html = await fetchCourseHtml(xn, xq);
  333. } catch (e) {
  334. html = null;
  335. }
  336. if (!html) {
  337. await window.shiguangBridgePromise.showAlert(
  338. '获取失败',
  339. '未能获取课表数据,可能尚未登录或会话已过期,请确认登录后重试。',
  340. '确定'
  341. );
  342. return;
  343. }
  344. const courses = mergeAndDistinctCourses(parseCourses(html));
  345. if (courses.length === 0) {
  346. await window.shiguangBridgePromise.showAlert(
  347. '没有课程',
  348. '已连接教务系统,但该学期没有解析到课程记录。',
  349. '确定'
  350. );
  351. return;
  352. }
  353. // 3. 开学日期(教务第一周课表)并让用户确认
  354. window.shiguangBridge.showToast('正在获取开学日期...');
  355. const startDate = await fetchSemesterStartDate(xn, xq);
  356. const confirmedDate = await window.shiguangBridgePromise.showPrompt(
  357. '确认开学日期',
  358. '请确认本学期第一周周一的日期(格式 YYYY-MM-DD):',
  359. startDate || '',
  360. 'validateDateInput'
  361. );
  362. if (confirmedDate === null) {
  363. window.shiguangBridge.showToast('导入已取消。');
  364. return;
  365. }
  366. // 4. 总周数按课程周次最大值推算
  367. let maxWeek = 0;
  368. for (const c of courses) {
  369. for (const w of c.weeks) if (w > maxWeek) maxWeek = w;
  370. }
  371. try {
  372. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
  373. semesterStartDate: String(confirmedDate).trim(),
  374. semesterTotalWeeks: maxWeek > 0 ? maxWeek : 20,
  375. defaultClassDuration: 45,
  376. defaultBreakDuration: 10
  377. }));
  378. } catch (e) {
  379. window.shiguangBridge.showToast('课表配置保存失败:' + e.message);
  380. }
  381. // 5. 作息时间(教务公布优先,失败回退内置)
  382. const timeSlots = await fetchTimeSlots();
  383. try {
  384. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  385. } catch (e) {
  386. window.shiguangBridge.showToast('作息时间保存失败:' + e.message);
  387. }
  388. // 6. 提交课程
  389. try {
  390. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  391. } catch (e) {
  392. await window.shiguangBridgePromise.showAlert('导入失败', '课程数据保存失败:' + e.message, '确定');
  393. return;
  394. }
  395. await window.shiguangBridgePromise.showAlert(
  396. '导入成功',
  397. '已导入 ' + courses.length + ' 条课程记录、作息时间与开学日期 ' + String(confirmedDate).trim() + '。',
  398. '确定'
  399. );
  400. window.shiguangBridge.notifyTaskCompletion();
  401. }
  402. runImportFlow();