// 中国科学技术大学(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 属性的
是包含课程数据的行
if (!rows[i].hasAttribute("class")) continue;
var cells = rows[i].getElementsByTagName("td");
var count = 0;
var dayIdx = dayNumber;
// 找到第 dayNumber 个不带 style 的 | (即对应星期几的列)
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
*/
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();
|