Explorar o código

add: 肇庆学院(ZQU)教务系统适配(WebVPN版) (#801)

XingYES hai 4 semanas
pai
achega
ef2284a334
Modificáronse 3 ficheiros con 546 adicións e 0 borrados
  1. 5 0
      index/root_index.yaml
  2. 9 0
      resources/ZQU/adapters.yaml
  3. 532 0
      resources/ZQU/zqu_01.js

+ 5 - 0
index/root_index.yaml

@@ -1202,3 +1202,8 @@ schools:
     name: "青岛大学"
     initial: "Q"
     resource_folder: "QDU"
+
+  - id: "ZQU"
+    name: "肇庆学院"
+    initial: "Z"
+    resource_folder: "ZQU"

+ 9 - 0
resources/ZQU/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/ZQU/adapters.yaml
+adapters:
+  - adapter_id: "ZQU_01"
+    adapter_name: "肇庆学院(WebVPN)"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "zqu_01.js"
+    import_url: "https://webvpn.zqu.edu.cn/login"
+    maintainer: "NoobLLiu"
+    description: "肇庆学院URP教务,校外访问走WebVPN。登录进入教务系统后点击开始导入,脚本直取课表JSON接口(getKbRq)一次读取整学期数据,导入过程中会请用户确认开学日期。无需打开课表查询页面,停留在教务系统任意页面即可。校内直连版本另行提交。非本校开发者适配,如有误差欢迎提issue"

+ 532 - 0
resources/ZQU/zqu_01.js

@@ -0,0 +1,532 @@
+// 肇庆学院(zqu.edu.cn) 拾光课程表适配脚本
+// 教务系统:URP 系(Struts2,形如 xxx!yyy.action)
+// 适用范围:本脚本为【WebVPN 校外访问】版本;校内直连版本另行提交
+// 维护者:NoobLLiu
+//
+// 课表数据直接取自后台 JSON 接口,不做 HTML 表格解析:
+//     GET xsgrkbcx!getKbRq.action?xnxqdm=<学年学期>&zc=<周次>&xsdm=
+// zc 传空即不限周次,一次可取回整学期排课记录;每条记录自带 zc 字段
+// 标明该门课的上课周次,因此无需逐周请求,也无需读取页面 DOM。
+//
+// 使用前提:用户已在弹窗打开的网页中登录 WebVPN 并进入教务系统,
+// 停留在教务系统内的任意页面即可,无需打开课表查询页。
+
+// ---------------------------------------------------------------- 常量
+
+// 「我的课表」内容页,本脚本只用它取服务端渲染的学期下拉框 #xnxqdm
+const KB_LIST_PAGE = 'xsgrkbcx!getXsgrbkList.action';
+// 课表数据接口(返回 JSON)
+const KB_DATA = 'xsgrkbcx!getKbRq.action';
+
+// 肇庆学院作息时间(每天 14 节),依据学校实际作息表。
+// 教务系统不提供作息数据,导入时按此预设写入。
+const PRESET_TIME_SLOTS = [
+    { number: 1, startTime: '08:00', endTime: '08:40' },
+    { number: 2, startTime: '08:50', endTime: '09:30' },
+    { number: 3, startTime: '09:50', endTime: '10:30' },
+    { number: 4, startTime: '10:40', endTime: '11:20' },
+    { number: 5, startTime: '11:30', endTime: '12:10' },
+    { number: 6, startTime: '14:30', endTime: '15:10' },
+    { number: 7, startTime: '15:20', endTime: '16:00' },
+    { number: 8, startTime: '16:15', endTime: '16:55' },
+    { number: 9, startTime: '17:05', endTime: '17:45' },
+    { number: 10, startTime: '17:55', endTime: '18:35' },
+    { number: 11, startTime: '19:00', endTime: '19:40' },
+    { number: 12, startTime: '19:50', endTime: '20:30' },
+    { number: 13, startTime: '20:40', endTime: '21:20' },
+    { number: 14, startTime: '21:30', endTime: '22:10' }
+];
+
+// 教室字段为空时的占位文本(教务系统尚未给出教室信息)
+const UNKNOWN_POSITION = '未获取到教室';
+
+// ---------------------------------------------------------------- 工具函数
+
+/**
+ * 推算教务系统的站点根路径。
+ *
+ * WebVPN 下页面地址形如:
+ *   https://webvpn.zqu.edu.cn/https/<加密串>/xsgrkbcx!getXsgrbkList.action
+ * 校内直连下形如:
+ *   https://jwgl.zqu.edu.cn/xsgrkbcx!getXsgrbkList.action
+ * 取到 <加密串>/ 这一层,后续所有接口都基于它拼接。
+ */
+function siteBase() {
+    try {
+        const u = new URL(window.location.href);
+        const path = u.pathname;
+        // 本脚本只请求 xsgrkbcx! 下两个接口,取该段之前的一段即为站点基址。
+        // 停留在别的页面时匹配不到,退回按最后一段斜杠截断,效果相同。
+        const i = path.indexOf('xsgrkbcx!');
+        if (i > 0) return u.origin + path.slice(0, i);
+        const j = path.lastIndexOf('/');
+        return u.origin + (j > 0 ? path.slice(0, j + 1) : '/');
+    } catch (e) {
+        const href = window.location.href.split(/[?#]/)[0];
+        const i = href.lastIndexOf('/');
+        return i > 0 ? href.slice(0, i + 1) : href;
+    }
+}
+
+/**
+ * 读取某个 document 里的下拉框选项(跳过空值项)。
+ * 目前只用于解析学期下拉框 #xnxqdm。
+ * @returns {{cur: string, opts: Array<{value: string, text: string}>}|null}
+ */
+function readSelectOptions(doc, id) {
+    let sel;
+    try {
+        sel = doc.querySelector('#' + id);
+    } catch (e) {
+        return null;
+    }
+    if (!sel || !sel.options) return null;
+
+    const opts = [];
+    for (let i = 0; i < sel.options.length; i++) {
+        const v = String(sel.options[i].value).trim();
+        if (v === '') continue;
+        opts.push({ value: v, text: (sel.options[i].textContent || '').trim() });
+    }
+    if (opts.length === 0) return null;
+    return { cur: String(sel.value).trim(), opts: opts };
+}
+
+/**
+ * 解析节次字段,返回节号数组。
+ * jcdm2 形如 "01,02,03";jcdm 形如 "0102"(连写)。
+ */
+function parseSections(jcdm2, jcdm) {
+    const txt = String(jcdm2 || jcdm || '').trim();
+    const nums = [];
+    const push = n => { if (n > 0 && nums.indexOf(n) === -1) nums.push(n); };
+    for (const raw of txt.split(/[,\s]+/)) {
+        const part = raw.trim();
+        if (!part || !/^\d+$/.test(part)) continue;
+        if (part.length <= 2) {
+            push(parseInt(part, 10));
+        } else {
+            for (let i = 0; i < part.length; i += 2) {
+                push(parseInt(part.slice(i, i + 2), 10));
+            }
+        }
+    }
+    return nums.sort((a, b) => a - b);
+}
+
+/**
+ * 解析课程记录自带的周次字段,返回周号数组。
+ *
+ * 教务系统在不同部署下 zc 字段有多种写法,故一并兼容:
+ *   "1-8,10-17周"      区间(URP 系最常见)
+ *   "1,3,5周"          逗号分隔
+ *   "1111111100000000" 位串(正方形教务 classWeek 写法,第 i 位为 1 表示第 i+1 周)
+ *   "1-16(单)"         带单双周后缀
+ * 返回值已去重并升序。
+ */
+function parseWeekText(zc) {
+    const txt = String(zc === null || zc === undefined ? '' : zc).trim();
+    if (!txt) return [];
+
+    // 位串:只由 0/1 组成且长度 >= 4,视为「第 i+1 周」标记
+    if (/^[01]{4,}$/.test(txt)) {
+        const out = [];
+        for (let i = 0; i < txt.length; i++) {
+            if (txt[i] === '1') out.push(i + 1);
+        }
+        return out;
+    }
+
+    // 区间 / 列表:先去掉「周、单、双、(单)、(双)」等修饰再切分
+    const cleaned = txt
+        .replace(/周|星期/g, '')
+        .replace(/[((][^))]*[))]/g, '')
+        .replace(/[单双]/g, '');
+    const out = [];
+    for (const raw of cleaned.split(/[,,\s]+/)) {
+        const part = raw.trim();
+        if (!part) continue;
+        const range = /^(\d+)\s*-\s*(\d+)$/.exec(part);
+        if (range) {
+            const a = parseInt(range[1], 10);
+            const b = parseInt(range[2], 10);
+            if (a > 0 && b >= a) {
+                for (let i = a; i <= b; i++) out.push(i);
+            }
+            continue;
+        }
+        if (/^\d+$/.test(part)) {
+            const n = parseInt(part, 10);
+            if (n > 0) out.push(n);
+        }
+    }
+    return Array.from(new Set(out)).sort((a, b) => a - b);
+}
+
+/**
+ * 节次与周次合并去重函数(摘自拾光课程表官方 wiki 的参考实现)。
+ */
+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) => {
+        return 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 step1Merged = [];
+    let current = list[0];
+
+    for (let i = 1; i < list.length; i++) {
+        const next = list[i];
+
+        const isSameCourseAndWeeks =
+            current.name === next.name &&
+            current.teacher === next.teacher &&
+            current.position === next.position &&
+            current.day === next.day &&
+            current.weeks.join(',') === next.weeks.join(',');
+
+        const isContinuous = current.endSection + 1 === next.startSection;
+        const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
+
+        if (isSameCourseAndWeeks && isContinuous) {
+            current.endSection = next.endSection;
+        } else if (isSameCourseAndWeeks && isDuplicate) {
+            continue;
+        } else {
+            step1Merged.push(current);
+            current = next;
+        }
+    }
+    step1Merged.push(current);
+
+    // 阶段 2:合并同节次的周次
+    step1Merged.sort((a, b) => {
+        return 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 step2Merged = [];
+    let cur = step1Merged[0];
+
+    for (let i = 1; i < step1Merged.length; i++) {
+        const nxt = step1Merged[i];
+
+        const isSameCourseAndSection =
+            cur.name === nxt.name &&
+            cur.teacher === nxt.teacher &&
+            cur.position === nxt.position &&
+            cur.day === nxt.day &&
+            cur.startSection === nxt.startSection &&
+            cur.endSection === nxt.endSection;
+
+        if (isSameCourseAndSection) {
+            cur.weeks = Array.from(new Set([...cur.weeks, ...nxt.weeks])).sort((a, b) => a - b);
+        } else {
+            step2Merged.push(cur);
+            cur = nxt;
+        }
+    }
+    step2Merged.push(cur);
+
+    return step2Merged;
+}
+
+// ---------------------------------------------------------------- 数据获取
+
+/**
+ * 读取学期下拉框。
+ *
+ * 学期下拉框由服务端随页面一起下发,所以请求一次内容页并解析返回的 HTML 即可。
+ */
+async function fetchTermOptions(base) {
+    const resp = await fetch(base + KB_LIST_PAGE, { method: 'GET', credentials: 'include' });
+    if (!resp.ok) throw new Error('学期列表请求失败(HTTP ' + resp.status + ')');
+
+    const html = await resp.text();
+    const doc = new DOMParser().parseFromString(html, 'text/html');
+    const terms = readSelectOptions(doc, 'xnxqdm');
+    if (!terms) throw new Error('未找到学期列表,请确认已登录并进入教务系统');
+    return terms;
+}
+
+/**
+ * 请求整个学期的课表原始数据(一次请求)。
+ *
+ * zc 传空字符串表示不限周次,服务端会返回整学期数据,
+ * 每条记录自带的 zc 字段即该门课上课的周次,无需逐周请求。
+ *
+ * 注意:不限周次时服务端不返回日期数组,学期开始日期需另行获取(见 fetchFirstWeekStart)。
+ * @returns {Array|null} 课程记录数组,取不到返回 null
+ */
+async function fetchTermData(base, termCode) {
+    const url = base + KB_DATA +
+        '?xnxqdm=' + encodeURIComponent(termCode) +
+        '&zc=&xsdm=';
+
+    const resp = await fetch(url, {
+        method: 'GET',
+        credentials: 'include',
+        headers: { 'X-Requested-With': 'XMLHttpRequest' }
+    });
+    if (!resp.ok) return null;
+
+    const text = await resp.text();
+    let data;
+    try {
+        data = JSON.parse(text);
+    } catch (e) {
+        // 未登录/被拦截时会返回 HTML,交由上层统一提示
+        return null;
+    }
+    if (!Array.isArray(data) || data.length < 2) return null;
+    return data[0] || [];
+}
+
+/**
+ * 请求第 1 周的课表,取其中的周一日期作为学期开始日期。
+ *
+ * 不限周次的那次请求不返回日期数组,所以这里单独取一次第 1 周 ——
+ * 一次请求即可确定开学日期,无需逐周探测。
+ * @returns {string|null} "YYYY-MM-DD",取不到返回 null
+ */
+async function fetchFirstWeekStart(base, termCode) {
+    const url = base + KB_DATA +
+        '?xnxqdm=' + encodeURIComponent(termCode) +
+        '&zc=1&xsdm=';
+
+    try {
+        const resp = await fetch(url, {
+            method: 'GET',
+            credentials: 'include',
+            headers: { 'X-Requested-With': 'XMLHttpRequest' }
+        });
+        if (!resp.ok) return null;
+
+        const data = JSON.parse(await resp.text());
+        if (!Array.isArray(data) || data.length < 2) return null;
+
+        // dates 里取星期一的日期
+        for (const d of (data[1] || [])) {
+            if (!d) continue;
+            if (parseInt(d.xqmc, 10) !== 1) continue;
+            const rq = String(d.rq || '').trim();
+            if (/^\d{4}-\d{2}-\d{2}$/.test(rq)) return rq;
+        }
+        return null;
+    } catch (e) {
+        return null;
+    }
+}
+
+/**
+ * 解析整学期数据,聚合成拾光课程表需要的课程数组。
+ *
+ * 服务端一次返回整学期的排课记录,每条自带 zc(该门课上课的周次),
+ * 因此这里按「课程名+教师+教室+星期+起止节次」聚合出 weeks 数组,
+ * 同一门课的多个周次会归并到同一条记录。
+ */
+function collectCourses(records) {
+    const courses = [];
+
+    for (const c of records) {
+        if (!c || typeof c !== 'object') continue;
+
+        const day = parseInt(c.xq, 10);
+        if (!Number.isInteger(day) || day < 1 || day > 7) continue;
+
+        const name = String(c.kcmc || '').trim();
+        if (!name) continue;
+
+        const sections = parseSections(c.jcdm2, c.jcdm);
+        if (sections.length === 0) continue;
+
+        const weeks = parseWeekText(c.zc);
+        if (weeks.length === 0) continue;
+
+        const teacher = String(c.teaxms || '').trim();
+        const position = String(c.jxcdmc || '').trim() || UNKNOWN_POSITION;
+        const startSection = sections[0];
+        const endSection = sections[sections.length - 1];
+
+        // 同一门课同一时段可能有多条记录(不同周次区间),按周次归并
+        const exist = courses.find(x =>
+            x.name === name && x.teacher === teacher && x.position === position &&
+            x.day === day && x.startSection === startSection && x.endSection === endSection
+        );
+        if (exist) {
+            exist.weeks = Array.from(new Set([...exist.weeks, ...weeks])).sort((a, b) => a - b);
+        } else {
+            courses.push({
+                name: name,
+                teacher: teacher,
+                position: position,
+                day: day,
+                startSection: startSection,
+                endSection: endSection,
+                weeks: weeks
+            });
+        }
+    }
+
+    // 总周数取所有课程周次的最大值;一条都取不到时保持 0,交给 App 默认值。
+    // 这里用 reduce 而非 Math.max(...spread),避免周次过多时参数展开爆栈。
+    const totalWeeks = courses.reduce(
+        (max, c) => c.weeks.reduce((m, w) => (w > m ? w : m), max),
+        0
+    );
+
+    return {
+        courses: mergeAndDistinctCourses(courses),
+        totalWeeks: totalWeeks
+    };
+}
+
+// ---------------------------------------------------------------- 交互
+
+async function promptUserToStart() {
+    return await window.shiguangBridgePromise.showAlert(
+        '肇庆学院课表导入',
+        '导入前请确认:\n1) 已在当前网页登录 WebVPN;\n2) 已进入教务系统(停留在任意页面即可,无需打开课表查询)。',
+        '开始导入'
+    );
+}
+
+/**
+ * 开学日期输入框的校验函数(须挂在全局作用域,供原生侧调用)。
+ * 返回 false 表示通过,返回字符串表示错误提示。
+ */
+function validateDate(input) {
+    if (!input || !input.trim()) return '请输入开学日期';
+    if (!/^\d{4}-\d{2}-\d{2}$/.test(input.trim())) return '格式应为 YYYY-MM-DD,例如 2026-08-31';
+    return false;
+}
+
+/**
+ * 让用户确认(必要时修正)开学日期。
+ * 自动取到的值作为默认值;若未能取到,则由用户手动输入。
+ */
+async function confirmStartDate(defaultDate) {
+    const tip = defaultDate
+        ? '请确认本学期第 1 周的周一日期(如有误可直接修改):'
+        : '未能自动取到开学日期,请手动输入本学期第 1 周的周一日期:';
+    return await window.shiguangBridgePromise.showPrompt(
+        '确认开学日期',
+        tip,
+        defaultDate || '',
+        'validateDate'
+    );
+}
+
+async function selectTerm(terms) {
+    const texts = terms.opts.map(o => o.text);
+    let defIdx = terms.opts.findIndex(o => o.value === terms.cur);
+    if (defIdx < 0) defIdx = 0;
+
+    const idx = await window.shiguangBridgePromise.showSingleSelection(
+        '选择学期', JSON.stringify(texts), defIdx);
+    if (idx === null || idx < 0 || idx >= terms.opts.length) return null;
+    return terms.opts[idx];
+}
+
+async function importPresetTimeSlots(timeSlots) {
+    try {
+        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
+    } catch (error) {
+        window.shiguangBridge.showToast('导入时间段失败: ' + error.message);
+    }
+}
+
+// ---------------------------------------------------------------- 主流程
+
+async function runImportFlow() {
+    const confirmed = await promptUserToStart();
+    if (!confirmed) {
+        window.shiguangBridge.showToast('用户取消了导入。');
+        return;
+    }
+
+    const base = siteBase();
+
+    let terms;
+    try {
+        window.shiguangBridge.showToast('正在读取学期列表 ...');
+        terms = await fetchTermOptions(base);
+    } catch (error) {
+        window.shiguangBridge.showToast('读取学期失败:' + error.message);
+        return;
+    }
+
+    const term = await selectTerm(terms);
+    if (term === null) {
+        window.shiguangBridge.showToast('未选择学期,导入流程终止。');
+        return;
+    }
+
+    window.shiguangBridge.showToast('正在读取课表 ...');
+    const records = await fetchTermData(base, term.value);
+    if (!records) {
+        window.shiguangBridge.showToast('课表接口未返回数据,请检查登录状态或稍后重试。');
+        return;
+    }
+
+    const result = collectCourses(records);
+    if (result.courses.length === 0) {
+        window.shiguangBridge.showToast('未获取到课表数据,请确认该学期已选课。');
+        return;
+    }
+
+    // 开学日期:额外取一次第 1 周的周一日期,再让用户确认/修正。
+    // 取不到也不阻塞流程,用户可自行输入。
+    window.shiguangBridge.showToast('正在确认开学日期 ...');
+    const startDate = await fetchFirstWeekStart(base, term.value);
+
+    const input = await confirmStartDate(startDate);
+    if (input === null) {
+        window.shiguangBridge.showToast('用户取消了导入。');
+        return;
+    }
+    const semesterStartDate = String(input).trim();
+
+    try {
+        await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(result.courses));
+    } catch (error) {
+        window.shiguangBridge.showToast('课程保存失败: ' + error.message);
+        return;
+    }
+
+    try {
+        // 只在取到值时才写这两个可选字段,否则交给 App 侧沿用已有设置
+        const config = {};
+        if (result.totalWeeks > 0) config.semesterTotalWeeks = result.totalWeeks;
+        if (semesterStartDate) config.semesterStartDate = semesterStartDate;
+        await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
+    } catch (error) {
+        window.shiguangBridge.showToast('课表配置保存失败: ' + error.message);
+    }
+
+    await importPresetTimeSlots(PRESET_TIME_SLOTS);
+
+    window.shiguangBridge.showToast('导入完成,共 ' + result.courses.length + ' 个课程时段。');
+    window.shiguangBridge.notifyTaskCompletion();
+}
+
+runImportFlow();