uestc.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  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. /** 去掉课程名末尾的课程编码,如 "大学物理Ⅱ(D1200440.18)" → "大学物理Ⅱ" */
  229. function cleanCourseName(name) {
  230. return name.replace(/\s*\([A-Z]{1,3}\d+\.[\w.]+\)\s*$/, '');
  231. }
  232. /**
  233. * 将解析出的活动条目合并为课程列表
  234. * 同一天 + 同课程名 + 同教师的连续节次合并为一条课程
  235. * "停课" 条目:其周次从正常条目中扣除,本身不作为独立课程输出
  236. */
  237. function mergeToCourses(activities) {
  238. // 第1步:按 (天, 课程名, 教师) 分组(跨房间)
  239. const groups = {};
  240. for (const act of activities) {
  241. const key = `${act.day}|${act.courseName}|${act.teacherName}`;
  242. if (!groups[key]) groups[key] = [];
  243. groups[key].push(act);
  244. }
  245. const allCourses = [];
  246. for (const key of Object.keys(groups)) {
  247. const acts = groups[key];
  248. const normals = acts.filter(a => a.roomName !== '停课');
  249. if (normals.length === 0) continue;
  250. // 第2步:按 (period, room) 聚合,每个组合得到独立周次集合
  251. // cellWeeks: "period|room" → Set<week>
  252. const cellWeeks = {};
  253. for (const act of normals) {
  254. const p = act.period;
  255. const room = act.roomName || '';
  256. const cellKey = `${p}|${room}`;
  257. if (!cellWeeks[cellKey]) cellWeeks[cellKey] = new Set();
  258. for (const w of parseWeeks(act.validWeeks)) cellWeeks[cellKey].add(w);
  259. }
  260. // 第3步:按房间分组,每个房间内找连续节次区间,合并周次
  261. const rooms = [...new Set(normals.map(a => a.roomName || ''))];
  262. const roomEntries = [];
  263. for (const room of rooms) {
  264. // 收集该房间所有 period 的周次
  265. const roomPeriods = {};
  266. for (const [cellKey, weeks] of Object.entries(cellWeeks)) {
  267. const [p, r] = cellKey.split('|');
  268. if (r === room) roomPeriods[Number(p)] = new Set([...weeks]);
  269. }
  270. const periods = Object.keys(roomPeriods).map(Number).sort((a, b) => a - b);
  271. if (periods.length === 0) continue;
  272. // 找连续区间(但相邻 period 周次差异过大时断开)
  273. let i = 0;
  274. while (i < periods.length) {
  275. let j = i;
  276. while (j + 1 < periods.length && periods[j + 1] === periods[j] + 1) {
  277. // 如果下一个 period 的周次与本区间已有周次交集 < 30%,则断开
  278. const curWeeks = new Set();
  279. for (let k = i; k <= j; k++) {
  280. for (const w of roomPeriods[periods[k]]) curWeeks.add(w);
  281. }
  282. const nextWeeks = roomPeriods[periods[j + 1]];
  283. const intersect = [...curWeeks].filter(w => nextWeeks.has(w)).length;
  284. const union = new Set([...curWeeks, ...nextWeeks]).size;
  285. const overlapRatio = union > 0 ? intersect / union : 0;
  286. if (overlapRatio < 0.3) break;
  287. j++;
  288. }
  289. const mergedWeeks = new Set();
  290. for (let k = i; k <= j; k++) {
  291. for (const w of roomPeriods[periods[k]]) mergedWeeks.add(w);
  292. }
  293. roomEntries.push({
  294. room, startSection: periods[i], endSection: periods[j], weeks: mergedWeeks,
  295. });
  296. i = j + 1;
  297. }
  298. }
  299. for (const entry of roomEntries) {
  300. const weeks = [...entry.weeks].sort((a, b) => a - b);
  301. allCourses.push({
  302. name: cleanCourseName(normals[0].courseName),
  303. teacher: normals[0].teacherName,
  304. position: entry.room,
  305. day: normals[0].day,
  306. startSection: entry.startSection,
  307. endSection: entry.endSection,
  308. weeks: weeks,
  309. });
  310. }
  311. }
  312. return allCourses;
  313. }
  314. // ═══════════════════════════════════════════════════════════
  315. // 课表数据获取
  316. // ═══════════════════════════════════════════════════════════
  317. function extractIds() {
  318. for (const el of document.querySelectorAll('form input[name="ids"]')) {
  319. if (el.value) return el.value;
  320. }
  321. for (const el of document.querySelectorAll('form input[name="params"]')) {
  322. const m = el.value.match(/[?&]ids=(\d+)/);
  323. if (m) return m[1];
  324. }
  325. return null;
  326. }
  327. async function fetchCourseHtml(semesterId) {
  328. const ids = extractIds();
  329. const baseParams = {
  330. 'ignoreHead': '1',
  331. 'setting.kind': 'std',
  332. 'startWeek': '',
  333. 'project.id': '1',
  334. 'isEng': '0',
  335. 'semester.id': semesterId,
  336. };
  337. // 尝试 1: 有 ids
  338. if (ids) {
  339. const html = await doFetch({ ...baseParams, ids });
  340. if (html && isValidResponse(html)) return html;
  341. }
  342. // 尝试 2: 无 ids
  343. const html2 = await doFetch(baseParams);
  344. if (html2 && isValidResponse(html2)) return html2;
  345. throw new Error('无法获取课表数据,请确认已登录 eams.uestc.edu.cn');
  346. }
  347. async function doFetch(params) {
  348. const body = Object.entries(params)
  349. .map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v))
  350. .join('&');
  351. try {
  352. const resp = await fetch('/eams/courseTableForStd!courseTable.action', {
  353. method: 'POST',
  354. headers: {
  355. 'Content-Type': 'application/x-www-form-urlencoded',
  356. 'X-Requested-With': 'XMLHttpRequest',
  357. },
  358. body: body,
  359. });
  360. if (!resp.ok) return null;
  361. return await resp.text();
  362. } catch (e) {
  363. console.warn('Fetch 失败:', e.message);
  364. return null;
  365. }
  366. }
  367. function isValidResponse(html) {
  368. return html && html.length > 100 && html.includes('TaskActivity');
  369. }
  370. // ═══════════════════════════════════════════════════════════
  371. // 主流程
  372. // ═══════════════════════════════════════════════════════════
  373. async function runImportFlow() {
  374. console.log('📚 电子科技大学 EAMS 教务导入开始');
  375. try {
  376. // ── Step 1: 欢迎提示 ──
  377. const ready = await window.AndroidBridgePromise.showAlert(
  378. '电子科技大学 - 教务导入',
  379. '请确保已在 eams.uestc.edu.cn 完成登录。\n\n建议先在教务中打开「个人课表」页面,\n然后返回本应用执行导入。',
  380. '已登录,开始导入'
  381. );
  382. if (!ready) { console.log('❌ 用户取消'); return; }
  383. // ── Step 2: 选择学期 ──
  384. const options = buildSemesterOptions();
  385. const labels = options.map(o => o.label);
  386. const defaultIdx = Math.min(4, labels.length - 1);
  387. const selectedIdx = await window.AndroidBridgePromise.showSingleSelection(
  388. '选择学期',
  389. JSON.stringify(labels),
  390. defaultIdx
  391. );
  392. if (selectedIdx === null || selectedIdx === -1) { console.log('❌ 取消学期选择'); return; }
  393. const semesterId = options[selectedIdx].id;
  394. console.log(`📅 学期: ${options[selectedIdx].label} (id=${semesterId})`);
  395. // ── Step 3: 获取课表数据 ──
  396. AndroidBridge.showToast('正在获取课表数据...');
  397. const html = await fetchCourseHtml(semesterId);
  398. // ── Step 4: 解析 ──
  399. console.log(`📄 响应长度: ${html.length}`);
  400. const { activities } = parseTaskActivities(html);
  401. console.log(`📊 活动记录: ${activities.length} 条`);
  402. if (activities.length === 0) {
  403. AndroidBridge.showToast('未找到课程数据');
  404. await window.AndroidBridgePromise.showAlert('导入结果', '未在当前学期找到课程数据。', '知道了');
  405. return;
  406. }
  407. const courses = mergeToCourses(activities);
  408. console.log(`📋 课程: ${courses.length} 门`);
  409. // ── Step 5: 保存 ──
  410. await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
  411. console.log('✅ 课程已保存');
  412. try {
  413. await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(DEFAULT_TIME_SLOTS));
  414. console.log('✅ 时间段已保存');
  415. } catch (e) { console.warn('⚠️ 时间段保存失败:', e.message); }
  416. try {
  417. await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify({
  418. semesterTotalWeeks: UESTC_CONFIG.defaultTotalWeeks,
  419. }));
  420. console.log('✅ 配置已保存');
  421. } catch (e) { console.warn('⚠️ 配置保存失败:', e.message); }
  422. // ── Step 6: 完成 ──
  423. AndroidBridge.showToast(`导入成功!共 ${courses.length} 门课程`);
  424. await window.AndroidBridgePromise.showAlert(
  425. '✅ 导入完成',
  426. `成功导入 ${courses.length} 门课程。\n学期: ${options[selectedIdx].label}\n请返回课表查看。`,
  427. '好的'
  428. );
  429. AndroidBridge.notifyTaskCompletion();
  430. } catch (error) {
  431. console.error('❌ 导入失败:', error);
  432. AndroidBridge.showToast('导入失败: ' + error.message);
  433. try {
  434. await window.AndroidBridgePromise.showAlert(
  435. '❌ 导入失败',
  436. '错误: ' + error.message + '\n\n请检查:\n1. 已登录 eams.uestc.edu.cn\n2. 网络正常\n3. 可先打开个人课表页面',
  437. '知道了'
  438. );
  439. } catch (_) {}
  440. }
  441. }
  442. // 暴露到全局(兼容 Tester 和 APP)
  443. window.runImportFlow = runImportFlow;
  444. // 自动启动
  445. runImportFlow();
  446. })();