zua.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. // 郑州航空工业管理学院 (zua.edu.cn) 拾光课程表适配脚本
  2. // 复用 HFNU 的树维 EAMS 解析流程,并针对 ZUA 的课程名和教室格式做适配。
  3. const BASE_URL = "http://jwglxt.zua.edu.cn";
  4. const MAX_SUPPORTED_WEEK = 60;
  5. const ZUA_TIME_SLOTS = [
  6. { number: 1, startTime: "08:00", endTime: "08:45" },
  7. { number: 2, startTime: "08:55", endTime: "09:40" },
  8. { number: 3, startTime: "10:00", endTime: "10:45" },
  9. { number: 4, startTime: "10:55", endTime: "11:40" },
  10. { number: 5, startTime: "14:30", endTime: "15:15" },
  11. { number: 6, startTime: "15:25", endTime: "16:10" },
  12. { number: 7, startTime: "16:30", endTime: "17:15" },
  13. { number: 8, startTime: "17:25", endTime: "18:10" },
  14. { number: 9, startTime: "19:30", endTime: "20:15" },
  15. { number: 10, startTime: "20:25", endTime: "21:10" }
  16. ];
  17. function powerSplit(paramsRaw) {
  18. const args = [];
  19. let current = "";
  20. let depth = 0;
  21. let inQuote = false;
  22. let quoteChar = "";
  23. for (let i = 0; i < paramsRaw.length; i++) {
  24. const char = paramsRaw[i];
  25. if ((char === '"' || char === "'") && (i === 0 || paramsRaw[i - 1] !== "\\")) {
  26. if (!inQuote) {
  27. inQuote = true;
  28. quoteChar = char;
  29. } else if (char === quoteChar) {
  30. inQuote = false;
  31. }
  32. }
  33. if (!inQuote) {
  34. if (char === "(" || char === "[" || char === "{") depth++;
  35. if (char === ")" || char === "]" || char === "}") depth--;
  36. }
  37. if (char === "," && depth === 0 && !inQuote) {
  38. args.push(cleanArg(current));
  39. current = "";
  40. } else {
  41. current += char;
  42. }
  43. }
  44. args.push(cleanArg(current));
  45. return args;
  46. }
  47. function cleanArg(value) {
  48. const trimmed = value.trim();
  49. if (trimmed === "null") return null;
  50. return trimmed.replace(/^["']|["']$/g, "");
  51. }
  52. function cleanCourseName(name) {
  53. return String(name || "未知课程").replace(/\([^()]*\)\s*$/, "").trim();
  54. }
  55. function cleanPosition(position) {
  56. return String(position || "未知地点").replace(/\s+/g, " ").trim();
  57. }
  58. function parseWeeksBitmap(bitmap) {
  59. const weeks = [];
  60. const value = String(bitmap || "");
  61. // 树维 EAMS 位图的下标就是周次,下标 0 是占位符;拾光使用 1 基周次。
  62. for (let week = 1; week < value.length && week <= MAX_SUPPORTED_WEEK; week++) {
  63. if (value[week] === "1") weeks.push(week);
  64. }
  65. return weeks;
  66. }
  67. /**
  68. * 沿用 HFNU 的按周次矩阵合并算法:只有课程名、教师、地点和星期均相同
  69. * 且在同一周内节次连续时才合并。remark 不参与分组,重复实验项会由 Set 去重。
  70. */
  71. function mergeContinuousLessons(lessons) {
  72. if (!lessons || lessons.length === 0) return [];
  73. const groups = {};
  74. lessons.forEach(lesson => {
  75. const key = `${lesson.name}|${lesson.teacher}|${lesson.position}|${lesson.day}`;
  76. if (!groups[key]) {
  77. groups[key] = {
  78. name: lesson.name,
  79. teacher: lesson.teacher,
  80. position: lesson.position,
  81. day: lesson.day,
  82. weeksMatrix: Array.from({ length: MAX_SUPPORTED_WEEK + 1 }, () => new Set())
  83. };
  84. }
  85. if (Array.isArray(lesson.weeks)) {
  86. lesson.weeks.forEach(week => {
  87. if (Number.isInteger(week) && week > 0 && week <= MAX_SUPPORTED_WEEK) {
  88. for (let section = lesson.startSection; section <= lesson.endSection; section++) {
  89. groups[key].weeksMatrix[week].add(section);
  90. }
  91. }
  92. });
  93. }
  94. });
  95. const merged = [];
  96. for (const key in groups) {
  97. const group = groups[key];
  98. const blockMap = {};
  99. for (let week = 1; week < group.weeksMatrix.length; week++) {
  100. const sections = Array.from(group.weeksMatrix[week]).sort((a, b) => a - b);
  101. if (sections.length === 0) continue;
  102. let start = sections[0];
  103. let previous = sections[0];
  104. for (let i = 1; i < sections.length; i++) {
  105. const current = sections[i];
  106. if (current === previous + 1) {
  107. previous = current;
  108. } else {
  109. const blockKey = `${start}-${previous}`;
  110. if (!blockMap[blockKey]) blockMap[blockKey] = [];
  111. blockMap[blockKey].push(week);
  112. start = current;
  113. previous = current;
  114. }
  115. }
  116. const blockKey = `${start}-${previous}`;
  117. if (!blockMap[blockKey]) blockMap[blockKey] = [];
  118. blockMap[blockKey].push(week);
  119. }
  120. for (const blockKey in blockMap) {
  121. const [startSection, endSection] = blockKey.split("-").map(Number);
  122. merged.push({
  123. name: group.name,
  124. teacher: group.teacher,
  125. position: group.position,
  126. day: group.day,
  127. startSection,
  128. endSection,
  129. weeks: blockMap[blockKey]
  130. });
  131. }
  132. }
  133. merged.sort((a, b) => {
  134. if (a.day !== b.day) return a.day - b.day;
  135. if (a.startSection !== b.startSection) return a.startSection - b.startSection;
  136. if (a.name !== b.name) return a.name.localeCompare(b.name);
  137. return a.position.localeCompare(b.position);
  138. });
  139. return merged;
  140. }
  141. function parseTeacherName(block) {
  142. const teachersMatch = block.match(/actTeachers\s*=\s*\[([\s\S]*?)\]\s*;/);
  143. if (!teachersMatch) return "未知教师";
  144. const names = [];
  145. const nameRegex = /\bname\s*:\s*"([^"]+)"/g;
  146. let match;
  147. while ((match = nameRegex.exec(teachersMatch[1])) !== null) {
  148. if (!names.includes(match[1])) names.push(match[1]);
  149. }
  150. return names.length > 0 ? names.join(",") : "未知教师";
  151. }
  152. function parseTaskActivities(html) {
  153. const rawResults = [];
  154. const unitCountMatch = html.match(/\bunitCount\s*=\s*(\d+)\s*;/);
  155. const unitCount = unitCountMatch ? parseInt(unitCountMatch[1], 10) : 14;
  156. const indexRegex = new RegExp(
  157. `index\\s*=\\s*(\\d+)\\s*\\*\\s*(?:unitCount|${unitCount})\\s*\\+\\s*(\\d+)\\s*;`,
  158. "g"
  159. );
  160. const blocks = html.split(/var\s+teachers\s*=/);
  161. for (let i = 1; i < blocks.length; i++) {
  162. const block = blocks[i];
  163. const teacher = parseTeacherName(block);
  164. const activityRegex = /new\s+TaskActivity\(([\s\S]*?)\)\s*;/g;
  165. const activities = [];
  166. let activityMatch;
  167. while ((activityMatch = activityRegex.exec(block)) !== null) {
  168. activities.push({
  169. argsRaw: activityMatch[1],
  170. start: activityMatch.index,
  171. end: activityRegex.lastIndex
  172. });
  173. }
  174. for (let activityIndex = 0; activityIndex < activities.length; activityIndex++) {
  175. const activity = activities[activityIndex];
  176. const args = powerSplit(activity.argsRaw);
  177. if (args.length < 7) continue;
  178. const name = cleanCourseName(args[3]);
  179. const position = cleanPosition(args[5]);
  180. const weeks = parseWeeksBitmap(args[6]);
  181. if (weeks.length === 0) continue;
  182. const nextActivityStart = activityIndex + 1 < activities.length
  183. ? activities[activityIndex + 1].start
  184. : block.length;
  185. const activityScope = block.slice(activity.end, nextActivityStart);
  186. indexRegex.lastIndex = 0;
  187. let indexMatch;
  188. while ((indexMatch = indexRegex.exec(activityScope)) !== null) {
  189. const rawDay = parseInt(indexMatch[1], 10);
  190. const rawSection = parseInt(indexMatch[2], 10);
  191. if (rawDay < 0 || rawDay > 6 || rawSection < 0 || rawSection >= unitCount) continue;
  192. const day = rawDay + 1;
  193. const section = rawSection + 1;
  194. rawResults.push({
  195. name,
  196. teacher,
  197. position,
  198. day,
  199. startSection: section,
  200. endSection: section,
  201. weeks: [...weeks]
  202. });
  203. }
  204. }
  205. }
  206. return mergeContinuousLessons(rawResults);
  207. }
  208. function parseParameters(html) {
  209. const idsMatch = html.match(/bg\.form\.addInput\(\s*form\s*,\s*["']ids["']\s*,\s*["'](\d+)["']\s*\)/);
  210. const tagIdMatch = html.match(/id=["'](semesterBar\d+Semester)["']/);
  211. if (!idsMatch || !tagIdMatch) return null;
  212. const tagId = tagIdMatch[1];
  213. const escapedTagId = tagId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  214. const elementMatch = html.match(new RegExp(`<[^>]*\\bid=["']${escapedTagId}["'][^>]*>`, "i"));
  215. const valueMatch = elementMatch ? elementMatch[0].match(/\bvalue=["'](\d+)["']/i) : null;
  216. return {
  217. ids: idsMatch[1],
  218. tagId,
  219. currentSemesterId: valueMatch ? valueMatch[1] : null
  220. };
  221. }
  222. function parseSemesterResponse(raw) {
  223. const data = Function(`return (${raw});`)();
  224. const semesters = [];
  225. for (const key of Object.keys(data.semesters || {})) {
  226. const entries = Array.isArray(data.semesters[key]) ? data.semesters[key] : [];
  227. entries.forEach(semester => {
  228. if (semester && semester.id !== undefined) {
  229. const term = String(semester.name || "").trim();
  230. const label = /^第.*学期$/.test(term) ? term : `第${term}学期`;
  231. semesters.push({
  232. id: String(semester.id),
  233. schoolYear: String(semester.schoolYear || ""),
  234. term,
  235. name: `${semester.schoolYear} ${label}`.trim()
  236. });
  237. }
  238. });
  239. }
  240. semesters.sort((a, b) => {
  241. const yearCompare = b.schoolYear.localeCompare(a.schoolYear);
  242. if (yearCompare !== 0) return yearCompare;
  243. return b.term.localeCompare(a.term, undefined, { numeric: true });
  244. });
  245. return {
  246. semesters,
  247. currentSemesterId: data.semesterId === undefined ? null : String(data.semesterId)
  248. };
  249. }
  250. function normalizeNumericDate(year, month, day) {
  251. const yearNumber = Number(year);
  252. const monthNumber = Number(month);
  253. const dayNumber = Number(day);
  254. const date = new Date(Date.UTC(yearNumber, monthNumber - 1, dayNumber));
  255. if (
  256. date.getUTCFullYear() !== yearNumber ||
  257. date.getUTCMonth() + 1 !== monthNumber ||
  258. date.getUTCDate() !== dayNumber
  259. ) {
  260. return null;
  261. }
  262. return [
  263. String(yearNumber).padStart(4, "0"),
  264. String(monthNumber).padStart(2, "0"),
  265. String(dayNumber).padStart(2, "0")
  266. ].join("-");
  267. }
  268. function parseCalendarInfo(html) {
  269. const text = String(html || "")
  270. .replace(/<[^>]*>/g, " ")
  271. .replace(/&nbsp;|&#160;|&#x0*A0;/gi, " ")
  272. .replace(/\u00a0/g, " ");
  273. const match = text.match(
  274. /开始\s*\/\s*结束日期\s*[::]?\s*(\d{4})\s*-\s*(\d{1,2})\s*-\s*(\d{1,2})\s*~\s*(\d{4})\s*-\s*(\d{1,2})\s*-\s*(\d{1,2})\s*\(\s*(\d+)\s*\)/
  275. );
  276. if (!match) return null;
  277. const semesterStartDate = normalizeNumericDate(match[1], match[2], match[3]);
  278. const semesterEndDate = normalizeNumericDate(match[4], match[5], match[6]);
  279. const semesterTotalWeeks = Number(match[7]);
  280. if (
  281. !semesterStartDate ||
  282. !semesterEndDate ||
  283. semesterEndDate < semesterStartDate ||
  284. !Number.isInteger(semesterTotalWeeks) ||
  285. semesterTotalWeeks < 1 ||
  286. semesterTotalWeeks > MAX_SUPPORTED_WEEK
  287. ) {
  288. return null;
  289. }
  290. return {
  291. semesterStartDate,
  292. semesterEndDate,
  293. semesterTotalWeeks,
  294. firstDayOfWeek: 1
  295. };
  296. }
  297. function getZuaTimeSlots() {
  298. return ZUA_TIME_SLOTS.map(slot => ({ ...slot }));
  299. }
  300. async function request(url, options = {}) {
  301. const response = await fetch(url, { credentials: "include", ...options });
  302. if (!response.ok) throw new Error(`网络请求失败: ${response.status}`);
  303. return await response.text();
  304. }
  305. async function detectParameters() {
  306. const html = await request(`${BASE_URL}/eams/courseTableForStd.action`);
  307. return parseParameters(html);
  308. }
  309. async function getSelectedSemester(tagId, currentSemesterId) {
  310. const form = new URLSearchParams();
  311. form.set("tagId", tagId);
  312. form.set("dataType", "semesterCalendar");
  313. if (currentSemesterId) form.set("value", currentSemesterId);
  314. form.set("empty", "false");
  315. const raw = await request(`${BASE_URL}/eams/dataQuery.action`, {
  316. method: "POST",
  317. headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
  318. body: form.toString()
  319. });
  320. const parsed = parseSemesterResponse(raw);
  321. if (parsed.semesters.length === 0) throw new Error("未获取到可选学期");
  322. const selectedId = currentSemesterId || parsed.currentSemesterId;
  323. const defaultIndex = parsed.semesters.findIndex(semester => semester.id === selectedId);
  324. const index = await window.AndroidBridgePromise.showSingleSelection(
  325. "选择学期",
  326. JSON.stringify(parsed.semesters.map(semester => semester.name)),
  327. defaultIndex
  328. );
  329. return Number.isInteger(index) && index >= 0 && index < parsed.semesters.length
  330. ? parsed.semesters[index]
  331. : null;
  332. }
  333. async function fetchAndParseCourses(semesterId, ids) {
  334. const form = new URLSearchParams();
  335. form.set("ignoreHead", "1");
  336. form.set("setting.kind", "std");
  337. form.set("startWeek", "");
  338. form.set("semester.id", String(semesterId));
  339. form.set("ids", String(ids));
  340. const html = await request(`${BASE_URL}/eams/courseTableForStd!courseTable.action`, {
  341. method: "POST",
  342. headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
  343. body: form.toString()
  344. });
  345. return parseTaskActivities(html);
  346. }
  347. async function fetchCalendarInfo(semesterId) {
  348. const form = new URLSearchParams();
  349. form.set("version", "1");
  350. form.set("semesterId", String(semesterId));
  351. const html = await request(`${BASE_URL}/eams/base/calendar-info.action`, {
  352. method: "POST",
  353. headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
  354. body: form.toString()
  355. });
  356. const calendarInfo = parseCalendarInfo(html);
  357. if (!calendarInfo) throw new Error("未能解析学期日历");
  358. return calendarInfo;
  359. }
  360. async function trySaveCalendarInfo(semesterId) {
  361. try {
  362. const calendarInfo = await fetchCalendarInfo(semesterId);
  363. const saveResult = await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify({
  364. semesterStartDate: calendarInfo.semesterStartDate,
  365. semesterTotalWeeks: calendarInfo.semesterTotalWeeks,
  366. firstDayOfWeek: calendarInfo.firstDayOfWeek
  367. }));
  368. return saveResult === true;
  369. } catch (error) {
  370. console.warn(`[ZUA 学期信息设置失败] ${error.message}`);
  371. return false;
  372. }
  373. }
  374. async function trySaveTimeSlots() {
  375. try {
  376. const saveResult = await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(getZuaTimeSlots()));
  377. return saveResult === true;
  378. } catch (error) {
  379. console.warn(`[ZUA 作息时间设置失败] ${error.message}`);
  380. return false;
  381. }
  382. }
  383. function buildCompletionMessage(calendarSaved, timeSlotsSaved) {
  384. if (calendarSaved && timeSlotsSaved) return "成功导入课表、学期信息和郑航作息";
  385. if (!calendarSaved && !timeSlotsSaved) {
  386. return "课表已导入,学期日期和作息时间设置失败,请在设置中确认";
  387. }
  388. if (!calendarSaved) return "课表已导入,学期日期获取失败,请在设置中确认";
  389. return "课表已导入,作息时间设置失败,请在设置中确认";
  390. }
  391. async function runImportFlow() {
  392. try {
  393. AndroidBridge.showToast("开始探测郑航教务参数...");
  394. const params = await detectParameters();
  395. if (!params) throw new Error("未能识别教务参数,请确认已登录郑航教务系统");
  396. const semester = await getSelectedSemester(params.tagId, params.currentSemesterId);
  397. if (!semester) return;
  398. AndroidBridge.showToast("正在同步课表...");
  399. const courses = await fetchAndParseCourses(semester.id, params.ids);
  400. if (!courses || courses.length === 0) throw new Error("未解析到课程数据");
  401. const saveResult = await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
  402. if (!saveResult) throw new Error("课程保存失败");
  403. const calendarSaved = await trySaveCalendarInfo(semester.id);
  404. const timeSlotsSaved = await trySaveTimeSlots();
  405. AndroidBridge.showToast(buildCompletionMessage(calendarSaved, timeSlotsSaved));
  406. AndroidBridge.notifyTaskCompletion();
  407. } catch (error) {
  408. console.error(`[ZUA 课表导入异常] ${error.message}`);
  409. AndroidBridge.showToast(error.message);
  410. }
  411. }
  412. runImportFlow();