| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464 |
- // 江苏科技大学(just.edu.cn) 拾光课程表适配脚本
- // 教务系统:正方新一代教务(/jwglxt)
- // 校外访问:深信服 enlink WebVPN,统一身份认证 https://client.v.just.edu.cn/
- // WebVPN 代理路径形如 /http/webvpn<hex>/jwglxt/...,<hex> 与登录会话相关不能写死,
- // 脚本从当前页面 URL 动态取前缀。
- // 课表页的学年/学期是 <select id="xnm">(学年)/ <select id="xqm">(学期),
- // 被 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://<host>/jwglxt/...,WebVPN 下前面还有 /http/webvpn<hex>(<hex> 与会话相关)
- 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. 学年学期 ----------
- // 学年/学期是 <select id="xnm"> / <select id="xqm">,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;
- }
- })();
|