Explorar el Código

feat: 新增南华大学(USC)教务适配 (#828)

UVhyx hace 3 semanas
padre
commit
1052e6248f
Se han modificado 3 ficheros con 224 adiciones y 0 borrados
  1. 5 0
      index/root_index.yaml
  2. 9 0
      resources/USC/adapters.yaml
  3. 210 0
      resources/USC/usc_01.js

+ 5 - 0
index/root_index.yaml

@@ -263,6 +263,11 @@ schools:
     initial: "N"
     resource_folder: "NUAA"
 
+  - id: "USC"
+    name: "南华大学"
+    initial: "N"
+    resource_folder: "USC"
+
   - id: "XMCU"
     name: "厦门城市职业学院"
     initial: "X"

+ 9 - 0
resources/USC/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/USC/adapters.yaml
+adapters:
+  - adapter_id: "USC_01"
+    adapter_name: "南华大学强智教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "usc_01.js"
+    import_url: "http://jwzx.usc.edu.cn:8924"
+    maintainer: "UVhyx"
+    description: "南华大学教务在线(湖南强智科技 jsxsd)适配,支持获取开学日期"

+ 210 - 0
resources/USC/usc_01.js

@@ -0,0 +1,210 @@
+// 南华大学(http://jwzx.usc.edu.cn:8924)拾光课程表适配脚本
+
+
+const BASE_URL = "http://jwzx.usc.edu.cn:8924";
+const [URL_COURSE, URL_CALENDAR] = [`${BASE_URL}/jsxsd/xskb/xskb_list.do`, `${BASE_URL}/jsxsd/jxzl/jxzl_query`];
+
+// 工具函数
+const parseSlots = arr => arr.map((s, i) => ({ number: i + 1, startTime: s.slice(0, 5), endTime: s.slice(6) }));
+const TIME_SLOTS_SUMMER = parseSlots(["08:00-08:45","08:55-09:40","10:00-10:45","10:55-11:40","15:00-15:45","15:55-16:40","17:00-17:45","17:55-18:40","20:00-20:45","20:55-21:40"]);
+const TIME_SLOTS_WINTER = parseSlots(["08:20-09:05","09:15-10:00","10:20-11:05","11:15-12:00","14:30-15:15","15:25-16:10","16:30-17:15","17:25-18:10","19:30-20:15","20:25-21:10"]);
+
+const pad2 = n => String(n).padStart(2, "0");
+const WEEK_MAP = { 一:1, 二:2, 三:3, 四:4, 五:5, 六:6, 日:7, 天:7 };
+
+const parseWeeks = str => [...new Set((str || "").split("(")[0].split(",").flatMap(s => {
+    const [st, ed] = s.split("-").map(Number);
+    return (st && ed) ? Array.from({length: ed - st + 1}, (_, i) => st + i) : [st].filter(n => !isNaN(n));
+}))].sort((a, b) => a - b);
+
+const parseSectionRange = text => {
+    const [_, s, e] = String(text || "").match(/(\d+)\s*(?:-\s*(\d+))?/) || [];
+    return s ? { start: +s, end: +(e || s) } : null;
+};
+
+function parseDayColumns(table) {
+    for (const row of table.rows) {
+        const days = [...row.children].filter(el => /^(TH|TD)$/.test(el.tagName))
+            .map(el => WEEK_MAP[el.textContent.trim().match(/^星期([一二三四五六日天])$/)?.[1]] || null);
+        if (days.filter(Boolean).length >= 5) return days.filter(d => d !== null);
+    }
+    return null;
+}
+
+function parseCellCourses(cell, selector, day, rowSec) {
+    const block = cell.querySelector(selector);
+    if (!block?.innerHTML.trim() || block.innerHTML.trim() === " ") return [];
+
+    return block.innerHTML.split(/---------------------?/).flatMap(part => {
+        if (!part.trim()) return [];
+        const temp = document.createElement("div");
+        temp.innerHTML = part;
+
+        let name = "";
+        for (const node of temp.childNodes) {
+            if (node.tagName === "FONT") break;
+            if (node.nodeType === 3) name += node.textContent.replace(/\s+/g, " ");
+        }
+        if (!(name = name.trim())) return [];
+
+        const textOf = sel => (temp.querySelector(sel)?.textContent || "").trim();
+        const teacher = textOf("font[title='老师'], font[title='教师']").replace("任课教师:", "") || "未知教师";
+        const position = textOf("font[title='教室']") || "未知地点";
+        const weekStr = textOf("font[title='周次(节次)']");
+
+        const nums = (weekStr.match(/\[([\d\-,\s]+?)节\]/)?.[1] || "").split(/[^\d]+/).map(Number).filter(n => n > 0);
+        const startSection = nums.length ? Math.min(...nums) : (rowSec?.start || 0);
+        const endSection = nums.length ? Math.max(...nums) : (rowSec?.end || 0);
+        const weeks = parseWeeks(weekStr);
+
+        return (weeks.length && startSection) ? [{ name, teacher, position, day, startSection, endSection: Math.max(startSection, endSection), weeks }] : [];
+    });
+}
+
+// 合并函数
+function mergeAndDistinctCourses(courses) {
+    if (!courses?.length) return [];
+    const key1 = c => [c.name, c.teacher, c.position, c.day, c.weeks.join()].join("|");
+    const key2 = c => [c.name, c.teacher, c.position, c.day, c.startSection, c.endSection].join("|");
+
+    return courses.map(c => ({...c, weeks: [...c.weeks].sort((a, b) => a - b)}))
+        .sort((a, b) => key1(a).localeCompare(key1(b)) || a.startSection - b.startSection)
+        .reduce((acc, n) => { // 阶段1:合并连续/重复节次
+            const cur = acc[acc.length - 1];
+            if (cur && key1(cur) === key1(n)) {
+                if (cur.endSection + 1 === n.startSection) cur.endSection = n.endSection;
+                else if (cur.startSection !== n.startSection || cur.endSection !== n.endSection) acc.push(n);
+            } else acc.push(n);
+            return acc;
+        }, [])
+        .sort((a, b) => key2(a).localeCompare(key2(b)))
+        .reduce((acc, n) => { // 阶段2:合并相同节次的周次
+            const cur = acc[acc.length - 1];
+            if (cur && key2(cur) === key2(n)) cur.weeks = [...new Set([...cur.weeks, ...n.weeks])].sort((a,b) => a - b);
+            else acc.push(n);
+            return acc;
+        }, []);
+}
+
+const parseTimetableToModel = doc => {
+    const table = doc.getElementById("kbtable");
+    if (!table) return [];
+    const dayMap = parseDayColumns(table) || [1, 2, 3, 4, 5, 6, 7];
+    const selector = table.querySelector("div.kbcontent") ? "div.kbcontent" : "div.kbcontent1";
+
+    return mergeAndDistinctCourses([...table.rows].flatMap(row => {
+        const cells = [...row.querySelectorAll("td")];
+        const dayCells = cells.length === dayMap.length + 1 ? cells.slice(1) : cells;
+        if (dayCells.length !== dayMap.length) return [];
+        const rowSec = parseSectionRange(row.querySelector("th")?.textContent);
+        return dayCells.flatMap((cell, i) => dayMap[i] ? parseCellCourses(cell, selector, dayMap[i], rowSec) : []);
+    }));
+};
+
+// 网络请求与解析
+const parseHtml = html => new DOMParser().parseFromString(html, "text/html");
+const req = async (url, opts) => {
+    const res = await fetch(url, { credentials: "include", ...opts });
+    if (!res.ok) throw new Error(`HTTP ${res.status}`);
+    return res.text();
+};
+const formOpts = body => ({ method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body });
+
+const requestCoursePage = semId => req(URL_COURSE, formOpts(`jx0404id=&cj0701id=&zc=&demo=&xnxq01id=${semId||""}`));
+const requestCoursePageByGet = semId => req(`${URL_COURSE}?xnxq01id=${encodeURIComponent(semId||"")}&zc=&cj0701id=&demo=`, { method: "GET" });
+const requestCalendarPage = semId => req(URL_CALENDAR, formOpts(`xnxq01id=${semId}`));
+
+function parseSemesterOptions(html) {
+    const select = parseHtml(html).getElementById("xnxq01id");
+    if (!select) return { list: [], defaultIndex: -1 };
+    const all = [...select.options].map(o => ({ value: o.value, label: o.textContent.trim(), selected: o.selected })).filter(o => o.value);
+    const idx = Math.max(0, all.findIndex(o => o.selected));
+    const start = Math.max(0, idx - 3);
+    return { list: all.slice(start, idx + 4), defaultIndex: idx - start };
+}
+
+async function fetchCourses(semId) {
+    let html = await requestCoursePage(semId).catch(() => null);
+    let courses = html ? parseTimetableToModel(parseHtml(html)) : [];
+    if (!courses.length) {
+        html = await requestCoursePageByGet(semId).catch(() => null);
+        if (html) courses = parseTimetableToModel(parseHtml(html));
+    }
+    return courses;
+}
+
+async function fetchSemesterInfo(semId) {
+    try {
+        const doc = parseHtml(await requestCalendarPage(semId));
+        const dates = [...doc.querySelectorAll("td[title]")].map(td => {
+            const m = td.getAttribute("title")?.match(/(\d{4})年(\d{1,2})月(\d{1,2})日?/);
+            return m ? `${m[1]}-${pad2(m[2])}-${pad2(m[3])}` : null;
+        }).filter(Boolean).sort();
+        const totalWeeks = Math.max(0, ...[...doc.querySelectorAll("#kbtable tr td:first-child")].map(td => parseInt(td.textContent, 10)).filter(n => !isNaN(n)));
+        return { startDate: dates[0] || null, totalWeeks: totalWeeks || null };
+    } catch { return { startDate: null, totalWeeks: null }; }
+}
+
+// 全局校验
+window.validateStartDateInput = input => !input?.trim() || /^\d{4}-\d{1,2}-\d{1,2}$/.test(input.trim()) ? false : "请输入形如 2026-09-01 的日期,或留空跳过";
+
+async function promptSemesterStartDate() {
+    const input = await window.shiguangBridgePromise.showPrompt("设置开学日期", "未能自动获取开学日期...\n请输入本学期第一周的星期一日期(如 2026-09-01),留空则跳过:", "", "validateStartDateInput");
+    const m = input?.trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
+    return m ? `${m[1]}-${pad2(m[2])}-${pad2(m[3])}` : null;
+}
+
+const computeTotalWeeks = courses => {
+    const max = Math.max(0, ...courses.flatMap(c => c.weeks || []));
+    return (max > 0 && max <= 40) ? max : 20;
+};
+
+function buildComboSchedule(semId, label) {
+    const [_, y0, y1, term] = String(semId || "").match(/^(\d{4})-(\d{4})-(\d)$/) || [];
+    if (!term) return null;
+    const Y = term === "2" ? +y1 : +y0;
+    const winter = { name: "秋冬季作息", startDate: `${term === "2" ? Y - 1 : Y}-10-01`, endDate: `${term === "2" ? Y : Y + 1}-04-30`, timeSlots: [] };
+    return {
+        name: `南华大学 ${label || semId} 作息方案`,
+        publicSchedules: [ { name: "夏秋季作息", startDate: `${Y}-05-01`, endDate: `${Y}-09-30`, timeSlots: TIME_SLOTS_SUMMER }, winter ].sort((a,b) => a.startDate.localeCompare(b.startDate))
+    };
+}
+
+const saveAppConfig = (startDate, weeks) => window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({ semesterTotalWeeks: weeks || 20, firstDayOfWeek: 7, defaultClassDuration: 45, ...(startDate && { semesterStartDate: startDate }) }));
+const saveAppTimeSlots = () => window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(TIME_SLOTS_WINTER));
+
+// 主流程
+(async function runImportFlow() {
+    const bridge = window.shiguangBridge, pBridge = window.shiguangBridgePromise;
+    try {
+        if (!await pBridge.showAlert("提示", "请确保已登录教务在线,并已打开「学期理论课表」页面。\n是否开始导入?", "开始")) return;
+
+        bridge.showToast("正在获取学期列表...");
+        const { list, defaultIndex } = parseSemesterOptions(await requestCoursePage(""));
+        if (!list.length) return bridge.showToast("未获取到学期列表,请检查登录状态。");
+
+        const selIdx = await pBridge.showSingleSelection("选择学期", JSON.stringify(list.map(s => s.label)), defaultIndex);
+        if (selIdx === null) return bridge.showToast("已取消导入。");
+
+        bridge.showToast("正在请求课程数据...");
+        const sem = list[selIdx], courses = await fetchCourses(sem.value);
+        if (!courses.length) return bridge.showToast("未发现课程,请检查学期选择或登录状态。");
+
+        bridge.showToast("正在获取学期信息...");
+        const { startDate, totalWeeks } = await fetchSemesterInfo(sem.value);
+        const finalStart = startDate || await promptSemesterStartDate();
+
+        await saveAppConfig(finalStart, totalWeeks || computeTotalWeeks(courses));
+        await pBridge.saveImportedCourses(JSON.stringify(courses));
+
+        try {
+            if (await saveAppTimeSlots()) {
+                const combo = buildComboSchedule(sem.value, sem.label);
+                if (combo) await pBridge.saveComboSchedule(JSON.stringify(combo));
+            }
+        } catch { bridge.showToast("时间段或组合作息导入异常,已跳过。"); }
+
+        bridge.showToast(`成功导入 ${courses.length} 条课程`);
+        bridge.notifyTaskCompletion();
+    } catch (e) { bridge.showToast("异常: " + e.message); }
+})();