| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380 |
- // 河南科技学院(青果 / KINGOSOFT 教务系统) 拾光课程表适配脚本
- //
- // 教务系统入口:http://jwgl.hist.edu.cn/cas/login.action
- // 课程表数据接口:/wsxk/xkjg.ckdgxsxdkchj_data10319.jsp?params=<base64(xn=..&xq=..)>
- // 说明: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();
|