// 河南科技学院(青果 / KINGOSOFT 教务系统) 拾光课程表适配脚本 // // 教务系统入口:http://jwgl.hist.edu.cn/cas/login.action // 课程表数据接口:/wsxk/xkjg.ckdgxsxdkchj_data10319.jsp?params= // 说明:params 为 Base64,内容是 "xn=2025&xq=0";xh(学号/uid) 可省略,服务端用会话即可。 // xn = 学年(如 2025 表示 2025-2026 学年);xq = 0 第一学期(秋季) / 1 第二学期(春季)。 // // 适配者:@星河欲转 社区贡献(河南科技学院) // 说明:本脚本按青果课表接口进行解析,若学校变更菜单编号(10319)或表格结构, // 请参照文档在“更多→开发者功能”中打开源码调试。 const HOST = 'http://jwgl.hist.edu.cn'; const KB_DATA_URL = HOST + '/wsxk/xkjg.ckdgxsxdkchj_data10319.jsp'; const DAY_MAP = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7, '天': 7 }; function base64Encode(str) { return btoa(str); } function isLoginPage() { const url = window.location.href; if (url.indexOf('/cas/') !== -1) return true; if (url.indexOf('jwgl.hist.edu.cn') !== -1 && /login\.action|login\.jsp|\/cas\//i.test(url)) return true; return false; } // ---------- token 解析 ---------- function matchDay(text) { // 兼容 "星期四[1-2]" / "周四[1-2]" / "二[7-8]"(青果导出常为单个汉字) let m = text.match(/(?:星期|周)([一二三四五六日天])/); if (m) return { day: DAY_MAP[m[1]] || 7, str: m[1] }; m = text.match(/([一二三四五六日天])\s*\[/); if (m) return { day: DAY_MAP[m[1]] || 7, str: m[1] }; return null; } function matchSection(text) { // 优先匹配方括号内的节次(青果:星期一[7-8] / 二[7-8]);方括号里的数字必为节次。 let m = text.match(/\[(\d{1,2})\s*[-—~]\s*(\d{1,2})\]/); if (m) { const s = parseInt(m[1], 10), e = parseInt(m[2], 10); if (s >= 1 && e >= s && e <= 30) return { start: s, end: e, str: m[0] }; } m = text.match(/(\d{1,2})\s*[-—~]\s*(\d{1,2})\s*节/); if (m) { const s = parseInt(m[1], 10), e = parseInt(m[2], 10); if (s >= 1 && e >= s && e <= 30) return { start: s, end: e, str: m[0] }; } m = text.match(/(?:第)?\s*(\d{1,2})\s*节/); if (m) { const s = parseInt(m[1], 10); if (s >= 1 && s <= 30) return { start: s, end: s, str: m[0] }; } return null; } function matchWeeks(text) { let flag = 0; if (text.includes('单')) flag = 1; else if (text.includes('双')) flag = 2; let clean = text.replace(/单/g, '').replace(/双/g, '').replace(/[()()]/g, ''); const m = clean.match(/([\d,,\-—~]+)\s*周/); if (!m) return []; const weeks = []; for (const part of m[1].split(/[,,]/)) { const p = part.trim(); if (!p) continue; const range = p.match(/^(\d{1,3})[\-—~](\d{1,3})$/); if (range) { const s = parseInt(range[1], 10), e = parseInt(range[2], 10); for (let i = s; i <= e; i++) { if (flag === 1 && i % 2 !== 0) weeks.push(i); else if (flag === 2 && i % 2 === 0) weeks.push(i); else if (flag === 0) weeks.push(i); } } else if (/^\d{1,3}$/.test(p)) { const i = parseInt(p, 10); if (flag === 1 && i % 2 !== 0) weeks.push(i); else if (flag === 2 && i % 2 === 0) weeks.push(i); else if (flag === 0) weeks.push(i); } } return [...new Set(weeks)].sort((a, b) => a - b); } function parseTimePlaceCell(text) { const items = []; if (!text) return items; const normalized = text .replace(///g, '/').replace(/,/g, ',').replace(/;/g, ';') .replace(/\u3000/g, ' ').replace(/<[^>]+>/g, ''); // 每个时段形如:周次 星期[节次] 地点;多个时段用逗号分隔。 // 用“周次 + 星期[节次]”为锚点整体捕获,避免把周次列表里的逗号(如 6-8,10周)误当成时段分隔。 const re = /(\S*周[^\s\[\]]*)\s*([一二三四五六日天])\s*\[(\d{1,2})\s*[-—~]\s*(\d{1,2})\]\s*([^,,;;\n]*)/g; let m; while ((m = re.exec(normalized)) !== null) { const day = DAY_MAP[m[2]] || 7; const start = parseInt(m[3], 10), end = parseInt(m[4], 10); if (!(start >= 1 && end >= start && end <= 30)) continue; const weeks = matchWeeks(m[1]); if (weeks.length === 0) continue; let position = (m[5] || '') .replace(/\((\d+)\)\s*$/, '') // 去掉结尾教室容量 "(98)" .replace(/[\[\]()()]/g, ' ') .replace(/[,,;;\/]/g, ' ') .replace(/单|双/g, ' ') .replace(/\s+/g, ' ').trim(); if (position === '' || /^\d+$/.test(position)) position = ''; items.push({ day, startSection: start, endSection: end, weeks, position }); } // 兜底:若某时段缺地点,而同行后面的时段有地点,则继承该地点 const lastPos = items.map(i => i.position).filter(p => p).slice(-1)[0] || ''; if (lastPos) { for (const it of items) if (!it.position) it.position = lastPos; } return items; } // ---------- HTML 解析 ---------- // 清理教师串:格式 "[工号]姓名 [工号]姓名 …" → "姓名 & 姓名" function cleanTeacher(t) { const name = t.split('[').map(part => { const idx = part.indexOf(']'); return idx >= 0 ? part.slice(idx + 1).replace(/ | /g, ' ').trim() : ''; }).filter(Boolean).join(' & '); return name || t.trim(); } function findNameAndTeacher(cells, dataIdx) { let name = '', teacher = '', nameIdx = -1; // 课程名:形如 [xxx]课程名 for (let i = 0; i < cells.length; i++) { if (i === dataIdx) continue; const t = (cells[i]?.textContent || '').trim(); if (/^\s*\[[^\]]*\]\s*\S/.test(t)) { name = t.replace(/^\s*\[[^\]]*\]\s*/g, '').trim(); nameIdx = i; break; } } // 教师:形如 [工号]姓名 …(含 [工号] 且不是课程名) for (let i = 0; i < cells.length; i++) { if (i === dataIdx || i === nameIdx) continue; const t = (cells[i]?.textContent || '').trim(); if (/\d/.test(t) && /\[[^\]]*\]\s*/.test(t)) { const ct = cleanTeacher(t); if (ct) { teacher = ct; break; } } } return { name, teacher }; } function parseScheduleHtml(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const rows = doc.querySelectorAll('tr'); const courses = []; for (const tr of rows) { const cells = Array.from(tr.querySelectorAll('td')); if (cells.length < 3) continue; let dataCell = null; let dataIdx = -1; for (let i = cells.length - 1; i >= 0; i--) { const t = cells[i].textContent || ''; // 时间地点单元格特征:含“周”(周次)或方括号节次 [N-M],或“星期X[”/“X[” if (/周/.test(t) || /\[(\d{1,2})\s*[-—~]\s*(\d{1,2})\]/.test(t) || /[一二三四五六日天]\s*\[/.test(t)) { dataCell = t; dataIdx = i; break; } } if (dataCell === null) continue; // 课程名与教师采用“按内容识别”,对导出/接口两种列布局都健壮: // 课程名单元格形如 "[2500T0002]大学生心理健康教育" // 教师单元格形如 "[201201059]贾普君 [202201013]李金璐" const info = findNameAndTeacher(cells, dataIdx); let name = info.name; if (!name) name = (cells[2]?.textContent || '').trim() || (cells[1]?.textContent || '').trim() || (cells[0]?.textContent || '').trim(); name = name.replace(/^\s*\[[^\]]*\]\s*/g, '').trim(); if (!name) continue; const teacher = info.teacher; const items = parseTimePlaceCell(String(dataCell)); for (const it of items) { courses.push({ name: name, teacher: teacher, position: it.position, day: it.day, startSection: it.startSection, endSection: it.endSection, weeks: it.weeks }); } } return mergeCourses(courses); } function mergeCourses(courses) { const map = new Map(); for (const c of courses) { const key = [c.name, c.teacher, c.position, c.day, c.startSection, c.endSection].join('|'); if (!map.has(key)) map.set(key, { ...c, weeks: [] }); const entry = map.get(key); entry.weeks = [...new Set([...entry.weeks, ...c.weeks])].sort((a, b) => a - b); } return Array.from(map.values()); } // ---------- 流程 ---------- function validateYearInput(input) { if (/^[0-9]{4}$/.test(input)) return false; return '请输入四位数字的学年!'; } async function promptUserToStart() { return await window.shiguangBridgePromise.showAlert( '教务系统课表导入', '导入前请确认已在本页面登录教务系统(登录成功后无需再点登录)。', '好的,开始导入' ); } async function selectYear() { const now = new Date(); const month = now.getMonth() + 1; let year = now.getFullYear(); if (month < 7) year -= 1; const input = await window.shiguangBridgePromise.showPrompt( '选择学年', '请输入要导入课程的起始学年(例如 2025-2026 学年输入 2025):', String(year), 'validateYearInput' ); if (input === null) return null; return parseInt(input, 10); } async function selectSemester() { const semesters = ['第一学期(秋季)', '第二学期(春季)']; return await window.shiguangBridgePromise.showSingleSelection('选择学期', JSON.stringify(semesters), 0); } // 自动判断响应编码:UTF-8 与 GBK 二选一(谁替换字符少、中文多就用谁) function decodeBest(buf) { const utf8 = (() => { try { return new TextDecoder('utf-8').decode(buf); } catch (e) { return ''; } })(); const gbk = (() => { try { return new TextDecoder('gbk').decode(buf); } catch (e) { return null; } })(); const repl = s => (s.match(/\uFFFD/g) || []).length; const cjk = s => (s.match(/[\u4e00-\u9fa5]/g) || []).length; if (gbk == null) return utf8; if (repl(utf8) === 0 && repl(gbk) > 0) return utf8; if (repl(gbk) === 0 && repl(utf8) > 0) return gbk; if (cjk(gbk) > cjk(utf8)) return gbk; return repl(gbk) < repl(utf8) ? gbk : utf8; } async function fetchScheduleHtml(academicYear, semesterIndex) { const xq = semesterIndex === 0 ? 0 : 1; const params = base64Encode(`xn=${academicYear}&xq=${xq}`); const url = `${KB_DATA_URL}?params=${encodeURIComponent(params)}`; window.shiguangBridge.showToast('正在请求课表数据…'); console.log('HIST: fetch ->', url); const resp = await fetch(url, { method: 'GET', credentials: 'include' }); const buf = await resp.arrayBuffer(); const text = decodeBest(buf); if (text.includes('凭证已失效') || text.includes('请重新登录')) { window.shiguangBridge.showToast('请先登录教务系统!'); return null; } return text; } // 内置备用作息:key 0=第一学期(2026-2027实测), 1=第二学期(旧版兜底,仅抓取失败时用) const FALLBACK_TIMES = { 0: [ { number: 1, startTime: '08:00', endTime: '08:45' }, { number: 2, startTime: '08:55', endTime: '09:40' }, { number: 3, startTime: '10:10', endTime: '10:55' }, { number: 4, startTime: '11:05', endTime: '11:50' }, { number: 5, startTime: '15:00', endTime: '15:45' }, { number: 6, startTime: '15:55', endTime: '16:40' }, { number: 7, startTime: '17:10', endTime: '17:55' }, { number: 8, startTime: '18:05', endTime: '18:50' }, { number: 9, startTime: '20:00', endTime: '20:45' }, { number: 10, startTime: '20:55', endTime: '21:40' } ], 1: [ { number: 1, startTime: '08:00', endTime: '08:45' }, { number: 2, startTime: '08:55', endTime: '09:40' }, { number: 3, startTime: '10:10', endTime: '10:55' }, { number: 4, startTime: '11:05', endTime: '11:50' }, { number: 5, startTime: '14:30', endTime: '15:15' }, { number: 6, startTime: '15:25', endTime: '16:10' }, { number: 7, startTime: '16:40', endTime: '17:25' }, { number: 8, startTime: '17:35', endTime: '18:20' }, { number: 9, startTime: '19:30', endTime: '20:15' }, { number: 10, startTime: '20:25', endTime: '21:10' } ] }; // 解析教务作息页表格:节次行形如 ["1","08:00","08:45"] 或 ["上午","1","08:00","08:45"] function parseTimetableHtml(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const out = []; for (const tr of doc.querySelectorAll('tr')) { const cells = Array.from(tr.querySelectorAll('td,th')) .map(c => (c.textContent || '').trim()).filter(c => c !== ''); let num = parseInt(cells[0] || '', 10); if (isNaN(num)) num = parseInt(cells[1] || '', 10); let start = '', end = ''; for (let i = 1; i < cells.length; i++) { if (/^\d{2}:\d{2}$/.test(cells[i])) { if (!start) start = cells[i]; else if (!end) { end = cells[i]; break; } } } if (num >= 1 && num <= 30 && start && end) out.push({ number: num, startTime: start, endTime: end }); } const map = new Map(); out.forEach(s => { if (!map.has(s.number)) map.set(s.number, s); }); return Array.from(map.values()).sort((a, b) => a.number - b.number); } // 动态抓取教务公布的当学期作息(公开同源接口,免登录) async function fetchTermTimeSlots(year, xq) { const url = HOST + '/public/SchoolTimetable.show.jsp'; const body = 'xn=' + year + '&xq_m=' + xq + '&menucode=&is_ssxq=0&axq=&ssxq='; const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: body, credentials: 'include' }); const buf = await resp.arrayBuffer(); const text = decodeBest(buf); if (text.includes('未设置') || text.includes('未设定') || text.includes('未发布')) return null; const slots = parseTimetableHtml(text); return slots.length ? slots : null; } async function runImportFlow() { if (isLoginPage()) { window.shiguangBridge.showToast('请先登录教务系统,再点击导入!'); return; } const confirmed = await promptUserToStart(); if (!confirmed) return; const year = await selectYear(); if (year === null) return; const semIdx = await selectSemester(); if (semIdx === null || semIdx === -1) return; const html = await fetchScheduleHtml(year, semIdx); if (!html) return; if (html.includes('凭证已失效') || html.includes('请重新登录')) { window.shiguangBridge.showToast('登录已失效,请重新登录后重试。'); return; } const courses = parseScheduleHtml(html); if (!courses || courses.length === 0) { window.shiguangBridge.showToast('未解析到课程,可能是本学期无课或页面结构已变化。'); console.log('HIST: raw html head ->', (html || '').slice(0, 1200)); return; } await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses, null, 2)); window.shiguangBridge.showToast(`课程解析成功:${courses.length} 条`); // 作息与学年学期强相关:合并为一步,按所选学期自动抓取教务公布作息;失败用内置备用并提示 const xq = semIdx === 0 ? 0 : 1; const termName = xq === 0 ? '第一学期' : '第二学期'; let slots = null; try { slots = await fetchTermTimeSlots(year, xq); } catch (e) { slots = null; } if (slots && slots.length) { await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(slots)); window.shiguangBridge.showToast(`已按 ${year}-${year + 1} 学年${termName}作息导入`); } else { const fallback = FALLBACK_TIMES[xq] || FALLBACK_TIMES[0]; await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(fallback)); window.shiguangBridge.showToast(`未能获取学校${termName}作息(可能未发布),已用备用作息,请核对`); } window.shiguangBridge.showToast(`导入完成,共 ${courses.length} 门课程!`); window.shiguangBridge.notifyTaskCompletion(); } runImportFlow();