|
|
@@ -0,0 +1,327 @@
|
|
|
+// 华南师范大学本科教务系统。登录后,在教务系统内任意页面执行;学年学期在弹窗中确认,默认为当前学期。
|
|
|
+// 主接口包含部分实验课;独立实验预约系统的安排不在本脚本的导入范围内。
|
|
|
+(async function () {
|
|
|
+ const bridge = window.shiguangBridgePromise;
|
|
|
+ const native = window.shiguangBridge;
|
|
|
+ if (!bridge || !native) throw new Error('请通过拾光课程表或适配测试器运行此脚本。');
|
|
|
+ if (window.__scnuImportRunning) {
|
|
|
+ native.showToast('华师课表正在导入,请勿重复点击。');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ window.__scnuImportRunning = true;
|
|
|
+ let saving = false;
|
|
|
+
|
|
|
+ // 华师教务页面(zftal-ui 的 jquery.extends.contact)改写了部分原型方法:
|
|
|
+ // Array.prototype.filter/some 的回调实参变成 (下标, 元素, 数组),every 恒为 true,
|
|
|
+ // String.prototype.trim 会删除所有空白而非仅首尾。本脚本只使用原生安全的写法:
|
|
|
+ // 用 replace 实现 trim,不用 filter/some/every 做数组过滤。
|
|
|
+ const text = value => String(value == null ? '' : value).replace(/^\s+|\s+$/g, '');
|
|
|
+
|
|
|
+ // 整段匹配,遇到未知格式就停止,避免部分解析造成漏课或多排课。
|
|
|
+ function parseNumbers(value, unit, limit, allowParity) {
|
|
|
+ const normalized = text(value).replace(/\s/g, '').replace(/,|、/g, ',')
|
|
|
+ .replace(/(/g, '(').replace(/)/g, ')');
|
|
|
+ const numbers = new Set();
|
|
|
+ for (const part of normalized.split(',')) {
|
|
|
+ const match = part.match(/^(\d+)(?:-(\d+))?(?:周|节)?(?:\(([单双])(?:周)?\))?$/);
|
|
|
+ if (!match || (match[3] && !allowParity) || (unit === '周' ? part.includes('节') : part.includes('周'))) {
|
|
|
+ throw new Error(`无法识别${unit}次:${value}`);
|
|
|
+ }
|
|
|
+ const start = Number(match[1]);
|
|
|
+ const end = Number(match[2] || match[1]);
|
|
|
+ if (start < 1 || end < start || end > limit) throw new Error(`${unit}次范围无效:${value}`);
|
|
|
+ for (let n = start; n <= end; n++) {
|
|
|
+ if (match[3] === '单' && n % 2 === 0) continue;
|
|
|
+ if (match[3] === '双' && n % 2 !== 0) continue;
|
|
|
+ numbers.add(n);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!numbers.size) throw new Error(`${unit}次为空:${value}`);
|
|
|
+ return [...numbers].sort((a, b) => a - b);
|
|
|
+ }
|
|
|
+
|
|
|
+ function parseCourses(rows) {
|
|
|
+ const entries = [];
|
|
|
+ for (const raw of rows) {
|
|
|
+ const name = text(raw.kcmc);
|
|
|
+ try {
|
|
|
+ const day = Number(raw.xqj);
|
|
|
+ if (!name || !Number.isInteger(day) || day < 1 || day > 7) throw new Error('缺少课程名称或有效星期');
|
|
|
+ const campusId = text(raw.xqh_id);
|
|
|
+ if (!campusId) throw new Error('缺少校区编号,无法确定作息时间');
|
|
|
+ const weeks = parseNumbers(raw.zcd, '周', 100, true);
|
|
|
+ const sections = parseNumbers(raw.jcs, '节', 30, false);
|
|
|
+ const type = { '*': '理论', '&': '实验', '#': '实践' }[text(raw.xslxbj)];
|
|
|
+ // 当前 App 的此导入路径不保存 remark,课程类型直接写入名称。
|
|
|
+ const courseName = type && type !== '理论' && !name.includes(type) ? `${name}(${type})` : name;
|
|
|
+ for (let i = 0; i < sections.length; i++) {
|
|
|
+ const startSection = sections[i];
|
|
|
+ while (i + 1 < sections.length && sections[i + 1] === sections[i] + 1) i++;
|
|
|
+ entries.push({
|
|
|
+ campusId,
|
|
|
+ campusName: text(raw.xqmc) || campusId,
|
|
|
+ course: {
|
|
|
+ name: courseName,
|
|
|
+ teacher: text(raw.xm),
|
|
|
+ // 页面的 Array.prototype.filter 已被污染,拼接后统一去空白。
|
|
|
+ position: text(`${text(raw.xqmc)} ${text(raw.cdmc)}`),
|
|
|
+ day, startSection, endSection: sections[i], weeks
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ throw new Error(`${name || '未命名课程'}:${error.message}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return entries;
|
|
|
+ }
|
|
|
+
|
|
|
+ function parseTimeSlots(rows) {
|
|
|
+ if (!Array.isArray(rows) || !rows.length) throw new Error('没有取得校区作息时间');
|
|
|
+ const slots = rows.map(row => ({
|
|
|
+ number: Number(row.jcmc), startTime: text(row.qssj), endTime: text(row.jssj)
|
|
|
+ })).sort((a, b) => a.number - b.number);
|
|
|
+ let previousEnd = '';
|
|
|
+ slots.forEach((slot, index) => {
|
|
|
+ const validTime = value => /^([01]\d|2[0-3]):[0-5]\d$/.test(value);
|
|
|
+ if (slot.number !== index + 1 || !validTime(slot.startTime) || !validTime(slot.endTime)
|
|
|
+ || slot.startTime >= slot.endTime || slot.startTime < previousEnd) {
|
|
|
+ throw new Error('校区作息时间格式无效、节次不连续或时间重叠');
|
|
|
+ }
|
|
|
+ previousEnd = slot.endTime;
|
|
|
+ });
|
|
|
+ return slots;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 周次校历接口(xskbcxZccx_cxZcByXnxq)返回每周的起止日期,可推导开学日期、总周数与每周起始日。
|
|
|
+ function parseWeekCalendar(rows) {
|
|
|
+ if (!Array.isArray(rows) || !rows.length) throw new Error('没有取得学期周次校历');
|
|
|
+ const weeks = rows.map(row => {
|
|
|
+ const number = Number(row.zs);
|
|
|
+ const start = text(row.rq).split('/')[0];
|
|
|
+ if (!Number.isInteger(number) || number < 1 || !/^\d{4}-\d{2}-\d{2}$/.test(start)) {
|
|
|
+ throw new Error('学期周次校历格式无效');
|
|
|
+ }
|
|
|
+ const date = new Date(`${start}T00:00:00Z`);
|
|
|
+ if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== start) {
|
|
|
+ throw new Error(`学期周次校历包含无效日期:${start}`);
|
|
|
+ }
|
|
|
+ return { number, start };
|
|
|
+ }).sort((a, b) => a.number - b.number);
|
|
|
+ weeks.forEach((week, index) => {
|
|
|
+ if (week.number !== index + 1) throw new Error('学期周次校历的周次不连续');
|
|
|
+ });
|
|
|
+ // App 的 firstDayOfWeek 约定为 ISO 星期(1=周一 … 7=周日),由开学日当天推算。
|
|
|
+ const firstDayOfWeek = (new Date(`${weeks[0].start}T00:00:00Z`).getUTCDay() + 6) % 7 + 1;
|
|
|
+ return { startDate: weeks[0].start, totalWeeks: weeks.length, firstDayOfWeek };
|
|
|
+ }
|
|
|
+
|
|
|
+ function toMinutes(value) {
|
|
|
+ const [hours, minutes] = value.split(':').map(Number);
|
|
|
+ return hours * 60 + minutes;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 同一课程可能被教务拆成多条记录(如 1-8周 与 9-16周、1-2节 与 3-4节),参考 QQHRIT 适配做两阶段合并:
|
|
|
+ // 先并相接/重复的节次,再并同节次的周次,减少课表里的重复卡片。
|
|
|
+ // 判等键包含自定义时间,作息不同的记录不会被错误合并。
|
|
|
+ function mergeCourses(courses) {
|
|
|
+ if (courses.length <= 1) return courses;
|
|
|
+ const keyOf = c => [c.name, c.teacher, c.position, c.day,
|
|
|
+ c.isCustomTime ? `${c.customStartTime}-${c.customEndTime}` : ''].join('\u0001');
|
|
|
+ const compareKey = (a, b) => {
|
|
|
+ const ka = keyOf(a), kb = keyOf(b);
|
|
|
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
|
+ };
|
|
|
+ const compareText = (x, y) => x < y ? -1 : x > y ? 1 : 0;
|
|
|
+ const byStart = [...courses].sort((a, b) => compareKey(a, b)
|
|
|
+ || compareText(a.weeks.join(','), b.weeks.join(','))
|
|
|
+ || a.startSection - b.startSection);
|
|
|
+ const step1 = [];
|
|
|
+ for (const c of byStart) {
|
|
|
+ const prev = step1[step1.length - 1];
|
|
|
+ if (prev && compareKey(prev, c) === 0 && prev.weeks.join(',') === c.weeks.join(',')
|
|
|
+ && c.startSection <= prev.endSection + 1) {
|
|
|
+ if (c.endSection > prev.endSection) prev.endSection = c.endSection;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ step1.push({ ...c, weeks: [...c.weeks] });
|
|
|
+ }
|
|
|
+ step1.sort((a, b) => compareKey(a, b) || a.startSection - b.startSection || a.endSection - b.endSection);
|
|
|
+ const step2 = [];
|
|
|
+ for (const c of step1) {
|
|
|
+ const prev = step2[step2.length - 1];
|
|
|
+ if (prev && compareKey(prev, c) === 0
|
|
|
+ && prev.startSection === c.startSection && prev.endSection === c.endSection) {
|
|
|
+ prev.weeks = [...new Set([...prev.weeks, ...c.weeks])].sort((a, b) => a - b);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ step2.push(c);
|
|
|
+ }
|
|
|
+ return step2;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 参考 QQHRIT 适配:抓取课表查询页 HTML,读出学年学期下拉框的选项与默认选中项,
|
|
|
+ // 让用户在弹窗里确认或修改,无需事先停留在课表页面。
|
|
|
+ async function fetchAcademicOptions() {
|
|
|
+ // 课表查询页地址必须带其菜单功能代码,否则服务端直接报错;N253508 为个人课表查询页面的菜单代码。
|
|
|
+ const response = await fetch('/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N253508&layout=default', { credentials: 'same-origin' });
|
|
|
+ if (!response.ok) throw new Error(`读取学年学期选项失败(HTTP ${response.status})`);
|
|
|
+ const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
|
|
|
+ const readOptions = selector => {
|
|
|
+ const select = doc.querySelector(selector);
|
|
|
+ if (!select) return [];
|
|
|
+ const options = [];
|
|
|
+ for (const option of select.querySelectorAll('option')) {
|
|
|
+ if (!option.value) continue;
|
|
|
+ options.push({ value: option.value, label: text(option.textContent), selected: option.selected || option.hasAttribute('selected') });
|
|
|
+ }
|
|
|
+ return options;
|
|
|
+ };
|
|
|
+ const years = readOptions('#xnm');
|
|
|
+ const semesters = readOptions('#xqm');
|
|
|
+ if (!years.length || !semesters.length) throw new Error('读取学年学期选项失败,请确认登录未过期');
|
|
|
+ return { years, semesters };
|
|
|
+ }
|
|
|
+
|
|
|
+ async function selectYearSemester() {
|
|
|
+ const { years, semesters } = await fetchAcademicOptions();
|
|
|
+ const selectedYear = years.findIndex(o => o.selected);
|
|
|
+ // 学年选项跨度通常很大,以当前学年为中心截取前后各两年,默认选中原学年。
|
|
|
+ const start = Math.max(0, (selectedYear === -1 ? 0 : selectedYear) - 2);
|
|
|
+ const yearChoices = years.slice(start, start + 5);
|
|
|
+ const pickedYear = await bridge.showSingleSelection('选择学年', JSON.stringify(yearChoices.map(o => o.label)), selectedYear === -1 ? 0 : selectedYear - start);
|
|
|
+ if (pickedYear === null || pickedYear === -1) return null;
|
|
|
+ if (!Number.isInteger(pickedYear) || !yearChoices[pickedYear]) throw new Error('学年选择无效');
|
|
|
+ const selectedSemester = semesters.findIndex(o => o.selected);
|
|
|
+ const pickedSemester = await bridge.showSingleSelection('选择学期', JSON.stringify(semesters.map(o => `第 ${o.label} 学期`)), selectedSemester === -1 ? 0 : selectedSemester);
|
|
|
+ if (pickedSemester === null || pickedSemester === -1) return null;
|
|
|
+ if (!Number.isInteger(pickedSemester) || !semesters[pickedSemester]) throw new Error('学期选择无效');
|
|
|
+ return { xnm: yearChoices[pickedYear].value, xnmText: yearChoices[pickedYear].label, xqm: semesters[pickedSemester].value, xqmText: semesters[pickedSemester].label };
|
|
|
+ }
|
|
|
+
|
|
|
+ async function requestJson(path, params) {
|
|
|
+ const controller = new AbortController();
|
|
|
+ const timer = setTimeout(() => controller.abort(), 20000);
|
|
|
+ try {
|
|
|
+ const response = await fetch(path, {
|
|
|
+ method: 'POST', credentials: 'same-origin',
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
|
+ 'X-Requested-With': 'XMLHttpRequest'
|
|
|
+ },
|
|
|
+ body: new URLSearchParams(params).toString(), signal: controller.signal
|
|
|
+ });
|
|
|
+ if (!response.ok) throw new Error(`教务请求失败(HTTP ${response.status})`);
|
|
|
+ let data;
|
|
|
+ try {
|
|
|
+ data = await response.json();
|
|
|
+ } catch (_) {
|
|
|
+ throw new Error('教务系统没有返回 JSON,请确认登录未过期后重试');
|
|
|
+ }
|
|
|
+ if (!data || typeof data !== 'object') throw new Error('教务系统未返回有效数据,请检查登录状态及查询权限');
|
|
|
+ return data;
|
|
|
+ } catch (error) {
|
|
|
+ if (error.name === 'AbortError') throw new Error('教务请求超时,请稍后重试');
|
|
|
+ throw error;
|
|
|
+ } finally {
|
|
|
+ clearTimeout(timer);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function save(method, value) {
|
|
|
+ const result = await bridge[method](JSON.stringify(value));
|
|
|
+ if (result !== true) throw new Error(`${method} 未确认保存成功`);
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (window.location.hostname !== 'jwxt.scnu.edu.cn') {
|
|
|
+ throw new Error('请先登录华师教务系统,停留在教务系统内任意页面后再导入');
|
|
|
+ }
|
|
|
+ const selection = await selectYearSemester();
|
|
|
+ if (!selection) return;
|
|
|
+ const { xnm, xnmText, xqm, xqmText } = selection;
|
|
|
+ if (!/^\d{4}$/.test(xnm) || !xqm) throw new Error('学年学期选项无效');
|
|
|
+ native.showToast('正在获取华师课表、校区作息与学期校历…');
|
|
|
+ const data = await requestJson('/kbcx/xskbcx_cxXsgrkb.html', {
|
|
|
+ xnm, xqm, kzlx: 'ck', xsdm: '', kclbdm: '', kclxdm: ''
|
|
|
+ });
|
|
|
+ if (!Array.isArray(data.kbList)) throw new Error('响应缺少课表数据,请确认登录未过期');
|
|
|
+ if (data.sjkList != null && !Array.isArray(data.sjkList)) throw new Error('未排课数据格式异常');
|
|
|
+ const other = (data.sjkList || []).map(c => text(`${text(c.kcmc) || '未命名课程'} ${text(c.qsjsz)}`));
|
|
|
+ if (!data.kbList.length) {
|
|
|
+ await bridge.showAlert('没有已排课程', ['所选学期没有可导入的已排课程,原课表未改动。', ...other].join('\n'), '确定');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const entries = parseCourses(data.kbList);
|
|
|
+ const campuses = [...new Map(entries.map(e => [e.campusId, e.campusName])).entries()];
|
|
|
+ // 校区作息与学期周次校历互不依赖,并行获取。
|
|
|
+ const schedules = new Map();
|
|
|
+ const fetchSchedule = async (id, name) => {
|
|
|
+ try {
|
|
|
+ schedules.set(id, parseTimeSlots(await requestJson('/kbcx/xskbcx_cxRjc.html', { xnm, xqm, xqh_id: id })));
|
|
|
+ } catch (error) {
|
|
|
+ throw new Error(`${name}校区:${error.message}`);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const fetchCalendar = async () => {
|
|
|
+ try {
|
|
|
+ return parseWeekCalendar(await requestJson('/kbcx/xskbcxZccx_cxZcByXnxq.html', { xnm, xqm }));
|
|
|
+ } catch (error) {
|
|
|
+ throw new Error(`学期周次校历:${error.message}`);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const [calendar] = await Promise.all([fetchCalendar(), ...campuses.map(([id, name]) => fetchSchedule(id, name))]);
|
|
|
+ let presetIndex = 0;
|
|
|
+ if (campuses.length > 1) {
|
|
|
+ presetIndex = await bridge.showSingleSelection('默认作息校区', JSON.stringify(campuses.map(c => c[1])), 0);
|
|
|
+ if (presetIndex === null || presetIndex === -1) return;
|
|
|
+ if (!Number.isInteger(presetIndex) || !campuses[presetIndex]) throw new Error('校区选择无效');
|
|
|
+ }
|
|
|
+ const [presetCampus, presetName] = campuses[presetIndex];
|
|
|
+ const timeSlots = schedules.get(presetCampus);
|
|
|
+ const courses = entries.map(({ course, campusId }) => {
|
|
|
+ const slots = schedules.get(campusId);
|
|
|
+ const first = slots[course.startSection - 1];
|
|
|
+ const last = slots[course.endSection - 1];
|
|
|
+ if (!first || !last) throw new Error(`${course.name} 的节次超出校区作息范围`);
|
|
|
+ if (campusId !== presetCampus && JSON.stringify(slots) !== JSON.stringify(timeSlots)) {
|
|
|
+ return { ...course, isCustomTime: true, customStartTime: first.startTime, customEndTime: last.endTime };
|
|
|
+ }
|
|
|
+ return course;
|
|
|
+ });
|
|
|
+ const merged = mergeCourses(courses);
|
|
|
+ const config = {
|
|
|
+ semesterStartDate: calendar.startDate,
|
|
|
+ semesterTotalWeeks: calendar.totalWeeks,
|
|
|
+ defaultClassDuration: toMinutes(timeSlots[0].endTime) - toMinutes(timeSlots[0].startTime),
|
|
|
+ defaultBreakDuration: timeSlots.length > 1 ? toMinutes(timeSlots[1].startTime) - toMinutes(timeSlots[0].endTime) : 10,
|
|
|
+ firstDayOfWeek: calendar.firstDayOfWeek
|
|
|
+ };
|
|
|
+ const summary = [
|
|
|
+ `${xnmText} 学年第 ${xqmText} 学期`,
|
|
|
+ `${merged.length} 条排课记录;默认作息:${presetName}。`,
|
|
|
+ `开学日期:${calendar.startDate},共 ${calendar.totalWeeks} 周。`,
|
|
|
+ '将覆盖所选目标课表的课程、作息与学期配置',
|
|
|
+ '本次仅导入个人课表主接口中的安排;独立实验预约安排请另行核对。'
|
|
|
+ ];
|
|
|
+ if (other.length) summary.push(`以下 ${other.length} 门课程未提供星期和节次,不会导入,请按实际安排手动添加:`, ...other);
|
|
|
+ if (!await bridge.showAlert('确认导入华师课表', summary.join('\n\n'), '确认导入')) return;
|
|
|
+
|
|
|
+ // 三个 Bridge 保存操作彼此独立,失败后不能宣称已全部完成或已回滚。
|
|
|
+ saving = true;
|
|
|
+ await save('saveImportedCourses', merged);
|
|
|
+ await save('savePresetTimeSlots', timeSlots);
|
|
|
+ await save('saveCourseConfig', config);
|
|
|
+ await bridge.showAlert('华师课表导入完成', [
|
|
|
+ `已导入 ${merged.length} 条排课记录及校区作息。`,
|
|
|
+ `开学日期 ${calendar.startDate},共 ${calendar.totalWeeks} 周,已写入课表配置。`,
|
|
|
+ '课程周次已按教务系统返回的数据导入。'
|
|
|
+ ].join('\n\n'), '确定');
|
|
|
+ native.notifyTaskCompletion();
|
|
|
+ } catch (error) {
|
|
|
+ const suffix = saving ? '\n保存已经开始,目标课表可能已部分更新。请检查后重新导入。' : '\n尚未写入目标课表。';
|
|
|
+ await bridge.showAlert('华师课表导入失败', `${error.message}${suffix}`, '确定');
|
|
|
+ } finally {
|
|
|
+ delete window.__scnuImportRunning;
|
|
|
+ }
|
|
|
+})();
|