| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- // 中国科学技术大学(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();
|