jngcxy.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. // 济南工程职业技术学院 — 正方新一代教务(jwglxt)
  2. // 维护者:西郊有密林(@xianzhaowang0-code)
  3. const GNMKDM = "N2151";
  4. const JW_ORIGIN = "http://vpnxt.jngcxy.edu.cn";
  5. const MIN_TOTAL_WEEKS = 20;
  6. const DEFAULT_TIME_SLOTS = [
  7. { number: 1, startTime: "08:30", endTime: "09:15" },
  8. { number: 2, startTime: "09:20", endTime: "10:05" },
  9. { number: 3, startTime: "10:20", endTime: "11:05" },
  10. { number: 4, startTime: "11:10", endTime: "11:55" },
  11. { number: 5, startTime: "13:30", endTime: "14:15" },
  12. { number: 6, startTime: "14:20", endTime: "15:05" },
  13. { number: 7, startTime: "15:15", endTime: "16:00" },
  14. { number: 8, startTime: "16:05", endTime: "16:50" },
  15. { number: 9, startTime: "19:00", endTime: "19:50" },
  16. { number: 10, startTime: "20:00", endTime: "20:50" }
  17. ];
  18. // 教务页面自带的 zftal 会改写 Array.prototype 上的高阶方法,遍历与排序一律用显式循环
  19. function sortBy(list, key) {
  20. const out = [];
  21. for (let i = 0; i < list.length; i++) out.push(list[i]);
  22. for (let i = 1; i < out.length; i++) {
  23. const current = out[i];
  24. const currentKey = key(current);
  25. let j = i - 1;
  26. while (j >= 0 && key(out[j]) > currentKey) {
  27. out[j + 1] = out[j];
  28. j--;
  29. }
  30. out[j + 1] = current;
  31. }
  32. return out;
  33. }
  34. function toList(data) {
  35. if (Array.isArray(data)) return data;
  36. return (data && (data.data || data.rows)) || [];
  37. }
  38. function buildApiUrl(modulePath) {
  39. const path = String(window.location.pathname || "");
  40. const prefix = (path.match(/^\/(?:https?|webvpn)\/[0-9a-z]+/i) || [""])[0];
  41. const origin = /\/jwglxt\//i.test(path) ? window.location.origin : JW_ORIGIN;
  42. return origin + prefix + "/jwglxt" + modulePath + "?gnmkdm=" + GNMKDM;
  43. }
  44. async function postForm(modulePath, body) {
  45. const response = await fetch(buildApiUrl(modulePath), {
  46. method: "POST",
  47. credentials: "include",
  48. headers: {
  49. "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
  50. "X-Requested-With": "XMLHttpRequest"
  51. },
  52. body: body
  53. });
  54. if (!response.ok) throw new Error("HTTP " + response.status);
  55. const text = await response.text();
  56. if (text.trim().charAt(0) === "<") throw new Error("SESSION_LOST");
  57. return JSON.parse(text);
  58. }
  59. function isOnSsoPage() {
  60. const host = String(window.location.hostname || "");
  61. const path = String(window.location.pathname || "");
  62. return /^uap\./i.test(host) || /\/authserver\//i.test(path) || /\/lyuapServer\//i.test(path);
  63. }
  64. function parseWeeks(text) {
  65. const weeks = new Set();
  66. const parts = String(text || "")
  67. .replace(/[(]/g, "(")
  68. .replace(/[)]/g, ")")
  69. .replace(/[,、;;]/g, ",")
  70. .split(",");
  71. for (let i = 0; i < parts.length; i++) {
  72. const seg = parts[i].trim();
  73. if (!seg) continue;
  74. const range = seg.match(/(\d+)\s*[-~至]\s*(\d+)/);
  75. const single = seg.match(/\d+/);
  76. const start = range ? Number(range[1]) : Number(single && single[0]);
  77. const end = range ? Number(range[2]) : start;
  78. if (!(start > 0) || !(end >= start)) continue;
  79. const odd = seg.indexOf("单") >= 0;
  80. const even = seg.indexOf("双") >= 0;
  81. for (let week = start; week <= end; week++) {
  82. if (odd && week % 2 === 0) continue;
  83. if (even && week % 2 !== 0) continue;
  84. weeks.add(week);
  85. }
  86. }
  87. return sortBy(Array.from(weeks), (week) => week);
  88. }
  89. function parseSections(text) {
  90. const nums = String(text || "").match(/\d+/g);
  91. if (!nums) return null;
  92. let start = 0;
  93. let end = 0;
  94. for (let i = 0; i < nums.length; i++) {
  95. const value = Number(nums[i]);
  96. if (!(value > 0)) continue;
  97. if (!start || value < start) start = value;
  98. if (value > end) end = value;
  99. }
  100. return start ? [start, end] : null;
  101. }
  102. function parseKbList(json) {
  103. const rows = (json && json.kbList) || [];
  104. const courses = [];
  105. const seen = new Set();
  106. let skipped = 0;
  107. for (let i = 0; i < rows.length; i++) {
  108. const row = rows[i] || {};
  109. const name = String(row.kcmc || "").trim();
  110. const day = Number(row.xqj);
  111. const weeks = parseWeeks(row.zcd);
  112. const sections = parseSections(row.jcs);
  113. if (!name || !(day >= 1 && day <= 7) || !weeks.length || !sections) {
  114. skipped++;
  115. continue;
  116. }
  117. const key = [name, row.xm, row.cdmc, day, sections[0], sections[1], weeks.join("-")].join("|");
  118. if (seen.has(key)) continue;
  119. seen.add(key);
  120. const course = {
  121. name: name,
  122. teacher: String(row.xm || "").trim(),
  123. position: String(row.cdmc || "").trim(),
  124. day: day,
  125. startSection: sections[0],
  126. endSection: sections[1],
  127. weeks: weeks
  128. };
  129. const credit = Number(row.xf);
  130. if (credit > 0) course.credit = credit;
  131. courses.push(course);
  132. }
  133. const sorted = sortBy(courses, (course) => course.day * 100 + course.startSection);
  134. console.log(`JS: 课表解析完成,共 ${sorted.length} 条课程记录,跳过 ${skipped} 条无固定时间的记录`);
  135. return sorted;
  136. }
  137. function readOptions(doc, selector) {
  138. const nodes = doc.querySelectorAll(selector + " option");
  139. const options = [];
  140. for (let i = 0; i < nodes.length; i++) {
  141. const node = nodes[i];
  142. const value = String(node.getAttribute("value") || "").trim();
  143. if (!value) continue;
  144. options.push({
  145. value: value,
  146. text: (node.textContent || "").trim(),
  147. selected: node.hasAttribute("selected")
  148. });
  149. }
  150. return options;
  151. }
  152. async function fetchAcademicOptions() {
  153. const response = await fetch(buildApiUrl("/kbcx/xskbcx_cxXskbcxIndex.html"), {
  154. method: "GET",
  155. credentials: "include"
  156. });
  157. if (!response.ok) throw new Error("HTTP " + response.status);
  158. const html = await response.text();
  159. if (/\/authserver\/|name="password"/i.test(html)) throw new Error("SESSION_LOST");
  160. const doc = new DOMParser().parseFromString(html, "text/html");
  161. const yearOptions = readOptions(doc, "#xnm");
  162. const termOptions = readOptions(doc, "#xqm");
  163. if (!yearOptions.length || !termOptions.length) throw new Error("未取到学年/学期选项");
  164. return { yearOptions: yearOptions, termOptions: termOptions };
  165. }
  166. async function chooseYearAndSemester() {
  167. const options = await fetchAcademicOptions();
  168. const { yearOptions, termOptions } = options;
  169. let defaultYear = 0;
  170. for (let i = 0; i < yearOptions.length; i++) {
  171. if (yearOptions[i].selected) defaultYear = i;
  172. }
  173. const yearTexts = [];
  174. for (let i = 0; i < yearOptions.length; i++) yearTexts.push(yearOptions[i].text);
  175. const yearIndex = await window.shiguangBridgePromise.showSingleSelection(
  176. "选择学年", JSON.stringify(yearTexts), defaultYear
  177. );
  178. if (!(yearIndex >= 0)) return null;
  179. let defaultTerm = 0;
  180. for (let i = 0; i < termOptions.length; i++) {
  181. if (termOptions[i].selected) defaultTerm = i;
  182. }
  183. const termTexts = [];
  184. for (let i = 0; i < termOptions.length; i++) termTexts.push(termOptions[i].text);
  185. const termIndex = await window.shiguangBridgePromise.showSingleSelection(
  186. "选择学期", JSON.stringify(termTexts), defaultTerm
  187. );
  188. if (!(termIndex >= 0)) return null;
  189. return {
  190. xnm: yearOptions[yearIndex].value,
  191. xqm: termOptions[termIndex].value
  192. };
  193. }
  194. async function fetchCourseTable(xnm, xqm) {
  195. const body = `xnm=${encodeURIComponent(xnm)}&xqm=${encodeURIComponent(xqm)}&kzlx=ck&xsdm=&kclbdm=`;
  196. return postForm("/kbcx/xskbcx_cxXsgrkb.html", body);
  197. }
  198. async function fetchStartDate(xnm, xqm) {
  199. const body = `xnm=${encodeURIComponent(xnm)}&xqm=${encodeURIComponent(xqm)}`;
  200. const rows = toList(await postForm("/kbcx/xskbcxZccx_cxZcByXnxq.html", body));
  201. let first = rows[0];
  202. for (let i = 0; i < rows.length; i++) {
  203. const item = rows[i] || {};
  204. if (String(item.zs || item.zcmc || "") === "1") first = item;
  205. }
  206. const match = String((first && (first.rq || first.zcrq)) || "").match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
  207. if (!match) throw new Error("未取到本学期开学日期");
  208. return `${match[1]}-${match[2].padStart(2, "0")}-${match[3].padStart(2, "0")}`;
  209. }
  210. async function fetchTimeSlots(xnm, xqm) {
  211. const body = `xnm=${encodeURIComponent(xnm)}&xqm=${encodeURIComponent(xqm)}&kzlx=ck&xsdm=&kclbdm=&kclxdm=`;
  212. let rows;
  213. try {
  214. rows = toList(await postForm("/kbcx/xskbcx_cxRjc.html", body));
  215. } catch (e) {
  216. console.warn("JS: 读取节次作息失败:" + (e && e.message));
  217. return [];
  218. }
  219. const slots = [];
  220. for (let i = 0; i < rows.length; i++) {
  221. const item = rows[i] || {};
  222. const number = Number(item.jcdm);
  223. const sksj = String(item.sksj || "").split(/[~\-—至到]/);
  224. const startTime = String(item.qssj || sksj[0] || "").trim().slice(0, 5);
  225. const endTime = String(item.jssj || sksj[1] || "").trim().slice(0, 5);
  226. if (!(number > 0) || !startTime || !endTime) continue;
  227. slots.push({ number: number, startTime: startTime, endTime: endTime });
  228. }
  229. return sortBy(slots, (slot) => slot.number);
  230. }
  231. function toMinutes(time) {
  232. const match = String(time || "").match(/(\d{1,2}):(\d{2})/);
  233. return match ? Number(match[1]) * 60 + Number(match[2]) : 0;
  234. }
  235. function buildCourseConfig(courses, startDate, firstDayOfWeek, slots) {
  236. let maxWeek = 0;
  237. for (let i = 0; i < courses.length; i++) {
  238. const weeks = courses[i].weeks;
  239. for (let j = 0; j < weeks.length; j++) {
  240. if (weeks[j] > maxWeek) maxWeek = weeks[j];
  241. }
  242. }
  243. return {
  244. semesterStartDate: startDate,
  245. semesterTotalWeeks: Math.max(maxWeek, MIN_TOTAL_WEEKS),
  246. firstDayOfWeek: firstDayOfWeek,
  247. defaultClassDuration: toMinutes(slots[0].endTime) - toMinutes(slots[0].startTime),
  248. defaultBreakDuration: toMinutes(slots[1].startTime) - toMinutes(slots[0].endTime)
  249. };
  250. }
  251. async function runImportFlow() {
  252. const bridge = window.shiguangBridgePromise;
  253. if (isOnSsoPage()) {
  254. await bridge.showAlert(
  255. "还停在统一认证页",
  256. "当前页面是统一认证,请先完成登录,再从门户进入「教务系统」," +
  257. "等地址栏变成 vpnxt.jngcxy.edu.cn/jwglxt/... 之后再执行导入。",
  258. "知道了"
  259. );
  260. return;
  261. }
  262. const confirmed = await bridge.showAlert(
  263. "济南工程职业技术学院课表导入",
  264. "开始前请确认:\n" +
  265. "1. 已登录教务系统,且当前页面在教务系统内(地址带 /jwglxt/);\n" +
  266. "2. 校外访问需先连接学校的 aTrust 客户端,否则教务页面无法打开;\n" +
  267. "3. 导入会覆盖应用内现有课表,请先备份需要保留的课程。\n\n" +
  268. "点击「开始导入」后请依次选择学年和学期。",
  269. "开始导入"
  270. );
  271. if (!confirmed) {
  272. window.shiguangBridge.showToast("已取消导入。");
  273. return;
  274. }
  275. window.shiguangBridge.showToast("正在拉取课表...");
  276. let courses;
  277. let startDate;
  278. let slots;
  279. let firstDayOfWeek;
  280. try {
  281. const selection = await chooseYearAndSemester();
  282. if (selection === null) {
  283. window.shiguangBridge.showToast("已取消导入。");
  284. return;
  285. }
  286. const json = await fetchCourseTable(selection.xnm, selection.xqm);
  287. courses = parseKbList(json);
  288. if (!courses.length) {
  289. await bridge.showAlert(
  290. "没有取到课程",
  291. "接口返回了空课表。请确认所选学年/学期是否正确;若确认无误,可能是本学期尚未排课。",
  292. "知道了"
  293. );
  294. return;
  295. }
  296. startDate = await fetchStartDate(selection.xnm, selection.xqm);
  297. slots = await fetchTimeSlots(selection.xnm, selection.xqm);
  298. if (slots.length < 2) {
  299. slots = DEFAULT_TIME_SLOTS;
  300. console.warn("JS: 未取到节次作息,使用内置的本校作息");
  301. window.shiguangBridge.showToast("未取到真实作息,改用内置的本校作息。");
  302. }
  303. const qsxqj = Number(json.qsxqj);
  304. firstDayOfWeek = (qsxqj >= 1 && qsxqj <= 7) ? qsxqj : 1;
  305. window.shiguangBridge.showToast(`解析到 ${courses.length} 条课程记录,正在保存...`);
  306. await bridge.saveImportedCourses(JSON.stringify(courses));
  307. await bridge.savePresetTimeSlots(JSON.stringify(slots));
  308. await bridge.saveCourseConfig(JSON.stringify(
  309. buildCourseConfig(courses, startDate, firstDayOfWeek, slots)
  310. ));
  311. } catch (e) {
  312. const message = e && e.message;
  313. if (message === "SESSION_LOST") {
  314. await bridge.showAlert(
  315. "未登录或会话已失效",
  316. "教务系统返回的是登录页。请先在页面中重新登录教务,再执行导入。",
  317. "知道了"
  318. );
  319. } else {
  320. await bridge.showAlert(
  321. "导入失败",
  322. "错误信息:" + message + "\n\n请确认:\n" +
  323. "1. 已连接校园网,或已先打开学校的 aTrust 客户端;\n" +
  324. "2. 已登录教务系统且页面在 /jwglxt/ 内;\n" +
  325. "3. 所选学年/学期正确。",
  326. "知道了"
  327. );
  328. }
  329. return;
  330. }
  331. window.shiguangBridge.showToast(`导入完成,共 ${courses.length} 条课程`);
  332. console.log(`JS: 导入完成,共 ${courses.length} 条课程,作息 ${slots.length} 节,开学日期 ${startDate}`);
  333. window.shiguangBridge.notifyTaskCompletion();
  334. }
  335. runImportFlow();