yu.js 16 KB

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