zqu_01.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. // 肇庆学院(zqu.edu.cn) 拾光课程表适配脚本
  2. // 教务系统:URP 系(Struts2,形如 xxx!yyy.action)
  3. // 适用范围:本脚本为【WebVPN 校外访问】版本;校内直连版本另行提交
  4. // 维护者:NoobLLiu
  5. //
  6. // 课表数据直接取自后台 JSON 接口,不做 HTML 表格解析:
  7. // GET xsgrkbcx!getKbRq.action?xnxqdm=<学年学期>&zc=<周次>&xsdm=
  8. // zc 传空即不限周次,一次可取回整学期排课记录;每条记录自带 zc 字段
  9. // 标明该门课的上课周次,因此无需逐周请求,也无需读取页面 DOM。
  10. //
  11. // 使用前提:用户已在弹窗打开的网页中登录 WebVPN 并进入教务系统,
  12. // 停留在教务系统内的任意页面即可,无需打开课表查询页。
  13. // ---------------------------------------------------------------- 常量
  14. // 「我的课表」内容页,本脚本只用它取服务端渲染的学期下拉框 #xnxqdm
  15. const KB_LIST_PAGE = 'xsgrkbcx!getXsgrbkList.action';
  16. // 课表数据接口(返回 JSON)
  17. const KB_DATA = 'xsgrkbcx!getKbRq.action';
  18. // 肇庆学院作息时间(每天 14 节),依据学校实际作息表。
  19. // 教务系统不提供作息数据,导入时按此预设写入。
  20. const PRESET_TIME_SLOTS = [
  21. { number: 1, startTime: '08:00', endTime: '08:40' },
  22. { number: 2, startTime: '08:50', endTime: '09:30' },
  23. { number: 3, startTime: '09:50', endTime: '10:30' },
  24. { number: 4, startTime: '10:40', endTime: '11:20' },
  25. { number: 5, startTime: '11:30', endTime: '12:10' },
  26. { number: 6, startTime: '14:30', endTime: '15:10' },
  27. { number: 7, startTime: '15:20', endTime: '16:00' },
  28. { number: 8, startTime: '16:15', endTime: '16:55' },
  29. { number: 9, startTime: '17:05', endTime: '17:45' },
  30. { number: 10, startTime: '17:55', endTime: '18:35' },
  31. { number: 11, startTime: '19:00', endTime: '19:40' },
  32. { number: 12, startTime: '19:50', endTime: '20:30' },
  33. { number: 13, startTime: '20:40', endTime: '21:20' },
  34. { number: 14, startTime: '21:30', endTime: '22:10' }
  35. ];
  36. // 教室字段为空时的占位文本(教务系统尚未给出教室信息)
  37. const UNKNOWN_POSITION = '未获取到教室';
  38. // ---------------------------------------------------------------- 工具函数
  39. /**
  40. * 推算教务系统的站点根路径。
  41. *
  42. * WebVPN 下页面地址形如:
  43. * https://webvpn.zqu.edu.cn/https/<加密串>/xsgrkbcx!getXsgrbkList.action
  44. * 校内直连下形如:
  45. * https://jwgl.zqu.edu.cn/xsgrkbcx!getXsgrbkList.action
  46. * 取到 <加密串>/ 这一层,后续所有接口都基于它拼接。
  47. */
  48. function siteBase() {
  49. try {
  50. const u = new URL(window.location.href);
  51. const path = u.pathname;
  52. // 本脚本只请求 xsgrkbcx! 下两个接口,取该段之前的一段即为站点基址。
  53. // 停留在别的页面时匹配不到,退回按最后一段斜杠截断,效果相同。
  54. const i = path.indexOf('xsgrkbcx!');
  55. if (i > 0) return u.origin + path.slice(0, i);
  56. const j = path.lastIndexOf('/');
  57. return u.origin + (j > 0 ? path.slice(0, j + 1) : '/');
  58. } catch (e) {
  59. const href = window.location.href.split(/[?#]/)[0];
  60. const i = href.lastIndexOf('/');
  61. return i > 0 ? href.slice(0, i + 1) : href;
  62. }
  63. }
  64. /**
  65. * 读取某个 document 里的下拉框选项(跳过空值项)。
  66. * 目前只用于解析学期下拉框 #xnxqdm。
  67. * @returns {{cur: string, opts: Array<{value: string, text: string}>}|null}
  68. */
  69. function readSelectOptions(doc, id) {
  70. let sel;
  71. try {
  72. sel = doc.querySelector('#' + id);
  73. } catch (e) {
  74. return null;
  75. }
  76. if (!sel || !sel.options) return null;
  77. const opts = [];
  78. for (let i = 0; i < sel.options.length; i++) {
  79. const v = String(sel.options[i].value).trim();
  80. if (v === '') continue;
  81. opts.push({ value: v, text: (sel.options[i].textContent || '').trim() });
  82. }
  83. if (opts.length === 0) return null;
  84. return { cur: String(sel.value).trim(), opts: opts };
  85. }
  86. /**
  87. * 解析节次字段,返回节号数组。
  88. * jcdm2 形如 "01,02,03";jcdm 形如 "0102"(连写)。
  89. */
  90. function parseSections(jcdm2, jcdm) {
  91. const txt = String(jcdm2 || jcdm || '').trim();
  92. const nums = [];
  93. const push = n => { if (n > 0 && nums.indexOf(n) === -1) nums.push(n); };
  94. for (const raw of txt.split(/[,\s]+/)) {
  95. const part = raw.trim();
  96. if (!part || !/^\d+$/.test(part)) continue;
  97. if (part.length <= 2) {
  98. push(parseInt(part, 10));
  99. } else {
  100. for (let i = 0; i < part.length; i += 2) {
  101. push(parseInt(part.slice(i, i + 2), 10));
  102. }
  103. }
  104. }
  105. return nums.sort((a, b) => a - b);
  106. }
  107. /**
  108. * 解析课程记录自带的周次字段,返回周号数组。
  109. *
  110. * 教务系统在不同部署下 zc 字段有多种写法,故一并兼容:
  111. * "1-8,10-17周" 区间(URP 系最常见)
  112. * "1,3,5周" 逗号分隔
  113. * "1111111100000000" 位串(正方形教务 classWeek 写法,第 i 位为 1 表示第 i+1 周)
  114. * "1-16(单)" 带单双周后缀
  115. * 返回值已去重并升序。
  116. */
  117. function parseWeekText(zc) {
  118. const txt = String(zc === null || zc === undefined ? '' : zc).trim();
  119. if (!txt) return [];
  120. // 位串:只由 0/1 组成且长度 >= 4,视为「第 i+1 周」标记
  121. if (/^[01]{4,}$/.test(txt)) {
  122. const out = [];
  123. for (let i = 0; i < txt.length; i++) {
  124. if (txt[i] === '1') out.push(i + 1);
  125. }
  126. return out;
  127. }
  128. // 区间 / 列表:先去掉「周、单、双、(单)、(双)」等修饰再切分
  129. const cleaned = txt
  130. .replace(/周|星期/g, '')
  131. .replace(/[((][^))]*[))]/g, '')
  132. .replace(/[单双]/g, '');
  133. const out = [];
  134. for (const raw of cleaned.split(/[,,\s]+/)) {
  135. const part = raw.trim();
  136. if (!part) continue;
  137. const range = /^(\d+)\s*-\s*(\d+)$/.exec(part);
  138. if (range) {
  139. const a = parseInt(range[1], 10);
  140. const b = parseInt(range[2], 10);
  141. if (a > 0 && b >= a) {
  142. for (let i = a; i <= b; i++) out.push(i);
  143. }
  144. continue;
  145. }
  146. if (/^\d+$/.test(part)) {
  147. const n = parseInt(part, 10);
  148. if (n > 0) out.push(n);
  149. }
  150. }
  151. return Array.from(new Set(out)).sort((a, b) => a - b);
  152. }
  153. /**
  154. * 节次与周次合并去重函数(摘自拾光课程表官方 wiki 的参考实现)。
  155. */
  156. function mergeAndDistinctCourses(courses) {
  157. if (!Array.isArray(courses) || courses.length <= 1) return courses;
  158. const list = courses.map(c => ({
  159. ...c,
  160. name: c.name || '',
  161. teacher: c.teacher || '',
  162. position: c.position || '',
  163. weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : []
  164. }));
  165. // 阶段 1:合并连续节次与完全重复记录
  166. list.sort((a, b) => {
  167. return a.name.localeCompare(b.name) ||
  168. a.teacher.localeCompare(b.teacher) ||
  169. a.position.localeCompare(b.position) ||
  170. (a.day || 0) - (b.day || 0) ||
  171. a.weeks.join(',').localeCompare(b.weeks.join(',')) ||
  172. (a.startSection || 0) - (b.startSection || 0);
  173. });
  174. const step1Merged = [];
  175. let current = list[0];
  176. for (let i = 1; i < list.length; i++) {
  177. const next = list[i];
  178. const isSameCourseAndWeeks =
  179. current.name === next.name &&
  180. current.teacher === next.teacher &&
  181. current.position === next.position &&
  182. current.day === next.day &&
  183. current.weeks.join(',') === next.weeks.join(',');
  184. const isContinuous = current.endSection + 1 === next.startSection;
  185. const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
  186. if (isSameCourseAndWeeks && isContinuous) {
  187. current.endSection = next.endSection;
  188. } else if (isSameCourseAndWeeks && isDuplicate) {
  189. continue;
  190. } else {
  191. step1Merged.push(current);
  192. current = next;
  193. }
  194. }
  195. step1Merged.push(current);
  196. // 阶段 2:合并同节次的周次
  197. step1Merged.sort((a, b) => {
  198. return a.name.localeCompare(b.name) ||
  199. a.teacher.localeCompare(b.teacher) ||
  200. a.position.localeCompare(b.position) ||
  201. (a.day || 0) - (b.day || 0) ||
  202. (a.startSection || 0) - (b.startSection || 0) ||
  203. (a.endSection || 0) - (b.endSection || 0);
  204. });
  205. const step2Merged = [];
  206. let cur = step1Merged[0];
  207. for (let i = 1; i < step1Merged.length; i++) {
  208. const nxt = step1Merged[i];
  209. const isSameCourseAndSection =
  210. cur.name === nxt.name &&
  211. cur.teacher === nxt.teacher &&
  212. cur.position === nxt.position &&
  213. cur.day === nxt.day &&
  214. cur.startSection === nxt.startSection &&
  215. cur.endSection === nxt.endSection;
  216. if (isSameCourseAndSection) {
  217. cur.weeks = Array.from(new Set([...cur.weeks, ...nxt.weeks])).sort((a, b) => a - b);
  218. } else {
  219. step2Merged.push(cur);
  220. cur = nxt;
  221. }
  222. }
  223. step2Merged.push(cur);
  224. return step2Merged;
  225. }
  226. // ---------------------------------------------------------------- 数据获取
  227. /**
  228. * 读取学期下拉框。
  229. *
  230. * 学期下拉框由服务端随页面一起下发,所以请求一次内容页并解析返回的 HTML 即可。
  231. */
  232. async function fetchTermOptions(base) {
  233. const resp = await fetch(base + KB_LIST_PAGE, { method: 'GET', credentials: 'include' });
  234. if (!resp.ok) throw new Error('学期列表请求失败(HTTP ' + resp.status + ')');
  235. const html = await resp.text();
  236. const doc = new DOMParser().parseFromString(html, 'text/html');
  237. const terms = readSelectOptions(doc, 'xnxqdm');
  238. if (!terms) throw new Error('未找到学期列表,请确认已登录并进入教务系统');
  239. return terms;
  240. }
  241. /**
  242. * 请求整个学期的课表原始数据(一次请求)。
  243. *
  244. * zc 传空字符串表示不限周次,服务端会返回整学期数据,
  245. * 每条记录自带的 zc 字段即该门课上课的周次,无需逐周请求。
  246. *
  247. * 注意:不限周次时服务端不返回日期数组,学期开始日期需另行获取(见 fetchFirstWeekStart)。
  248. * @returns {Array|null} 课程记录数组,取不到返回 null
  249. */
  250. async function fetchTermData(base, termCode) {
  251. const url = base + KB_DATA +
  252. '?xnxqdm=' + encodeURIComponent(termCode) +
  253. '&zc=&xsdm=';
  254. const resp = await fetch(url, {
  255. method: 'GET',
  256. credentials: 'include',
  257. headers: { 'X-Requested-With': 'XMLHttpRequest' }
  258. });
  259. if (!resp.ok) return null;
  260. const text = await resp.text();
  261. let data;
  262. try {
  263. data = JSON.parse(text);
  264. } catch (e) {
  265. // 未登录/被拦截时会返回 HTML,交由上层统一提示
  266. return null;
  267. }
  268. if (!Array.isArray(data) || data.length < 2) return null;
  269. return data[0] || [];
  270. }
  271. /**
  272. * 请求第 1 周的课表,取其中的周一日期作为学期开始日期。
  273. *
  274. * 不限周次的那次请求不返回日期数组,所以这里单独取一次第 1 周 ——
  275. * 一次请求即可确定开学日期,无需逐周探测。
  276. * @returns {string|null} "YYYY-MM-DD",取不到返回 null
  277. */
  278. async function fetchFirstWeekStart(base, termCode) {
  279. const url = base + KB_DATA +
  280. '?xnxqdm=' + encodeURIComponent(termCode) +
  281. '&zc=1&xsdm=';
  282. try {
  283. const resp = await fetch(url, {
  284. method: 'GET',
  285. credentials: 'include',
  286. headers: { 'X-Requested-With': 'XMLHttpRequest' }
  287. });
  288. if (!resp.ok) return null;
  289. const data = JSON.parse(await resp.text());
  290. if (!Array.isArray(data) || data.length < 2) return null;
  291. // dates 里取星期一的日期
  292. for (const d of (data[1] || [])) {
  293. if (!d) continue;
  294. if (parseInt(d.xqmc, 10) !== 1) continue;
  295. const rq = String(d.rq || '').trim();
  296. if (/^\d{4}-\d{2}-\d{2}$/.test(rq)) return rq;
  297. }
  298. return null;
  299. } catch (e) {
  300. return null;
  301. }
  302. }
  303. /**
  304. * 解析整学期数据,聚合成拾光课程表需要的课程数组。
  305. *
  306. * 服务端一次返回整学期的排课记录,每条自带 zc(该门课上课的周次),
  307. * 因此这里按「课程名+教师+教室+星期+起止节次」聚合出 weeks 数组,
  308. * 同一门课的多个周次会归并到同一条记录。
  309. */
  310. function collectCourses(records) {
  311. const courses = [];
  312. for (const c of records) {
  313. if (!c || typeof c !== 'object') continue;
  314. const day = parseInt(c.xq, 10);
  315. if (!Number.isInteger(day) || day < 1 || day > 7) continue;
  316. const name = String(c.kcmc || '').trim();
  317. if (!name) continue;
  318. const sections = parseSections(c.jcdm2, c.jcdm);
  319. if (sections.length === 0) continue;
  320. const weeks = parseWeekText(c.zc);
  321. if (weeks.length === 0) continue;
  322. const teacher = String(c.teaxms || '').trim();
  323. const position = String(c.jxcdmc || '').trim() || UNKNOWN_POSITION;
  324. const startSection = sections[0];
  325. const endSection = sections[sections.length - 1];
  326. // 同一门课同一时段可能有多条记录(不同周次区间),按周次归并
  327. const exist = courses.find(x =>
  328. x.name === name && x.teacher === teacher && x.position === position &&
  329. x.day === day && x.startSection === startSection && x.endSection === endSection
  330. );
  331. if (exist) {
  332. exist.weeks = Array.from(new Set([...exist.weeks, ...weeks])).sort((a, b) => a - b);
  333. } else {
  334. courses.push({
  335. name: name,
  336. teacher: teacher,
  337. position: position,
  338. day: day,
  339. startSection: startSection,
  340. endSection: endSection,
  341. weeks: weeks
  342. });
  343. }
  344. }
  345. // 总周数取所有课程周次的最大值;一条都取不到时保持 0,交给 App 默认值。
  346. // 这里用 reduce 而非 Math.max(...spread),避免周次过多时参数展开爆栈。
  347. const totalWeeks = courses.reduce(
  348. (max, c) => c.weeks.reduce((m, w) => (w > m ? w : m), max),
  349. 0
  350. );
  351. return {
  352. courses: mergeAndDistinctCourses(courses),
  353. totalWeeks: totalWeeks
  354. };
  355. }
  356. // ---------------------------------------------------------------- 交互
  357. async function promptUserToStart() {
  358. return await window.shiguangBridgePromise.showAlert(
  359. '肇庆学院课表导入',
  360. '导入前请确认:\n1) 已在当前网页登录 WebVPN;\n2) 已进入教务系统(停留在任意页面即可,无需打开课表查询)。',
  361. '开始导入'
  362. );
  363. }
  364. /**
  365. * 开学日期输入框的校验函数(须挂在全局作用域,供原生侧调用)。
  366. * 返回 false 表示通过,返回字符串表示错误提示。
  367. */
  368. function validateDate(input) {
  369. if (!input || !input.trim()) return '请输入开学日期';
  370. if (!/^\d{4}-\d{2}-\d{2}$/.test(input.trim())) return '格式应为 YYYY-MM-DD,例如 2026-08-31';
  371. return false;
  372. }
  373. /**
  374. * 让用户确认(必要时修正)开学日期。
  375. * 自动取到的值作为默认值;若未能取到,则由用户手动输入。
  376. */
  377. async function confirmStartDate(defaultDate) {
  378. const tip = defaultDate
  379. ? '请确认本学期第 1 周的周一日期(如有误可直接修改):'
  380. : '未能自动取到开学日期,请手动输入本学期第 1 周的周一日期:';
  381. return await window.shiguangBridgePromise.showPrompt(
  382. '确认开学日期',
  383. tip,
  384. defaultDate || '',
  385. 'validateDate'
  386. );
  387. }
  388. async function selectTerm(terms) {
  389. const texts = terms.opts.map(o => o.text);
  390. let defIdx = terms.opts.findIndex(o => o.value === terms.cur);
  391. if (defIdx < 0) defIdx = 0;
  392. const idx = await window.shiguangBridgePromise.showSingleSelection(
  393. '选择学期', JSON.stringify(texts), defIdx);
  394. if (idx === null || idx < 0 || idx >= terms.opts.length) return null;
  395. return terms.opts[idx];
  396. }
  397. async function importPresetTimeSlots(timeSlots) {
  398. try {
  399. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  400. } catch (error) {
  401. window.shiguangBridge.showToast('导入时间段失败: ' + error.message);
  402. }
  403. }
  404. // ---------------------------------------------------------------- 主流程
  405. async function runImportFlow() {
  406. const confirmed = await promptUserToStart();
  407. if (!confirmed) {
  408. window.shiguangBridge.showToast('用户取消了导入。');
  409. return;
  410. }
  411. const base = siteBase();
  412. let terms;
  413. try {
  414. window.shiguangBridge.showToast('正在读取学期列表 ...');
  415. terms = await fetchTermOptions(base);
  416. } catch (error) {
  417. window.shiguangBridge.showToast('读取学期失败:' + error.message);
  418. return;
  419. }
  420. const term = await selectTerm(terms);
  421. if (term === null) {
  422. window.shiguangBridge.showToast('未选择学期,导入流程终止。');
  423. return;
  424. }
  425. window.shiguangBridge.showToast('正在读取课表 ...');
  426. const records = await fetchTermData(base, term.value);
  427. if (!records) {
  428. window.shiguangBridge.showToast('课表接口未返回数据,请检查登录状态或稍后重试。');
  429. return;
  430. }
  431. const result = collectCourses(records);
  432. if (result.courses.length === 0) {
  433. window.shiguangBridge.showToast('未获取到课表数据,请确认该学期已选课。');
  434. return;
  435. }
  436. // 开学日期:额外取一次第 1 周的周一日期,再让用户确认/修正。
  437. // 取不到也不阻塞流程,用户可自行输入。
  438. window.shiguangBridge.showToast('正在确认开学日期 ...');
  439. const startDate = await fetchFirstWeekStart(base, term.value);
  440. const input = await confirmStartDate(startDate);
  441. if (input === null) {
  442. window.shiguangBridge.showToast('用户取消了导入。');
  443. return;
  444. }
  445. const semesterStartDate = String(input).trim();
  446. try {
  447. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(result.courses));
  448. } catch (error) {
  449. window.shiguangBridge.showToast('课程保存失败: ' + error.message);
  450. return;
  451. }
  452. try {
  453. // 只在取到值时才写这两个可选字段,否则交给 App 侧沿用已有设置
  454. const config = {};
  455. if (result.totalWeeks > 0) config.semesterTotalWeeks = result.totalWeeks;
  456. if (semesterStartDate) config.semesterStartDate = semesterStartDate;
  457. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  458. } catch (error) {
  459. window.shiguangBridge.showToast('课表配置保存失败: ' + error.message);
  460. }
  461. await importPresetTimeSlots(PRESET_TIME_SLOTS);
  462. window.shiguangBridge.showToast('导入完成,共 ' + result.courses.length + ' 个课程时段。');
  463. window.shiguangBridge.notifyTaskCompletion();
  464. }
  465. runImportFlow();