yu.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. // 课表以空 HTML 表格返回,课程数据通过 JavaScript 脚本动态注入
  2. // 脚本中包含 `new TaskActivity(...)` 构造函数调用来定义课程
  3. // 需要从脚本文本中直接提取课程信息,而不是解析 DOM
  4. (function () {
  5. const BASE = "https://jwc3-yangtzeu-edu-cn-s.atrust.yangtzeu.edu.cn";
  6. function extractCourseHtmlDebugInfo(courseHtml) {
  7. const text = String(courseHtml || "");
  8. const hasTaskActivity = /new\s+TaskActivity\s*\(/i.test(text);
  9. return {
  10. responseLength: text.length,
  11. hasTaskActivity
  12. };
  13. }
  14. async function requestText(url, options) {
  15. const requestOptions = {
  16. credentials: "include",
  17. ...options
  18. };
  19. const res = await fetch(url, requestOptions);
  20. const text = await res.text();
  21. if (!res.ok) {
  22. throw new Error(`网络请求失败: ${res.status}`);
  23. }
  24. return text;
  25. }
  26. // 从入口页提取学生 ID 和学期组件 tagId
  27. function parseEntryParams(entryHtml) {
  28. const idsMatch = entryHtml.match(/bg\.form\.addInput\(form,"ids","(\d+)"\)/);
  29. const tagIdMatch = entryHtml.match(/id="(semesterBar\d+Semester)"/);
  30. return {
  31. studentId: idsMatch ? idsMatch[1] : "",
  32. tagId: tagIdMatch ? tagIdMatch[1] : ""
  33. };
  34. }
  35. // 学期接口返回对象字面量,这里按脚本文本解析
  36. function parseSemesterResponse(rawText) {
  37. let data;
  38. try {
  39. data = Function(`return (${String(rawText || "").trim()});`)();
  40. } catch (_) {
  41. throw new Error("学期数据解析失败");
  42. }
  43. const semesters = [];
  44. if (!data || !data.semesters || typeof data.semesters !== "object") {
  45. return semesters;
  46. }
  47. Object.keys(data.semesters).forEach((k) => {
  48. const arr = data.semesters[k];
  49. if (!Array.isArray(arr)) return;
  50. arr.forEach((s) => {
  51. if (!s || !s.id) return;
  52. semesters.push({
  53. id: String(s.id),
  54. name: `${s.schoolYear || ""} 第${s.name || ""}学期`.trim()
  55. });
  56. });
  57. });
  58. return semesters;
  59. }
  60. // 清除课程名后面的课程序号
  61. function cleanCourseName(name) {
  62. return String(name || "").replace(/\(\d+\)\s*$/, "").trim();
  63. }
  64. // 解析周次位图字符串
  65. function parseValidWeeksBitmap(bitmap) {
  66. if (!bitmap || typeof bitmap !== "string") return [];
  67. const weeks = [];
  68. for (let i = 0; i < bitmap.length; i++) {
  69. if (bitmap[i] === "1" && i >= 1) weeks.push(i);
  70. }
  71. return weeks;
  72. }
  73. function normalizeWeeks(weeks) {
  74. const list = Array.from(new Set((weeks || []).filter((w) => Number.isInteger(w) && w > 0)));
  75. list.sort((a, b) => a - b);
  76. return list;
  77. }
  78. // 节次编号与 TimeSlots 编号映射
  79. function mapSectionToTimeSlotNumber(section) {
  80. const mapping = {
  81. 1: 1,
  82. 2: 2,
  83. 3: 4,
  84. 4: 5,
  85. 5: 7,
  86. 6: 8,
  87. 7: 3,
  88. 8: 6
  89. };
  90. return mapping[section] || section;
  91. }
  92. // 反引号化 JavaScript 字面量字符串,处理转义字符
  93. function unquoteJsLiteral(token) {
  94. const text = String(token || "").trim();
  95. if (!text) return "";
  96. if (text === "null" || text === "undefined") return "";
  97. if ((text.startsWith("\"") && text.endsWith("\"")) || (text.startsWith("'") && text.endsWith("'"))) {
  98. const quote = text[0];
  99. let inner = text.slice(1, -1);
  100. inner = inner
  101. .replace(/\\\\/g, "\\")
  102. .replace(new RegExp(`\\\\${quote}`, "g"), quote)
  103. .replace(/\\n/g, "\n")
  104. .replace(/\\r/g, "\r")
  105. .replace(/\\t/g, "\t");
  106. return inner;
  107. }
  108. return text;
  109. }
  110. // 分割 JavaScript 函数参数字符串,正确处理引号和转义
  111. function splitJsArgs(argsText) {
  112. const args = [];
  113. let curr = "";
  114. let inQuote = "";
  115. let escaped = false;
  116. for (let i = 0; i < argsText.length; i++) {
  117. const ch = argsText[i];
  118. if (escaped) {
  119. curr += ch;
  120. escaped = false;
  121. continue;
  122. }
  123. if (ch === "\\") {
  124. curr += ch;
  125. escaped = true;
  126. continue;
  127. }
  128. if (inQuote) {
  129. curr += ch;
  130. if (ch === inQuote) inQuote = "";
  131. continue;
  132. }
  133. if (ch === "\"" || ch === "'") {
  134. curr += ch;
  135. inQuote = ch;
  136. continue;
  137. }
  138. if (ch === ",") {
  139. args.push(curr.trim());
  140. curr = "";
  141. continue;
  142. }
  143. curr += ch;
  144. }
  145. if (curr.trim() || argsText.endsWith(",")) {
  146. args.push(curr.trim());
  147. }
  148. return args;
  149. }
  150. // 从脚本文本中的 TaskActivity 还原课程
  151. function parseCoursesFromTaskActivityScript(htmlText) {
  152. const text = String(htmlText || "");
  153. if (!text) return [];
  154. const unitCountMatch = text.match(/\bvar\s+unitCount\s*=\s*(\d+)\s*;/);
  155. const unitCount = unitCountMatch ? parseInt(unitCountMatch[1], 10) : 0;
  156. if (!Number.isInteger(unitCount) || unitCount <= 0) return [];
  157. const courses = [];
  158. const stats = {
  159. blocks: 0,
  160. teacherRecovered: 0,
  161. teacherUnresolvedExpression: 0
  162. };
  163. const blockRe = /activity\s*=\s*new\s+TaskActivity\(([^]*?)\)\s*;\s*index\s*=\s*(?:(\d+)\s*\*\s*unitCount\s*\+\s*(\d+)|(\d+))\s*;\s*table\d+\.activities\[index\]/g;
  164. let match;
  165. while ((match = blockRe.exec(text)) !== null) {
  166. stats.blocks += 1;
  167. const argsText = match[1] || "";
  168. const args = splitJsArgs(argsText);
  169. if (args.length < 7) continue;
  170. const dayPart = match[2];
  171. const sectionPart = match[3];
  172. const directIndexPart = match[4];
  173. let indexValue = -1;
  174. if (dayPart != null && sectionPart != null) {
  175. indexValue = parseInt(dayPart, 10) * unitCount + parseInt(sectionPart, 10);
  176. } else if (directIndexPart != null) {
  177. indexValue = parseInt(directIndexPart, 10);
  178. }
  179. if (!Number.isInteger(indexValue) || indexValue < 0) continue;
  180. const day = Math.floor(indexValue / unitCount) + 1;
  181. let section = (indexValue % unitCount) + 1;
  182. section = mapSectionToTimeSlotNumber(section);
  183. if (day < 1 || day > 7 || section < 1 || section > 16) continue;
  184. let teacher = unquoteJsLiteral(args[1]);
  185. if (teacher && !/^['"]/.test(String(args[1]).trim()) && /join\s*\(/.test(String(args[1]))) {
  186. const resolved = resolveTeachersForTaskActivityBlock(text, match.index);
  187. if (resolved) {
  188. teacher = resolved;
  189. stats.teacherRecovered += 1;
  190. } else {
  191. stats.teacherUnresolvedExpression += 1;
  192. }
  193. }
  194. const name = cleanCourseName(unquoteJsLiteral(args[3]));
  195. const position = unquoteJsLiteral(args[5]);
  196. const weekBitmap = unquoteJsLiteral(args[6]);
  197. const weeks = normalizeWeeks(parseValidWeeksBitmap(weekBitmap));
  198. if (!name) continue;
  199. courses.push({
  200. name,
  201. teacher,
  202. position,
  203. day,
  204. startSection: section,
  205. endSection: section,
  206. weeks
  207. });
  208. }
  209. console.info("[课程解析 TaskActivity]", {
  210. blocks: stats.blocks,
  211. parsedCourses: courses.length,
  212. teacherRecovered: stats.teacherRecovered,
  213. teacherUnresolvedExpression: stats.teacherUnresolvedExpression
  214. });
  215. return mergeContiguousSections(courses);
  216. }
  217. // 当教师名为表达式时,尝试在附近代码中回溯真实教师名
  218. function resolveTeachersForTaskActivityBlock(fullText, blockStartIndex) {
  219. const start = Math.max(0, blockStartIndex - 2200);
  220. const segment = fullText.slice(start, blockStartIndex);
  221. const re = /var\s+actTeachers\s*=\s*\[([^]*?)\]\s*;/g;
  222. let m;
  223. let last = null;
  224. while ((m = re.exec(segment)) !== null) {
  225. last = m[1];
  226. }
  227. if (!last) return "";
  228. const names = [];
  229. const nameRe = /name\s*:\s*(?:"([^"]*)"|'([^']*)')/g;
  230. let nm;
  231. while ((nm = nameRe.exec(last)) !== null) {
  232. const name = (nm[1] || nm[2] || "").trim();
  233. if (name) names.push(name);
  234. }
  235. if (names.length === 0) return "";
  236. return Array.from(new Set(names)).join(",");
  237. }
  238. // 合并同一课程的连续节次
  239. function mergeContiguousSections(courses) {
  240. const list = (courses || [])
  241. .filter((c) => c && c.name && Number.isInteger(c.day) && Number.isInteger(c.startSection) && Number.isInteger(c.endSection))
  242. .map((c) => ({
  243. ...c,
  244. weeks: normalizeWeeks(c.weeks)
  245. }));
  246. list.sort((a, b) => {
  247. const ak = `${a.name}|${a.teacher}|${a.position}|${a.day}|${a.weeks.join(",")}`;
  248. const bk = `${b.name}|${b.teacher}|${b.position}|${b.day}|${b.weeks.join(",")}`;
  249. if (ak < bk) return -1;
  250. if (ak > bk) return 1;
  251. return a.startSection - b.startSection;
  252. });
  253. const merged = [];
  254. for (const item of list) {
  255. const prev = merged[merged.length - 1];
  256. const canMerge = prev
  257. && prev.name === item.name
  258. && prev.teacher === item.teacher
  259. && prev.position === item.position
  260. && prev.day === item.day
  261. && prev.weeks.join(",") === item.weeks.join(",")
  262. && prev.endSection + 1 >= item.startSection;
  263. if (canMerge) {
  264. prev.endSection = Math.max(prev.endSection, item.endSection);
  265. } else {
  266. merged.push({ ...item });
  267. }
  268. }
  269. return merged;
  270. }
  271. function getPresetTimeSlots() {
  272. return [
  273. { number: 1, startTime: "08:00", endTime: "09:35" },
  274. { number: 2, startTime: "10:05", endTime: "11:40" },
  275. { number: 3, startTime: "12:00", endTime: "13:35" }, // 午间课
  276. { number: 4, startTime: "14:00", endTime: "15:35" },
  277. { number: 5, startTime: "16:05", endTime: "17:40" },
  278. { number: 6, startTime: "17:45", endTime: "18:30" }, // 晚间课,部分课程为 18:00-18:45
  279. { number: 7, startTime: "19:00", endTime: "20:35" },
  280. { number: 8, startTime: "20:45", endTime: "22:20" }
  281. ];
  282. }
  283. function validateSemesterStartDateInput(input) {
  284. const value = String(input || "").trim();
  285. if (!value) return "请输入开学日期";
  286. if (!/^\d{4}[-/.]\d{2}[-/.]\d{2}$/.test(value)) return "请输入 YYYY-MM-DD";
  287. const normalized = value.replace(/[/.]/g, "-");
  288. const parts = normalized.split("-");
  289. const year = Number(parts[0]);
  290. const month = Number(parts[1]);
  291. const day = Number(parts[2]);
  292. if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return "请输入有效日期";
  293. const date = new Date(year, month - 1, day);
  294. const isValidDate = date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
  295. return isValidDate ? false : "请输入有效日期";
  296. }
  297. window.validateSemesterStartDateInput = validateSemesterStartDateInput;
  298. async function selectSemesterStartDate() {
  299. const picked = await window.AndroidBridgePromise.showPrompt(
  300. "选择开学日期",
  301. "请输入开学日期(YYYY-MM-DD)",
  302. "",
  303. "validateSemesterStartDateInput"
  304. );
  305. if (picked === null) return null;
  306. const value = String(picked || "").trim().replace(/[/.]/g, "-");
  307. return value || null;
  308. }
  309. async function runImportFlow() {
  310. if (!window.AndroidBridgePromise) {
  311. throw new Error("AndroidBridgePromise 不可用,无法进行导入交互。");
  312. }
  313. AndroidBridge.showToast("开始自动探测长江大学教务参数...");
  314. // 探测学生 ID 和学期组件
  315. const entryUrl = `${BASE}/eams/courseTableForStd.action?&sf_request_type=ajax`;
  316. const entryHtml = await requestText(entryUrl, {
  317. method: "GET",
  318. headers: { "x-requested-with": "XMLHttpRequest" }
  319. });
  320. const params = parseEntryParams(entryHtml);
  321. if (!params.studentId || !params.tagId) {
  322. await window.AndroidBridgePromise.showAlert(
  323. "参数探测失败",
  324. "未能识别学生 ID 或学期组件 tagId,请确认已登录后重试。",
  325. "确定"
  326. );
  327. return;
  328. }
  329. // 学期选择
  330. const semesterRaw = await requestText(`${BASE}/eams/dataQuery.action?sf_request_type=ajax`, {
  331. method: "POST",
  332. headers: { "content-type": "application/x-www-form-urlencoded; charset=UTF-8" },
  333. body: `tagId=${encodeURIComponent(params.tagId)}&dataType=semesterCalendar`
  334. });
  335. const allSemesters = parseSemesterResponse(semesterRaw);
  336. if (allSemesters.length === 0) {
  337. throw new Error("学期列表为空,无法继续导入。");
  338. }
  339. const recentSemesters = allSemesters.slice(-8);
  340. const selectIndex = await window.AndroidBridgePromise.showSingleSelection(
  341. "请选择导入学期",
  342. JSON.stringify(recentSemesters.map((s) => s.name || s.id)),
  343. recentSemesters.length - 1
  344. );
  345. if (selectIndex === null) {
  346. AndroidBridge.showToast("已取消导入");
  347. return;
  348. }
  349. const index = Number.isInteger(Number(selectIndex)) ? Number(selectIndex) : recentSemesters.length - 1;
  350. const selectedSemester = recentSemesters[index >= 0 && index < recentSemesters.length ? index : recentSemesters.length - 1];
  351. const semesterStartDate = await selectSemesterStartDate();
  352. if (semesterStartDate === null) {
  353. AndroidBridge.showToast("已取消导入");
  354. return;
  355. }
  356. AndroidBridge.showToast("正在获取课表数据...");
  357. // 拉取并解析课表
  358. const courseHtml = await requestText(`${BASE}/eams/courseTableForStd!courseTable.action?sf_request_type=ajax`, {
  359. method: "POST",
  360. headers: { "content-type": "application/x-www-form-urlencoded; charset=UTF-8" },
  361. body: [
  362. "ignoreHead=1",
  363. "setting.kind=std",
  364. "startWeek=",
  365. `semester.id=${encodeURIComponent(selectedSemester.id)}`,
  366. `ids=${encodeURIComponent(params.studentId)}`
  367. ].join("&")
  368. });
  369. const courses = parseCoursesFromTaskActivityScript(courseHtml);
  370. if (courses.length === 0) {
  371. const debugInfo = extractCourseHtmlDebugInfo(courseHtml);
  372. await window.AndroidBridgePromise.showAlert(
  373. "解析失败",
  374. `未能从课表响应中识别到课程。\n响应长度: ${debugInfo.responseLength}\n包含 TaskActivity: ${debugInfo.hasTaskActivity}`,
  375. "确定"
  376. );
  377. return;
  378. }
  379. await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
  380. await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(getPresetTimeSlots()));
  381. await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify({ semesterStartDate }));
  382. AndroidBridge.showToast(`导入成功,共 ${courses.length} 条课程`);
  383. AndroidBridge.notifyTaskCompletion();
  384. }
  385. (async function bootstrap() {
  386. try {
  387. await runImportFlow();
  388. } catch (error) {
  389. console.error("导入流程失败:", error);
  390. AndroidBridge.showToast(`导入失败:${error && error.message ? error.message : "请检查教务连接"}`);
  391. }
  392. })();
  393. })();