Преглед изворни кода

add: 中国科学技术大学 (USTC) 适配

Enthusjast пре 2 недеља
родитељ
комит
901f958492
3 измењених фајлова са 383 додато и 1 уклоњено
  1. 6 1
      index/root_index.yaml
  2. 9 0
      resources/USTC/adapters.yaml
  3. 368 0
      resources/USTC/ustc.js

+ 6 - 1
index/root_index.yaml

@@ -646,4 +646,9 @@ schools:
   - id: "JLU"
     name: "吉林大学"
     initial: "J"
-    resource_folder: "JLU"
+    resource_folder: "JLU"
+
+  - id: "USTC"
+    name: "中国科学技术大学"
+    initial: "Z"
+    resource_folder: "USTC"

+ 9 - 0
resources/USTC/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/USTC/adapters.yaml
+adapters:
+  - adapter_id: "USTC"
+    adapter_name: "中国科学技术大学本科教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "ustc.js"
+    import_url: "https://id.ustc.edu.cn/cas/login?service=https%3A%2F%2Fjw.ustc.edu.cn%2Fucas-sso%2Flogin"
+    maintainer: "Enthusjast"
+    description: "中国科学技术大学本科教务系统适配,登录后进入课表查询页面,等待课表加载完成后点击导入"

+ 368 - 0
resources/USTC/ustc.js

@@ -0,0 +1,368 @@
+// 中国科学技术大学(ustc.edu.cn) 拾光课程表适配脚本
+
+// ========== 辅助函数 ==========
+
+/**
+ * 解析 USTC 周次字符串
+ * 支持格式: "1-16周", "1-8,10-16周", "1-15(单)周", "2-16(双)周", "1-5,9-11(单),12-15周"
+ * @param {string} str - 周次字符串
+ * @returns {number[]} - 周次数组,如 [1,2,3,4,5,9,11,12,13,14,15]
+ */
+function parseWeekString(str) {
+    if (!str) return [];
+    const parts = str.replace(/[^0-9,()单双\-]/g, '').split(',');
+    const result = [];
+    for (const part of parts) {
+        if (!part.trim()) continue;
+        // 匹配 "5-10(单)" 或 "6-12(双)" 格式
+        const oddEvenMatch = part.match(/(\d+)-(\d+)\((单|双)\)/);
+        if (oddEvenMatch) {
+            const start = parseInt(oddEvenMatch[1]);
+            const end = parseInt(oddEvenMatch[2]);
+            const flag = oddEvenMatch[3];
+            for (let i = start; i <= end; i++) {
+                if (flag === '单' && i % 2 === 0) continue;
+                if (flag === '双' && i % 2 !== 0) continue;
+                result.push(i);
+            }
+        } else if (part.includes('-')) {
+            // 匹配 "1-8" 格式
+            const cleaned = part.replace(/[()]/g, '');
+            const [start, end] = cleaned.split('-').map(Number);
+            if (!isNaN(start) && !isNaN(end)) {
+                for (let i = start; i <= end; i++) result.push(i);
+            }
+        } else {
+            // 匹配单个数字 "3"
+            const num = parseInt(part);
+            if (!isNaN(num)) result.push(num);
+        }
+    }
+    return [...new Set(result)].sort((a, b) => a - b);
+}
+
+/**
+ * 解析节次字符串(复用 getWeekNumber 逻辑,支持范围格式)
+ * @param {string} str - 如 "3,4,5" 或 "3-5"
+ * @returns {number[]} - 如 [3, 4, 5]
+ */
+function parseTimeSpan(str) {
+    if (!str) return [];
+    const cleaned = str.replace(/[^0-9,-]/g, '');
+    const parts = cleaned.split(',');
+    const result = [];
+    for (const part of parts) {
+        if (!part.trim()) continue;
+        if (part.includes('-')) {
+            const [start, end] = part.split('-').map(Number);
+            if (!isNaN(start) && !isNaN(end)) {
+                for (let i = start; i <= end; i++) result.push(i);
+            }
+        } else {
+            const num = Number(part);
+            if (!isNaN(num)) result.push(num);
+        }
+    }
+    return [...new Set(result)].sort((a, b) => a - b);
+}
+
+/**
+ * 合并同课程不同周的数据
+ * 同一门课在不同周、相同时间段的条目合并 weeks 数组
+ * @param {Array} courses - 原始课程数组
+ * @returns {Array} - 合并后的课程数组
+ */
+function mergeCourses(courses) {
+    const groups = {};
+    courses.forEach(c => {
+        const key = `${c.name}|${c.teacher}|${c.position}|${c.day}|${c.startSection}|${c.endSection}`;
+        if (!groups[key]) {
+            groups[key] = { name: c.name, teacher: c.teacher, position: c.position, day: c.day, startSection: c.startSection, endSection: c.endSection, weeks: [] };
+        }
+        c.weeks.forEach(w => {
+            if (!groups[key].weeks.includes(w)) groups[key].weeks.push(w);
+        });
+    });
+    return Object.values(groups).sort((a, b) =>
+        a.day !== b.day ? a.day - b.day : a.startSection - b.startSection
+    );
+}
+
+// ========== DOM 解析 ==========
+
+/**
+ * 获取课表所在的 document 对象
+ * USTC 教务系统将课表加载在 iframe 中(name="e-home-iframe-1")
+ * 需要先尝试直接访问,再尝试从 iframe 中获取
+ * @returns {Document|null} - 课表所在的 document,未找到返回 null
+ */
+function getTimetableDocument() {
+    // 策略 1:课表表格直接在当前页面(如脚本运行在 iframe 内部)
+    var table = document.evaluate(
+        "/html/body/div[2]/div[3]/div/div/table",
+        document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
+    ).singleNodeValue;
+    if (table) return document;
+
+    // 策略 2:课表在 iframe 中(e-home-iframe-1 或任意 iframe)
+    var iframes = document.querySelectorAll('iframe');
+    for (var i = 0; i < iframes.length; i++) {
+        try {
+            var iframeDoc = iframes[i].contentDocument || iframes[i].contentWindow.document;
+            if (!iframeDoc) continue;
+            var t = iframeDoc.evaluate(
+                "/html/body/div[2]/div[3]/div/div/table",
+                iframeDoc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
+            ).singleNodeValue;
+            if (t) return iframeDoc;
+        } catch (e) {
+            // 跨域 iframe 无法访问,跳过
+        }
+    }
+
+    // 策略 3:尝试更通用的选择器(适配页面结构变化)
+    // 在当前页面和 iframe 中查找包含课程元素的 table
+    var allDocs = [document];
+    for (var j = 0; j < iframes.length; j++) {
+        try {
+            var doc = iframes[j].contentDocument || iframes[j].contentWindow.document;
+            if (doc) allDocs.push(doc);
+        } catch (e) { /* skip cross-origin */ }
+    }
+    for (var k = 0; k < allDocs.length; k++) {
+        var courseElements = allDocs[k].querySelectorAll('.c');
+        if (courseElements.length > 0) return allDocs[k];
+    }
+
+    return null;
+}
+
+/**
+ * 从指定 document 中解析所有课程数据
+ *
+ * 课程元素(.c class)内部结构:
+ * - .title: 课程名称
+ * - .teacher .name: 教师姓名(可多个)
+ * - .time .week: 上课周次
+ * - .timespan: 上课节次
+ * - .classroom .name: 上课教室
+ *
+ * @param {Document} doc - 包含课表的 document 对象
+ * @returns {Array} - 拾光课表格式的课程数组
+ */
+function parseCoursesFromDoc(doc) {
+    var timeTable = doc.evaluate(
+        "/html/body/div[2]/div[3]/div/div/table",
+        doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
+    ).singleNodeValue;
+    if (!timeTable) return [];
+
+    var rows = timeTable.getElementsByTagName("tr");
+    var allCourses = [];
+
+    for (var dayNumber = 0; dayNumber < 7; dayNumber++) {
+        for (var i = 0; i < rows.length; i++) {
+            // 带有 class 属性的 <tr> 是包含课程数据的行
+            if (!rows[i].hasAttribute("class")) continue;
+            var cells = rows[i].getElementsByTagName("td");
+            var count = 0;
+            var dayIdx = dayNumber;
+            // 找到第 dayNumber 个不带 style 的 <td>(即对应星期几的列)
+            var target = Array.from(cells).find(function(elem) {
+                if (!elem.hasAttribute("style")) return ++count > dayIdx;
+                return false;
+            });
+            if (!target) continue;
+
+            // 提取该单元格中所有课程元素
+            var elements = target.querySelectorAll(".c");
+            elements.forEach(function(el) {
+                var name = el.querySelector(".title")?.innerText?.trim();
+                var teacher = Array.from(el.querySelectorAll(".teacher .name"))
+                    .map(function(e) { return e.innerText.trim(); }).join(", ");
+                var weekStr = el.querySelector(".time .week")?.innerText?.trim();
+                var timeSpanStr = el.querySelector(".timespan")?.innerText?.trim();
+                var location = el.querySelector(".classroom .name")?.innerText?.trim();
+
+                if (!name || !weekStr || !timeSpanStr) return;
+
+                var weeks = parseWeekString(weekStr);
+                var timeSpan = parseTimeSpan(timeSpanStr);
+                if (weeks.length === 0 || timeSpan.length === 0) return;
+
+                allCourses.push({
+                    name: name,
+                    teacher: teacher || "未知教师",
+                    position: location || "",
+                    day: dayNumber + 1,  // 0=周一 → 1
+                    startSection: Math.min.apply(null, timeSpan),
+                    endSection: Math.max.apply(null, timeSpan),
+                    weeks: weeks
+                });
+            });
+        }
+    }
+
+    return mergeCourses(allCourses);
+}
+
+/**
+ * 从页面提取学期名称
+ * 在当前页面和 iframe 中查找学期选择器
+ * @returns {string} - 学期名称
+ */
+function getSemesterName() {
+    // 尝试在当前页面查找
+    var el = document.querySelector(
+        '.selectize-input.items.full.has-options.has-items .item'
+    );
+    if (el) return el.textContent.trim();
+
+    // 尝试在 iframe 中查找
+    var iframes = document.querySelectorAll('iframe');
+    for (var i = 0; i < iframes.length; i++) {
+        try {
+            var iframeDoc = iframes[i].contentDocument || iframes[i].contentWindow.document;
+            if (!iframeDoc) continue;
+            var iframeEl = iframeDoc.querySelector(
+                '.selectize-input.items.full.has-options.has-items .item'
+            );
+            if (iframeEl) return iframeEl.textContent.trim();
+        } catch (e) { /* skip cross-origin */ }
+    }
+
+    // 尝试更通用的选择器
+    var semesterSpan = document.querySelector('.currentSemester');
+    if (semesterSpan) return semesterSpan.textContent.trim().replace(/,$/, '');
+
+    return '未知学期';
+}
+
+// ========== USTC 时间段 ==========
+
+/**
+ * 获取 USTC 课程时间段配置
+ * @returns {Array} - TimeSlotJsonModel 数组
+ */
+function getUSTCTimeSlots() {
+    return [
+        { number: 1, startTime: "07:50", endTime: "08:35" },
+        { number: 2, startTime: "08:40", endTime: "09:25" },
+        { number: 3, startTime: "09:45", endTime: "10:30" },
+        { number: 4, startTime: "10:35", endTime: "11:20" },
+        { number: 5, startTime: "11:25", endTime: "12:10" },
+        { number: 6, startTime: "14:00", endTime: "14:45" },
+        { number: 7, startTime: "14:50", endTime: "15:35" },
+        { number: 8, startTime: "15:55", endTime: "16:40" },
+        { number: 9, startTime: "16:45", endTime: "17:30" },
+        { number: 10, startTime: "17:35", endTime: "18:20" },
+        { number: 11, startTime: "19:30", endTime: "20:15" },
+        { number: 12, startTime: "20:20", endTime: "21:05" },
+        { number: 13, startTime: "21:10", endTime: "21:55" }
+    ];
+}
+
+// ========== 轮询等待 DOM 加载 ==========
+
+/**
+ * 延迟指定毫秒
+ * @param {number} ms - 毫秒数
+ * @returns {Promise}
+ */
+function delay(ms) {
+    return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+/**
+ * 轮询等待课表 DOM 加载完成
+ * 尝试在当前页面和 iframe 中查找课表表格
+ * @param {number} intervalMs - 轮询间隔(毫秒)
+ * @param {number} maxAttempts - 最大尝试次数
+ * @returns {Promise<Document|null>} - 课表所在的 document,超时返回 null
+ */
+async function pollUntilTableReady(intervalMs, maxAttempts) {
+    intervalMs = intervalMs || 1000;
+    maxAttempts = maxAttempts || 30;
+    for (var i = 0; i < maxAttempts; i++) {
+        var doc = getTimetableDocument();
+        if (doc) return doc;
+        if (i < maxAttempts - 1) await delay(intervalMs);
+    }
+    return null;
+}
+
+// ========== 主流程 ==========
+
+/**
+ * 编排整个课程导入流程
+ * 在任何一步用户取消或发生错误时,都会立即退出
+ * AndroidBridge.notifyTaskCompletion() 只在完全成功后调用
+ */
+async function runImportFlow() {
+    // 步骤 1:提示用户
+    const confirmed = await window.AndroidBridgePromise.showAlert(
+        "USTC 课表导入",
+        "请确保您已登录教务系统(jw.ustc.edu.cn)并处于课表查询页面。\n\n" +
+        "操作步骤:\n" +
+        "1. 登录教务系统\n" +
+        "2. 进入「学生课表」页面\n" +
+        "3. 选择需要导入的学期\n" +
+        "4. 等待课表加载完成后点击「开始导入」",
+        "好的,开始导入"
+    );
+    if (!confirmed) return;
+
+    // 步骤 2:检查当前页面是否为教务系统
+    if (!window.location.href.startsWith("https://jw.ustc.edu.cn")) {
+        await window.AndroidBridgePromise.showAlert(
+            "页面错误",
+            "当前页面不是教务系统页面,请先登录并导航到「学生课表」页面。",
+            "确定"
+        );
+        return;
+    }
+
+    // 步骤 3:轮询等待课表 DOM 加载完成(含 iframe 检测)
+    AndroidBridge.showToast("正在等待课表加载...");
+    var timetableDoc = await pollUntilTableReady();
+    if (!timetableDoc) {
+        AndroidBridge.showToast("未找到课表,请确认已进入「学生课表」页面且课表已加载");
+        return;
+    }
+
+    // 步骤 4:解析课程数据(表格已加载,可能为空学期)
+    var courses = parseCoursesFromDoc(timetableDoc);
+    if (courses.length === 0) {
+        AndroidBridge.showToast("该学期暂无课程数据");
+        return;
+    }
+
+    // 步骤 5:提取学期信息(用于 Toast 提示)
+    var semesterName = getSemesterName();
+
+    // 步骤 6:保存时间段
+    try {
+        await window.AndroidBridgePromise.savePresetTimeSlots(
+            JSON.stringify(getUSTCTimeSlots())
+        );
+    } catch (e) {
+        AndroidBridge.showToast("保存时间段失败: " + e.message);
+        return;
+    }
+
+    // 步骤 7:保存课程
+    try {
+        await window.AndroidBridgePromise.saveImportedCourses(
+            JSON.stringify(courses)
+        );
+    } catch (e) {
+        AndroidBridge.showToast("保存课程失败: " + e.message);
+        return;
+    }
+
+    // 步骤 8:完成
+    AndroidBridge.showToast("成功导入 " + courses.length + " 门课程(" + semesterName + ")");
+    AndroidBridge.notifyTaskCompletion();
+}
+
+runImportFlow();