// 中南林业科技大学(csuft.edu.cn) 拾光课程表适配脚本 // 强智教务系统(新版 qz- 周历表界面),通过 WebVPN 访问 // // 2026-09 页面改版同步: // 1. 教务域名由 jwgl 变更为 jwxt:http-jwgl-... -> http-jwxt-... // 2. 课表表格由 #kbtable 改为 table.qz-weeklyTable(layui 周历表) // 课程结构:td[name=kbDataTd] > ul.courselists > li.courselists-item // 课程名 .qz-hasCourse-title,其余信息在 .qz-hasCourse-abbrinfo // (形如 "老师:张三;时间:1-8周[1-2节];地点:树人楼(树人楼北502)") // 3. 请求方式由 POST 改为 GET,新增 时间模式(kbjcmsid) 等参数 // 4. 跨大节的课会用 rowspan 合并单元格,必须还原网格才能定位星期 // 5. 2026-09 按官方《适配脚本开发指南 v2》迁移桥接 API: // window.shiguangBridgePromise(异步)+ window.shiguangBridge(同步) // notifyTaskCompletion 仅在整条流程成功后调用 // 6. 2026-09-30 通过 /jsxsd/jxzl/jxzl_query(教学周历)自动获取开学日期, // 传入 saveCourseConfig 的 semesterStartDate;失败时静默降级 // 7. 2026-11-01 WebVPN 资源由 http-jwxt-...-80 迁到 https-jwxt-...-443, // 改为多域名探测(同源 → https-443 → 旧 http-80),避免跨域取不到学期列表 // WebVPN 把目标站点编码进域名(协议-主机-端口),学校换过一次(jwgl→jwxt), // 2026-11 起主用 https-443;旧 http-80 仍在线,但会 302 到新域名。 // 顺序:当前所在教务页(同源、无跨域)→ 新域名 → 旧域名兜底。 var JWXT_ORIGINS = (function () { var list = []; try { if (/jwxt/i.test(location.hostname)) list.push(location.origin); } catch (e) { } list.push("https://https-jwxt-csuft-edu-cn-443.webvpn.csuft.edu.cn"); list.push("https://http-jwxt-csuft-edu-cn-80.webvpn.csuft.edu.cn"); var out = [], seen = {}; for (var i = 0; i < list.length; i++) { if (list[i] && !seen[list[i]]) { seen[list[i]] = 1; out.push(list[i]); } } return out; })(); // 记住本次会话里可用的域名,后续请求直接优先它,避免反复试探失效域名 var JWXT_GOOD_ORIGIN = null; function orderedOrigins() { if (!JWXT_GOOD_ORIGIN) return JWXT_ORIGINS; var out = [JWXT_GOOD_ORIGIN]; for (var i = 0; i < JWXT_ORIGINS.length; i++) { if (JWXT_ORIGINS[i] !== JWXT_GOOD_ORIGIN) out.push(JWXT_ORIGINS[i]); } return out; } // 依次尝试各教务域名,返回第一个「响应正常且内容符合预期」的页面文本。 // 返回 {text, origin} 成功;{error} 全部失败。 async function fetchJwxt(pathAndQuery, options, check) { var lastError = ""; var origins = orderedOrigins(); for (var i = 0; i < origins.length; i++) { // 单个域名最多等 15 秒,避免某个已失效的域名把整条导入卡住 var ctrl = (typeof AbortController !== "undefined") ? new AbortController() : null; var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, 15000) : null; try { var opts = Object.assign({ credentials: "include" }, options || {}); if (ctrl) opts.signal = ctrl.signal; var resp = await fetch(origins[i] + "/jsxsd" + pathAndQuery, opts); if (!resp.ok) { lastError = "HTTP " + resp.status; continue; } var text = await resp.text(); if (!check || check(text)) { JWXT_GOOD_ORIGIN = origins[i]; return { text: text, origin: origins[i] }; } lastError = "返回页面异常"; } catch (e) { lastError = (e && e.message) ? e.message : String(e); } finally { if (timer) clearTimeout(timer); } } return { error: lastError || "无法访问教务系统" }; } var WEEK_DAY_MAP = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7, '天': 7 }; var TIME_SLOTS = [ { number: 1, startTime: "08:00", endTime: "08:45" }, { number: 2, startTime: "08:55", endTime: "09:40" }, { number: 3, startTime: "10:00", endTime: "10:45" }, { number: 4, startTime: "10:55", endTime: "11:40" }, { number: 5, startTime: "14:00", endTime: "14:45" }, { number: 6, startTime: "14:55", endTime: "15:40" }, { number: 7, startTime: "16:00", endTime: "16:45" }, { number: 8, startTime: "16:55", endTime: "17:40" }, { number: 9, startTime: "19:00", endTime: "19:45" }, { number: 10, startTime: "19:55", endTime: "20:40" } ]; function trimText(s) { return (s || '').replace(/^[\s\u00a0]+|[\s\u00a0]+$/g, ''); } function uniqueSort(arr) { var seen = {}, out = []; for (var i = 0; i < arr.length; i++) { if (!seen[arr[i]]) { seen[arr[i]] = 1; out.push(arr[i]); } } out.sort(function (a, b) { return a - b; }); return out; } // 解析周次:支持 "1-8周"、"2,4,6,8周"、"14周"、"1-8周(单)"、"1-8周(双)" function parseWeeks(timeStr) { var weeks = []; if (!timeStr) return weeks; var s = String(timeStr).split('[')[0]; var odd = null; if (/[((]\s*单\s*[))]/.test(s)) odd = true; else if (/[((]\s*双\s*[))]/.test(s)) odd = false; s = s.replace(/[((][^(())]*[))]/g, '').replace(/周/g, '').replace(/\s/g, ''); var segs = s.split(/[,,]/); for (var i = 0; i < segs.length; i++) { var m = segs[i].match(/^(\d+)(?:\s*[-~]\s*(\d+))?$/); if (!m) continue; var a = parseInt(m[1], 10); var b = m[2] ? parseInt(m[2], 10) : a; if (isNaN(a) || isNaN(b) || b < a) continue; for (var w = a; w <= b; w++) { if (odd === true && w % 2 === 0) continue; if (odd === false && w % 2 === 1) continue; weeks.push(w); } } return uniqueSort(weeks); } // 把带 rowspan/colspan 的表格还原成二维网格 function buildGrid(table) { var grid = []; var trs = table.getElementsByTagName('tr'); for (var r = 0; r < trs.length; r++) { if (!grid[r]) grid[r] = []; var cells = trs[r].children; var c = 0; for (var i = 0; i < cells.length; i++) { var cell = cells[i]; while (grid[r][c]) c++; var rs = parseInt(cell.getAttribute('rowspan'), 10); var cs = parseInt(cell.getAttribute('colspan'), 10); if (!rs || rs < 1) rs = 1; if (!cs || cs < 1) cs = 1; for (var dr = 0; dr < rs; dr++) { if (!grid[r + dr]) grid[r + dr] = []; for (var dc = 0; dc < cs; dc++) grid[r + dr][c + dc] = cell; } c += cs; } } return grid; } // 去掉地点中重复的楼栋名,如 "树人楼(树人楼北508)" -> "树人楼(北508)" function dedupePosition(pos) { if (!pos) return pos; var m = pos.match(/^(.+?)\s*[((]\s*\1\s*(.+?)[))]\s*$/); return m ? m[1] + '(' + m[2] + ')' : pos; } function textOf(el, selector) { var t = el.querySelector(selector); return t ? trimText(t.textContent) : ''; } function parseSchedule(doc) { var table = doc.querySelector('table.qz-weeklyTable'); if (!table) return []; var grid = buildGrid(table); // 1. 表头行:列号 -> 星期 var colDay = {}; var headerRow = -1; for (var r = 0; r < grid.length; r++) { var row = grid[r] || []; var hit = false; for (var c = 1; c < row.length; c++) { var txt = trimText(row[c] ? row[c].textContent : '').replace(/\s/g, ''); var m = txt.match(/^星期([一二三四五六日天])/); if (m) { colDay[c] = WEEK_DAY_MAP[m[1]]; hit = true; } } if (hit) { headerRow = r; break; } } if (headerRow < 0) return []; // 2. 定位每个单元格的星期 与 行对应的节次(rowspan 合并的行会被正确填充) for (var r2 = headerRow + 1; r2 < grid.length; r2++) { var row2 = grid[r2] || []; var label = row2[0]; var lm = label ? trimText(label.textContent).match(/第\s*(\d+)\s*[,,、]\s*(\d+)\s*节/) : null; var sec = lm ? [parseInt(lm[1], 10), parseInt(lm[2], 10)] : null; for (var c2 = 1; c2 < row2.length; c2++) { var cell = row2[c2]; if (!cell || !colDay[c2]) continue; if (cell.__sgDay === undefined) cell.__sgDay = colDay[c2]; if (cell.__sgSec === undefined) cell.__sgSec = sec; } } // 3. 逐单元格解析课程 var raw = []; var cells = table.querySelectorAll('td[name="kbDataTd"]'); for (var i = 0; i < cells.length; i++) { var td = cells[i]; var day = td.__sgDay; if (!day) continue; var items = td.querySelectorAll('li.courselists-item') || []; for (var j = 0; j < items.length; j++) { var li = items[j]; var name = textOf(li, '.qz-hasCourse-title'); if (!name) continue; var abbr = textOf(li, '.qz-hasCourse-abbrinfo'); var teacher = (abbr.match(/老师[::]\s*([^;;]*)/) || [])[1] || ''; var position = (abbr.match(/地点[::]\s*([^;;]*)/) || [])[1] || ''; if (teacher) teacher = trimText(teacher); if (position) position = trimText(dedupePosition(position)); var weeks = [], start = 0, end = 0; var times = abbr.match(/时间[::]\s*([^;;]*)/g) || []; for (var k = 0; k < times.length; k++) { var v = trimText(times[k].replace(/^时间[::]/, '')); weeks = weeks.concat(parseWeeks(v)); var sm = v.match(/[\[[]\s*(\d+)\s*[-~]\s*(\d+)\s*节\s*[\]]]/); if (sm) { var s1 = parseInt(sm[1], 10), e1 = parseInt(sm[2], 10); start = start ? Math.min(start, s1) : s1; end = Math.max(end, e1); } } weeks = uniqueSort(weeks); // 时间字段缺失时,退回使用行首的节次标签 if (!start && td.__sgSec) { start = td.__sgSec[0]; end = td.__sgSec[1]; } if (!start) continue; raw.push({ name: name, teacher: teacher, weeks: weeks, position: position, day: day, startSection: start, endSection: end || start }); } } return mergeCourses(raw); } // 从教学周历页(jxzl_query)解析开学日期与总周数,返回 {startDate, totalWeeks}。 // 新版强智(qz-)校历每行形如 "第1周 | 07 | 08 | 09 | 10 | 11 | 09月12日 | 09月13日": // 周一~周五只显示日号,周末才有"月日",所以取最早的带月份单元格作锚点, // 用 (锚点日期 - 距第1周周一的天数) 反推第一周周一;年份由学期号推断。 function parseTermInfo(html, semId) { var doc = new DOMParser().parseFromString(html, "text/html"); var pad = function (n) { return (n < 10 ? '0' : '') + n; }; var fullRe = /(\d{4})\s*[-/.年]\s*(\d{1,2})\s*[-/.月]\s*(\d{1,2})/; var mdRe = /(\d{1,2})\s*月\s*(\d{1,2})/; var anchors = [], maxWeek = 0; var table = null, tables = doc.getElementsByTagName('table'); for (var i = 0; i < tables.length; i++) { if (/第\s*1\s*周/.test(tables[i].textContent)) { table = tables[i]; break; } } if (!table) return null; var rows = table.getElementsByTagName('tr'); for (var r = 0; r < rows.length; r++) { var cells = rows[r].getElementsByTagName('td'); if (cells.length < 2) continue; var wm = trimText(cells[0].textContent).match(/第\s*(\d+)\s*周/); if (!wm) continue; var w = parseInt(wm[1], 10); if (w > maxWeek) maxWeek = w; for (var d = 0; d < 7 && d + 1 < cells.length; d++) { var t = trimText(cells[d + 1].textContent); var m = t.match(fullRe) || t.match(mdRe); if (!m) continue; anchors.push({ idx: (w - 1) * 7 + d, year: m.length === 4 ? parseInt(m[1], 10) : null, month: parseInt(m[m.length - 2], 10), day: parseInt(m[m.length - 1], 10) }); } } if (!anchors.length) return null; anchors.sort(function (a, b) { return a.idx - b.idx; }); var a0 = anchors[0]; var y0 = parseInt((String(semId).match(/^(\d{4})/) || [])[1], 10); var year = a0.year || (a0.month >= 8 ? (y0 || new Date().getFullYear()) : (y0 ? y0 + 1 : new Date().getFullYear())); var start = new Date(year, a0.month - 1, a0.day - a0.idx); // Date 自动处理跨月/负数日 if (isNaN(start.getTime())) return null; return { startDate: start.getFullYear() + '-' + pad(start.getMonth() + 1) + '-' + pad(start.getDate()), totalWeeks: maxWeek || null }; } // 按 YSSDUFE 标准姿势获取学期配置:POST 带 xnxq01id 请求所选学期的校历, // 失败则退回 GET 带学期参数(ZHKU 姿势)→ GET 不带参数(仅当前学期)。 async function fetchTermInfo(semesterId) { var attempts = [ { path: "/jxzl/jxzl_query", options: { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "xnxq01id=" + encodeURIComponent(semesterId || "") } }, { path: "/jxzl/jxzl_query" + (semesterId ? "?xnxq01id=" + encodeURIComponent(semesterId) : ""), options: { method: "GET" } } ]; for (var i = 0; i < attempts.length; i++) { var got = await fetchJwxt(attempts[i].path, attempts[i].options, function (t) { return /第\s*1\s*周/.test(t); }); if (!got.text) continue; var info = parseTermInfo(got.text, semesterId); if (info && info.startDate) return info; } return null; } // 兜底:首页月历(/jsxsd/framework/mainv_zltable.jsp?ny=YYYY-MM)用 isActiveWeek // 标出当前教学周,结合今天的日期即可反推第一周周一。 // 只对「当前学期」有效——月历显示的永远是本学期,历史学期不能用。 function parseActiveWeek(html) { var m = html.match(/isActiveWeek[^>]*>\s*([^<]*)