neuq.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. /**
  2. * 东北大学秦皇岛校区(树维教务系统)课表导入适配脚本
  3. *
  4. * 树维教务系统特点:
  5. * 1. 课表以空 HTML 表格返回,课程数据通过 JavaScript 脚本动态注入
  6. * 2. 脚本中包含 `new TaskActivity(...)` 构造函数调用来定义课程
  7. * 3. 需要从脚本文本中直接提取课程信息,而不是解析 DOM
  8. *
  9. * 适用于使用树维教务系统的其他高校(需修改 BASE 地址)
  10. */
  11. (function () {
  12. const BASE = "https://jwxt.neuq.edu.cn";
  13. function truncateText(value, maxLen) {
  14. const text = String(value == null ? "" : value);
  15. if (text.length <= maxLen) return text;
  16. return `${text.slice(0, maxLen)}...`;
  17. }
  18. // 保留用于解析失败时输出关键诊断信息
  19. function extractCourseHtmlDebugInfo(courseHtml) {
  20. const text = String(courseHtml || "");
  21. const hasTaskActivity = /new\s+TaskActivity\s*\(/i.test(text);
  22. const hasUnitCount = /\bvar\s+unitCount\s*=\s*\d+/i.test(text);
  23. return {
  24. responseLength: text.length,
  25. hasTaskActivity,
  26. hasUnitCount
  27. };
  28. }
  29. async function requestText(url, options) {
  30. const requestOptions = { credentials: "include", ...options };
  31. const res = await fetch(url, requestOptions);
  32. if (!res.ok) {
  33. throw new Error(`网络请求失败: ${res.status}`);
  34. }
  35. return await res.text();
  36. }
  37. // 从入口页面 HTML 中提取学生 ID 和学期选择组件的 tagId
  38. function parseEntryParams(entryHtml) {
  39. const idsMatch = entryHtml.match(/bg\.form\.addInput\(form,"ids","(\d+)"\)/);
  40. const tagIdMatch = entryHtml.match(/id="(semesterBar\d+Semester)"/);
  41. return {
  42. studentId: idsMatch ? idsMatch[1] : "",
  43. tagId: tagIdMatch ? tagIdMatch[1] : ""
  44. };
  45. }
  46. // 解析学期列表
  47. function parseSemesterResponse(rawText) {
  48. let data;
  49. try {
  50. data = Function(`return (${String(rawText || "").trim()});`)();
  51. } catch (parseError) {
  52. throw new Error("学期数据解析失败");
  53. }
  54. const semesters = [];
  55. if (!data || !data.semesters || typeof data.semesters !== "object") {
  56. return semesters;
  57. }
  58. Object.keys(data.semesters).forEach((k) => {
  59. const arr = data.semesters[k];
  60. if (!Array.isArray(arr)) return;
  61. arr.forEach((s) => {
  62. if (!s || !s.id) return;
  63. semesters.push({
  64. id: String(s.id),
  65. name: `${s.schoolYear || ""} ${s.name || ""}学期`.trim()
  66. });
  67. });
  68. });
  69. return semesters;
  70. }
  71. // 清除课程名后面的课程序号
  72. function cleanCourseName(name) {
  73. return String(name || "").replace(/\([\d.]+\)\s*$/, "").trim();
  74. }
  75. // 解析周次位图字符串
  76. function parseValidWeeksBitmap(bitmap) {
  77. if (!bitmap || typeof bitmap !== "string") return [];
  78. const weeks = [];
  79. for (let i = 0; i < bitmap.length; i++) {
  80. if (bitmap[i] === "1") weeks.push(i);
  81. }
  82. return weeks;
  83. }
  84. function normalizeWeeks(weeks) {
  85. const list = Array.from(new Set((weeks || []).filter((w) => Number.isInteger(w) && w > 0)));
  86. list.sort((a, b) => a - b);
  87. return list;
  88. }
  89. // 反引号化 JavaScript 字面量字符串
  90. function unquoteJsLiteral(token) {
  91. const text = String(token || "").trim();
  92. if (!text) return "";
  93. if (text === "null" || text === "undefined") return "";
  94. if ((text.startsWith("\"") && text.endsWith("\"")) || (text.startsWith("'") && text.endsWith("'"))) {
  95. return text.slice(1, -1);
  96. }
  97. if (text.includes('+') && /^[a-zA-Z_$][\w$]*\s*\+/.test(text)) {
  98. const varName = text.split('+')[0].trim();
  99. return varName;
  100. }
  101. return text;
  102. }
  103. // 分割 JavaScript 函数参数字符串
  104. function splitJsArgs(argsText) {
  105. const args = [];
  106. let curr = "";
  107. let inQuote = "";
  108. let escaped = false;
  109. for (let i = 0; i < argsText.length; i++) {
  110. const ch = argsText[i];
  111. if (escaped) { curr += ch; escaped = false; continue; }
  112. if (ch === "\\") { curr += ch; escaped = true; continue; }
  113. if (inQuote) { curr += ch; if (ch === inQuote) inQuote = ""; continue; }
  114. if (ch === "\"" || ch === "'") { curr += ch; inQuote = ch; continue; }
  115. if (ch === ",") { args.push(curr.trim()); curr = ""; continue; }
  116. curr += ch;
  117. }
  118. if (curr.trim() || argsText.endsWith(",")) { args.push(curr.trim()); }
  119. return args;
  120. }
  121. // 核心:从脚本中解析 TaskActivity
  122. function parseCoursesFromTaskActivityScript(htmlText) {
  123. const text = String(htmlText || "");
  124. if (!text) return [];
  125. const unitCountMatch = text.match(/\bvar\s+unitCount\s*=\s*(\d+)\s*;/);
  126. const unitCount = unitCountMatch ? parseInt(unitCountMatch[1], 10) : 12;
  127. const courses = [];
  128. const activityBlockRegex = /activity\s*=\s*new\s+TaskActivity\(([\s\S]*?)\);([\s\S]*?)(?=var\s+taskId|activity\s*=\s*new|$)/g;
  129. let blockMatch;
  130. while ((blockMatch = activityBlockRegex.exec(text)) !== null) {
  131. const argsText = blockMatch[1];
  132. const indexAssignmentText = blockMatch[2];
  133. const args = splitJsArgs(argsText);
  134. if (args.length < 7) continue;
  135. const teacherRaw = args[1];
  136. let teacher = unquoteJsLiteral(teacherRaw);
  137. if (teacherRaw && !/^['"]/.test(teacherRaw.trim()) && /join\s*\(/.test(teacherRaw)) {
  138. const resolved = resolveTeachersForTaskActivityBlock(text, blockMatch.index);
  139. if (resolved) teacher = resolved;
  140. }
  141. const nameRaw = args[3];
  142. let name = unquoteJsLiteral(nameRaw);
  143. if (nameRaw && !/^['"]/.test(nameRaw.trim()) && /courseName\s*\+/.test(nameRaw)) {
  144. const resolved = resolveCourseNameForTaskActivityBlock(text, blockMatch.index);
  145. if (resolved) {
  146. const suffixMatch = nameRaw.match(/\+\s*["']([^)]+)["']$/);
  147. const suffix = suffixMatch ? suffixMatch[1] : "";
  148. name = resolved + (suffix ? `(${suffix})` : "");
  149. }
  150. }
  151. name = cleanCourseName(name);
  152. let position = unquoteJsLiteral(args[5])
  153. .replace(/"/g, "")
  154. .replace(/\(.*\)/g, "")
  155. .trim();
  156. const weekBitmap = unquoteJsLiteral(args[6]);
  157. const weeks = normalizeWeeks(parseValidWeeksBitmap(weekBitmap));
  158. const indexRegex = /index\s*=\s*(\d+)\s*\*\s*unitCount\s*\+\s*(\d+)/g;
  159. let indexMatch;
  160. let sections = [];
  161. let day = -1;
  162. while ((indexMatch = indexRegex.exec(indexAssignmentText)) !== null) {
  163. day = parseInt(indexMatch[1], 10) + 1;
  164. sections.push(parseInt(indexMatch[2], 10) + 1);
  165. }
  166. if (day !== -1 && sections.length > 0) {
  167. sections.sort((a, b) => a - b);
  168. courses.push({
  169. name: name,
  170. teacher: teacher,
  171. position: position,
  172. day: day,
  173. startSection: sections[0],
  174. endSection: sections[sections.length - 1],
  175. weeks: weeks
  176. });
  177. }
  178. }
  179. return mergeContiguousSections(courses);
  180. }
  181. function resolveTeachersForTaskActivityBlock(fullText, blockStartIndex) {
  182. const start = Math.max(0, blockStartIndex - 2200);
  183. const segment = fullText.slice(start, blockStartIndex);
  184. const re = /var\s+actTeachers\s*=\s*\[([^]*?)\]\s*;/g;
  185. let m; let last = null;
  186. while ((m = re.exec(segment)) !== null) { last = m[1]; }
  187. if (!last) return "";
  188. const names = [];
  189. const nameRe = /name\s*:\s*(?:"([^"]*)"|'([^']*)')/g;
  190. let nm;
  191. while ((nm = nameRe.exec(last)) !== null) {
  192. const name = (nm[1] || nm[2] || "").trim();
  193. if (name) names.push(name);
  194. }
  195. if (names.length === 0) return "";
  196. return Array.from(new Set(names)).join(",");
  197. }
  198. function resolveCourseNameForTaskActivityBlock(fullText, blockStartIndex) {
  199. const start = Math.max(0, blockStartIndex - 3000);
  200. const segment = fullText.slice(start, blockStartIndex);
  201. const re = /(?:var\s+)?courseName\s*=\s*(?:"([^"]*)"|'([^']*)')(?:\s*;)?/gi;
  202. let match; const values = [];
  203. while ((match = re.exec(segment)) !== null) {
  204. const value = (match[1] || match[2] || "").trim();
  205. if (value) { values.push(value); }
  206. }
  207. return values.length > 0 ? values[values.length - 1] : null;
  208. }
  209. function mergeContiguousSections(courses) {
  210. const list = (courses || [])
  211. .filter((c) => c && c.name && Number.isInteger(c.day) && Number.isInteger(c.startSection) && Number.isInteger(c.endSection))
  212. .map((c) => ({ ...c, weeks: normalizeWeeks(c.weeks) }));
  213. list.sort((a, b) => {
  214. const ak = `${a.name}|${a.teacher}|${a.position}|${a.day}|${JSON.stringify(a.weeks)}`;
  215. const bk = `${b.name}|${b.teacher}|${b.position}|${b.day}|${JSON.stringify(b.weeks)}`;
  216. if (ak < bk) return -1; if (ak > bk) return 1;
  217. return a.startSection - b.startSection;
  218. });
  219. const merged = [];
  220. for (const item of list) {
  221. const prev = merged[merged.length - 1];
  222. const sameCourse = prev
  223. && prev.name === item.name
  224. && prev.teacher === item.teacher
  225. && prev.position === item.position
  226. && prev.day === item.day
  227. && JSON.stringify(prev.weeks) === JSON.stringify(item.weeks);
  228. const isContiguous = sameCourse && prev.endSection + 1 === item.startSection;
  229. if (isContiguous) { prev.endSection = item.endSection; }
  230. else { merged.push({ ...item }); }
  231. }
  232. return merged;
  233. }
  234. function getPresetTimeSlots() {
  235. return [
  236. { number: 1, startTime: "08:00", endTime: "08:45" },
  237. { number: 2, startTime: "08:50", endTime: "09:35" },
  238. { number: 3, startTime: "10:05", endTime: "10:50" },
  239. { number: 4, startTime: "10:55", endTime: "11:40" },
  240. { number: 5, startTime: "14:00", endTime: "14:45" },
  241. { number: 6, startTime: "14:50", endTime: "15:35" },
  242. { number: 7, startTime: "16:05", endTime: "16:50" },
  243. { number: 8, startTime: "16:55", endTime: "17:40" },
  244. { number: 9, startTime: "18:40", endTime: "19:25" },
  245. { number: 10, startTime: "19:30", endTime: "20:15" },
  246. { number: 11, startTime: "20:25", endTime: "21:10" },
  247. { number: 12, startTime: "21:15", endTime: "22:00" }
  248. ];
  249. }
  250. async function runImportFlow() {
  251. // 确保桥接可用
  252. if (!window.AndroidBridgePromise) {
  253. throw new Error("AndroidBridgePromise 不可用,无法进行导入交互。");
  254. }
  255. // 1. 探测学生 ID 和学期组件 tagId
  256. const entryUrl = `${BASE}/eams/courseTableForStd.action?&sf_request_type=ajax`;
  257. const entryHtml = await requestText(entryUrl, {
  258. method: "GET",
  259. headers: { "x-requested-with": "XMLHttpRequest" }
  260. });
  261. const params = parseEntryParams(entryHtml);
  262. if (!params.studentId || !params.tagId) {
  263. await window.AndroidBridgePromise.showAlert(
  264. "参数探测失败",
  265. "未能识别学生 ID 或学期组件 tagId,请确认已登录后重试。",
  266. "确定"
  267. );
  268. return;
  269. }
  270. // 2. 获取学期列表并让用户选择
  271. const semesterRaw = await requestText(`${BASE}/eams/dataQuery.action?sf_request_type=ajax`, {
  272. method: "POST",
  273. headers: { "content-type": "application/x-www-form-urlencoded; charset=UTF-8" },
  274. body: `tagId=${encodeURIComponent(params.tagId)}&dataType=semesterCalendar`
  275. });
  276. const allSemesters = parseSemesterResponse(semesterRaw);
  277. if (allSemesters.length === 0) {
  278. throw new Error("学期列表为空,无法继续导入。");
  279. }
  280. const recentSemesters = allSemesters;
  281. const selectIndex = await window.AndroidBridgePromise.showSingleSelection(
  282. "请选择导入学期",
  283. JSON.stringify(recentSemesters.map((s) => s.name || s.id)),
  284. -1
  285. );
  286. if (selectIndex === null) {
  287. AndroidBridge.showToast("已取消导入");
  288. return;
  289. }
  290. const selectedSemester = recentSemesters[selectIndex];
  291. AndroidBridge.showToast("正在获取课表数据...");
  292. // 3. 拉取并解析课表
  293. const courseHtml = await requestText(`${BASE}/eams/courseTableForStd!courseTable.action?sf_request_type=ajax`, {
  294. method: "POST",
  295. headers: { "content-type": "application/x-www-form-urlencoded; charset=UTF-8" },
  296. body: [
  297. "ignoreHead=1",
  298. "setting.kind=std",
  299. "startWeek=",
  300. `semester.id=${encodeURIComponent(selectedSemester.id)}`,
  301. `ids=${encodeURIComponent(params.studentId)}`
  302. ].join("&")
  303. });
  304. const courses = parseCoursesFromTaskActivityScript(courseHtml);
  305. if (courses.length === 0) {
  306. const debugInfo = extractCourseHtmlDebugInfo(courseHtml);
  307. await window.AndroidBridgePromise.showAlert(
  308. "解析失败",
  309. `未能从课表响应中识别到课程。\n响应长度: ${debugInfo.responseLength}\n包含 TaskActivity: ${debugInfo.hasTaskActivity}`,
  310. "确定"
  311. );
  312. return;
  313. }
  314. // 4. 保存结果
  315. await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
  316. await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(getPresetTimeSlots()));
  317. AndroidBridge.showToast(`导入成功,共 ${courses.length} 条课程`);
  318. AndroidBridge.notifyTaskCompletion();
  319. }
  320. (async function bootstrap() {
  321. try {
  322. await runImportFlow();
  323. } catch (error) {
  324. console.error("导入流程失败:", error);
  325. AndroidBridge.showToast("自动探测失败,请检查教务连接");
  326. }
  327. })();
  328. })();