hpu.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. // 文件: hpu.js
  2. // 河南理工大学 树维教务系统课程表导入脚本
  3. // 适配目标: zhjw.hpu.edu.cn (树维教务系统, Struts2 .action, /eams/ 路径) + uia.hpu.edu.cn 统一身份认证(CAS)
  4. // 数据链路(已对 HPU 线上服务器实测):
  5. // 1. GET courseTableForStd.action?sf_request_type=ajax → 解析 ids(学生) + tagId(学期栏)
  6. // 2. POST dataQuery.action (dataType=semesterCalendar) → 学期列表
  7. // 3. POST courseTableForStd!courseTable.action (semester.id) → 课表 HTML
  8. // 课表 HTML 内课程以 TaskActivity JS 块内嵌(unitCount=11, bitmap周次), 表格骨架含节次/时间
  9. // 4. 解析 TaskActivity → 课程; 解析 #manualArrangeCourseTable 节次格 → 时间段
  10. // 兜底: 若当前页面已是渲染后的课表页(#manualArrangeCourseTable + .infoTitle), 直接解析 DOM
  11. // 解析参考: HIIT/hiit_01.js (同款 eams 架构系统)
  12. (function () {
  13. function showToast(message) {
  14. if (typeof window.shiguangBridge !== "undefined" && window.shiguangBridge.showToast) {
  15. window.shiguangBridge.showToast(String(message || ""));
  16. } else {
  17. console.log("[HPU适配]", message);
  18. }
  19. }
  20. async function request(url, options) {
  21. const res = await fetch(url, { credentials: "include", ...(options || {}) });
  22. if (!res.ok) throw new Error("网络请求失败(" + res.status + "): " + url);
  23. return await res.text();
  24. }
  25. // ================= 通用小工具 =================
  26. const CN_DIGIT = { "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9 };
  27. function cnDigit(c) { return CN_DIGIT[c] || 0; }
  28. function cnToInt(s) {
  29. if (!s) return 0;
  30. if (s === "十") return 10;
  31. const i = s.indexOf("十");
  32. if (i >= 0) {
  33. const tens = i > 0 ? cnDigit(s.charAt(0)) : 1;
  34. const ones = s.length > i + 1 ? cnDigit(s.charAt(i + 1)) : 0;
  35. return tens * 10 + ones;
  36. }
  37. return cnDigit(s);
  38. }
  39. function splitJsArgs(argsText) {
  40. const args = [];
  41. let current = "";
  42. let quote = "";
  43. let escaped = false;
  44. for (let i = 0; i < argsText.length; i++) {
  45. const ch = argsText[i];
  46. if (escaped) { current += ch; escaped = false; continue; }
  47. if (ch === "\\") { current += ch; escaped = true; continue; }
  48. if (quote) { current += ch; if (ch === quote) quote = ""; continue; }
  49. if (ch === "'" || ch === "\"") { current += ch; quote = ch; continue; }
  50. if (ch === ",") { args.push(current.trim()); current = ""; continue; }
  51. current += ch;
  52. }
  53. if (current.trim()) args.push(current.trim());
  54. return args;
  55. }
  56. function unquoteJsLiteral(token) {
  57. const text = String(token || "").trim();
  58. if (!text || text === "null" || text === "undefined") return "";
  59. if ((text.startsWith("\"") && text.endsWith("\"")) || (text.startsWith("'") && text.endsWith("'"))) {
  60. const quote = text[0];
  61. return text.slice(1, -1)
  62. .replace(/\\\\/g, "\\")
  63. .replace(new RegExp("\\\\" + quote, "g"), quote)
  64. .replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t");
  65. }
  66. return text;
  67. }
  68. function parseValidWeeksBitmap(bitmap) {
  69. const weeks = [];
  70. const text = String(bitmap || "");
  71. for (let i = 0; i < text.length; i++) {
  72. if (text[i] === "1" && i >= 1) weeks.push(i);
  73. }
  74. return weeks;
  75. }
  76. function normalizeWeeks(weeks) {
  77. return Array.from(new Set((weeks || []).filter(function (w) { return Number.isInteger(w) && w > 0; }))).sort(function (a, b) { return a - b; });
  78. }
  79. function cleanCourseName(name) {
  80. return String(name || "").replace(/\s*\([^()]*\)\s*$/, "").trim();
  81. }
  82. function resolveTeachersForTaskActivityBlock(fullText, blockStartIndex) {
  83. const start = Math.max(0, blockStartIndex - 2500);
  84. const segment = fullText.slice(start, blockStartIndex);
  85. const teachersRegex = /var\s+teachers\s*=\s*\[([^]*?)\];/g;
  86. let lastTeachersBlock = "";
  87. let match;
  88. while ((match = teachersRegex.exec(segment)) !== null) lastTeachersBlock = match[1] || "";
  89. if (!lastTeachersBlock) return "";
  90. const names = [];
  91. const nameRegex = /name\s*:\s*(?:"([^"]*)"|'([^']*)')/g;
  92. let nameMatch;
  93. while ((nameMatch = nameRegex.exec(lastTeachersBlock)) !== null) {
  94. const name = (nameMatch[1] || nameMatch[2] || "").trim();
  95. if (name) names.push(name);
  96. }
  97. return Array.from(new Set(names)).join(",");
  98. }
  99. // ================= 课程解析 (TaskActivity, 从 AJAX HTML) =================
  100. function parseCoursesFromTaskActivityScript(htmlText) {
  101. const text = String(htmlText || "");
  102. const unitCountMatch = text.match(/\bvar\s+unitCount\s*=\s*(\d+)\s*;/);
  103. const unitCount = unitCountMatch ? Number(unitCountMatch[1]) : 0;
  104. if (!unitCount) return [];
  105. const courses = [];
  106. const blockRegex = /activity\s*=\s*new\s+TaskActivity\(([^]*?)\)\s*;([\s\S]*?)(?=activity\s*=\s*new\s+TaskActivity\(|table\d+\.marshalTable|$)/g;
  107. let match;
  108. while ((match = blockRegex.exec(text)) !== null) {
  109. const args = splitJsArgs(match[1] || "");
  110. if (args.length < 7) continue;
  111. let teacher = unquoteJsLiteral(args[1]);
  112. if (/join\s*\(/.test(String(args[1] || ""))) {
  113. teacher = resolveTeachersForTaskActivityBlock(text, match.index) || teacher;
  114. }
  115. const name = cleanCourseName(unquoteJsLiteral(args[3]));
  116. const position = String(unquoteJsLiteral(args[5]) || "").replace(/\s+/g, " ").trim();
  117. const weeks = normalizeWeeks(parseValidWeeksBitmap(unquoteJsLiteral(args[6])));
  118. if (!name) continue;
  119. const indexBlock = match[2] || "";
  120. const indexRegex = /index\s*=\s*(?:(\d+)\s*\*\s*unitCount\s*\+\s*(\d+)|(\d+))\s*;/g;
  121. let indexMatch;
  122. while ((indexMatch = indexRegex.exec(indexBlock)) !== null) {
  123. let linearIndex = -1;
  124. if (indexMatch[1] != null && indexMatch[2] != null) {
  125. linearIndex = Number(indexMatch[1]) * unitCount + Number(indexMatch[2]);
  126. } else if (indexMatch[3] != null) {
  127. linearIndex = Number(indexMatch[3]);
  128. }
  129. if (linearIndex < 0) continue;
  130. const day = Math.floor(linearIndex / unitCount) + 1;
  131. const section = (linearIndex % unitCount) + 1;
  132. if (day < 1 || day > 7) continue;
  133. courses.push({
  134. name: name,
  135. teacher: teacher || "未知教师",
  136. position: position || "待定",
  137. day: day,
  138. startSection: section,
  139. endSection: section,
  140. weeks: weeks
  141. });
  142. }
  143. }
  144. return mergeContiguousSections(courses);
  145. }
  146. function mergeContiguousSections(courses) {
  147. const normalized = (courses || []).map(function (c) {
  148. return { ...c, weeks: normalizeWeeks(c.weeks) };
  149. });
  150. normalized.sort(function (a, b) {
  151. const keyA = a.name + "|" + a.teacher + "|" + a.position + "|" + a.day + "|" + a.weeks.join(",");
  152. const keyB = b.name + "|" + b.teacher + "|" + b.position + "|" + b.day + "|" + b.weeks.join(",");
  153. if (keyA < keyB) return -1;
  154. if (keyA > keyB) return 1;
  155. return a.startSection - b.startSection;
  156. });
  157. const merged = [];
  158. normalized.forEach(function (course) {
  159. const previous = merged[merged.length - 1];
  160. const canMerge = previous
  161. && previous.name === course.name
  162. && previous.teacher === course.teacher
  163. && previous.position === course.position
  164. && previous.day === course.day
  165. && previous.weeks.join(",") === course.weeks.join(",")
  166. && previous.endSection + 1 >= course.startSection;
  167. if (canMerge) {
  168. previous.endSection = Math.max(previous.endSection, course.endSection);
  169. } else {
  170. merged.push({ ...course });
  171. }
  172. });
  173. return merged;
  174. }
  175. // ================= 时间段解析 (从 HTML 表格骨架) =================
  176. function parseTimeSlotsFromHtml(htmlText) {
  177. const doc = new DOMParser().parseFromString(String(htmlText || ""), "text/html");
  178. const slots = [];
  179. const slotMap = {};
  180. const table = doc.querySelector("#manualArrangeCourseTable");
  181. if (!table) return slots;
  182. table.querySelectorAll("tbody tr").forEach(function (row) {
  183. const cells = Array.from(row.querySelectorAll("td"));
  184. cells.forEach(function (cell) {
  185. const txt = (cell.textContent || "").replace(/\s+/g, "");
  186. const m = txt.match(/第([一二三四五六七八九十]+)节/);
  187. if (!m) return;
  188. const tm = txt.match(/(\d{1,2}:\d{2})-(\d{1,2}:\d{2})/);
  189. if (!tm) return;
  190. const num = cnToInt(m[1]);
  191. if (num && !slotMap[num]) {
  192. slotMap[num] = { number: num, startTime: tm[1], endTime: tm[2] };
  193. }
  194. });
  195. });
  196. Object.keys(slotMap).map(Number).sort(function (a, b) { return a - b; })
  197. .forEach(function (k) { slots.push(slotMap[k]); });
  198. return slots;
  199. }
  200. // ================= 兜底: DOM 解析(已渲染的课表页) =================
  201. function parseCourseCellFromInfoTitle(raw) {
  202. const clean = String(raw || "").replace(/;;;/g, " ")
  203. .replace(/[\t\r\n]+/g, " ").replace(/\s+/g, " ").trim();
  204. const m = clean.match(/^(.+?)\(\d[\d.]*\)\s*\(([^)]*)\)\s*\(([^)]+)\)\s*$/);
  205. if (!m) return null;
  206. const info = m[3];
  207. const comma = info.indexOf(",");
  208. if (comma < 0) return null;
  209. // 周次字符串(可能含"单3-17"式样)直接转为 bitmap 语义
  210. const weekStr = info.substring(0, comma).trim();
  211. const weeks = [];
  212. const segs = weekStr.replace(/周/g, "").split(/[,,;;]/);
  213. segs.forEach(function (seg) {
  214. const isEven = seg.indexOf("双") >= 0;
  215. const isOdd = seg.indexOf("单") >= 0;
  216. const cleanSeg = seg.replace(/[单双]/g, "").trim();
  217. if (cleanSeg.indexOf("-") >= 0) {
  218. const p = cleanSeg.split("-");
  219. const s = parseInt(p[0], 10), e = parseInt(p[1], 10);
  220. if (!isNaN(s) && !isNaN(e)) {
  221. for (let w = s; w <= e; w++) {
  222. if (isEven && w % 2 !== 0) continue;
  223. if (isOdd && w % 2 === 0) continue;
  224. weeks.push(w);
  225. }
  226. }
  227. } else {
  228. const n = parseInt(cleanSeg, 10);
  229. if (!isNaN(n) && n > 0) weeks.push(n);
  230. }
  231. });
  232. const finalWeeks = Array.from(new Set(weeks)).sort(function (a, b) { return a - b; });
  233. if (!finalWeeks.length) return null;
  234. return {
  235. name: m[1].trim(),
  236. teacher: m[2].trim() || "未知教师",
  237. position: info.substring(comma + 1).replace(/\s+/g, " ").trim() || "待定",
  238. weeks: finalWeeks
  239. };
  240. }
  241. function parseCoursesFromCurrentDom() {
  242. const table = document.querySelector("#manualArrangeCourseTable");
  243. if (!table) return [];
  244. const courses = [];
  245. const seen = {};
  246. table.querySelectorAll("tbody tr").forEach(function (tr) {
  247. const tds = Array.from(tr.querySelectorAll("td"));
  248. let labelIdx = -1, section = 0;
  249. tds.forEach(function (td, i) {
  250. const txt = (td.textContent || "").replace(/\s+/g, "");
  251. const m = txt.match(/第([一二三四五六七八九十]+)节/);
  252. if (m) { labelIdx = i; section = cnToInt(m[1]); }
  253. });
  254. if (labelIdx < 0) return;
  255. tds.forEach(function (td, i) {
  256. if (i <= labelIdx) return;
  257. if ((td.className || "").indexOf("infoTitle") < 0) return;
  258. const raw = td.getAttribute("title") || td.textContent || "";
  259. if (!raw.trim()) return;
  260. const day = i - labelIdx - 1;
  261. if (day < 0 || day > 6) return;
  262. const parsed = parseCourseCellFromInfoTitle(raw);
  263. if (!parsed) return;
  264. const rowspan = td.rowSpan > 1 ? td.rowSpan : 1;
  265. const key = [day, section, rowspan, parsed.name, parsed.teacher, parsed.position, parsed.weeks.join(",")].join("|");
  266. if (seen[key]) return;
  267. seen[key] = 1;
  268. courses.push({
  269. name: parsed.name, teacher: parsed.teacher, position: parsed.position,
  270. day: day + 1, startSection: section, endSection: section + rowspan - 1,
  271. weeks: parsed.weeks
  272. });
  273. });
  274. });
  275. return courses;
  276. }
  277. // ================= AJAX 链路 =================
  278. async function detectParams() {
  279. if (/cas\/login|loginExt/i.test(window.location.href)) throw new Error("请先登录教务系统后再执行导入");
  280. const html = await request(window.location.origin + "/eams/courseTableForStd.action?sf_request_type=ajax", {
  281. headers: { "x-requested-with": "XMLHttpRequest" }
  282. });
  283. const idsM = html.match(/bg\.form\.addInput\(form,"ids","(\d+)"\)/);
  284. const tagM = html.match(/id="(semesterBar\d+Semester)"/);
  285. return (idsM && tagM) ? { ids: idsM[1], tagId: tagM[1] } : null;
  286. }
  287. async function fetchSemesters(tagId) {
  288. const raw = await request(window.location.origin + "/eams/dataQuery.action?sf_request_type=ajax", {
  289. method: "POST",
  290. headers: { "content-type": "application/x-www-form-urlencoded; charset=UTF-8" },
  291. body: "tagId=" + encodeURIComponent(tagId) + "&dataType=semesterCalendar"
  292. });
  293. const data = Function("return (" + String(raw).trim() + ");")();
  294. const list = [];
  295. if (data && data.semesters && typeof data.semesters === "object") {
  296. Object.keys(data.semesters).forEach(function (k) {
  297. (data.semesters[k] || []).forEach(function (s) {
  298. if (!s || !s.id) return;
  299. const term = String(s.name || "").trim();
  300. const termName = term === "1" ? "第一学期" : term === "2" ? "第二学期" : "第" + term + "学期";
  301. list.push({
  302. id: String(s.id),
  303. schoolYear: String(s.schoolYear || "").trim(),
  304. term: term,
  305. name: (String(s.schoolYear || "") + "学年" + termName).trim()
  306. });
  307. });
  308. });
  309. }
  310. return list;
  311. }
  312. // 按当前日期猜测所在学期(用于默认选中): 8-12月→该学年第一学期; 2-7月→上学年第二学期; 1月→上学年第一学期
  313. function guessCurrentSemesterIndex(semesters) {
  314. const now = new Date();
  315. const y = now.getFullYear();
  316. const m = now.getMonth() + 1;
  317. let targetYear, targetTerm;
  318. if (m >= 8) { targetYear = y; targetTerm = "1"; }
  319. else if (m >= 2) { targetYear = y - 1; targetTerm = "2"; }
  320. else { targetYear = y - 1; targetTerm = "1"; }
  321. for (let i = 0; i < semesters.length; i++) {
  322. const s = semesters[i];
  323. if (s.schoolYear === targetYear + "-" + (targetYear + 1) && s.term === targetTerm) return i;
  324. }
  325. return semesters.length - 1;
  326. }
  327. async function fetchCourseHtml(params, semesterId) {
  328. return await request(window.location.origin + "/eams/courseTableForStd!courseTable.action?sf_request_type=ajax", {
  329. method: "POST",
  330. headers: {
  331. "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
  332. "x-requested-with": "XMLHttpRequest"
  333. },
  334. body: "ignoreHead=1&setting.kind=std&startWeek=&semester.id=" + encodeURIComponent(semesterId) + "&ids=" + encodeURIComponent(params.ids)
  335. });
  336. }
  337. // ================= 保存 =================
  338. async function save(courses, timeSlots) {
  339. const okCourses = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  340. if (okCourses !== true) throw new Error("课程保存失败: " + String(okCourses));
  341. if (timeSlots && timeSlots.length) {
  342. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  343. }
  344. }
  345. // ================= 主流程 =================
  346. async function runImportFlow() {
  347. // ① 已在渲染后的课表页 → DOM 直解
  348. let courses = parseCoursesFromCurrentDom();
  349. let timeSlots = [];
  350. if (courses.length) {
  351. showToast("已从当前课表页解析到 " + courses.length + " 门课");
  352. } else {
  353. // ② AJAX(可自选学期)
  354. showToast("正在识别课表参数...");
  355. const params = await detectParams();
  356. if (!params) throw new Error("未能自动识别课表参数,请登录后在任意教务页面重试");
  357. showToast("正在获取学期列表...");
  358. const semesters = await fetchSemesters(params.tagId);
  359. if (!semesters.length) throw new Error("未获取到学期列表");
  360. const showList = semesters.slice(-12); // 只展示最近6年, 避免列表过长
  361. const defaultIdx = Math.min(guessCurrentSemesterIndex(semesters), semesters.length - 1);
  362. const showDefault = Math.max(0, defaultIdx - (semesters.length - showList.length));
  363. const picked = await window.shiguangBridgePromise.showSingleSelection(
  364. "选择要导入的学期",
  365. JSON.stringify(showList.map(function (s) { return s.name; })),
  366. showDefault
  367. );
  368. if (picked === null || picked < 0 || picked >= showList.length) throw new Error("已取消导入");
  369. showToast("正在获取 " + showList[picked].name + " 课表...");
  370. const html = await fetchCourseHtml(params, showList[picked].id);
  371. courses = parseCoursesFromTaskActivityScript(html);
  372. timeSlots = parseTimeSlotsFromHtml(html);
  373. if (!courses.length) throw new Error("该学期未解析到课程,请确认有课或尝试其他学期");
  374. }
  375. await save(courses, timeSlots);
  376. showToast("导入成功,共 " + courses.length + " 门课程");
  377. if (typeof window.shiguangBridge !== "undefined" && window.shiguangBridge.notifyTaskCompletion) {
  378. window.shiguangBridge.notifyTaskCompletion();
  379. }
  380. }
  381. (async function bootstrap() {
  382. try {
  383. await runImportFlow();
  384. } catch (e) {
  385. console.error("[HPU适配]", e);
  386. showToast("导入失败:" + (e && e.message ? e.message : String(e)));
  387. }
  388. })();
  389. })();