Parcourir la source

Merge pull request #545 from HaoZai000/add-fafu

星河欲转 il y a 2 semaines
Parent
commit
92cd793786
3 fichiers modifiés avec 262 ajouts et 0 suppressions
  1. 5 0
      index/root_index.yaml
  2. 247 0
      resources/FAFU/FAFU.js
  3. 10 0
      resources/FAFU/adapters.yaml

+ 5 - 0
index/root_index.yaml

@@ -153,6 +153,11 @@ schools:
     initial: "F"
     resource_folder: "FJCPC"
 
+  - id: "FAFU"
+    name: "福建农林大学"
+    initial: "F"
+    resource_folder: "FAFU"
+
   - id: "FOSU"
     name: "佛山大学"
     initial: "F"

+ 247 - 0
resources/FAFU/FAFU.js

@@ -0,0 +1,247 @@
+// 文件: FAFU.js (v3)
+// 福建农林大学教务系统课程表导入脚本
+// 适配对象: 经典正方教务系统 (jwgl.fafu.edu.cn, default2.aspx)
+// 说明: 解析"时间网格"式课表 —— 行为节次、列为星期,
+//      课程单元格为分行文本(课程名 / 周X第Y节{第X-Y周} / 教师 / 地点)。
+//      v3: 支持课表位于同源 iframe 内;用 innerHTML 按 <br> 切行;失败时输出诊断。
+
+// ---------------- 通用工具 ----------------
+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 按 <br> 切行(不依赖 innerText,隐藏表格也能解析)
+function cellLines(td) {
+    const html = td.innerHTML || '';
+    return html.split(/<br\s*\/?>/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();

+ 10 - 0
resources/FAFU/adapters.yaml

@@ -0,0 +1,10 @@
+# resources/FAFU/adapters.yaml
+# 福建农林大学教务系统适配器配置
+adapters:
+  - adapter_id: "FAFU_01"
+    adapter_name: "福建农林大学教务系统"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "FAFU.js"
+    import_url: "http://jwgl.fafu.edu.cn/"
+    maintainer: "Haooz"
+    description: "基于正方教务 HTML 课表网格解析,登录后在个人课表查询页面点击执行导入"