zzvcae.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713
  1. // 郑州汽车工程职业学院 (zzvcae.edu.cn) 拾光课程表适配脚本
  2. // 平台:树维 EAMS 教务(与郑州航空工业管理学院 jwglxt.zua.edu.cn 同平台)
  3. // 参考:郑州航院 zua.js(树维 EAMS Fetch 解析流程),针对本校 CAS 单点登录与作息做适配
  4. //
  5. // 使用说明:
  6. // 1. 在 App 内本适配的入口地址会打开 CAS 统一身份认证登录页
  7. // 2. 登录 CAS 成功后,进入【服务大厅】→【学生课表查询】,等待课表页面加载完成
  8. // 3. 回到 App 点击【执行导入】即可
  9. // 注意:教务系统禁止从 jw.zzvcae.edu.cn 直接登录,必须经由 CAS 登录并在服务大厅访问过一次课表,
  10. // 这样 jw.zzvcae.edu.cn 域下才会建立有效的教务会话,脚本的接口请求才能成功。
  11. const BASE_URL = "https://jw.zzvcae.edu.cn";
  12. const MAX_SUPPORTED_WEEK = 60;
  13. // 本校作息兜底(学校未发布公开作息表时使用;正常情况下脚本会优先从课表页动态解析作息)
  14. // 这里暂按常见高职作息填写占位,实测后以教务系统课表页解析出的作息为准
  15. const ZZQCC_TIME_SLOTS_FALLBACK = [
  16. { number: 1, startTime: "08:00", endTime: "08:45" },
  17. { number: 2, startTime: "08:55", endTime: "09:40" },
  18. { number: 3, startTime: "10:00", endTime: "10:45" },
  19. { number: 4, startTime: "10:55", endTime: "11:40" },
  20. { number: 5, startTime: "14:30", endTime: "15:15" },
  21. { number: 6, startTime: "15:25", endTime: "16:10" },
  22. { number: 7, startTime: "16:30", endTime: "17:15" },
  23. { number: 8, startTime: "17:25", endTime: "18:10" },
  24. { number: 9, startTime: "19:30", endTime: "20:15" },
  25. { number: 10, startTime: "20:25", endTime: "21:10" }
  26. ];
  27. function powerSplit(paramsRaw) {
  28. const args = [];
  29. let current = "";
  30. let depth = 0;
  31. let inQuote = false;
  32. let quoteChar = "";
  33. for (let i = 0; i < paramsRaw.length; i++) {
  34. const char = paramsRaw[i];
  35. if ((char === '"' || char === "'") && (i === 0 || paramsRaw[i - 1] !== "\\")) {
  36. if (!inQuote) {
  37. inQuote = true;
  38. quoteChar = char;
  39. } else if (char === quoteChar) {
  40. inQuote = false;
  41. }
  42. }
  43. if (!inQuote) {
  44. if (char === "(" || char === "[" || char === "{") depth++;
  45. if (char === ")" || char === "]" || char === "}") depth--;
  46. }
  47. if (char === "," && depth === 0 && !inQuote) {
  48. args.push(cleanArg(current));
  49. current = "";
  50. } else {
  51. current += char;
  52. }
  53. }
  54. args.push(cleanArg(current));
  55. return args;
  56. }
  57. function cleanArg(value) {
  58. const trimmed = value.trim();
  59. if (trimmed === "null") return null;
  60. return trimmed.replace(/^["']|["']$/g, "");
  61. }
  62. function cleanCourseName(name) {
  63. return String(name || "未知课程")
  64. .replace(/<[^>]*>/g, "") // 去掉教务页嵌入的 HTML 标签(如 <sup style='...'></sup> 颜色标记)
  65. .replace(/&nbsp;|&#160;/gi, " ")
  66. .replace(/\s+/g, " ")
  67. .replace(/\([^()]*\)\s*$/, "") // 去除尾部纯括号说明(如"(二)")
  68. .trim();
  69. }
  70. function cleanPosition(position) {
  71. return String(position || "未知地点")
  72. .replace(/<[^>]*>/g, "")
  73. .replace(/&nbsp;|&#160;/gi, " ")
  74. .replace(/\s+/g, " ")
  75. .trim();
  76. }
  77. function parseWeeksBitmap(bitmap) {
  78. const weeks = [];
  79. const value = String(bitmap || "");
  80. // 树维 EAMS 位图的下标就是周次,下标 0 是占位符;拾光使用 1 基周次。
  81. for (let week = 1; week < value.length && week <= MAX_SUPPORTED_WEEK; week++) {
  82. if (value[week] === "1") weeks.push(week);
  83. }
  84. return weeks;
  85. }
  86. /**
  87. * 按周次矩阵合并算法:只有课程名、教师、地点和星期均相同
  88. * 且在同一周内节次连续时才合并。
  89. */
  90. function mergeContinuousLessons(lessons) {
  91. if (!lessons || lessons.length === 0) return [];
  92. const groups = {};
  93. lessons.forEach(lesson => {
  94. const key = `${lesson.name}|${lesson.teacher}|${lesson.position}|${lesson.day}`;
  95. if (!groups[key]) {
  96. groups[key] = {
  97. name: lesson.name,
  98. teacher: lesson.teacher,
  99. position: lesson.position,
  100. day: lesson.day,
  101. weeksMatrix: Array.from({ length: MAX_SUPPORTED_WEEK + 1 }, () => new Set())
  102. };
  103. }
  104. if (Array.isArray(lesson.weeks)) {
  105. lesson.weeks.forEach(week => {
  106. if (Number.isInteger(week) && week > 0 && week <= MAX_SUPPORTED_WEEK) {
  107. for (let section = lesson.startSection; section <= lesson.endSection; section++) {
  108. groups[key].weeksMatrix[week].add(section);
  109. }
  110. }
  111. });
  112. }
  113. });
  114. const merged = [];
  115. for (const key in groups) {
  116. const group = groups[key];
  117. const blockMap = {};
  118. for (let week = 1; week < group.weeksMatrix.length; week++) {
  119. const sections = Array.from(group.weeksMatrix[week]).sort((a, b) => a - b);
  120. if (sections.length === 0) continue;
  121. let start = sections[0];
  122. let previous = sections[0];
  123. for (let i = 1; i < sections.length; i++) {
  124. const current = sections[i];
  125. if (current === previous + 1) {
  126. previous = current;
  127. } else {
  128. const blockKey = `${start}-${previous}`;
  129. if (!blockMap[blockKey]) blockMap[blockKey] = [];
  130. blockMap[blockKey].push(week);
  131. start = current;
  132. previous = current;
  133. }
  134. }
  135. const blockKey = `${start}-${previous}`;
  136. if (!blockMap[blockKey]) blockMap[blockKey] = [];
  137. blockMap[blockKey].push(week);
  138. }
  139. for (const blockKey in blockMap) {
  140. const [startSection, endSection] = blockKey.split("-").map(Number);
  141. merged.push({
  142. name: group.name,
  143. teacher: group.teacher,
  144. position: group.position,
  145. day: group.day,
  146. startSection,
  147. endSection,
  148. weeks: blockMap[blockKey]
  149. });
  150. }
  151. }
  152. merged.sort((a, b) => {
  153. if (a.day !== b.day) return a.day - b.day;
  154. if (a.startSection !== b.startSection) return a.startSection - b.startSection;
  155. if (a.name !== b.name) return a.name.localeCompare(b.name);
  156. return a.position.localeCompare(b.position);
  157. });
  158. return merged;
  159. }
  160. function parseTeacherName(block) {
  161. const teachersMatch = block.match(/actTeachers\s*=\s*\[([\s\S]*?)\]\s*;/);
  162. if (!teachersMatch) return "未知教师";
  163. const names = [];
  164. const nameRegex = /\bname\s*:\s*"([^"]+)"/g;
  165. let match;
  166. while ((match = nameRegex.exec(teachersMatch[1])) !== null) {
  167. if (!names.includes(match[1])) names.push(match[1]);
  168. }
  169. return names.length > 0 ? names.join(",") : "未知教师";
  170. }
  171. function parseTaskActivities(html) {
  172. const rawResults = [];
  173. const unitCountMatch = html.match(/\bunitCount\s*=\s*(\d+)\s*;/);
  174. const unitCount = unitCountMatch ? parseInt(unitCountMatch[1], 10) : 14;
  175. const indexRegex = new RegExp(
  176. `index\\s*=\\s*(\\d+)\\s*\\*\\s*(?:unitCount|${unitCount})\\s*\\+\\s*(\\d+)\\s*;`,
  177. "g"
  178. );
  179. const blocks = html.split(/var\s+teachers\s*=/);
  180. for (let i = 1; i < blocks.length; i++) {
  181. const block = blocks[i];
  182. const teacher = parseTeacherName(block);
  183. const activityRegex = /new\s+TaskActivity\(([\s\S]*?)\)\s*;/g;
  184. const activities = [];
  185. let activityMatch;
  186. while ((activityMatch = activityRegex.exec(block)) !== null) {
  187. activities.push({
  188. argsRaw: activityMatch[1],
  189. start: activityMatch.index,
  190. end: activityRegex.lastIndex
  191. });
  192. }
  193. for (let activityIndex = 0; activityIndex < activities.length; activityIndex++) {
  194. const activity = activities[activityIndex];
  195. const args = powerSplit(activity.argsRaw);
  196. if (args.length < 7) continue;
  197. const name = cleanCourseName(args[3]);
  198. const position = cleanPosition(args[5]);
  199. const weeks = parseWeeksBitmap(args[6]);
  200. if (weeks.length === 0) continue;
  201. const nextActivityStart = activityIndex + 1 < activities.length
  202. ? activities[activityIndex + 1].start
  203. : block.length;
  204. const activityScope = block.slice(activity.end, nextActivityStart);
  205. indexRegex.lastIndex = 0;
  206. let indexMatch;
  207. while ((indexMatch = indexRegex.exec(activityScope)) !== null) {
  208. const rawDay = parseInt(indexMatch[1], 10);
  209. const rawSection = parseInt(indexMatch[2], 10);
  210. if (rawDay < 0 || rawDay > 6 || rawSection < 0 || rawSection >= unitCount) continue;
  211. const day = rawDay + 1;
  212. const section = rawSection + 1;
  213. rawResults.push({
  214. name,
  215. teacher,
  216. position,
  217. day,
  218. startSection: section,
  219. endSection: section,
  220. weeks: [...weeks]
  221. });
  222. }
  223. }
  224. }
  225. return mergeContinuousLessons(rawResults);
  226. }
  227. function parseParameters(html) {
  228. // 多种可能的 ids 表达方式
  229. const idsPatterns = [
  230. /bg\.form\.addInput\(\s*form\s*,\s*["']ids["']\s*,\s*["'](\d+)["']\s*\)/,
  231. /["']ids["']\s*,\s*["'](\d+)["']/,
  232. /name=["']ids["'][^>]*value=["'](\d+)["']/i,
  233. /value=["'](\d+)["'][^>]*name=["']ids["']/i,
  234. /ids\s*=\s*["'](\d+)["']/
  235. ];
  236. let ids = null;
  237. for (const pattern of idsPatterns) {
  238. const match = html.match(pattern);
  239. if (match && match[1]) {
  240. ids = match[1];
  241. break;
  242. }
  243. }
  244. // 学期标签 id 匹配:优先 semesterBar\d+Semester,其次任意含 semester 的 id
  245. let tagIdMatch = html.match(/id=["'](semesterBar\d+Semester)["']/);
  246. if (!tagIdMatch) {
  247. tagIdMatch = html.match(/id=["']([^"']*[Ss]emester[^"']*)["']/);
  248. }
  249. if (!ids || !tagIdMatch) return null;
  250. const tagId = tagIdMatch[1];
  251. const escapedTagId = tagId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  252. const elementMatch = html.match(new RegExp(`<[^>]*\\bid=["']${escapedTagId}["'][^>]*>`, "i"));
  253. const valueMatch = elementMatch ? elementMatch[0].match(/\bvalue=["'](\d+)["']/i) : null;
  254. return {
  255. ids,
  256. tagId,
  257. currentSemesterId: valueMatch ? valueMatch[1] : null
  258. };
  259. }
  260. /**
  261. * 从课表页 HTML 中动态解析作息时间(树维 EAMS 的节次表头为 th[id="0_节次"],文本形如 "(08:00-08:45)")
  262. */
  263. function parseTimeSlotsFromHtml(html) {
  264. const timeSlots = [];
  265. const thRegex = /<th[^>]*id=["']0_(\d+)["'][^>]*>([\s\S]*?)<\/th>/gi;
  266. let match;
  267. while ((match = thRegex.exec(html)) !== null) {
  268. const number = parseInt(match[1], 10);
  269. const text = match[2].replace(/<[^>]*>/g, " ").replace(/&nbsp;|&#160;/gi, " ").replace(/\u00a0/g, " ");
  270. const timeMatch = text.match(/(\d{1,2}:\d{2})\s*[-~至]\s*(\d{1,2}:\d{2})/);
  271. if (timeMatch && !isNaN(number)) {
  272. timeSlots.push({
  273. number,
  274. startTime: timeMatch[1].padStart(5, "0"),
  275. endTime: timeMatch[2].padStart(5, "0")
  276. });
  277. }
  278. }
  279. timeSlots.sort((a, b) => a.number - b.number);
  280. // 校验编号从 1 开始连续
  281. for (let i = 0; i < timeSlots.length; i++) {
  282. if (timeSlots[i].number !== i + 1) return [];
  283. }
  284. return timeSlots;
  285. }
  286. /**
  287. * 从学期对象中提取日期字段(兼容多种字段名与格式)
  288. */
  289. function extractDateFromObj(obj, keys) {
  290. for (const k of keys) {
  291. const v = obj ? obj[k] : null;
  292. if (v !== null && v !== undefined && v !== "") {
  293. const m = String(v).match(/(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})日?/);
  294. if (m) {
  295. const d = normalizeNumericDate(m[1], m[2], m[3]);
  296. if (d) return d;
  297. }
  298. }
  299. }
  300. // 兜底:扫描对象所有字段值里是否含日期
  301. for (const k in obj || {}) {
  302. const v = obj[k];
  303. if (typeof v === "string") {
  304. const m = v.match(/(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})日?/);
  305. if (m) {
  306. const d = normalizeNumericDate(m[1], m[2], m[3]);
  307. if (d) return d;
  308. }
  309. }
  310. }
  311. return null;
  312. }
  313. function parseSemesterResponse(raw) {
  314. const data = Function(`return (${raw});`)();
  315. const semesters = [];
  316. for (const key of Object.keys(data.semesters || {})) {
  317. const entries = Array.isArray(data.semesters[key]) ? data.semesters[key] : [];
  318. entries.forEach(semester => {
  319. if (semester && semester.id !== undefined) {
  320. const term = String(semester.name || "").trim();
  321. const label = /^第.*学期$/.test(term) ? term : `第${term}学期`;
  322. semesters.push({
  323. id: String(semester.id),
  324. schoolYear: String(semester.schoolYear || ""),
  325. term,
  326. name: `${semester.schoolYear} ${label}`.trim(),
  327. startDate: extractDateFromObj(semester, ["startDate", "beginDate", "start", "startTime", "dateBegin"]),
  328. endDate: extractDateFromObj(semester, ["endDate", "end", "endTime", "finishDate", "dateEnd"])
  329. });
  330. }
  331. });
  332. }
  333. semesters.sort((a, b) => {
  334. const yearCompare = b.schoolYear.localeCompare(a.schoolYear);
  335. if (yearCompare !== 0) return yearCompare;
  336. return b.term.localeCompare(a.term, undefined, { numeric: true });
  337. });
  338. return {
  339. semesters,
  340. currentSemesterId: data.semesterId === undefined ? null : String(data.semesterId)
  341. };
  342. }
  343. function normalizeNumericDate(year, month, day) {
  344. const yearNumber = Number(year);
  345. const monthNumber = Number(month);
  346. const dayNumber = Number(day);
  347. const date = new Date(Date.UTC(yearNumber, monthNumber - 1, dayNumber));
  348. if (
  349. date.getUTCFullYear() !== yearNumber ||
  350. date.getUTCMonth() + 1 !== monthNumber ||
  351. date.getUTCDate() !== dayNumber
  352. ) {
  353. return null;
  354. }
  355. return [
  356. String(yearNumber).padStart(4, "0"),
  357. String(monthNumber).padStart(2, "0"),
  358. String(dayNumber).padStart(2, "0")
  359. ].join("-");
  360. }
  361. function parseCalendarInfo(html) {
  362. const text = String(html || "")
  363. .replace(/<[^>]*>/g, " ")
  364. .replace(/&nbsp;|&#160;|&#x0*A0;/gi, " ")
  365. .replace(/\u00a0/g, " ")
  366. .replace(/\s+/g, " ");
  367. // 兼容多种表达:开始/结束日期、开始~结束、起止、YYYY-M-D / YYYY年M月D日 等
  368. const patterns = [
  369. /开始\s*\/\s*结束日期\s*[::]?\s*(\d{4})[-/年]\s*(\d{1,2})[-/月]\s*(\d{1,2})日?\s*[~—~至到]\s*(\d{4})[-/年]\s*(\d{1,2})[-/月]\s*(\d{1,2})日?\s*\(?\s*(\d+)\s*周?\s*\)?/i,
  370. /开始\s*[::]?\s*(\d{4})[-/年]\s*(\d{1,2})[-/月]\s*(\d{1,2})日?\s*[~—~至到结束]?\s*(\d{4})?[-/年]?\s*(\d{1,2})?[-/月]?\s*(\d{1,2})?日?\s*[^0-9]{0,10}(\d+)\s*周?/i,
  371. /(\d{4})[-/年]\s*(\d{1,2})[-/月]\s*(\d{1,2})日?\s*[~—~至到]\s*(\d{4})[-/年]\s*(\d{1,2})[-/月]\s*(\d{1,2})日?/
  372. ];
  373. let match = null;
  374. for (const pattern of patterns) {
  375. match = text.match(pattern);
  376. if (match) break;
  377. }
  378. if (!match) return null;
  379. // 计算总周数:优先取显式数字,否则按起止日期估算
  380. let semesterTotalWeeks = null;
  381. const explicitWeeks = match[7] ? Number(match[7]) : NaN;
  382. if (Number.isInteger(explicitWeeks) && explicitWeeks >= 1 && explicitWeeks <= MAX_SUPPORTED_WEEK) {
  383. semesterTotalWeeks = explicitWeeks;
  384. }
  385. const semesterStartDate = normalizeNumericDate(match[1], match[2], match[3]);
  386. if (!semesterStartDate) return null;
  387. // 结束日期用于估算周数(可选)
  388. let semesterEndDate = null;
  389. if (match[4] && match[5] && match[6]) {
  390. semesterEndDate = normalizeNumericDate(match[4], match[5], match[6]);
  391. }
  392. if (!semesterTotalWeeks && semesterEndDate && semesterEndDate >= semesterStartDate) {
  393. const days = Math.round((new Date(semesterEndDate) - new Date(semesterStartDate)) / 86400000) + 1;
  394. const estimated = Math.round(days / 7);
  395. if (estimated >= 1 && estimated <= MAX_SUPPORTED_WEEK) semesterTotalWeeks = estimated;
  396. }
  397. if (!semesterTotalWeeks) return null;
  398. return {
  399. semesterStartDate,
  400. semesterEndDate,
  401. semesterTotalWeeks,
  402. firstDayOfWeek: 1
  403. };
  404. }
  405. async function request(url, options = {}) {
  406. const response = await fetch(url, { credentials: "include", ...options });
  407. if (!response.ok) throw new Error(`网络请求失败: ${response.status}`);
  408. return await response.text();
  409. }
  410. async function detectParameters() {
  411. const html = await request(`${BASE_URL}/eams/courseTableForStd.action`);
  412. const params = parseParameters(html);
  413. if (!params) {
  414. console.warn("[参数识别失败] 未能从课表页识别 ids 与 semesterBar");
  415. throw new Error("未能识别教务参数,请确认已通过服务大厅登录并访问过课表页");
  416. }
  417. return params;
  418. }
  419. async function getSelectedSemester(tagId, currentSemesterId) {
  420. const form = new URLSearchParams();
  421. form.set("tagId", tagId);
  422. form.set("dataType", "semesterCalendar");
  423. if (currentSemesterId) form.set("value", currentSemesterId);
  424. form.set("empty", "false");
  425. const raw = await request(`${BASE_URL}/eams/dataQuery.action`, {
  426. method: "POST",
  427. headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
  428. body: form.toString()
  429. });
  430. const parsed = parseSemesterResponse(raw);
  431. if (parsed.semesters.length === 0) throw new Error("未获取到可选学期");
  432. const selectedId = currentSemesterId || parsed.currentSemesterId;
  433. const defaultIndex = parsed.semesters.findIndex(semester => semester.id === selectedId);
  434. const index = await window.shiguangBridgePromise.showSingleSelection(
  435. "选择学期",
  436. JSON.stringify(parsed.semesters.map(semester => semester.name)),
  437. defaultIndex
  438. );
  439. return Number.isInteger(index) && index >= 0 && index < parsed.semesters.length
  440. ? parsed.semesters[index]
  441. : null;
  442. }
  443. async function fetchAndParseCourses(semesterId, ids) {
  444. const form = new URLSearchParams();
  445. form.set("ignoreHead", "1");
  446. form.set("setting.kind", "std");
  447. form.set("startWeek", "");
  448. form.set("semester.id", String(semesterId));
  449. form.set("ids", String(ids));
  450. const html = await request(`${BASE_URL}/eams/courseTableForStd!courseTable.action`, {
  451. method: "POST",
  452. headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
  453. body: form.toString()
  454. });
  455. // 如果返回了登录页,说明教务会话未建立
  456. if (/loginExt|登录/i.test(html) && !html.includes("courseTableForStd") && html.length < 30000) {
  457. throw new Error("教务会话未建立(请求被重定向到登录页),请先在服务大厅打开过课表页面");
  458. }
  459. return parseTaskActivities(html);
  460. }
  461. async function fetchCalendarInfo(semesterId) {
  462. const form = new URLSearchParams();
  463. form.set("version", "1");
  464. form.set("semesterId", String(semesterId));
  465. const html = await request(`${BASE_URL}/eams/base/calendar-info.action`, {
  466. method: "POST",
  467. headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
  468. body: form.toString()
  469. });
  470. const calendarInfo = parseCalendarInfo(html);
  471. if (!calendarInfo) {
  472. const cleaned = String(html || "")
  473. .replace(/<[^>]*>/g, " ")
  474. .replace(/&nbsp;|&#160;/gi, " ")
  475. .replace(/\u00a0/g, " ")
  476. .replace(/\s+/g, " ").trim();
  477. console.warn(`[学期日历] 未能解析学期日历,原文(清理后前400字): ${cleaned.slice(0, 400)}`);
  478. throw new Error("未能解析学期日历");
  479. }
  480. return calendarInfo;
  481. }
  482. /**
  483. * 获取作息时间:优先从课表页 HTML 动态解析,失败则使用兜底作息
  484. */
  485. async function fetchTimeSlots() {
  486. try {
  487. const html = await request(`${BASE_URL}/eams/courseTableForStd.action`);
  488. const parsed = parseTimeSlotsFromHtml(html);
  489. if (parsed && parsed.length > 0) {
  490. console.log(`[作息] 已从课表页解析出 ${parsed.length} 个节次时间段`);
  491. return parsed;
  492. }
  493. } catch (error) {
  494. console.warn(`[作息] 课表页解析失败,使用兜底作息: ${error.message}`);
  495. }
  496. console.log(`[作息] 使用兜底作息(${ZZQCC_TIME_SLOTS_FALLBACK.length} 个节次)`);
  497. return ZZQCC_TIME_SLOTS_FALLBACK.map(slot => ({ ...slot }));
  498. }
  499. async function trySaveCalendarInfo(semesterId) {
  500. try {
  501. const calendarInfo = await fetchCalendarInfo(semesterId);
  502. const saveResult = await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
  503. semesterStartDate: calendarInfo.semesterStartDate,
  504. semesterTotalWeeks: calendarInfo.semesterTotalWeeks,
  505. firstDayOfWeek: calendarInfo.firstDayOfWeek
  506. }));
  507. return saveResult === true;
  508. } catch (error) {
  509. console.warn(`[学期信息设置失败] ${error.message}`);
  510. return false;
  511. }
  512. }
  513. async function trySaveTimeSlots() {
  514. try {
  515. const timeSlots = await fetchTimeSlots();
  516. const saveResult = await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  517. return saveResult === true;
  518. } catch (error) {
  519. console.warn(`[作息时间设置失败] ${error.message}`);
  520. return false;
  521. }
  522. }
  523. function buildCompletionMessage(calendarSaved, timeSlotsSaved) {
  524. if (calendarSaved && timeSlotsSaved) return "成功导入课表、学期信息和本校作息";
  525. if (!calendarSaved && !timeSlotsSaved) {
  526. return "课表已导入,学期日期和作息时间设置失败,请在设置中确认";
  527. }
  528. if (!calendarSaved) return "课表已导入,学期日期获取失败,请在设置中确认";
  529. return "课表已导入,作息时间设置失败,请在设置中确认";
  530. }
  531. /**
  532. * 从选中学期的数据中保存学期配置(开始日期 + 总周数=课表最大周次),失败返回 false
  533. */
  534. async function trySaveSemesterConfig(semester, courses) {
  535. try {
  536. const startDate = semester && semester.startDate;
  537. if (!startDate) {
  538. console.warn("[学期配置] 学期数据中未找到开始日期,改用 calendar-info 兜底");
  539. return false;
  540. }
  541. let maxWeek = 0;
  542. (courses || []).forEach(course => {
  543. (course.weeks || []).forEach(w => {
  544. if (Number.isInteger(w) && w > maxWeek) maxWeek = w;
  545. });
  546. });
  547. const totalWeeks = maxWeek >= 1 ? maxWeek : 20;
  548. const config = { semesterStartDate: startDate, semesterTotalWeeks: totalWeeks, firstDayOfWeek: 1 };
  549. console.log(`[学期配置] 保存: ${JSON.stringify(config)}`);
  550. const saveResult = await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  551. return saveResult === true;
  552. } catch (error) {
  553. console.warn(`[学期配置] 保存失败: ${error.message}`);
  554. return false;
  555. }
  556. }
  557. /**
  558. * 日期输入校验函数(供 showPrompt 使用)
  559. */
  560. function validateDateInput(input) {
  561. const m = String(input || "").trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
  562. if (!m) return "请输入 YYYY-MM-DD 格式(如 2026-09-07)";
  563. const d = normalizeNumericDate(m[1], m[2], m[3]);
  564. if (!d) return "日期无效,请检查月份和日期";
  565. return false;
  566. }
  567. /**
  568. * 最终兜底:让用户输入本学期开学日期,并保存学期配置
  569. */
  570. async function promptSemesterStartDate(courses) {
  571. try {
  572. const input = await window.shiguangBridgePromise.showPrompt(
  573. "设置本学期开学日期",
  574. "未能自动获取学期日期,请输入本学期开学日期(格式 YYYY-MM-DD,例如 2026-09-07)",
  575. "2026-09-07",
  576. "validateDateInput"
  577. );
  578. if (!input) {
  579. window.shiguangBridge.showToast("已跳过开学日期设置");
  580. return false;
  581. }
  582. const m = String(input).trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
  583. if (!m) return false;
  584. const startDate = normalizeNumericDate(m[1], m[2], m[3]);
  585. if (!startDate) return false;
  586. let maxWeek = 0;
  587. (courses || []).forEach(course => {
  588. (course.weeks || []).forEach(w => {
  589. if (Number.isInteger(w) && w > maxWeek) maxWeek = w;
  590. });
  591. });
  592. const totalWeeks = maxWeek >= 1 ? maxWeek : 20;
  593. const config = { semesterStartDate: startDate, semesterTotalWeeks: totalWeeks, firstDayOfWeek: 1 };
  594. console.log(`[学期配置] 用户输入开学日期保存: ${JSON.stringify(config)}`);
  595. const saveResult = await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  596. return saveResult === true;
  597. } catch (error) {
  598. console.warn(`[学期配置] 用户输入保存失败: ${error.message}`);
  599. return false;
  600. }
  601. }
  602. async function runImportFlow() {
  603. try {
  604. const alertConfirmed = await window.shiguangBridgePromise.showAlert(
  605. "郑州汽车工程职业学院课表导入",
  606. "请确认已完成以下步骤:\n1. 已在页面上通过统一身份认证(CAS)登录\n2. 已从【服务大厅】→【学生课表查询】打开过课表页面\n3. 当前页面处于 jw.zzvcae.edu.cn 域下\n\n确认后开始同步课表。",
  607. "开始同步"
  608. );
  609. if (!alertConfirmed) return;
  610. window.shiguangBridge.showToast("开始探测教务参数...");
  611. const params = await detectParameters();
  612. if (!params) throw new Error("未能识别教务参数,请确认已通过服务大厅登录并访问过课表页");
  613. const semester = await getSelectedSemester(params.tagId, params.currentSemesterId);
  614. if (!semester) return;
  615. window.shiguangBridge.showToast("正在同步课表...");
  616. const courses = await fetchAndParseCourses(semester.id, params.ids);
  617. if (!courses || courses.length === 0) {
  618. console.warn("[课程解析] TaskActivity 解析结果为空,请检查上方[诊断]输出中的 TaskActivity/var teachers 标记");
  619. throw new Error("未解析到课程数据(请把控制台[诊断]信息反馈给开发者)");
  620. }
  621. console.log(`[课程解析] 成功解析 ${courses.length} 门课程时段`);
  622. const saveResult = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  623. if (!saveResult) throw new Error("课程保存失败");
  624. // 学期配置:优先学期数据 → calendar-info 兜底 → 用户手动输入兜底
  625. let calendarSaved = await trySaveSemesterConfig(semester, courses);
  626. if (!calendarSaved) {
  627. calendarSaved = await trySaveCalendarInfo(semester.id);
  628. }
  629. if (!calendarSaved) {
  630. calendarSaved = await promptSemesterStartDate(courses);
  631. }
  632. const timeSlotsSaved = await trySaveTimeSlots();
  633. window.shiguangBridge.showToast(buildCompletionMessage(calendarSaved, timeSlotsSaved));
  634. window.shiguangBridge.notifyTaskCompletion();
  635. } catch (error) {
  636. console.error(`[郑州汽车工程职业学院课表导入异常] ${error && error.stack ? error.stack : error}`);
  637. try {
  638. await window.shiguangBridgePromise.showAlert("导入失败", error.message, "知道了");
  639. } catch (dialogError) {
  640. window.shiguangBridge.showToast(`导入失败: ${error.message}`);
  641. }
  642. }
  643. }
  644. runImportFlow();