qvtu.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. // ============================================================
  2. // 泉州职业技术大学(jw.qvtu.edu.cn/jsxsd,强智教务)
  3. // 拾光课程表适配脚本
  4. //
  5. // 参照官方强智适配案例(YSSDUFE_01,见 shiguangschedule Wiki
  6. // "常见教务系统适配案例参考")编写:
  7. // 1. 教学周历 jxzl_query 提供学期列表 / 开学日期 / 总周数;
  8. // 2. 学期理论课表 xskb_list.do 提供课程数据(#kbtable / div.kbcontent 官方结构)。
  9. // 若站点周历页结构与案例不同,自动降级:跳过选学期、不提交开学日期,
  10. // 任何情况都不会阻塞导入。
  11. // 流程遵循官方开发文档约定:学期配置 → 课程 → 作息时间(可选,失败不阻止完成);
  12. // notifyTaskCompletion 只在流程成功后调用。
  13. // 出现问题请联系开发者或提交 PR 更改。
  14. // ============================================================
  15. // 整体包一层 IIFE:本脚本可能被反复注入同一页面(测试器每次点击都会
  16. // 重新注入且不刷新页面),顶层 const/function 会与上一次注入冲突
  17. // (Identifier has already been declared),也会覆盖教务页面自己的全局函数。
  18. (function () {
  19. // ================= 配置 =================
  20. // 作息时间:来自教务"大节"时段,小节按"45 分钟上课 + 15 分钟休息"拆分。
  21. // 注:站点里"第七大节(13,14小节) 12:00-13:59"与晚间节次时间重叠,
  22. // 会被拾光"时间段不可重叠"校验拒绝,故不提供 13、14 节时间(当前无课程使用)。
  23. const QVTU_TIME_SLOTS = [
  24. { number: 1, startTime: "08:10", endTime: "08:55" },
  25. { number: 2, startTime: "09:10", endTime: "09:55" },
  26. { number: 3, startTime: "10:10", endTime: "10:55" },
  27. { number: 4, startTime: "11:10", endTime: "11:55" },
  28. { number: 5, startTime: "14:10", endTime: "14:55" },
  29. { number: 6, startTime: "15:10", endTime: "15:55" },
  30. { number: 7, startTime: "16:10", endTime: "16:55" },
  31. { number: 8, startTime: "17:10", endTime: "17:55" },
  32. { number: 9, startTime: "19:10", endTime: "19:55" },
  33. { number: 10, startTime: "20:10", endTime: "20:55" },
  34. { number: 11, startTime: "21:10", endTime: "21:55" },
  35. { number: 12, startTime: "22:10", endTime: "22:55" }
  36. ];
  37. // 教务系统地址与接口
  38. const QVTU_ORIGIN_CHECK = /jw\.qvtu\.edu\.cn$/i; // 只在教务站点上运行
  39. const QVTU_JXZL_URL = "/jsxsd/jxzl/jxzl_query"; // 教学周历(学期列表 + 开学日期 + 总周数)
  40. const QVTU_XSKB_URL = "/jsxsd/xskb/xskb_list.do"; // 学期理论课表
  41. const QVTU_REQUEST_TIMEOUT = 8000; // 单请求超时(毫秒)
  42. // ================= 工具函数 =================
  43. function toast(message) {
  44. window.shiguangBridge.showToast(message);
  45. }
  46. // 生命周期结束信号:只在流程成功后调用(官方约定)
  47. function notifyDone() {
  48. window.shiguangBridge.notifyTaskCompletion();
  49. }
  50. // 带超时保护的 fetch(门户切换页面时站点脚本会中断进行中的请求,失败重试一次)
  51. async function fetchText(url, options, tag) {
  52. const opts = options || { method: "GET", credentials: "include" };
  53. let lastErr = null;
  54. for (let attempt = 1; attempt <= 2; attempt++) {
  55. try {
  56. const p = fetch(url, opts).then(r => r.text());
  57. if (typeof setTimeout !== "function") return await p;
  58. return await Promise.race([p, new Promise((_, reject) => {
  59. setTimeout(() => reject(new Error("请求超时:" + tag)), QVTU_REQUEST_TIMEOUT);
  60. })]);
  61. } catch (e) {
  62. lastErr = e;
  63. }
  64. }
  65. throw new Error("请求失败(" + tag + "):" + (lastErr && lastErr.message ? lastErr.message : "网络错误"));
  66. }
  67. // 解析 HTML 文本为文档对象
  68. function parseHtml(html) {
  69. return new DOMParser().parseFromString(String(html || ""), "text/html");
  70. }
  71. // 只认可解析得了的课表(登录页/错误页不含这些标记)。
  72. // 不同强智版本课表格子用 class="kbcontent" 或 class="kb_table",本站另有 <p title=...> 格式。
  73. function hasCourse(text) {
  74. return /课程名称:|kbcontent|kb_table/.test(String(text || ""));
  75. }
  76. // ================= 核心解析逻辑(一):教学周历 =================
  77. // GET 周历页:解析学期下拉框,以当前选中学期为原点上下各取 3 个。
  78. // 站点若无 xnxq01id 下拉框则返回空列表(流程自动跳过选学期)。
  79. async function fetchSemesterList() {
  80. const doc = parseHtml(await fetchText(location.origin + QVTU_JXZL_URL, null, "教学周历页"));
  81. const select = doc.getElementById("xnxq01id");
  82. if (!select) return { list: [], defaultIndex: -1 };
  83. const all = Array.from(select.querySelectorAll("option")).map(opt => ({
  84. value: opt.value,
  85. label: opt.textContent.trim(),
  86. selected: opt.hasAttribute("selected")
  87. }));
  88. let idx = all.findIndex(o => o.selected);
  89. if (idx === -1) idx = 0;
  90. const start = Math.max(0, idx - 3);
  91. return {
  92. list: all.slice(start, Math.min(all.length, idx + 4)),
  93. defaultIndex: idx - start
  94. };
  95. }
  96. // POST 周历页:解析指定学期的开学日期(首行 td[title])与总周数(首列最大整数)。
  97. // 结构不符时返回 { startDate: null, totalWeeks: null },不阻塞流程。
  98. async function fetchSemesterInfo(semesterId) {
  99. const html = await fetchText(location.origin + QVTU_JXZL_URL, {
  100. method: "POST",
  101. headers: { "Content-Type": "application/x-www-form-urlencoded" },
  102. body: "xnxq01id=" + encodeURIComponent(semesterId),
  103. credentials: "include"
  104. }, "教学周历详情");
  105. const doc = parseHtml(html);
  106. const timetable = doc.getElementById("kbtable");
  107. if (!timetable) return { startDate: null, totalWeeks: null };
  108. const rows = Array.from(timetable.querySelectorAll("tr")).filter(r => r.querySelector("td"));
  109. if (!rows.length) return { startDate: null, totalWeeks: null };
  110. // 开学日期:第一行首个带 title 的 td,如 "2025年9月1日"(兼容不补零)
  111. const title = (rows[0].querySelector("td[title]") || {}).getAttribute
  112. ? rows[0].querySelector("td[title]").getAttribute("title") : "";
  113. const m = String(title).match(/^(\d{4})年(\d{1,2})月(\d{1,2})日$/);
  114. const pad = n => (n < 10 ? "0" + n : "" + n);
  115. const startDate = m ? m[1] + "-" + pad(+m[2]) + "-" + pad(+m[3]) : null;
  116. // 总周数:所有行首列能解析为整数的最大值
  117. let totalWeeks = null;
  118. for (const row of rows) {
  119. const td = row.querySelector("td");
  120. if (!td) continue;
  121. const n = parseInt(td.textContent.trim(), 10);
  122. if (!isNaN(n) && n > 0 && (totalWeeks === null || n > totalWeeks)) totalWeeks = n;
  123. }
  124. return { startDate, totalWeeks };
  125. }
  126. // ================= 核心解析逻辑(二):课程 =================
  127. // "第3周"、"第1-16周"、"第1,3,5-8周"、"第1-15周(单周)" → [3] / [1..16] / 奇数周…
  128. function parseWeeks(text) {
  129. let t = String(text || "").replace(/\s/g, "");
  130. const parity = t.indexOf("单周") >= 0 ? 1 : t.indexOf("双周") >= 0 ? 2 : 0;
  131. t = t.replace(/[((][^))]*[))]/g, "");
  132. const set = {};
  133. const rangeRe = /(\d+)\s*[-–—~]\s*(\d+)/g;
  134. let m;
  135. while ((m = rangeRe.exec(t)) !== null) {
  136. for (let w = Math.min(+m[1], +m[2]); w <= Math.max(+m[1], +m[2]); w++) set[w] = 1;
  137. }
  138. for (const s of t.replace(rangeRe, " ").match(/\d+/g) || []) set[+s] = 1;
  139. const weeks = [];
  140. for (const k in set) {
  141. const n = +k;
  142. if (n >= 1 && n <= 30 && !(parity === 1 && n % 2 === 0) && !(parity === 2 && n % 2 === 1)) weeks.push(n);
  143. }
  144. return weeks.sort((a, b) => a - b);
  145. }
  146. // 把解析结果合并进总表(同名同天同节次区间同地点 → 合并周次)
  147. function mergeCourse(map, c) {
  148. if (!c.name || !c.day || !c.weeks.length || !c.startSection) return;
  149. const key = [c.name, c.day, c.startSection + "-" + c.endSection, c.position].join("|");
  150. if (!map[key]) {
  151. map[key] = c;
  152. } else {
  153. for (const w of c.weeks) if (!map[key].weeks.includes(w)) map[key].weeks.push(w);
  154. map[key].weeks.sort((a, b) => a - b);
  155. }
  156. }
  157. // 解析路径 A:官方案例的 DOM 结构(#kbtable 内 div.kbcontent,多课程以长横线分隔)
  158. function parseKbcontent(doc) {
  159. const timetable = doc.getElementById("kbtable");
  160. if (!timetable) return [];
  161. const map = {};
  162. const rows = Array.from(timetable.querySelectorAll("tr")).filter(r => r.querySelector("td"));
  163. rows.forEach(row => {
  164. const cells = row.querySelectorAll("td");
  165. cells.forEach((cell, dayIndex) => {
  166. const day = dayIndex + 1;
  167. cell.querySelectorAll("div.kbcontent").forEach(div => {
  168. const raw = div.innerHTML.trim();
  169. if (!raw || raw === "&nbsp;" || div.innerText.trim().length < 2) return;
  170. raw.split(/-{10,}/).forEach(block => {
  171. if (!block.trim()) return;
  172. const box = document.createElement("div");
  173. box.innerHTML = block;
  174. let name = "";
  175. for (const node of box.childNodes) {
  176. if (node.nodeType === 3 && node.textContent.trim() !== "") { name = node.textContent.trim(); break; }
  177. }
  178. const teacher = (box.querySelector('font[title="老师"], font[title="教师"]') || {}).innerText || "";
  179. const position = (box.querySelector('font[title="教室"]') || {}).innerText || "未知地点";
  180. const weekStr = ((box.querySelector('font[title="周次(节次)"]') || {}).innerText) || "";
  181. let startSection = 0, endSection = 0;
  182. const sec = weekStr.match(/\[(.*?)节\]/);
  183. if (sec && sec[1]) {
  184. const nums = sec[1].split("-").map(Number).filter(n => !isNaN(n));
  185. if (nums.length) { startSection = nums[0]; endSection = nums[nums.length - 1]; }
  186. }
  187. if (name && startSection > 0) {
  188. mergeCourse(map, {
  189. name, teacher: teacher.replace(/任课教师[::]/, "").trim() || "未知教师",
  190. position, day, weeks: parseWeeks(weekStr),
  191. startSection, endSection
  192. });
  193. }
  194. });
  195. });
  196. });
  197. });
  198. return Object.values(map);
  199. }
  200. // ================= 数据抓取与编排 =================
  201. async function fetchAndParseCourses() {
  202. if (!QVTU_ORIGIN_CHECK.test(location.hostname)) {
  203. throw new Error("请先在浏览器/WebView 打开教务网站(https://jw.qvtu.edu.cn/jsxsd)并登录,再运行。当前页面:" + location.href);
  204. }
  205. // 1) 学期列表 + 用户选择(站点无学期下拉框时自动跳过,导入当前学期)
  206. let semesterId = null;
  207. const { list, defaultIndex } = await fetchSemesterList();
  208. if (list.length) {
  209. const picked = await window.shiguangBridgePromise.showSingleSelection(
  210. "选择学期", JSON.stringify(list.map(s => s.label)), defaultIndex
  211. );
  212. if (picked === null) { toast("已取消导入。"); return null; }
  213. semesterId = list[picked].value;
  214. }
  215. // 2) 教学周历 → 开学日期 + 总周数(结构不符则降级为 null)
  216. let startDate = null, totalWeeks = null;
  217. if (semesterId) {
  218. const info = await fetchSemesterInfo(semesterId);
  219. startDate = info.startDate;
  220. totalWeeks = info.totalWeeks;
  221. }
  222. // 3) 课表:选了学期用 POST 指定学期,否则 GET 当前学期
  223. const html = await fetchText(location.origin + QVTU_XSKB_URL, semesterId ? {
  224. method: "POST",
  225. headers: { "Content-Type": "application/x-www-form-urlencoded" },
  226. body: "jx0404id=&cj0701id=&zc=&demo=&xnxq01id=" + encodeURIComponent(semesterId),
  227. credentials: "include"
  228. } : null, "学期课表页");
  229. if (!hasCourse(html)) {
  230. if (/用户登录|请登录|loginForm|passwd/i.test(String(html))) {
  231. throw new Error("教务会话已过期或尚未登录(接口返回登录页)。请重新登录教务系统后运行。");
  232. }
  233. console.warn("[泉州职业技术大学适配器] 学期课表页返回了意外内容(前 300 字符):",
  234. String(html || "").replace(/\s+/g, " ").slice(0, 300));
  235. throw new Error("学期课表页返回了无法识别的内容,请按 F12 打开控制台,把警告信息发给开发者核对。");
  236. }
  237. // 4) 解析(官方案例 DOM 结构)
  238. const courses = parseKbcontent(parseHtml(html));
  239. if (!courses.length) {
  240. throw new Error("教务页面里没有解析到课程。请确认所选学期已有排课,或把错误信息发给我核对。");
  241. }
  242. // 5) 周历未给出总周数时,取课程最大周次兜底
  243. if (!totalWeeks) {
  244. for (const c of courses) for (const w of c.weeks) if (w > totalWeeks) totalWeeks = w;
  245. }
  246. return { courses, startDate, totalWeeks };
  247. }
  248. // ================= 保存步骤(顺序参照官方开发文档) =================
  249. // 保存学期配置(开学日期/总周数来自教学周历,没拿到就不提交/用课程最大周兜底)
  250. // defaultClassDuration/defaultBreakDuration 与作息表一致(45 分钟一节、小节间休息 15 分钟)
  251. async function saveCourseConfig(startDate, totalWeeks) {
  252. try {
  253. const config = {
  254. firstDayOfWeek: 1,
  255. defaultClassDuration: 45,
  256. defaultBreakDuration: 15,
  257. semesterTotalWeeks: totalWeeks > 0 ? totalWeeks : 20
  258. };
  259. if (startDate) config.semesterStartDate = startDate;
  260. const ok = await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  261. if (!ok) { toast("学期配置保存失败。"); return false; }
  262. return true;
  263. } catch (e) {
  264. toast("学期配置保存失败:" + e.message);
  265. return false;
  266. }
  267. }
  268. // 保存课程数据
  269. async function saveCourses(courses) {
  270. try {
  271. const ok = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  272. if (!ok) { toast("课程数据保存失败。"); return false; }
  273. return true;
  274. } catch (e) {
  275. toast("课程数据保存失败:" + e.message);
  276. return false;
  277. }
  278. }
  279. // 保存作息时间表(官方文档:可选步骤,失败不阻止导入完成)
  280. async function savePresetTimeSlots() {
  281. try {
  282. const ok = await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(QVTU_TIME_SLOTS));
  283. if (!ok) toast("作息时间保存失败。");
  284. return !!ok;
  285. } catch (e) {
  286. toast("作息时间保存失败:" + e.message);
  287. return false;
  288. }
  289. }
  290. // ================= 流程编排 =================
  291. /**
  292. * 编排整个课程导入流程(顺序与约定参照官方开发文档)。
  293. * 用户取消或关键步骤保存失败时立即结束;作息时间为可选步骤,失败不阻止完成;
  294. * notifyTaskCompletion 只在流程成功后调用(官方约定)。
  295. */
  296. async function runImportFlow() {
  297. try {
  298. // 1. 公告和前置检查
  299. const confirmed = await window.shiguangBridgePromise.showAlert(
  300. "提示",
  301. "请确保已成功登录泉州职业技术大学教务系统。是否开始导入?",
  302. "开始"
  303. );
  304. if (!confirmed) {
  305. toast("已取消导入。");
  306. return;
  307. }
  308. // 2. 网络请求和数据解析(内部含选学期;取消时返回 null)
  309. toast("正在获取学期课表…");
  310. const result = await fetchAndParseCourses();
  311. if (!result) return;
  312. const { courses, startDate, totalWeeks } = result;
  313. // 3. 保存学期配置
  314. if (!await saveCourseConfig(startDate, totalWeeks)) return;
  315. // 4. 保存课程数据
  316. toast("正在保存 " + courses.length + " 条课程安排…");
  317. if (!await saveCourses(courses)) return;
  318. // 5. 导入作息时间表(可选步骤,失败不阻止导入完成)
  319. if (!await savePresetTimeSlots()) {
  320. toast("提示:作息时间未导入,课程已保存,可在 App 里手动设置或稍后重试。");
  321. }
  322. // 6. 流程成功,发送结束信号
  323. toast(`成功导入 ${courses.length} 条课程安排!` +
  324. (startDate ? `学期 ${startDate} 起,共 ${totalWeeks} 周。` : "开学日期未能自动获取,可在 App 里手动设置。"));
  325. notifyDone();
  326. } catch (error) {
  327. // 任何一步失败:记录并提示用户,与官方适配一致不向外抛出,也不发送完成信号
  328. console.error("[泉州职业技术大学适配器]", error);
  329. toast("导入失败:" + (error && error.message ? error.message : error));
  330. }
  331. }
  332. // 启动导入流程
  333. runImportFlow();
  334. })();