| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379 |
- // ============================================================
- // 泉州职业技术大学(jw.qvtu.edu.cn/jsxsd,强智教务)
- // 拾光课程表适配脚本
- //
- // 参照官方强智适配案例(YSSDUFE_01,见 shiguangschedule Wiki
- // "常见教务系统适配案例参考")编写:
- // 1. 教学周历 jxzl_query 提供学期列表 / 开学日期 / 总周数;
- // 2. 学期理论课表 xskb_list.do 提供课程数据(#kbtable / div.kbcontent 官方结构)。
- // 若站点周历页结构与案例不同,自动降级:跳过选学期、不提交开学日期,
- // 任何情况都不会阻塞导入。
- // 流程遵循官方开发文档约定:学期配置 → 课程 → 作息时间(可选,失败不阻止完成);
- // notifyTaskCompletion 只在流程成功后调用。
- // 出现问题请联系开发者或提交 PR 更改。
- // ============================================================
- // 整体包一层 IIFE:本脚本可能被反复注入同一页面(测试器每次点击都会
- // 重新注入且不刷新页面),顶层 const/function 会与上一次注入冲突
- // (Identifier has already been declared),也会覆盖教务页面自己的全局函数。
- (function () {
- // ================= 配置 =================
- // 作息时间:来自教务"大节"时段,小节按"45 分钟上课 + 15 分钟休息"拆分。
- // 注:站点里"第七大节(13,14小节) 12:00-13:59"与晚间节次时间重叠,
- // 会被拾光"时间段不可重叠"校验拒绝,故不提供 13、14 节时间(当前无课程使用)。
- const QVTU_TIME_SLOTS = [
- { number: 1, startTime: "08:10", endTime: "08:55" },
- { number: 2, startTime: "09:10", endTime: "09:55" },
- { number: 3, startTime: "10:10", endTime: "10:55" },
- { number: 4, startTime: "11:10", endTime: "11:55" },
- { number: 5, startTime: "14:10", endTime: "14:55" },
- { number: 6, startTime: "15:10", endTime: "15:55" },
- { number: 7, startTime: "16:10", endTime: "16:55" },
- { number: 8, startTime: "17:10", endTime: "17:55" },
- { number: 9, startTime: "19:10", endTime: "19:55" },
- { number: 10, startTime: "20:10", endTime: "20:55" },
- { number: 11, startTime: "21:10", endTime: "21:55" },
- { number: 12, startTime: "22:10", endTime: "22:55" }
- ];
- // 教务系统地址与接口
- const QVTU_ORIGIN_CHECK = /jw\.qvtu\.edu\.cn$/i; // 只在教务站点上运行
- const QVTU_JXZL_URL = "/jsxsd/jxzl/jxzl_query"; // 教学周历(学期列表 + 开学日期 + 总周数)
- const QVTU_XSKB_URL = "/jsxsd/xskb/xskb_list.do"; // 学期理论课表
- const QVTU_REQUEST_TIMEOUT = 8000; // 单请求超时(毫秒)
- // ================= 工具函数 =================
- function toast(message) {
- window.shiguangBridge.showToast(message);
- }
- // 生命周期结束信号:只在流程成功后调用(官方约定)
- function notifyDone() {
- window.shiguangBridge.notifyTaskCompletion();
- }
- // 带超时保护的 fetch(门户切换页面时站点脚本会中断进行中的请求,失败重试一次)
- async function fetchText(url, options, tag) {
- const opts = options || { method: "GET", credentials: "include" };
- let lastErr = null;
- for (let attempt = 1; attempt <= 2; attempt++) {
- try {
- const p = fetch(url, opts).then(r => r.text());
- if (typeof setTimeout !== "function") return await p;
- return await Promise.race([p, new Promise((_, reject) => {
- setTimeout(() => reject(new Error("请求超时:" + tag)), QVTU_REQUEST_TIMEOUT);
- })]);
- } catch (e) {
- lastErr = e;
- }
- }
- throw new Error("请求失败(" + tag + "):" + (lastErr && lastErr.message ? lastErr.message : "网络错误"));
- }
- // 解析 HTML 文本为文档对象
- function parseHtml(html) {
- return new DOMParser().parseFromString(String(html || ""), "text/html");
- }
- // 只认可解析得了的课表(登录页/错误页不含这些标记)。
- // 不同强智版本课表格子用 class="kbcontent" 或 class="kb_table",本站另有 <p title=...> 格式。
- function hasCourse(text) {
- return /课程名称:|kbcontent|kb_table/.test(String(text || ""));
- }
- // ================= 核心解析逻辑(一):教学周历 =================
- // GET 周历页:解析学期下拉框,以当前选中学期为原点上下各取 3 个。
- // 站点若无 xnxq01id 下拉框则返回空列表(流程自动跳过选学期)。
- async function fetchSemesterList() {
- const doc = parseHtml(await fetchText(location.origin + QVTU_JXZL_URL, null, "教学周历页"));
- const select = doc.getElementById("xnxq01id");
- if (!select) return { list: [], defaultIndex: -1 };
- const all = Array.from(select.querySelectorAll("option")).map(opt => ({
- value: opt.value,
- label: opt.textContent.trim(),
- selected: opt.hasAttribute("selected")
- }));
- let idx = all.findIndex(o => o.selected);
- if (idx === -1) idx = 0;
- const start = Math.max(0, idx - 3);
- return {
- list: all.slice(start, Math.min(all.length, idx + 4)),
- defaultIndex: idx - start
- };
- }
- // POST 周历页:解析指定学期的开学日期(首行 td[title])与总周数(首列最大整数)。
- // 结构不符时返回 { startDate: null, totalWeeks: null },不阻塞流程。
- async function fetchSemesterInfo(semesterId) {
- const html = await fetchText(location.origin + QVTU_JXZL_URL, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: "xnxq01id=" + encodeURIComponent(semesterId),
- credentials: "include"
- }, "教学周历详情");
- const doc = parseHtml(html);
- const timetable = doc.getElementById("kbtable");
- if (!timetable) return { startDate: null, totalWeeks: null };
- const rows = Array.from(timetable.querySelectorAll("tr")).filter(r => r.querySelector("td"));
- if (!rows.length) return { startDate: null, totalWeeks: null };
- // 开学日期:第一行首个带 title 的 td,如 "2025年9月1日"(兼容不补零)
- const title = (rows[0].querySelector("td[title]") || {}).getAttribute
- ? rows[0].querySelector("td[title]").getAttribute("title") : "";
- const m = String(title).match(/^(\d{4})年(\d{1,2})月(\d{1,2})日$/);
- const pad = n => (n < 10 ? "0" + n : "" + n);
- const startDate = m ? m[1] + "-" + pad(+m[2]) + "-" + pad(+m[3]) : null;
- // 总周数:所有行首列能解析为整数的最大值
- let totalWeeks = null;
- for (const row of rows) {
- const td = row.querySelector("td");
- if (!td) continue;
- const n = parseInt(td.textContent.trim(), 10);
- if (!isNaN(n) && n > 0 && (totalWeeks === null || n > totalWeeks)) totalWeeks = n;
- }
- return { startDate, totalWeeks };
- }
- // ================= 核心解析逻辑(二):课程 =================
- // "第3周"、"第1-16周"、"第1,3,5-8周"、"第1-15周(单周)" → [3] / [1..16] / 奇数周…
- function parseWeeks(text) {
- let t = String(text || "").replace(/\s/g, "");
- const parity = t.indexOf("单周") >= 0 ? 1 : t.indexOf("双周") >= 0 ? 2 : 0;
- t = t.replace(/[((][^))]*[))]/g, "");
- const set = {};
- const rangeRe = /(\d+)\s*[-–—~]\s*(\d+)/g;
- let m;
- while ((m = rangeRe.exec(t)) !== null) {
- for (let w = Math.min(+m[1], +m[2]); w <= Math.max(+m[1], +m[2]); w++) set[w] = 1;
- }
- for (const s of t.replace(rangeRe, " ").match(/\d+/g) || []) set[+s] = 1;
- const weeks = [];
- for (const k in set) {
- const n = +k;
- if (n >= 1 && n <= 30 && !(parity === 1 && n % 2 === 0) && !(parity === 2 && n % 2 === 1)) weeks.push(n);
- }
- return weeks.sort((a, b) => a - b);
- }
- // 把解析结果合并进总表(同名同天同节次区间同地点 → 合并周次)
- function mergeCourse(map, c) {
- if (!c.name || !c.day || !c.weeks.length || !c.startSection) return;
- const key = [c.name, c.day, c.startSection + "-" + c.endSection, c.position].join("|");
- if (!map[key]) {
- map[key] = c;
- } else {
- for (const w of c.weeks) if (!map[key].weeks.includes(w)) map[key].weeks.push(w);
- map[key].weeks.sort((a, b) => a - b);
- }
- }
- // 解析路径 A:官方案例的 DOM 结构(#kbtable 内 div.kbcontent,多课程以长横线分隔)
- function parseKbcontent(doc) {
- const timetable = doc.getElementById("kbtable");
- if (!timetable) return [];
- const map = {};
- const rows = Array.from(timetable.querySelectorAll("tr")).filter(r => r.querySelector("td"));
- rows.forEach(row => {
- const cells = row.querySelectorAll("td");
- cells.forEach((cell, dayIndex) => {
- const day = dayIndex + 1;
- cell.querySelectorAll("div.kbcontent").forEach(div => {
- const raw = div.innerHTML.trim();
- if (!raw || raw === " " || div.innerText.trim().length < 2) return;
- raw.split(/-{10,}/).forEach(block => {
- if (!block.trim()) return;
- const box = document.createElement("div");
- box.innerHTML = block;
- let name = "";
- for (const node of box.childNodes) {
- if (node.nodeType === 3 && node.textContent.trim() !== "") { name = node.textContent.trim(); break; }
- }
- const teacher = (box.querySelector('font[title="老师"], font[title="教师"]') || {}).innerText || "";
- const position = (box.querySelector('font[title="教室"]') || {}).innerText || "未知地点";
- const weekStr = ((box.querySelector('font[title="周次(节次)"]') || {}).innerText) || "";
- let startSection = 0, endSection = 0;
- const sec = weekStr.match(/\[(.*?)节\]/);
- if (sec && sec[1]) {
- const nums = sec[1].split("-").map(Number).filter(n => !isNaN(n));
- if (nums.length) { startSection = nums[0]; endSection = nums[nums.length - 1]; }
- }
- if (name && startSection > 0) {
- mergeCourse(map, {
- name, teacher: teacher.replace(/任课教师[::]/, "").trim() || "未知教师",
- position, day, weeks: parseWeeks(weekStr),
- startSection, endSection
- });
- }
- });
- });
- });
- });
- return Object.values(map);
- }
- // ================= 数据抓取与编排 =================
- async function fetchAndParseCourses() {
- if (!QVTU_ORIGIN_CHECK.test(location.hostname)) {
- throw new Error("请先在浏览器/WebView 打开教务网站(https://jw.qvtu.edu.cn/jsxsd)并登录,再运行。当前页面:" + location.href);
- }
- // 1) 学期列表 + 用户选择(站点无学期下拉框时自动跳过,导入当前学期)
- let semesterId = null;
- const { list, defaultIndex } = await fetchSemesterList();
- if (list.length) {
- const picked = await window.shiguangBridgePromise.showSingleSelection(
- "选择学期", JSON.stringify(list.map(s => s.label)), defaultIndex
- );
- if (picked === null) { toast("已取消导入。"); return null; }
- semesterId = list[picked].value;
- }
- // 2) 教学周历 → 开学日期 + 总周数(结构不符则降级为 null)
- let startDate = null, totalWeeks = null;
- if (semesterId) {
- const info = await fetchSemesterInfo(semesterId);
- startDate = info.startDate;
- totalWeeks = info.totalWeeks;
- }
- // 3) 课表:选了学期用 POST 指定学期,否则 GET 当前学期
- const html = await fetchText(location.origin + QVTU_XSKB_URL, semesterId ? {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: "jx0404id=&cj0701id=&zc=&demo=&xnxq01id=" + encodeURIComponent(semesterId),
- credentials: "include"
- } : null, "学期课表页");
- if (!hasCourse(html)) {
- if (/用户登录|请登录|loginForm|passwd/i.test(String(html))) {
- throw new Error("教务会话已过期或尚未登录(接口返回登录页)。请重新登录教务系统后运行。");
- }
- console.warn("[泉州职业技术大学适配器] 学期课表页返回了意外内容(前 300 字符):",
- String(html || "").replace(/\s+/g, " ").slice(0, 300));
- throw new Error("学期课表页返回了无法识别的内容,请按 F12 打开控制台,把警告信息发给开发者核对。");
- }
- // 4) 解析(官方案例 DOM 结构)
- const courses = parseKbcontent(parseHtml(html));
- if (!courses.length) {
- throw new Error("教务页面里没有解析到课程。请确认所选学期已有排课,或把错误信息发给我核对。");
- }
- // 5) 周历未给出总周数时,取课程最大周次兜底
- if (!totalWeeks) {
- for (const c of courses) for (const w of c.weeks) if (w > totalWeeks) totalWeeks = w;
- }
- return { courses, startDate, totalWeeks };
- }
- // ================= 保存步骤(顺序参照官方开发文档) =================
- // 保存学期配置(开学日期/总周数来自教学周历,没拿到就不提交/用课程最大周兜底)
- // defaultClassDuration/defaultBreakDuration 与作息表一致(45 分钟一节、小节间休息 15 分钟)
- async function saveCourseConfig(startDate, totalWeeks) {
- try {
- const config = {
- firstDayOfWeek: 1,
- defaultClassDuration: 45,
- defaultBreakDuration: 15,
- semesterTotalWeeks: totalWeeks > 0 ? totalWeeks : 20
- };
- if (startDate) config.semesterStartDate = startDate;
- const ok = await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
- if (!ok) { toast("学期配置保存失败。"); return false; }
- return true;
- } catch (e) {
- toast("学期配置保存失败:" + e.message);
- return false;
- }
- }
- // 保存课程数据
- async function saveCourses(courses) {
- try {
- const ok = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
- if (!ok) { toast("课程数据保存失败。"); return false; }
- return true;
- } catch (e) {
- toast("课程数据保存失败:" + e.message);
- return false;
- }
- }
- // 保存作息时间表(官方文档:可选步骤,失败不阻止导入完成)
- async function savePresetTimeSlots() {
- try {
- const ok = await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(QVTU_TIME_SLOTS));
- if (!ok) toast("作息时间保存失败。");
- return !!ok;
- } catch (e) {
- toast("作息时间保存失败:" + e.message);
- return false;
- }
- }
- // ================= 流程编排 =================
- /**
- * 编排整个课程导入流程(顺序与约定参照官方开发文档)。
- * 用户取消或关键步骤保存失败时立即结束;作息时间为可选步骤,失败不阻止完成;
- * notifyTaskCompletion 只在流程成功后调用(官方约定)。
- */
- async function runImportFlow() {
- try {
- // 1. 公告和前置检查
- const confirmed = await window.shiguangBridgePromise.showAlert(
- "提示",
- "请确保已成功登录泉州职业技术大学教务系统。是否开始导入?",
- "开始"
- );
- if (!confirmed) {
- toast("已取消导入。");
- return;
- }
- // 2. 网络请求和数据解析(内部含选学期;取消时返回 null)
- toast("正在获取学期课表…");
- const result = await fetchAndParseCourses();
- if (!result) return;
- const { courses, startDate, totalWeeks } = result;
- // 3. 保存学期配置
- if (!await saveCourseConfig(startDate, totalWeeks)) return;
- // 4. 保存课程数据
- toast("正在保存 " + courses.length + " 条课程安排…");
- if (!await saveCourses(courses)) return;
- // 5. 导入作息时间表(可选步骤,失败不阻止导入完成)
- if (!await savePresetTimeSlots()) {
- toast("提示:作息时间未导入,课程已保存,可在 App 里手动设置或稍后重试。");
- }
- // 6. 流程成功,发送结束信号
- toast(`成功导入 ${courses.length} 条课程安排!` +
- (startDate ? `学期 ${startDate} 起,共 ${totalWeeks} 周。` : "开学日期未能自动获取,可在 App 里手动设置。"));
- notifyDone();
- } catch (error) {
- // 任何一步失败:记录并提示用户,与官方适配一致不向外抛出,也不发送完成信号
- console.error("[泉州职业技术大学适配器]", error);
- toast("导入失败:" + (error && error.message ? error.message : error));
- }
- }
- // 启动导入流程
- runImportFlow();
- })();
|