Browse Source

Add: 电子科技大学 (UESTC) 适配 (#416)

CorunLing 1 week ago
parent
commit
44b653db96
3 changed files with 499 additions and 1 deletions
  1. 5 1
      index/root_index.yaml
  2. 9 0
      resources/UESTC/adapters.yaml
  3. 485 0
      resources/UESTC/uestc.js

+ 5 - 1
index/root_index.yaml

@@ -651,4 +651,8 @@ schools:
   - id: "USTC"
     name: "中国科学技术大学"
     initial: "Z"
-    resource_folder: "USTC"
+    resource_folder: "USTC"
+  - id: "UESTC"
+    name: "电子科技大学"
+    initial: "D"
+    resource_folder: "UESTC"

+ 9 - 0
resources/UESTC/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/UESTC/adapters.yaml
+adapters:
+  - adapter_id: "UESTC_01"
+    adapter_name: "电子科技大学 EAMS 教务系统"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "uestc.js"
+    import_url: "https://eams.uestc.edu.cn/eams"
+    maintainer: "CorunLing"
+    description: "登录 eams.uestc.edu.cn 后,在弹出的学期列表中选择对应学期即可自动导入。支持单双周、连周、自定义时间段。"

+ 485 - 0
resources/UESTC/uestc.js

@@ -0,0 +1,485 @@
+/**
+ * 电子科技大学 (UESTC) - EAMS 教务系统课程导入适配器
+ *
+ * 2026-07-26 | CorunLing
+ *
+ * 适配流程:
+ *   1. 用户登录 https://eams.uestc.edu.cn/eams
+ *   2. 进入课程表页面或任意已登录页面
+ *   3. 执行导入,选择学期
+ *   4. 脚本通过 Fetch 请求课表 API,解析 TaskActivity 数据
+ *   5. 生成课程列表并保存
+ */
+
+(function () {
+'use strict';
+
+// ═══════════════════════════════════════════════════════════
+//  常量
+// ═══════════════════════════════════════════════════════════
+
+const UESTC_CONFIG = {
+  semesterBase: 483,    // 2025-2026 第一学期
+  semesterStep: 20,     // 每学期 +20
+  yearBase: 2025,
+  defaultTotalWeeks: 20,
+};
+
+/** 电子科技大学标准作息时间段(仅供参考,可在 APP 中调整) */
+const DEFAULT_TIME_SLOTS = [
+  { number: 1,  startTime: '08:30', endTime: '09:15' },
+  { number: 2,  startTime: '09:20', endTime: '10:05' },
+  { number: 3,  startTime: '10:25', endTime: '11:10' },
+  { number: 4,  startTime: '11:15', endTime: '12:00' },
+  { number: 5,  startTime: '14:00', endTime: '14:45' },
+  { number: 6,  startTime: '14:50', endTime: '15:35' },
+  { number: 7,  startTime: '15:55', endTime: '16:40' },
+  { number: 8,  startTime: '16:45', endTime: '17:30' },
+  { number: 9,  startTime: '19:00', endTime: '19:45' },
+  { number: 10, startTime: '19:50', endTime: '20:35' },
+  { number: 11, startTime: '20:40', endTime: '21:25' },
+  { number: 12, startTime: '21:30', endTime: '22:15' },
+];
+
+// ═══════════════════════════════════════════════════════════
+//  学期工具
+// ═══════════════════════════════════════════════════════════
+
+/** 正模运算(JS 的 % 对负数返回负值) */
+function mod(n, m) { return ((n % m) + m) % m; }
+
+/**
+ * 学期 ID → 中文名
+ * 483 → 2025-2026 第一学期, 503 → 2025-2026 第二学期, 523 → 2026-2027 第一学期 ...
+ */
+function semesterLabel(id) {
+  const n = parseInt(id) - UESTC_CONFIG.semesterBase;
+  const y = UESTC_CONFIG.yearBase + Math.floor(n / (UESTC_CONFIG.semesterStep * 2));
+  const first = mod(n, UESTC_CONFIG.semesterStep * 2) < UESTC_CONFIG.semesterStep;
+  return first ? `${y}-${y + 1} 第一学期` : `${y}-${y + 1} 第二学期`;
+}
+
+/** 生成可选学期列表(当前 ± 4 学期) */
+function buildSemesterOptions() {
+  const now = new Date();
+  const m = now.getMonth() + 1;
+  let curYear = now.getFullYear();
+  if (m >= 2) curYear -= 1; // 春季学期,学年是去年-今年
+
+  const offset = (curYear - UESTC_CONFIG.yearBase) * UESTC_CONFIG.semesterStep * 2;
+  const currentId = UESTC_CONFIG.semesterBase + offset;
+
+  const list = [];
+  for (let i = -4; i <= 2; i++) {
+    const id = currentId + i * UESTC_CONFIG.semesterStep;
+    if (id >= UESTC_CONFIG.semesterBase - 80) {
+      list.push({ id: String(id), label: semesterLabel(id) });
+    }
+  }
+  return list;
+}
+
+// ═══════════════════════════════════════════════════════════
+//  周次转换
+// ═══════════════════════════════════════════════════════════
+
+/**
+ * 将 53 位二进制周数字符串转为周数数组
+ * '111111111111111111100000...' → [1, 2, 3, ..., 19]
+ */
+function binaryWeeksToArray(binStr) {
+  if (!binStr || typeof binStr !== 'string') return [];
+  const weeks = [];
+  const len = Math.min(binStr.length, 54);
+  // EAMS 二进制串: position i = week i(position 0 始终是 0,忽略)
+  for (let i = 0; i < len; i++) {
+    if (binStr[i] === '1') weeks.push(i);
+  }
+  return weeks.filter(w => w > 0);
+}
+
+/**
+ * 将中文周数描述转为周数数组(安全降级用)
+ * '1-16周' → [1..16], '单1-17' → [1,3,5,...,17], '连1-16,单9-17' → 合并
+ */
+function parseChineseWeeks(str) {
+  if (!str) return [];
+  const weeksSet = new Set();
+
+  const segments = str.split(/[,,]/);
+  for (const seg of segments) {
+    const trimmed = seg.trim();
+
+    // 连续周: "连1-16"、"1-16"、"1-16周"
+    let m = trimmed.match(/(?:连)?(\d+)\s*-\s*(\d+)/);
+    if (m) {
+      const start = parseInt(m[1]), end = parseInt(m[2]);
+      for (let w = start; w <= end; w++) weeksSet.add(w);
+      continue;
+    }
+
+    // 单周: "单1-17"、"单3"
+    m = trimmed.match(/单(\d+)(?:\s*-\s*(\d+))?/);
+    if (m) {
+      const start = parseInt(m[1]);
+      const end = m[2] ? parseInt(m[2]) : start;
+      for (let w = start; w <= end; w += 2) weeksSet.add(w);
+      continue;
+    }
+
+    // 双周: "双2-16"、"双4"
+    m = trimmed.match(/双(\d+)(?:\s*-\s*(\d+))?/);
+    if (m) {
+      const start = parseInt(m[1]);
+      const end = m[2] ? parseInt(m[2]) : start;
+      for (let w = start; w <= end; w += 2) weeksSet.add(w);
+      continue;
+    }
+
+    // 显式逗号列表: "1,3,5,7"
+    const nums = trimmed.match(/\d+/g);
+    if (nums) {
+      for (const n of nums) weeksSet.add(parseInt(n));
+    }
+  }
+
+  return [...weeksSet].sort((a, b) => a - b);
+}
+
+/** 通用周数解析:尝试二进制 → 中文 → 空 */
+function parseWeeks(raw) {
+  if (!raw) return [];
+  if (/^[01]{20,54}$/.test(raw)) return binaryWeeksToArray(raw);
+  const parsed = parseChineseWeeks(raw);
+  return parsed.length > 0 ? parsed : [];
+}
+
+// ═══════════════════════════════════════════════════════════
+//  TaskActivity 解析
+// ═══════════════════════════════════════════════════════════
+
+/**
+ * 从 EAMS 课表页面的 HTML 响应中解析 TaskActivity
+ *
+ * HTML 中包含如下格式的 JavaScript:
+ *   activity = new TaskActivity("", "teacherName", "", "courseName", "", "room", "validWeeks", ...)
+ *   index = day * unitCount + period
+ */
+function parseTaskActivities(html) {
+  let unitCount = 12;
+  const unitMatch = html.match(/var\s+unitCount\s*=\s*(\d+)/);
+  if (unitMatch) unitCount = parseInt(unitMatch[1]);
+
+  const activities = [];
+  // 一次性从 HTML 中提取所有 activity 声明和 index 赋值
+  // 格式: activity = new TaskActivity(...); index = D*unitCount+P; ...; index = D*unitCount+P;
+  // 活动和索引可能在同一行,也可能跨行
+
+  // 1. 提取所有 activity = new TaskActivity(...) 及其参数
+  const actRegex = /new TaskActivity\(/g;
+  let match;
+  const actEntries = []; // { actStart, actEnd, line, content }
+
+  while ((match = actRegex.exec(html)) !== null) {
+    const contentStart = match.index + 'new TaskActivity('.length;
+    let depth = 0;
+    let i = contentStart;
+    for (; i < html.length; i++) {
+      if (html[i] === '(') depth++;
+      else if (html[i] === ')') {
+        if (depth === 0) break;
+        depth--;
+      }
+    }
+    const content = html.substring(contentStart, i);
+    const args = splitArgs(content);
+    if (args.length >= 7) {
+      actEntries.push({
+        teacherName: cleanStr(args[1]),
+        courseName: cleanStr(args[3]),
+        roomName: cleanStr(args[5]),
+        validWeeks: cleanStr(args[6]),
+        declEnd: i + 1, // 这个 activity 声明结束位置
+      });
+    }
+  }
+
+  // 2. 提取所有 index = D*unitCount+P 及位置
+  const idxRegex = /index\s*=\s*(\d+)\s*\*\s*unitCount\s*\+\s*(\d+)/g;
+  const idxEntries = []; // { pos, day, period }
+  while ((match = idxRegex.exec(html)) !== null) {
+    idxEntries.push({
+      pos: match.index,
+      day: parseInt(match[1]) + 1,
+      period: parseInt(match[2]) + 1,
+    });
+  }
+
+  // 3. 匹配:每个 index 归属于它前面最近的那个 activity 声明
+  for (const idx of idxEntries) {
+    // 找 pos 之前最近的 activity 声明
+    let bestAct = null;
+    for (const act of actEntries) {
+      if (act.declEnd <= idx.pos) {
+        bestAct = act;
+      } else break;
+    }
+    if (bestAct) {
+      activities.push({ ...bestAct, day: idx.day, period: idx.period });
+    }
+  }
+
+  // 移除多余的 declEnd 等字段
+  for (const a of activities) { delete a.declEnd; }
+
+  return { activities, unitCount };
+}
+
+function splitArgs(raw) {
+  const args = [];
+  let current = '';
+  let inQuote = false;
+  for (let i = 0; i < raw.length; i++) {
+    const ch = raw[i];
+    if (ch === '"') { inQuote = !inQuote; current += ch; }
+    else if (ch === ',' && !inQuote) { args.push(current); current = ''; }
+    else { current += ch; }
+  }
+  if (current) args.push(current);
+  return args;
+}
+
+function cleanStr(s) {
+  if (!s) return '';
+  return s.replace(/^["'\s]+|["'\s]+$/g, '');
+}
+
+// ═══════════════════════════════════════════════════════════
+//  课程分组与转换
+// ═══════════════════════════════════════════════════════════
+
+/**
+ * 将解析出的活动条目合并为课程列表
+ * 同一天 + 同课程名 + 同教师的连续节次合并为一条课程
+ * "停课" 条目:其周次从正常条目中扣除,本身不作为独立课程输出
+ */
+function mergeToCourses(activities) {
+  const groups = {};
+  for (const act of activities) {
+    const key = `${act.day}|${act.courseName}|${act.teacherName}`;
+    if (!groups[key]) groups[key] = [];
+    groups[key].push(act);
+  }
+
+  const courses = [];
+  for (const key of Object.keys(groups)) {
+    const acts = groups[key];
+    const normals = acts.filter(a => a.roomName !== '停课');
+    const canceled = acts.filter(a => a.roomName === '停课');
+
+    if (normals.length === 0) continue; // 全是停课,跳过
+
+    // 收集停课周次(用于扣除)
+    const canceledWeeks = new Set();
+    for (const ca of canceled) {
+      for (const w of parseWeeks(ca.validWeeks)) canceledWeeks.add(w);
+    }
+
+    // 按节次排序
+    normals.sort((a, b) => a.period - b.period);
+
+    // 找连续区间
+    let i = 0;
+    while (i < normals.length) {
+      let j = i;
+      while (j + 1 < normals.length && normals[j + 1].period === normals[j].period + 1) {
+        j++;
+      }
+
+      // 合并该区间内所有正常周次,再扣除停课周次
+      const mergedWeeks = new Set();
+      for (let k = i; k <= j; k++) {
+        for (const wk of parseWeeks(normals[k].validWeeks)) {
+          if (!canceledWeeks.has(wk)) mergedWeeks.add(wk);
+        }
+      }
+
+      // 取该区间最常见的位置(非空优先)
+      const positions = normals.slice(i, j + 1).map(a => a.roomName).filter(Boolean);
+      const position = positions.length > 0 ? positions[0] : '';
+
+      courses.push({
+        name: normals[0].courseName,
+        teacher: normals[0].teacherName,
+        position: position,
+        day: normals[0].day,
+        startSection: normals[i].period,
+        endSection: normals[j].period,
+        weeks: [...mergedWeeks].sort((a, b) => a - b),
+      });
+
+      i = j + 1;
+    }
+  }
+
+  return courses;
+}
+
+// ═══════════════════════════════════════════════════════════
+//  课表数据获取
+// ═══════════════════════════════════════════════════════════
+
+function extractIds() {
+  for (const el of document.querySelectorAll('form input[name="ids"]')) {
+    if (el.value) return el.value;
+  }
+  for (const el of document.querySelectorAll('form input[name="params"]')) {
+    const m = el.value.match(/[?&]ids=(\d+)/);
+    if (m) return m[1];
+  }
+  return null;
+}
+
+async function fetchCourseHtml(semesterId) {
+  const ids = extractIds();
+  const baseParams = {
+    'ignoreHead': '1',
+    'setting.kind': 'std',
+    'startWeek': '',
+    'project.id': '1',
+    'isEng': '0',
+    'semester.id': semesterId,
+  };
+
+  // 尝试 1: 有 ids
+  if (ids) {
+    const html = await doFetch({ ...baseParams, ids });
+    if (html && isValidResponse(html)) return html;
+  }
+
+  // 尝试 2: 无 ids
+  const html2 = await doFetch(baseParams);
+  if (html2 && isValidResponse(html2)) return html2;
+
+  throw new Error('无法获取课表数据,请确认已登录 eams.uestc.edu.cn');
+}
+
+async function doFetch(params) {
+  const body = Object.entries(params)
+    .map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v))
+    .join('&');
+
+  try {
+    const resp = await fetch('/eams/courseTableForStd!courseTable.action', {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/x-www-form-urlencoded',
+        'X-Requested-With': 'XMLHttpRequest',
+      },
+      body: body,
+    });
+    if (!resp.ok) return null;
+    return await resp.text();
+  } catch (e) {
+    console.warn('Fetch 失败:', e.message);
+    return null;
+  }
+}
+
+function isValidResponse(html) {
+  return html && html.length > 100 && html.includes('TaskActivity');
+}
+
+// ═══════════════════════════════════════════════════════════
+//  主流程
+// ═══════════════════════════════════════════════════════════
+
+async function runImportFlow() {
+  console.log('📚 电子科技大学 EAMS 教务导入开始');
+
+  try {
+    // ── Step 1: 欢迎提示 ──
+    const ready = await window.AndroidBridgePromise.showAlert(
+      '电子科技大学 - 教务导入',
+      '请确保已在 eams.uestc.edu.cn 完成登录。\n\n建议先在教务中打开「个人课表」页面,\n然后返回本应用执行导入。',
+      '已登录,开始导入'
+    );
+    if (!ready) { console.log('❌ 用户取消'); return; }
+
+    // ── Step 2: 选择学期 ──
+    const options = buildSemesterOptions();
+    const labels = options.map(o => o.label);
+    const defaultIdx = Math.min(4, labels.length - 1);
+
+    const selectedIdx = await window.AndroidBridgePromise.showSingleSelection(
+      '选择学期',
+      JSON.stringify(labels),
+      defaultIdx
+    );
+    if (selectedIdx === null || selectedIdx === -1) { console.log('❌ 取消学期选择'); return; }
+
+    const semesterId = options[selectedIdx].id;
+    console.log(`📅 学期: ${options[selectedIdx].label} (id=${semesterId})`);
+
+    // ── Step 3: 获取课表数据 ──
+    AndroidBridge.showToast('正在获取课表数据...');
+    const html = await fetchCourseHtml(semesterId);
+
+    // ── Step 4: 解析 ──
+    console.log(`📄 响应长度: ${html.length}`);
+    const { activities } = parseTaskActivities(html);
+    console.log(`📊 活动记录: ${activities.length} 条`);
+
+    if (activities.length === 0) {
+      AndroidBridge.showToast('未找到课程数据');
+      await window.AndroidBridgePromise.showAlert('导入结果', '未在当前学期找到课程数据。', '知道了');
+      return;
+    }
+
+    const courses = mergeToCourses(activities);
+    console.log(`📋 课程: ${courses.length} 门`);
+
+    // ── Step 5: 保存 ──
+    await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
+    console.log('✅ 课程已保存');
+
+    try {
+      await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(DEFAULT_TIME_SLOTS));
+      console.log('✅ 时间段已保存');
+    } catch (e) { console.warn('⚠️ 时间段保存失败:', e.message); }
+
+    try {
+      await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify({
+        semesterTotalWeeks: UESTC_CONFIG.defaultTotalWeeks,
+      }));
+      console.log('✅ 配置已保存');
+    } catch (e) { console.warn('⚠️ 配置保存失败:', e.message); }
+
+    // ── Step 6: 完成 ──
+    AndroidBridge.showToast(`导入成功!共 ${courses.length} 门课程`);
+    await window.AndroidBridgePromise.showAlert(
+      '✅ 导入完成',
+      `成功导入 ${courses.length} 门课程。\n学期: ${options[selectedIdx].label}\n请返回课表查看。`,
+      '好的'
+    );
+    AndroidBridge.notifyTaskCompletion();
+
+  } catch (error) {
+    console.error('❌ 导入失败:', error);
+    AndroidBridge.showToast('导入失败: ' + error.message);
+    try {
+      await window.AndroidBridgePromise.showAlert(
+        '❌ 导入失败',
+        '错误: ' + error.message + '\n\n请检查:\n1. 已登录 eams.uestc.edu.cn\n2. 网络正常\n3. 可先打开个人课表页面',
+        '知道了'
+      );
+    } catch (_) {}
+  }
+}
+
+// 暴露到全局(兼容 Tester 和 APP)
+window.runImportFlow = runImportFlow;
+// 自动启动
+runImportFlow();
+
+})();