| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535 |
- // 肇庆学院(zqu.edu.cn) 拾光课程表适配脚本
- // 教务系统:URP 系(Struts2,形如 xxx!yyy.action)
- // 适用范围:校外经 WebVPN 访问、或校内直连,两种渠道均支持。
- // 渠道由当前页面 URL 自动识别(见 siteBase),无需分别提供脚本。
- // 维护者:NoobLLiu
- //
- // 课表数据直接取自后台 JSON 接口,不做 HTML 表格解析:
- // GET xsgrkbcx!getKbRq.action?xnxqdm=<学年学期>&zc=<周次>&xsdm=
- // zc 传空即不限周次,一次可取回整学期排课记录;每条记录自带 zc 字段
- // 标明该门课的上课周次,因此无需逐周请求,也无需读取页面 DOM。
- //
- // 使用前提:用户已在弹窗打开的网页中登录教务系统,
- // 停留在教务系统内的任意页面即可,无需打开课表查询页。
- // ---------------------------------------------------------------- 常量
- // 「我的课表」内容页,本脚本只用它取服务端渲染的学期下拉框 #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
- *
- * 二者只是站点根不同,其余完全一致。因此这里不判断渠道,
- * 只按教务内部页面的路径特征截断:取到 xsgrkbcx! 之前的一段即为站点根
- * (WebVPN 场景会自然带上 <加密串>/ 这一层),后续所有接口都基于它拼接。
- */
- 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();
|