uestc.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. /**
  2. * 电子科技大学 (UESTC) - EAMS 教务系统课程导入适配器
  3. *
  4. * 2026-07-26 | CorunLing
  5. *
  6. * 适配流程:
  7. * 1. 用户登录 https://eams.uestc.edu.cn/eams
  8. * 2. 进入课程表页面或任意已登录页面
  9. * 3. 执行导入,选择学期
  10. * 4. 脚本通过 Fetch 请求课表 API,解析 TaskActivity 数据
  11. * 5. 生成课程列表并保存
  12. */
  13. (function () {
  14. 'use strict';
  15. // ═══════════════════════════════════════════════════════════
  16. // 常量
  17. // ═══════════════════════════════════════════════════════════
  18. const UESTC_CONFIG = {
  19. semesterBase: 483, // 2025-2026 第一学期
  20. semesterStep: 20, // 每学期 +20
  21. yearBase: 2025,
  22. defaultTotalWeeks: 20,
  23. };
  24. /** 电子科技大学标准作息时间段(仅供参考,可在 APP 中调整) */
  25. const DEFAULT_TIME_SLOTS = [
  26. { number: 1, startTime: '08:30', endTime: '09:15' },
  27. { number: 2, startTime: '09:20', endTime: '10:05' },
  28. { number: 3, startTime: '10:25', endTime: '11:10' },
  29. { number: 4, startTime: '11:15', endTime: '12:00' },
  30. { number: 5, startTime: '14:00', endTime: '14:45' },
  31. { number: 6, startTime: '14:50', endTime: '15:35' },
  32. { number: 7, startTime: '15:55', endTime: '16:40' },
  33. { number: 8, startTime: '16:45', endTime: '17:30' },
  34. { number: 9, startTime: '19:00', endTime: '19:45' },
  35. { number: 10, startTime: '19:50', endTime: '20:35' },
  36. { number: 11, startTime: '20:40', endTime: '21:25' },
  37. { number: 12, startTime: '21:30', endTime: '22:15' },
  38. ];
  39. // ═══════════════════════════════════════════════════════════
  40. // 学期工具
  41. // ═══════════════════════════════════════════════════════════
  42. /** 正模运算(JS 的 % 对负数返回负值) */
  43. function mod(n, m) { return ((n % m) + m) % m; }
  44. /**
  45. * 学期 ID → 中文名
  46. * 483 → 2025-2026 第一学期, 503 → 2025-2026 第二学期, 523 → 2026-2027 第一学期 ...
  47. */
  48. function semesterLabel(id) {
  49. const n = parseInt(id) - UESTC_CONFIG.semesterBase;
  50. const y = UESTC_CONFIG.yearBase + Math.floor(n / (UESTC_CONFIG.semesterStep * 2));
  51. const first = mod(n, UESTC_CONFIG.semesterStep * 2) < UESTC_CONFIG.semesterStep;
  52. return first ? `${y}-${y + 1} 第一学期` : `${y}-${y + 1} 第二学期`;
  53. }
  54. /** 生成可选学期列表(当前 ± 4 学期) */
  55. function buildSemesterOptions() {
  56. const now = new Date();
  57. const m = now.getMonth() + 1;
  58. let curYear = now.getFullYear();
  59. if (m >= 2) curYear -= 1; // 春季学期,学年是去年-今年
  60. const offset = (curYear - UESTC_CONFIG.yearBase) * UESTC_CONFIG.semesterStep * 2;
  61. const currentId = UESTC_CONFIG.semesterBase + offset;
  62. const list = [];
  63. for (let i = -4; i <= 2; i++) {
  64. const id = currentId + i * UESTC_CONFIG.semesterStep;
  65. if (id >= UESTC_CONFIG.semesterBase - 80) {
  66. list.push({ id: String(id), label: semesterLabel(id) });
  67. }
  68. }
  69. return list;
  70. }
  71. // ═══════════════════════════════════════════════════════════
  72. // 周次转换
  73. // ═══════════════════════════════════════════════════════════
  74. /**
  75. * 将 53 位二进制周数字符串转为周数数组
  76. * '111111111111111111100000...' → [1, 2, 3, ..., 19]
  77. */
  78. function binaryWeeksToArray(binStr) {
  79. if (!binStr || typeof binStr !== 'string') return [];
  80. const weeks = [];
  81. const len = Math.min(binStr.length, 54);
  82. // EAMS 二进制串: position i = week i(position 0 始终是 0,忽略)
  83. for (let i = 0; i < len; i++) {
  84. if (binStr[i] === '1') weeks.push(i);
  85. }
  86. return weeks.filter(w => w > 0);
  87. }
  88. /**
  89. * 将中文周数描述转为周数数组(安全降级用)
  90. * '1-16周' → [1..16], '单1-17' → [1,3,5,...,17], '连1-16,单9-17' → 合并
  91. */
  92. function parseChineseWeeks(str) {
  93. if (!str) return [];
  94. const weeksSet = new Set();
  95. const segments = str.split(/[,,]/);
  96. for (const seg of segments) {
  97. const trimmed = seg.trim();
  98. // 连续周: "连1-16"、"1-16"、"1-16周"
  99. let m = trimmed.match(/(?:连)?(\d+)\s*-\s*(\d+)/);
  100. if (m) {
  101. const start = parseInt(m[1]), end = parseInt(m[2]);
  102. for (let w = start; w <= end; w++) weeksSet.add(w);
  103. continue;
  104. }
  105. // 单周: "单1-17"、"单3"
  106. m = trimmed.match(/单(\d+)(?:\s*-\s*(\d+))?/);
  107. if (m) {
  108. const start = parseInt(m[1]);
  109. const end = m[2] ? parseInt(m[2]) : start;
  110. for (let w = start; w <= end; w += 2) weeksSet.add(w);
  111. continue;
  112. }
  113. // 双周: "双2-16"、"双4"
  114. m = trimmed.match(/双(\d+)(?:\s*-\s*(\d+))?/);
  115. if (m) {
  116. const start = parseInt(m[1]);
  117. const end = m[2] ? parseInt(m[2]) : start;
  118. for (let w = start; w <= end; w += 2) weeksSet.add(w);
  119. continue;
  120. }
  121. // 显式逗号列表: "1,3,5,7"
  122. const nums = trimmed.match(/\d+/g);
  123. if (nums) {
  124. for (const n of nums) weeksSet.add(parseInt(n));
  125. }
  126. }
  127. return [...weeksSet].sort((a, b) => a - b);
  128. }
  129. /** 通用周数解析:尝试二进制 → 中文 → 空 */
  130. function parseWeeks(raw) {
  131. if (!raw) return [];
  132. if (/^[01]{20,54}$/.test(raw)) return binaryWeeksToArray(raw);
  133. const parsed = parseChineseWeeks(raw);
  134. return parsed.length > 0 ? parsed : [];
  135. }
  136. // ═══════════════════════════════════════════════════════════
  137. // TaskActivity 解析
  138. // ═══════════════════════════════════════════════════════════
  139. /**
  140. * 从 EAMS 课表页面的 HTML 响应中解析 TaskActivity
  141. *
  142. * HTML 中包含如下格式的 JavaScript:
  143. * activity = new TaskActivity("", "teacherName", "", "courseName", "", "room", "validWeeks", ...)
  144. * index = day * unitCount + period
  145. */
  146. function parseTaskActivities(html) {
  147. let unitCount = 12;
  148. const unitMatch = html.match(/var\s+unitCount\s*=\s*(\d+)/);
  149. if (unitMatch) unitCount = parseInt(unitMatch[1]);
  150. const activities = [];
  151. // 一次性从 HTML 中提取所有 activity 声明和 index 赋值
  152. // 格式: activity = new TaskActivity(...); index = D*unitCount+P; ...; index = D*unitCount+P;
  153. // 活动和索引可能在同一行,也可能跨行
  154. // 1. 提取所有 activity = new TaskActivity(...) 及其参数
  155. const actRegex = /new TaskActivity\(/g;
  156. let match;
  157. const actEntries = []; // { actStart, actEnd, line, content }
  158. while ((match = actRegex.exec(html)) !== null) {
  159. const contentStart = match.index + 'new TaskActivity('.length;
  160. let depth = 0;
  161. let i = contentStart;
  162. for (; i < html.length; i++) {
  163. if (html[i] === '(') depth++;
  164. else if (html[i] === ')') {
  165. if (depth === 0) break;
  166. depth--;
  167. }
  168. }
  169. const content = html.substring(contentStart, i);
  170. const args = splitArgs(content);
  171. if (args.length >= 7) {
  172. actEntries.push({
  173. teacherName: cleanStr(args[1]),
  174. courseName: cleanStr(args[3]),
  175. roomName: cleanStr(args[5]),
  176. validWeeks: cleanStr(args[6]),
  177. declEnd: i + 1, // 这个 activity 声明结束位置
  178. });
  179. }
  180. }
  181. // 2. 提取所有 index = D*unitCount+P 及位置
  182. const idxRegex = /index\s*=\s*(\d+)\s*\*\s*unitCount\s*\+\s*(\d+)/g;
  183. const idxEntries = []; // { pos, day, period }
  184. while ((match = idxRegex.exec(html)) !== null) {
  185. idxEntries.push({
  186. pos: match.index,
  187. day: parseInt(match[1]) + 1,
  188. period: parseInt(match[2]) + 1,
  189. });
  190. }
  191. // 3. 匹配:每个 index 归属于它前面最近的那个 activity 声明
  192. for (const idx of idxEntries) {
  193. // 找 pos 之前最近的 activity 声明
  194. let bestAct = null;
  195. for (const act of actEntries) {
  196. if (act.declEnd <= idx.pos) {
  197. bestAct = act;
  198. } else break;
  199. }
  200. if (bestAct) {
  201. activities.push({ ...bestAct, day: idx.day, period: idx.period });
  202. }
  203. }
  204. // 移除多余的 declEnd 等字段
  205. for (const a of activities) { delete a.declEnd; }
  206. return { activities, unitCount };
  207. }
  208. function splitArgs(raw) {
  209. const args = [];
  210. let current = '';
  211. let inQuote = false;
  212. for (let i = 0; i < raw.length; i++) {
  213. const ch = raw[i];
  214. if (ch === '"') { inQuote = !inQuote; current += ch; }
  215. else if (ch === ',' && !inQuote) { args.push(current); current = ''; }
  216. else { current += ch; }
  217. }
  218. if (current) args.push(current);
  219. return args;
  220. }
  221. function cleanStr(s) {
  222. if (!s) return '';
  223. return s.replace(/^["'\s]+|["'\s]+$/g, '');
  224. }
  225. // ═══════════════════════════════════════════════════════════
  226. // 课程分组与转换
  227. // ═══════════════════════════════════════════════════════════
  228. /**
  229. * 将解析出的活动条目合并为课程列表
  230. * 同一天 + 同课程名 + 同教师的连续节次合并为一条课程
  231. * "停课" 条目:其周次从正常条目中扣除,本身不作为独立课程输出
  232. */
  233. function mergeToCourses(activities) {
  234. const groups = {};
  235. for (const act of activities) {
  236. const key = `${act.day}|${act.courseName}|${act.teacherName}`;
  237. if (!groups[key]) groups[key] = [];
  238. groups[key].push(act);
  239. }
  240. const courses = [];
  241. for (const key of Object.keys(groups)) {
  242. const acts = groups[key];
  243. const normals = acts.filter(a => a.roomName !== '停课');
  244. const canceled = acts.filter(a => a.roomName === '停课');
  245. if (normals.length === 0) continue; // 全是停课,跳过
  246. // 收集停课周次(用于扣除)
  247. const canceledWeeks = new Set();
  248. for (const ca of canceled) {
  249. for (const w of parseWeeks(ca.validWeeks)) canceledWeeks.add(w);
  250. }
  251. // 按节次排序
  252. normals.sort((a, b) => a.period - b.period);
  253. // 找连续区间
  254. let i = 0;
  255. while (i < normals.length) {
  256. let j = i;
  257. while (j + 1 < normals.length && normals[j + 1].period === normals[j].period + 1) {
  258. j++;
  259. }
  260. // 合并该区间内所有正常周次,再扣除停课周次
  261. const mergedWeeks = new Set();
  262. for (let k = i; k <= j; k++) {
  263. for (const wk of parseWeeks(normals[k].validWeeks)) {
  264. if (!canceledWeeks.has(wk)) mergedWeeks.add(wk);
  265. }
  266. }
  267. // 取该区间最常见的位置(非空优先)
  268. const positions = normals.slice(i, j + 1).map(a => a.roomName).filter(Boolean);
  269. const position = positions.length > 0 ? positions[0] : '';
  270. courses.push({
  271. name: normals[0].courseName,
  272. teacher: normals[0].teacherName,
  273. position: position,
  274. day: normals[0].day,
  275. startSection: normals[i].period,
  276. endSection: normals[j].period,
  277. weeks: [...mergedWeeks].sort((a, b) => a - b),
  278. });
  279. i = j + 1;
  280. }
  281. }
  282. return courses;
  283. }
  284. // ═══════════════════════════════════════════════════════════
  285. // 课表数据获取
  286. // ═══════════════════════════════════════════════════════════
  287. function extractIds() {
  288. for (const el of document.querySelectorAll('form input[name="ids"]')) {
  289. if (el.value) return el.value;
  290. }
  291. for (const el of document.querySelectorAll('form input[name="params"]')) {
  292. const m = el.value.match(/[?&]ids=(\d+)/);
  293. if (m) return m[1];
  294. }
  295. return null;
  296. }
  297. async function fetchCourseHtml(semesterId) {
  298. const ids = extractIds();
  299. const baseParams = {
  300. 'ignoreHead': '1',
  301. 'setting.kind': 'std',
  302. 'startWeek': '',
  303. 'project.id': '1',
  304. 'isEng': '0',
  305. 'semester.id': semesterId,
  306. };
  307. // 尝试 1: 有 ids
  308. if (ids) {
  309. const html = await doFetch({ ...baseParams, ids });
  310. if (html && isValidResponse(html)) return html;
  311. }
  312. // 尝试 2: 无 ids
  313. const html2 = await doFetch(baseParams);
  314. if (html2 && isValidResponse(html2)) return html2;
  315. throw new Error('无法获取课表数据,请确认已登录 eams.uestc.edu.cn');
  316. }
  317. async function doFetch(params) {
  318. const body = Object.entries(params)
  319. .map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v))
  320. .join('&');
  321. try {
  322. const resp = await fetch('/eams/courseTableForStd!courseTable.action', {
  323. method: 'POST',
  324. headers: {
  325. 'Content-Type': 'application/x-www-form-urlencoded',
  326. 'X-Requested-With': 'XMLHttpRequest',
  327. },
  328. body: body,
  329. });
  330. if (!resp.ok) return null;
  331. return await resp.text();
  332. } catch (e) {
  333. console.warn('Fetch 失败:', e.message);
  334. return null;
  335. }
  336. }
  337. function isValidResponse(html) {
  338. return html && html.length > 100 && html.includes('TaskActivity');
  339. }
  340. // ═══════════════════════════════════════════════════════════
  341. // 主流程
  342. // ═══════════════════════════════════════════════════════════
  343. async function runImportFlow() {
  344. console.log('📚 电子科技大学 EAMS 教务导入开始');
  345. try {
  346. // ── Step 1: 欢迎提示 ──
  347. const ready = await window.AndroidBridgePromise.showAlert(
  348. '电子科技大学 - 教务导入',
  349. '请确保已在 eams.uestc.edu.cn 完成登录。\n\n建议先在教务中打开「个人课表」页面,\n然后返回本应用执行导入。',
  350. '已登录,开始导入'
  351. );
  352. if (!ready) { console.log('❌ 用户取消'); return; }
  353. // ── Step 2: 选择学期 ──
  354. const options = buildSemesterOptions();
  355. const labels = options.map(o => o.label);
  356. const defaultIdx = Math.min(4, labels.length - 1);
  357. const selectedIdx = await window.AndroidBridgePromise.showSingleSelection(
  358. '选择学期',
  359. JSON.stringify(labels),
  360. defaultIdx
  361. );
  362. if (selectedIdx === null || selectedIdx === -1) { console.log('❌ 取消学期选择'); return; }
  363. const semesterId = options[selectedIdx].id;
  364. console.log(`📅 学期: ${options[selectedIdx].label} (id=${semesterId})`);
  365. // ── Step 3: 获取课表数据 ──
  366. AndroidBridge.showToast('正在获取课表数据...');
  367. const html = await fetchCourseHtml(semesterId);
  368. // ── Step 4: 解析 ──
  369. console.log(`📄 响应长度: ${html.length}`);
  370. const { activities } = parseTaskActivities(html);
  371. console.log(`📊 活动记录: ${activities.length} 条`);
  372. if (activities.length === 0) {
  373. AndroidBridge.showToast('未找到课程数据');
  374. await window.AndroidBridgePromise.showAlert('导入结果', '未在当前学期找到课程数据。', '知道了');
  375. return;
  376. }
  377. const courses = mergeToCourses(activities);
  378. console.log(`📋 课程: ${courses.length} 门`);
  379. // ── Step 5: 保存 ──
  380. await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
  381. console.log('✅ 课程已保存');
  382. try {
  383. await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(DEFAULT_TIME_SLOTS));
  384. console.log('✅ 时间段已保存');
  385. } catch (e) { console.warn('⚠️ 时间段保存失败:', e.message); }
  386. try {
  387. await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify({
  388. semesterTotalWeeks: UESTC_CONFIG.defaultTotalWeeks,
  389. }));
  390. console.log('✅ 配置已保存');
  391. } catch (e) { console.warn('⚠️ 配置保存失败:', e.message); }
  392. // ── Step 6: 完成 ──
  393. AndroidBridge.showToast(`导入成功!共 ${courses.length} 门课程`);
  394. await window.AndroidBridgePromise.showAlert(
  395. '✅ 导入完成',
  396. `成功导入 ${courses.length} 门课程。\n学期: ${options[selectedIdx].label}\n请返回课表查看。`,
  397. '好的'
  398. );
  399. AndroidBridge.notifyTaskCompletion();
  400. } catch (error) {
  401. console.error('❌ 导入失败:', error);
  402. AndroidBridge.showToast('导入失败: ' + error.message);
  403. try {
  404. await window.AndroidBridgePromise.showAlert(
  405. '❌ 导入失败',
  406. '错误: ' + error.message + '\n\n请检查:\n1. 已登录 eams.uestc.edu.cn\n2. 网络正常\n3. 可先打开个人课表页面',
  407. '知道了'
  408. );
  409. } catch (_) {}
  410. }
  411. }
  412. // 暴露到全局(兼容 Tester 和 APP)
  413. window.runImportFlow = runImportFlow;
  414. // 自动启动
  415. runImportFlow();
  416. })();