Jelajahi Sumber

feat: 新增四川工商职业技术学院(SCTBC)正方教务 V9 适配 (#827)

Violet 3 minggu lalu
induk
melakukan
94ce8187c2
3 mengubah file dengan 346 tambahan dan 0 penghapusan
  1. 5 0
      index/root_index.yaml
  2. 9 0
      resources/SCTBC/adapters.yaml
  3. 332 0
      resources/SCTBC/sctbc.js

+ 5 - 0
index/root_index.yaml

@@ -1237,3 +1237,8 @@ schools:
     name: "北京师范大学珠海校区"
     initial: "B"
     resource_folder: "BNUZH"    
+
+  - id: "SCTBC"
+    name: "四川工商职业技术学院"
+    initial: "S"
+    resource_folder: "SCTBC"

+ 9 - 0
resources/SCTBC/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/SCTBC/adapters.yaml
+adapters:
+  - adapter_id: "SCTBC_01"
+    adapter_name: "四川工商职业技术学院教务系统"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "sctbc.js"
+    import_url: "http://jw.sctbc.edu.cn/xtgl/login_slogin.html"
+    maintainer: "dclindev"
+    description: "正方教务 V9 新一代(前端 zftal-ui-v5,根路径部署)。可用正方直登(本条目地址)或统一身份认证 cas.sctbc.edu.cn 登录,停留在任意教务页面点击导入即可,自动读取学年学期、课表、开学日期与节次作息。"

+ 332 - 0
resources/SCTBC/sctbc.js

@@ -0,0 +1,332 @@
+// 四川工商职业技术学院(sctbc.edu.cn) 拾光课表适配脚本
+//
+// 教务系统:正方教务 V9(前端 zftal-ui-v5-1.0.2),http://jw.sctbc.edu.cn 根路径部署
+// 登录方式:正方直登 /xtgl/login_slogin.html,或统一身份认证 cas.sctbc.edu.cn
+//
+// 全部数据通过接口获取,登录后停在任意教务页面即可导入,无需进入课表查询页。
+// 以下接口均为本校实测通过:
+//   GET  kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151   学年学期选项
+//   POST kbcx/xskbcx_cxXsKb.html?gnmkdm=N2151          课程数据(kbList)
+//   POST kbcx/xskbcxZccx_cxZcByXnxq.html?gnmkdm=N2154  校历(开学日期 / 总周数)
+//   POST kbcx/xskbcx_cxRjc.html?gnmkdm=N2151           节次作息
+//
+// 字段映射:kcmc=课程名 xm=教师 cdmc=地点 xqj/xqjmc=星期 jcs=节次 zcd=周次 xf=学分
+
+const BASE = "http://jw.sctbc.edu.cn";
+const GNMKDM_KB = "N2151"; // 课表相关模块号
+const GNMKDM_XL = "N2154"; // 校历模块号
+
+/** 接口地址 */
+function apiUrl(path, gnmkdm) {
+    return `${BASE}/${path}?gnmkdm=${gnmkdm}`;
+}
+
+/**
+ * 带登录态发起 POST 请求并解析 JSON。
+ * 登录态由 App 内置浏览器复用,脚本不处理登录。
+ */
+async function postJson(path, gnmkdm, params) {
+    const response = await fetch(apiUrl(path, gnmkdm), {
+        method: "POST",
+        credentials: "include",
+        referrer: apiUrl("kbcx/xskbcx_cxXskbcxIndex.html", gnmkdm),
+        headers: {
+            "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
+            "X-Requested-With": "XMLHttpRequest",
+            Accept: "application/json, text/javascript, */*; q=0.01"
+        },
+        body: new URLSearchParams(params).toString()
+    });
+    if (!response.ok) {
+        throw new Error(`教务接口请求失败(HTTP ${response.status})`);
+    }
+    const text = await response.text();
+    try {
+        return JSON.parse(text);
+    } catch (error) {
+        throw new Error("登录状态可能已失效,请重新登录教务系统后再试");
+    }
+}
+
+// ==================== 数据解析 ====================
+
+/** 全角数字转半角(正方个别页面会返回全角字符) */
+function halfWidth(text) {
+    return String(text || "").replace(/[\uff10-\uff19]/g, (c) =>
+        String.fromCharCode(c.charCodeAt(0) - 65248));
+}
+
+/** 周次解析:"1-6周,9-18周" / "1-16周(单)" / "1,3,5" → [1,2,3...] */
+function toWeeks(text) {
+    const weeks = new Set();
+    for (const seg of halfWidth(text).replace(/第|周/g, "").split(/[,,、]/)) {
+        const range = seg.match(/(\d+)\s*-\s*(\d+)/);
+        const single = seg.match(/\d+/);
+        if (!range && !single) continue;
+
+        const start = Number(range ? range[1] : single[0]);
+        const end = Number(range ? range[2] : single[0]);
+        const odd = seg.includes("单");
+        const even = seg.includes("双");
+        for (let week = start; week <= end; week++) {
+            if (odd && week % 2 === 0) continue;
+            if (even && week % 2 !== 0) continue;
+            weeks.add(week);
+        }
+    }
+    return [...weeks].sort((a, b) => a - b);
+}
+
+/** 节次解析:"1-2" / "3" / "第1-2节" → { start, end };无法识别返回 null */
+function toSections(text) {
+    const numbers = (halfWidth(text).match(/\d+/g) || []).map(Number).filter((n) => n > 0);
+    if (!numbers.length) return null;
+    return { start: Math.min(...numbers), end: Math.max(...numbers) };
+}
+
+/** 星期解析:优先数字 xqj,退回中文 xqjmc(两者都查,避免只认一个来源导致丢课) */
+function toDay(xqj, xqjmc) {
+    const day = Number(xqj);
+    if (day >= 1 && day <= 7) return day;
+
+    const names = ["一", "二", "三", "四", "五", "六", "日", "天"];
+    const text = `${xqjmc || ""}${xqj || ""}`;
+    const index = names.findIndex((name) => text.includes(name));
+    return index < 0 ? 0 : (index === 7 ? 7 : index + 1);
+}
+
+/** 课表 JSON → 课程数组。集中实践类课程没有星期/节次,无法落到格子里,直接跳过 */
+function toCourses(kbList) {
+    if (!Array.isArray(kbList)) return [];
+
+    const courses = [];
+    for (const item of kbList || []) {
+        if (!item || typeof item !== "object") continue;
+
+        const sections = toSections(item.jcs);
+        const weeks = toWeeks(item.zcd);
+        const day = toDay(item.xqj, item.xqjmc);
+        const name = String(item.kcmc || "").trim();
+        if (!name || !sections || !weeks.length || !day) continue;
+
+        const course = {
+            name: name,
+            teacher: String(item.xm || "").trim(),
+            position: String(item.cdmc || "").trim(),
+            day: day,
+            startSection: sections.start,
+            endSection: sections.end,
+            weeks: weeks
+        };
+        const credit = Number(item.xf);
+        if (credit > 0) course.credit = credit;
+        courses.push(course);
+    }
+    return mergeAndDistinctCourses(courses);
+}
+
+/**
+ * 节次与周次合并去重(取自官方 wiki《课程合并与去重函数》)
+ * 连续节次 1-2 + 3-4 → 1-4;同节次单周 + 双周 → 全周;完全重复的记录去重。
+ */
+function mergeAndDistinctCourses(courses) {
+    if (!Array.isArray(courses) || courses.length <= 1) return courses || [];
+
+    const list = courses.map((c) => ({
+        ...c,
+        name: c.name || "",
+        teacher: c.teacher || "",
+        position: c.position || "",
+        weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : []
+    }));
+
+    // 阶段 1:合并连续节次与完全重复记录
+    list.sort((a, b) =>
+        a.name.localeCompare(b.name) ||
+        a.teacher.localeCompare(b.teacher) ||
+        a.position.localeCompare(b.position) ||
+        (a.day || 0) - (b.day || 0) ||
+        a.weeks.join(",").localeCompare(b.weeks.join(",")) ||
+        (a.startSection || 0) - (b.startSection || 0));
+
+    const step1 = [];
+    let current = list[0];
+    for (let i = 1; i < list.length; i++) {
+        const next = list[i];
+        const sameWeeks = current.name === next.name &&
+            current.teacher === next.teacher &&
+            current.position === next.position &&
+            current.day === next.day &&
+            current.weeks.join(",") === next.weeks.join(",");
+
+        if (sameWeeks && current.endSection + 1 === next.startSection) {
+            current.endSection = next.endSection; // 连续节次,延长
+        } else if (sameWeeks && current.startSection === next.startSection &&
+            current.endSection === next.endSection) {
+            continue; // 完全重复,丢弃
+        } else {
+            step1.push(current);
+            current = next;
+        }
+    }
+    step1.push(current);
+
+    // 阶段 2:合并同一节次上的不同周次(如单周 + 双周 → 全周)
+    step1.sort((a, b) =>
+        a.name.localeCompare(b.name) ||
+        a.teacher.localeCompare(b.teacher) ||
+        a.position.localeCompare(b.position) ||
+        (a.day || 0) - (b.day || 0) ||
+        (a.startSection || 0) - (b.startSection || 0) ||
+        (a.endSection || 0) - (b.endSection || 0));
+
+    const merged = [];
+    let kept = step1[0];
+    for (let i = 1; i < step1.length; i++) {
+        const next = step1[i];
+        const sameSection = kept.name === next.name &&
+            kept.teacher === next.teacher &&
+            kept.position === next.position &&
+            kept.day === next.day &&
+            kept.startSection === next.startSection &&
+            kept.endSection === next.endSection;
+
+        if (sameSection) {
+            kept.weeks = [...new Set([...kept.weeks, ...next.weeks])].sort((a, b) => a - b);
+        } else {
+            merged.push(kept);
+            kept = next;
+        }
+    }
+    merged.push(kept);
+    return merged;
+}
+
+// ==================== 学年学期 / 作息 / 校历 ====================
+
+/** 读取教务系统的学年学期选项(学年学期由教务提供,必须问系统要) */
+async function fetchSemesterOptions() {
+    const response = await fetch(apiUrl("kbcx/xskbcx_cxXskbcxIndex.html", GNMKDM_KB), {
+        credentials: "include"
+    });
+    if (!response.ok) return null;
+
+    const dom = new DOMParser().parseFromString(await response.text(), "text/html");
+    const readSelect = (id) => {
+        const select = dom.getElementById(id);
+        const options = [];
+        for (const option of (select ? select.options : [])) {
+            if (option.value) {
+                options.push({ value: option.value, text: option.text.trim(), selected: option.selected });
+            }
+        }
+        return options;
+    };
+
+    const years = readSelect("xnm");
+    const terms = readSelect("xqm");
+    return years.length && terms.length ? { years: years, terms: terms } : null;
+}
+
+/** 默认选中项:教务当前选中的那个,没有就用第一项 */
+function selectedIndexOf(options) {
+    const index = options.findIndex((option) => option.selected);
+    return index < 0 ? 0 : index;
+}
+
+/** 节次作息;接口不可用时返回空数组,不影响课程导入 */
+async function fetchTimeSlots(xnm, xqm) {
+    const data = await postJson("kbcx/xskbcx_cxRjc.html", GNMKDM_KB, {
+        xnm: xnm, xqm: xqm, kzlx: "ck", xsdm: "", kclbdm: "", kclxdm: ""
+    });
+    const rows = Array.isArray(data) ? data : (data && data.data) || [];
+
+    const slots = [];
+    for (const item of rows) {
+        const number = Number(item.jcdm || item.jcmc);
+        const startTime = String(item.qssj || "").slice(0, 5);
+        const endTime = String(item.jssj || "").slice(0, 5);
+        if (number > 0 && startTime && endTime) {
+            slots.push({ number: number, startTime: startTime, endTime: endTime });
+        }
+    }
+    return slots.sort((a, b) => a.number - b.number);
+}
+
+/** 校历:开学日期与学期总周数;取不到就返回空对象 */
+async function fetchConfig(xnm, xqm) {
+    const rows = await postJson("kbcx/xskbcxZccx_cxZcByXnxq.html", GNMKDM_XL, { xnm: xnm, xqm: xqm });
+    if (!Array.isArray(rows)) return {};
+
+    const config = {};
+    let maxWeek = 0;
+    let firstWeek = null;
+    for (const row of rows) {
+        const week = Number(String(row.zs || row.zsmc || "").replace(/\D/g, ""));
+        if (!(week >= 1)) continue;
+        if (week > maxWeek) maxWeek = week;
+        if (week === 1) firstWeek = row;
+    }
+    // 仅接受 10~30 周,避免把整年校历当成一个学期
+    if (maxWeek >= 10 && maxWeek <= 30) config.semesterTotalWeeks = maxWeek;
+
+    const row = firstWeek || {};
+    const date = String(row.rq || row.zcrq || row.ksrq || "").match(/\d{4}-\d{1,2}-\d{1,2}/);
+    if (date) config.semesterStartDate = date[0];
+    return config;
+}
+
+// ==================== 主流程 ====================
+
+async function runImportFlow() {
+    const confirmed = await window.shiguangBridgePromise.showAlert(
+        "四川工商职业技术学院课表导入",
+        "请确认已登录教务系统。登录后停在任意教务页面即可开始导入,脚本会自动读取学年学期、课表、开学日期与节次作息。",
+        "开始导入"
+    );
+    if (!confirmed) return;
+
+    const options = await fetchSemesterOptions();
+    if (!options) throw new Error("未能读取学年学期,请确认已登录教务系统");
+
+    const yearIndex = await window.shiguangBridgePromise.showSingleSelection(
+        "选择学年", JSON.stringify(options.years.map((o) => o.text)), selectedIndexOf(options.years));
+    if (yearIndex === null || yearIndex < 0) return;
+
+    const termIndex = await window.shiguangBridgePromise.showSingleSelection(
+        "选择学期", JSON.stringify(options.terms.map((o) => o.text)), selectedIndexOf(options.terms));
+    if (termIndex === null || termIndex < 0) return;
+
+    const xnm = options.years[yearIndex].value;
+    const xqm = options.terms[termIndex].value;
+
+    window.shiguangBridge.showToast("正在获取课表数据...");
+    const data = await postJson("kbcx/xskbcx_cxXsKb.html", GNMKDM_KB, { xnm: xnm, xqm: xqm, kzlx: "ck" });
+    const courses = toCourses(data.kbList);
+    if (!courses.length) throw new Error("所选学期没有解析到课程,请确认学期是否正确");
+
+    await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+
+    // 校历与作息属于附加信息,失败不影响课程导入
+    const config = await fetchConfig(xnm, xqm).catch(() => ({}));
+    const maxWeek = Math.max(...courses.map((course) => Math.max(...course.weeks)));
+    if (config.semesterTotalWeeks && maxWeek > config.semesterTotalWeeks) {
+        config.semesterTotalWeeks = maxWeek; // 总周数不得小于课程实际周次,否则课程会被截断
+    }
+    if (Object.keys(config).length) {
+        await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config)).catch(() => {});
+    }
+
+    const slots = await fetchTimeSlots(xnm, xqm).catch(() => []);
+    if (slots.length) {
+        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(slots)).catch(() => {});
+    }
+
+    window.shiguangBridge.showToast(`导入成功,共 ${courses.length} 条课程安排`);
+    window.shiguangBridge.notifyTaskCompletion();
+}
+
+runImportFlow().catch((error) => {
+    console.error("SCTBC 导入失败", error);
+    window.shiguangBridgePromise.showAlert("导入失败", error.message, "确定");
+});