// 文件: FAFU.js (v3)
// 福建农林大学教务系统课程表导入脚本
// 适配对象: 经典正方教务系统 (jwgl.fafu.edu.cn, default2.aspx)
// 说明: 解析"时间网格"式课表 —— 行为节次、列为星期,
// 课程单元格为分行文本(课程名 / 周X第Y节{第X-Y周} / 教师 / 地点)。
// v3: 支持课表位于同源 iframe 内;用 innerHTML 按
切行;失败时输出诊断。
// ---------------- 通用工具 ----------------
function cleanText(s) {
return (s || '')
.replace(/\u00a0/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// 展开周次数组 "1-16" 及 单/双周 过滤
function expandWeeks(start, end, parity) {
const weeks = [];
for (let i = start; i <= end; i++) {
if (parity === 1 && i % 2 !== 1) continue;
if (parity === 2 && i % 2 !== 0) continue;
weeks.push(i);
}
return weeks;
}
// 解析课程信息行:"周一第1,2节{第1-5周}" / "{第1-17周|2节/周}" / "{第16-16周|双周}"
function parseInfoLine(info) {
let day = 0;
const dayM = /星期?([一二三四五六日])/.exec(info || '');
if (dayM) day = '一二三四五六日'.indexOf(dayM[1]) + 1;
let sections = [];
const secM = /第(\d+(?:,\d+)*)节/.exec(info || '');
if (secM) sections = secM[1].split(',').map(Number);
let weekStart = 0, weekEnd = 0;
const wM = /第(\d+)(?:-(\d+))?周/.exec(info || '');
if (wM) {
weekStart = parseInt(wM[1]);
weekEnd = wM[2] ? parseInt(wM[2]) : weekStart;
}
let parity = 0;
if (/单周/.test(info || '')) parity = 1;
else if (/双周/.test(info || '')) parity = 2;
return { day, sections, weekStart, weekEnd, parity };
}
// ---------------- 若课表在 iframe 中,需递归收集同源文档 ----------------
function getAllDocs() {
const docs = [document];
const seen = new Set([document]);
(function walk(doc) {
try {
const frames = doc.querySelectorAll('iframe, frame');
frames.forEach(f => {
const inner = f.contentDocument;
if (inner && !seen.has(inner)) {
seen.add(inner);
docs.push(inner);
walk(inner);
}
});
} catch (e) { /* 跨域 iframe 无法访问,忽略 */ }
})(document);
return docs;
}
// 在所有文档中定位课表网格
function locateGridTable() {
for (const doc of getAllDocs()) {
const tables = doc.querySelectorAll('table');
for (const t of tables) {
const txt = cleanText(t.textContent || '');
if (txt.includes('上午') && txt.includes('第1节') &&
/星期[一二三四五六日]/.test(txt)) {
return t;
}
}
}
return null;
}
function countTables() {
return getAllDocs().reduce((n, d) => n + d.querySelectorAll('table').length, 0);
}
// 星期列的水平范围 {星期号: {left, right}}
function buildDayColumns(table) {
const headerTr = Array.from(table.querySelectorAll('tr'))
.find(tr => /星期[一二三四五六日]/.test(tr.textContent || ''));
if (!headerTr) return {};
const cols = {};
Array.from(headerTr.querySelectorAll('td, th')).forEach(td => {
const m = /星期([一二三四五六日])/.exec(td.textContent || '');
if (!m) return;
const dayNum = '一二三四五六日'.indexOf(m[1]) + 1;
const r = td.getBoundingClientRect();
if (r.width > 0) cols[dayNum] = { left: r.left, right: r.right };
});
return cols;
}
// 因单元格有 rowspan/colspan,用几何位置判断其属于哪个星期列
function dayFromRect(td, dayCols) {
const r = td.getBoundingClientRect();
const cx = (r.left + r.right) / 2;
for (const dayNum in dayCols) {
const c = dayCols[dayNum];
if (cx >= c.left - 2 && cx <= c.right + 2) return parseInt(dayNum);
}
return 0;
}
// 用单元格 HTML 按
切行(不依赖 innerText,隐藏表格也能解析)
function cellLines(td) {
const html = td.innerHTML || '';
return html.split(/
/i)
.map(s => cleanText(s.replace(/<[^>]*>/g, '')));
}
// 把行按空行切成课程块,每块 4 行:课程名/节次周次/教师/地点
function splitBlocks(lines) {
const blocks = [];
let cur = [];
for (const ln of lines) {
if (ln === '') {
if (cur.length) { blocks.push(cur); cur = []; }
} else {
cur.push(ln);
}
}
if (cur.length) blocks.push(cur);
return blocks;
}
// 解析网格课表
function parseGridTable(table) {
const dayCols = buildDayColumns(table);
const courses = [];
const rows = Array.from(table.querySelectorAll('tr'));
rows.forEach(tr => {
// 该行首个"第X节"标签(用于无节次信息的课程,如体育)
let rowSectionStart = 0;
Array.from(tr.querySelectorAll('td')).forEach(td => {
if (rowSectionStart) return;
const m = /^第(\d+)节$/.exec(cleanText(td.innerText || td.textContent || ''));
if (m) rowSectionStart = parseInt(m[1]);
});
Array.from(tr.querySelectorAll('td')).forEach(td => {
const lines = cellLines(td).map(l => l.trim());
if (!lines.some(l => l)) return; // 全空则跳过
const blocks = splitBlocks(lines);
blocks.forEach(block => {
// 课程块需至少 4 行且末行为地点(可过滤"上午/早晨/第X节"等标签)
if (block.length < 4 || !block[3]) return;
const name = block[0] || '';
const info = block[1] || '';
const teacher = block[2] || '';
const location = block[3] || '';
const parsed = parseInfoLine(info);
const day = parsed.day || dayFromRect(td, dayCols);
let sections = parsed.sections;
if (!sections.length && rowSectionStart) {
const span = td.rowSpan || 1;
sections = Array.from({ length: span }, (_, i) => rowSectionStart + i);
}
const weeks = expandWeeks(parsed.weekStart, parsed.weekEnd, parsed.parity);
if (!day || !sections.length || !weeks.length || !name) return;
courses.push({
name: name,
day: day,
weeks: weeks,
teacher: teacher,
position: location,
startSection: sections[0],
endSection: sections[sections.length - 1]
});
});
});
});
return courses;
}
// ---------------- 主流程 ----------------
async function scrapeAndParseCourses() {
window.shiguangBridge.showToast("正在解析课表数据...");
const table = locateGridTable();
if (!table) {
const ts = countTables();
await window.shiguangBridgePromise.showAlert(
"导入失败",
"未定位到课表网格。\n当前共检测到 " + ts + " 个表格。\n请确认:\n1.已登录教务系统\n2.已在【信息查询-学生个人课表】点击【查询】并显示课程\n3.若课表在 iframe/新窗口,脚本已自动尝试进入同源 iframe" +
(ts === 0 ? "\n\n提示:检测到 0 个表格,可能脚本运行于错误页面/跨域 iframe,请返回课表页面后重试。" : ""),
"确定"
);
return null;
}
const result = parseGridTable(table);
if (result.length === 0) {
await window.shiguangBridgePromise.showAlert(
"导入失败",
"已定位到课表,但未解析出任何课程。\n请点击【查询】让课表显示课程数据后重试。",
"确定"
);
return null;
}
return { courses: result };
}
async function saveCourses(courses) {
window.shiguangBridge.showToast(`正在保存 ${courses.length} 门课程...`);
try {
await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
return true;
} catch (error) {
window.shiguangBridge.showToast("课程保存失败: " + error.message);
return false;
}
}
async function runImportFlow() {
const confirmed = await window.shiguangBridgePromise.showAlert(
"福建农林大学课表导入",
"导入前请确保已在【学生个人课表】页面点击了【查询】并显示了课程。\n确认后脚本将自动解析当前课表。",
"好的,开始导入"
);
if (!confirmed) {
window.shiguangBridge.showToast("用户取消了导入。");
return;
}
const result = await scrapeAndParseCourses();
if (!result) return;
const ok = await saveCourses(result.courses);
if (!ok) return;
window.shiguangBridge.showToast(`课程导入成功,共 ${result.courses.length} 门!`);
window.shiguangBridge.notifyTaskCompletion();
}
runImportFlow();