zqu_01.js 19 KB

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