// 江苏科技大学(just.edu.cn) 拾光课程表适配脚本 // 教务系统:正方新一代教务(/jwglxt) // 校外访问:深信服 enlink WebVPN,统一身份认证 https://client.v.just.edu.cn/ // WebVPN 代理路径形如 /http/webvpn/jwglxt/..., 与登录会话相关不能写死, // 脚本从当前页面 URL 动态取前缀。 // 课表页的学年/学期是 (学期), // 被 chosen 组件包装成 div#xnm_chosen / div#xqm_chosen(原 select 是容器的前一个兄弟节点)。 // 脚本读出选项让用户确认,再请求课表、校历与校区作息。 // 维护者:abyss-stars (async function () { const bridge = window.shiguangBridgePromise; const native = window.shiguangBridge; if (!bridge || !native) return; if (window.__justImportRunning) { native.showToast('课表正在导入,请勿重复点击。'); return; } window.__justImportRunning = true; const GNMKDM = 'N2151'; const API = { course: '/kbcx/xskbcx_cxXsgrkb.html', calendar: '/kbcx/xskbcxZccx_cxZcByXnxq.html', timeSlots: '/kbcx/xskbcx_cxRjc.html' }; // ---------- 1. 接口 ---------- // 校内直连是 https:///jwglxt/...,WebVPN 下前面还有 /http/webvpn 与会话相关) const matched = (window.location.pathname || '').match(/^(.*?)\/jwglxt(?:\/|$)/i); const BASE = (matched ? matched[1] : '') + '/jwglxt'; // enlink 网关下通常需要 enlink-vpn 标记,两种写法依次尝试 async function request(modulePath, params) { const body = Object.keys(params) .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key] == null ? '' : params[key])}`) .join('&'); for (const suffix of [`?gnmkdm=${GNMKDM}&enlink-vpn`, `?gnmkdm=${GNMKDM}`]) { try { const response = await fetch(BASE + modulePath + suffix, { method: 'POST', credentials: 'include', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: body }); if (!response.ok) continue; const raw = await response.text(); const head = raw.replace(/^\s+/, '').charAt(0); // 非 JSON(例如被跳到登录页)就换下一种写法 if (head === '{' || head === '[') return JSON.parse(raw); } catch (error) { // 换下一种写法 } } return null; } // ---------- 2. 解析 ---------- const text = (value) => String(value == null ? '' : value).replace(/^\s+|\s+$/g, ''); // 周次:"1-16周"、"1-16周(单)"、"1,3,5-9周" 等 function parseWeeks(value) { const weeks = new Set(); const normalized = text(value).replace(/\s/g, '').replace(/[,、;;]/g, ',') .replace(/(/g, '(').replace(/)/g, ')'); for (const part of normalized.split(',')) { const odds = /单/.test(part); const evens = /双/.test(part); const digits = part.replace(/[^\d-]/g, ''); const matched = digits.match(/^(\d+)-(\d+)$/) || digits.match(/^(\d+)$/); if (!matched) continue; const start = Number(matched[1]); const end = Number(matched[2] || matched[1]); if (!(start > 0) || end < start) continue; for (let week = start; week <= end; week++) { if (odds && week % 2 === 0) continue; if (evens && week % 2 !== 0) continue; weeks.add(week); } } return [...weeks].sort((a, b) => a - b); } // 节次:"1-2"、"1"、"0102"(部分部署补零) function parseSections(value) { const raw = text(value); const hyphen = raw.match(/(\d+)\s*[-~—-]\s*(\d+)/); if (hyphen) return [Number(hyphen[1]), Number(hyphen[2])]; const digits = raw.replace(/\D/g, ''); if (!digits) return null; if (digits.length === 4) return [Number(digits.slice(0, 2)), Number(digits.slice(2))]; return [Number(digits), Number(digits)]; } function toMinutes(value) { const matched = String(value == null ? '' : value).match(/^(\d{1,2}):(\d{2})$/); return matched ? Number(matched[1]) * 60 + Number(matched[2]) : -1; } // 统一成 app 要求的 HH:mm function padTime(value) { const matched = String(value == null ? '' : value).match(/(\d{1,2}):(\d{2})/); if (!matched) return ''; const hour = Number(matched[1]); return hour > 23 ? '' : `${hour < 10 ? '0' : ''}${hour}:${matched[2]}`; } // 一条接口记录 -> 排课条目;没有固定星期/节次的(实践、网络课程等)返回 null function toCourseEntry(raw) { const name = text(raw.kcmc); const day = Number(text(raw.xqj)); const weeks = parseWeeks(raw.zcd); const sections = parseSections(text(raw.jcs) !== '' ? raw.jcs : raw.jc); if (!name || !(day >= 1 && day <= 7) || !weeks.length || !sections) return null; if (!(sections[0] > 0) || sections[1] < sections[0]) return null; return { campusId: text(raw.xqh_id) || '0', campusName: text(raw.xqmc), course: { name: name, teacher: text(raw.xm) || '未知教师', position: text(raw.cdmc) || text(raw.cdbh) || '未排地点', day: day, startSection: sections[0], endSection: sections[1], weeks: weeks } }; } // kbList 与 sjkList 通用:只取有固定星期与节次的记录,其余(实践课程、网络课程等)忽略 function collectCourses(list) { const entries = []; for (const raw of (Array.isArray(list) ? list : [])) { const entry = toCourseEntry(raw); if (entry) entries.push(entry); } return entries; } // 校区作息:jcmc=节次,qssj/jssj=起止时间 function parseTimeSlots(rows) { const slots = []; for (const row of (Array.isArray(rows) ? rows : [])) { const number = Number(text(row.jcmc != null ? row.jcmc : row.jcdm)); const startTime = padTime(row.qssj); const endTime = padTime(row.jssj); if (!(number > 0) || !startTime || !endTime) continue; if (toMinutes(startTime) >= toMinutes(endTime)) continue; slots.push({ number: number, startTime: startTime, endTime: endTime }); } slots.sort((a, b) => a.number - b.number); // app 要求节次从 1 连续,否则整段丢弃 const continuous = slots.length > 0 && (() => { for (let i = 0; i < slots.length; i++) { if (slots[i].number !== i + 1) return false; } return true; })(); return continuous ? slots : []; } // 学期校历:zs=周次,rq="起始日/结束日" function parseCalendar(rows) { const weeks = []; for (const row of (Array.isArray(rows) ? rows : [])) { const number = Number(text(row.zs != null ? row.zs : row.zsmc)); const start = text(row.rq || row.zcrq || row.ksrq).split('/')[0]; if (number > 0 && /^\d{4}-\d{2}-\d{2}$/.test(start)) weeks.push({ number: number, start: start }); } if (!weeks.length) return null; weeks.sort((a, b) => a.number - b.number); const first = weeks[0]; return { startDate: first.start, // app 的 firstDayOfWeek 是 1=周一 … 7=周日 firstDayOfWeek: (new Date(`${first.start}T00:00:00Z`).getUTCDay() + 6) % 7 + 1, totalWeeks: weeks[weeks.length - 1].number }; } // 教务处公布的作息时间表(接口取不到时兜底) const FALLBACK_TIME_SLOTS = { '长山': [[1, '08:30', '09:15'], [2, '09:20', '10:05'], [3, '10:25', '11:10'], [4, '11:15', '12:00'], [5, '14:00', '14:45'], [6, '14:50', '15:35'], [7, '15:55', '16:40'], [8, '16:45', '17:30'], [9, '18:30', '19:15'], [10, '19:20', '20:05']], '梦溪': [[1, '08:00', '08:45'], [2, '08:55', '09:40'], [3, '10:00', '10:45'], [4, '10:55', '11:40'], [5, '14:00', '14:45'], [6, '14:55', '15:40'], [7, '15:50', '16:35'], [8, '16:45', '17:30'], [9, '19:00', '19:45'], [10, '19:55', '20:40'], [11, '20:50', '21:35']], // 张家港取自 2026-2027-1 学期 xskbcx_cxRjc 接口实测(与梦溪不同) '张家港': [[1, '08:00', '08:45'], [2, '08:55', '09:40'], [3, '10:00', '10:45'], [4, '10:55', '11:40'], [5, '14:00', '14:45'], [6, '14:55', '15:40'], [7, '16:00', '16:45'], [8, '16:55', '17:40'], [9, '19:00', '19:45'], [10, '19:55', '20:40'], [11, '20:50', '21:35']] }; function fallbackSlots(campusName) { for (const key of Object.keys(FALLBACK_TIME_SLOTS)) { if (new RegExp(key).test(campusName)) { return FALLBACK_TIME_SLOTS[key].map((item) => ({ number: item[0], startTime: item[1], endTime: item[2] })); } } return null; } // 节次与周次合并去重(参考 wiki《课程合并与去重函数》) function mergeCourses(courses) { if (courses.length <= 1) return courses; const sameCourse = (a, b) => a.name === b.name && a.teacher === b.teacher && a.position === b.position && a.day === b.day && !!a.isCustomTime === !!b.isCustomTime && (!a.isCustomTime || (a.customStartTime === b.customStartTime && a.customEndTime === b.customEndTime)); const list = courses.map((course) => Object.assign({}, course, { weeks: [...course.weeks].sort((a, b) => a - b) })); list.sort((a, b) => a.name.localeCompare(b.name) || a.teacher.localeCompare(b.teacher) || a.position.localeCompare(b.position) || a.day - b.day || a.weeks.join(',').localeCompare(b.weeks.join(',')) || a.startSection - b.startSection); const merged = []; let current = list[0]; for (let i = 1; i < list.length; i++) { const next = list[i]; const mergeable = sameCourse(current, next) && !current.isCustomTime && current.weeks.join(',') === next.weeks.join(','); if (mergeable && current.endSection + 1 === next.startSection) { current.endSection = next.endSection; // 连续节次:1-2 + 3-4 -> 1-4 } else if (mergeable && current.startSection === next.startSection && current.endSection === next.endSection) { continue; // 完全重复 } else { merged.push(current); current = next; } } merged.push(current); // 同节次的周次合并(单周 + 双周 -> 全周) merged.sort((a, b) => a.name.localeCompare(b.name) || a.teacher.localeCompare(b.teacher) || a.position.localeCompare(b.position) || a.day - b.day || a.startSection - b.startSection || a.endSection - b.endSection); const result = []; let head = merged[0]; for (let i = 1; i < merged.length; i++) { const next = merged[i]; if (sameCourse(head, next) && head.startSection === next.startSection && head.endSection === next.endSection) { head.weeks = [...new Set([...head.weeks, ...next.weeks])].sort((a, b) => a - b); } else { result.push(head); head = next; } } result.push(head); return result; } // ---------- 3. 学年学期 ---------- // 学年/学期是 ,chosen 组件会在其后插入 div#xnm_chosen 容器 function findTermSelect(name) { const select = document.getElementById(name); if (select) return select; const chosen = document.getElementById(name + '_chosen'); const sibling = chosen ? chosen.previousElementSibling : null; return sibling && sibling.tagName === 'SELECT' ? sibling : null; } function readSelectOptions(name) { const select = findTermSelect(name); const options = []; if (!select) return options; // 注意:教务页面改写了 Array.prototype.filter/some/every(回调实参变成下标), // 所以这里全部用普通循环,不调用这些方法。 for (const option of Array.from(select.options)) { const value = text(option.value); if (value === '') continue; options.push({ value: value, text: text(option.textContent) || value, selected: option.selected === true }); } return options; } // 默认选中教务当前学年学期,没有标记则取第一项 function defaultIndex(options) { for (let i = 0; i < options.length; i++) { if (options[i].selected) return i; } return 0; } async function selectTerm() { const yearOptions = readSelectOptions('xnm'); const semesterOptions = readSelectOptions('xqm'); if (!yearOptions.length || !semesterOptions.length) { await bridge.showAlert('读取学年学期失败', '未在页面中找到学年(.xnm)/学期(.xqm)下拉。\n请确认:\n' + '1. 已登录教务系统(校外需先登录 WebVPN);\n' + '2. 当前停留在「信息查询-学生课表查询」页面。', '知道了'); return null; } const yearIndex = await bridge.showSingleSelection('选择学年', JSON.stringify(yearOptions.map((item) => item.text)), defaultIndex(yearOptions)); if (yearIndex === null || yearIndex === -1) return null; const semesterIndex = await bridge.showSingleSelection('选择学期', JSON.stringify(semesterOptions.map((item) => item.text)), defaultIndex(semesterOptions)); if (semesterIndex === null || semesterIndex === -1) return null; return { xnm: yearOptions[yearIndex].value, xnmText: yearOptions[yearIndex].text, xqm: semesterOptions[semesterIndex].value, xqmText: semesterOptions[semesterIndex].text }; } // 返回该校区节次作息数组;接口取不到时用教务处公布的作息,未知校区返回 null(跳过作息导入) async function fetchTimeSlots(campus, term) { if (campus.id !== '0') { const slots = parseTimeSlots(await request(API.timeSlots, Object.assign({ xqh_id: campus.id }, term))); if (slots.length) return slots; } return fallbackSlots(campus.name); } // ---------- 4. 主流程 ---------- try { if (!await bridge.showAlert('江苏科技大学课表导入', '导入前请确保已登录教务系统。\n校外需先登录 WebVPN(client.v.just.edu.cn),' + '并进入教务系统页面(如「信息查询-学生课表查询」)。', '好的,开始导入')) { native.showToast('用户取消了导入。'); return; } const term = await selectTerm(); if (!term) { native.showToast('导入已取消。'); return; } native.showToast('正在获取课表数据…'); const params = { xnm: term.xnm, xqm: term.xqm }; const [courseData, calendarRows] = await Promise.all([ request(API.course, Object.assign({ kzlx: 'ck', xsdm: '', kclbdm: '', kclxdm: '' }, params)), request(API.calendar, params) ]); const calendar = parseCalendar(calendarRows); if (!courseData || !Array.isArray(courseData.kbList)) { await bridge.showAlert('获取课表失败', '教务系统未返回课表数据,请确认已登录、且当前在教务系统页面内。', '知道了'); return; } // sjkList 是实践/集中教学安排,其中带星期与节次的同样可以排进课表 const entries = collectCourses(courseData.kbList).concat(collectCourses(courseData.sjkList)); if (!entries.length) { await bridge.showAlert('没有可导入的课程', '所选学期没有解析出已排课程。', '知道了'); return; } const campusMap = new Map(); for (const entry of entries) { const campus = campusMap.get(entry.campusId) || { id: entry.campusId, name: entry.campusName, count: 0 }; campus.count++; campusMap.set(entry.campusId, campus); } const campuses = [...campusMap.values()].sort((a, b) => b.count - a.count); native.showToast('正在获取校区作息…'); const slotsMap = new Map(); await Promise.all(campuses.map(async (campus) => { slotsMap.set(campus.id, await fetchTimeSlots(campus, params)); })); let campusIndex = 0; if (campuses.length > 1) { const index = await bridge.showSingleSelection('选择默认作息校区', JSON.stringify(campuses.map((campus) => `${campus.name || campus.id}(${campus.count} 条)`)), 0); if (index === null || index === -1) { native.showToast('导入已取消。'); return; } campusIndex = index; } const presetCampus = campuses[campusIndex]; const presetSlots = slotsMap.get(presetCampus.id) || null; // 与默认作息不同的校区,课程改用自定义时间 const courses = entries.map((entry) => { const course = Object.assign({}, entry.course); const slots = slotsMap.get(entry.campusId) || presetSlots; if (presetSlots && slots && JSON.stringify(slots) !== JSON.stringify(presetSlots)) { const first = slots[course.startSection - 1]; const last = slots[course.endSection - 1]; if (first && last) { course.isCustomTime = true; course.customStartTime = first.startTime; course.customEndTime = last.endTime; } } return course; }); const merged = mergeCourses(courses); // 课表配置 const maxWeek = Math.max(0, ...merged.map((course) => Math.max(...course.weeks))); const config = { semesterTotalWeeks: Math.max(calendar ? calendar.totalWeeks : 0, maxWeek, 1) }; if (calendar) { config.semesterStartDate = calendar.startDate; config.firstDayOfWeek = calendar.firstDayOfWeek; } const firstWeekday = Number(text(courseData.qsxqj)); if (firstWeekday >= 1 && firstWeekday <= 7) config.firstDayOfWeek = firstWeekday; if (presetSlots) { const classDuration = toMinutes(presetSlots[0].endTime) - toMinutes(presetSlots[0].startTime); if (classDuration > 0) config.defaultClassDuration = classDuration; if (presetSlots.length > 1) { const breakDuration = toMinutes(presetSlots[1].startTime) - toMinutes(presetSlots[0].endTime); if (breakDuration > 0) config.defaultBreakDuration = breakDuration; } } await bridge.saveImportedCourses(JSON.stringify(merged)); if (presetSlots) await bridge.savePresetTimeSlots(JSON.stringify(presetSlots)); await bridge.saveCourseConfig(JSON.stringify(config)); // 课表下方"未确认上课节次"的安排(实践课程、网络课程等)无法排进课表,直接忽略; // 只有本校区作息没取到时才提示一次 if (!presetSlots) { await bridge.showAlert('导入完成', `${term.xnmText} 学年第 ${term.xqmText} 学期:已导入 ${merged.length} 条排课记录。\n\n` + '未能读取本校区节次作息,已跳过作息导入,请在应用内手动设置节次时间。', '知道了'); } native.showToast(`课程导入成功,共导入 ${merged.length} 条排课记录!`); native.notifyTaskCompletion(); } catch (error) { await bridge.showAlert('江苏科技大学课表导入失败', (error && error.message) || String(error), '知道了'); } finally { delete window.__justImportRunning; } })();