zjbti.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. // 浙江工商职业技术学院(zjbti.net.cn)拾光课程表适配脚本
  2. // 基于官方推荐的青果教务系统 API 架构标准重构实现
  3. /**
  4. * 动态获取教务系统基地址(自动适配内网直连与 WebVPN 代理路径)
  5. */
  6. function getJwBaseUrl() {
  7. const href = window.location.href;
  8. const match = href.match(/^(https?:\/\/[^\/]+\/zjgsjw)/);
  9. if (match) return match[1];
  10. return window.location.origin + "/zjgsjw";
  11. }
  12. /**
  13. * Base64 编码 (用于生成请求参数)
  14. */
  15. function encodeParams(xn, xq) {
  16. const rawStr = `xn=${xn}&xq=${xq}`;
  17. return btoa(rawStr);
  18. }
  19. /**
  20. * 深度解析周数字符串 (支持 1-16, 1-8单, 9-17双, 1,3,5 等格式)
  21. */
  22. function parseWeeks(weekStr) {
  23. const weeks = [];
  24. const groups = String(weekStr).split(/[,,]/);
  25. groups.forEach(group => {
  26. const isSingle = group.includes("单");
  27. const isDouble = group.includes("双");
  28. const rangeMatch = group.match(/(\d+)-(\d+)/);
  29. if (rangeMatch) {
  30. const start = parseInt(rangeMatch[1]);
  31. const end = parseInt(rangeMatch[2]);
  32. for (let i = start; i <= end; i++) {
  33. if (isSingle && i % 2 === 0) continue;
  34. if (isDouble && i % 2 !== 0) continue;
  35. weeks.push(i);
  36. }
  37. } else {
  38. const num = parseInt(group.replace(/[^\d]/g, ""));
  39. if (!isNaN(num) && num > 0) {
  40. if (isSingle && num % 2 === 0) return;
  41. if (isDouble && num % 2 !== 0) return;
  42. weeks.push(num);
  43. }
  44. }
  45. });
  46. return Array.from(new Set(weeks)).sort((a, b) => a - b);
  47. }
  48. /**
  49. * 青果标准 HTML 数据解析函数
  50. */
  51. function parseAndMergeKingosoftData(htmlText) {
  52. const parser = new DOMParser();
  53. const doc = parser.parseFromString(htmlText, "text/html");
  54. const rawItems = [];
  55. const table = doc.getElementById("mytable") || doc.querySelector("table.table");
  56. if (!table) return [];
  57. // 动态确定星期列数 (一般为 7 列 或 5 列)
  58. let totalDays = 7;
  59. for (const tr of Array.from(table.rows)) {
  60. const texts = Array.from(tr.cells).map(c => c.textContent.trim());
  61. const hasMon = texts.some(t => t.includes("一") || t.includes("周一") || t.includes("星期一"));
  62. const hasSun = texts.some(t => t.includes("日") || t.includes("周日") || t.includes("星期日"));
  63. const hasSat = texts.some(t => t.includes("六") || t.includes("周六") || t.includes("星期六"));
  64. const hasFri = texts.some(t => t.includes("五") || t.includes("周五") || t.includes("星期五"));
  65. if (hasMon && (hasSun || hasSat)) {
  66. totalDays = 7;
  67. break;
  68. } else if (hasMon && hasFri) {
  69. totalDays = 5;
  70. break;
  71. }
  72. }
  73. const rows = Array.from(table.rows);
  74. rows.forEach(row => {
  75. const cells = Array.from(row.cells);
  76. if (cells.length < totalDays) return;
  77. cells.forEach((cell, colIndex) => {
  78. const distanceToLast = cells.length - 1 - colIndex;
  79. if (distanceToLast >= totalDays) return;
  80. const day = totalDays - distanceToLast;
  81. let courseDivs = Array.from(cell.querySelectorAll("div[style*='padding-bottom']"));
  82. if (courseDivs.length === 0) {
  83. const allDivs = Array.from(cell.querySelectorAll("div"));
  84. courseDivs = allDivs.filter(d => /\[\d+.*\]/.test(d.textContent));
  85. }
  86. if (courseDivs.length === 0 && /\[\d+.*\]/.test(cell.textContent)) {
  87. courseDivs = [cell];
  88. }
  89. courseDivs.forEach(div => {
  90. let lines = Array.from(div.childNodes)
  91. .map(n => n.textContent.trim())
  92. .filter(t => t.length > 0);
  93. if (lines.length === 1 && lines[0].includes("\n")) {
  94. lines = lines[0].split(/\n+/).map(t => t.trim()).filter(t => t.length > 0);
  95. }
  96. const timeIndex = lines.findIndex(l => /(.*)\[(.*)\]/.test(l));
  97. if (timeIndex !== -1) {
  98. const timeMatch = lines[timeIndex].match(/(.*)\[(.*)\]/);
  99. if (timeMatch) {
  100. const weeks = parseWeeks(timeMatch[1]);
  101. const sectionStr = timeMatch[2].replace(/[^\d\-]/g, "");
  102. const sections = sectionStr.split("-").map(Number).filter(n => !isNaN(n));
  103. if (weeks.length > 0 && sections.length > 0) {
  104. const name = lines[0] || "未知课程";
  105. const teacher = timeIndex > 1 ? lines[1] : "";
  106. const position = lines.slice(timeIndex + 1).join(" ") || "未知地点";
  107. rawItems.push({
  108. name,
  109. teacher,
  110. position,
  111. day,
  112. startSection: sections[0],
  113. endSection: sections[sections.length - 1],
  114. weeks
  115. });
  116. }
  117. }
  118. }
  119. });
  120. });
  121. });
  122. const groupMap = new Map();
  123. rawItems.forEach(item => {
  124. const key = `${item.name}|${item.teacher}|${item.position}|${item.day}`;
  125. if (!groupMap.has(key)) groupMap.set(key, []);
  126. groupMap.get(key).push(item);
  127. });
  128. const finalCourses = [];
  129. groupMap.forEach((items, key) => {
  130. const matrix = {};
  131. items.forEach(item => {
  132. item.weeks.forEach(w => {
  133. if (!matrix[w]) matrix[w] = new Set();
  134. for (let s = item.startSection; s <= item.endSection; s++) matrix[w].add(s);
  135. });
  136. });
  137. const patternMap = new Map();
  138. Object.keys(matrix).forEach(w => {
  139. const week = parseInt(w);
  140. const sections = Array.from(matrix[week]).sort((a, b) => a - b);
  141. let start = sections[0];
  142. for (let i = 0; i < sections.length; i++) {
  143. if (i === sections.length - 1 || sections[i + 1] !== sections[i] + 1) {
  144. const pKey = `${start}-${sections[i]}`;
  145. if (!patternMap.has(pKey)) patternMap.set(pKey, []);
  146. patternMap.get(pKey).push(week);
  147. if (i < sections.length - 1) start = sections[i + 1];
  148. }
  149. }
  150. });
  151. const [name, teacher, position, day] = key.split("|");
  152. patternMap.forEach((weeks, pKey) => {
  153. const [sStart, sEnd] = pKey.split("-").map(Number);
  154. finalCourses.push({
  155. name,
  156. teacher,
  157. position,
  158. day: parseInt(day),
  159. startSection: sStart,
  160. endSection: sEnd,
  161. weeks: weeks.sort((a, b) => a - b)
  162. });
  163. });
  164. });
  165. return finalCourses;
  166. }
  167. /**
  168. * 课程合并与去重处理
  169. */
  170. function mergeAndDistinctCourses(courses) {
  171. if (!Array.isArray(courses) || courses.length <= 1) return courses;
  172. const list = courses.map(c => ({
  173. ...c,
  174. name: c.name || "",
  175. teacher: c.teacher || "",
  176. position: c.position || "",
  177. weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : []
  178. }));
  179. // 阶段 1:合并连续节次与完全重复记录
  180. list.sort((a, b) => {
  181. return a.name.localeCompare(b.name) ||
  182. a.teacher.localeCompare(b.teacher) ||
  183. a.position.localeCompare(b.position) ||
  184. (a.day || 0) - (b.day || 0) ||
  185. a.weeks.join(",").localeCompare(b.weeks.join(",")) ||
  186. (a.startSection || 0) - (b.startSection || 0);
  187. });
  188. const step1Merged = [];
  189. let current = list[0];
  190. for (let i = 1; i < list.length; i++) {
  191. const next = list[i];
  192. const isSameCourseAndWeeks =
  193. current.name === next.name &&
  194. current.teacher === next.teacher &&
  195. current.position === next.position &&
  196. current.day === next.day &&
  197. current.weeks.join(",") === next.weeks.join(",");
  198. const isContinuous = current.endSection + 1 === next.startSection;
  199. const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
  200. if (isSameCourseAndWeeks && isContinuous) {
  201. current.endSection = next.endSection;
  202. } else if (isSameCourseAndWeeks && isDuplicate) {
  203. continue;
  204. } else {
  205. step1Merged.push(current);
  206. current = next;
  207. }
  208. }
  209. step1Merged.push(current);
  210. // 阶段 2:合并同节次的周次
  211. step1Merged.sort((a, b) => {
  212. return a.name.localeCompare(b.name) ||
  213. a.teacher.localeCompare(b.teacher) ||
  214. a.position.localeCompare(b.position) ||
  215. (a.day || 0) - (b.day || 0) ||
  216. (a.startSection || 0) - (b.startSection || 0) ||
  217. (a.endSection || 0) - (b.endSection || 0);
  218. });
  219. const step2Merged = [];
  220. let cur = step1Merged[0];
  221. for (let i = 1; i < step1Merged.length; i++) {
  222. const nxt = step1Merged[i];
  223. const isSameCourseAndSection =
  224. cur.name === nxt.name &&
  225. cur.teacher === nxt.teacher &&
  226. cur.position === nxt.position &&
  227. cur.day === nxt.day &&
  228. cur.startSection === nxt.startSection &&
  229. cur.endSection === nxt.endSection;
  230. if (isSameCourseAndSection) {
  231. cur.weeks = Array.from(new Set([...cur.weeks, ...nxt.weeks])).sort((a, b) => a - b);
  232. } else {
  233. step2Merged.push(cur);
  234. cur = nxt;
  235. }
  236. }
  237. step2Merged.push(cur);
  238. return step2Merged;
  239. }
  240. /**
  241. * 异步请求学期列表
  242. */
  243. async function getYearAndSemester(baseUrl) {
  244. try {
  245. window.shiguangBridge.showToast("正在获取学期列表...");
  246. const response = await fetch(`${baseUrl}/frame/droplist/getDropLists.action`, {
  247. method: "POST",
  248. headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
  249. body: "comboBoxName=StMsXnxqDxDesc&paramValue=&isYXB=0&isCDDW=0&isXQ=0&isDJKSLB=0&isZY=0",
  250. credentials: "include"
  251. });
  252. const list = await response.json();
  253. const names = list.map(item => item.name);
  254. const selectedIndex = await window.shiguangBridgePromise.showSingleSelection("选择导入学期", JSON.stringify(names), 0);
  255. if (selectedIndex === null) return null;
  256. const [xn, xq] = list[selectedIndex].code.split("-");
  257. return { xn, xq };
  258. } catch (error) {
  259. window.shiguangBridge.showToast("获取学期列表失败: " + error.message);
  260. return null;
  261. }
  262. }
  263. /**
  264. * 课表后台数据抓取
  265. */
  266. async function fetchCourses(baseUrl, xn, xq) {
  267. try {
  268. const paramsBase64 = encodeParams(xn, xq);
  269. const url = `${baseUrl}/student/wsxk.xskcb10319.jsp?params=${paramsBase64}`;
  270. window.shiguangBridge.showToast("正在提取课表数据...");
  271. const response = await fetch(url, { method: "GET", credentials: "include" });
  272. const arrayBuffer = await response.arrayBuffer();
  273. const htmlText = new TextDecoder("gbk").decode(arrayBuffer);
  274. return parseAndMergeKingosoftData(htmlText);
  275. } catch (error) {
  276. window.shiguangBridge.showToast("抓取课表失败: " + error.message);
  277. return null;
  278. }
  279. }
  280. /**
  281. * 获取第一周周一开学日期
  282. */
  283. async function fetchSemesterStartDate(baseUrl, xn, xq) {
  284. try {
  285. window.shiguangBridge.showToast("正在获取开学日期...");
  286. const url = `${baseUrl}/frame/desk/showLessonScheduleInfosV14.action?xn=${xn}&xq=${xq}&jxz=1`;
  287. const response = await fetch(url, {
  288. method: "POST",
  289. headers: { "x-requested-with": "XMLHttpRequest" },
  290. credentials: "include"
  291. });
  292. const arrayBuffer = await response.arrayBuffer();
  293. const htmlText = new TextDecoder("gbk").decode(arrayBuffer);
  294. const match = htmlText.match(/<br\s*\/?>\s*(\d{2})-(\d{2})/);
  295. if (match) {
  296. const month = match[1];
  297. const day = match[2];
  298. const year = xq === "1" ? String(parseInt(xn) + 1) : xn;
  299. return `${year}-${month}-${day}`;
  300. }
  301. return null;
  302. } catch (error) {
  303. console.warn("获取开学日期失败:", error);
  304. return null;
  305. }
  306. }
  307. /**
  308. * 日期格式校验
  309. */
  310. function validateDateInput(input) {
  311. if (/^\d{4}-\d{2}-\d{2}$/.test(input)) {
  312. return false;
  313. }
  314. return "请输入正确格式的日期,如 2026-09-07";
  315. }
  316. /**
  317. * 浙江工商职业技术学院 12 节课标准作息时间表
  318. */
  319. async function importPresetTimeSlots() {
  320. const slots = [
  321. { number: 1, startTime: "08:30", endTime: "09:15" },
  322. { number: 2, startTime: "09:20", endTime: "10:05" },
  323. { number: 3, startTime: "10:20", endTime: "11:05" },
  324. { number: 4, startTime: "11:10", endTime: "11:55" },
  325. { number: 5, startTime: "13:10", endTime: "13:55" },
  326. { number: 6, startTime: "14:00", endTime: "14:45" },
  327. { number: 7, startTime: "15:00", endTime: "15:45" },
  328. { number: 8, startTime: "15:50", endTime: "16:35" },
  329. { number: 9, startTime: "16:40", endTime: "17:25" },
  330. { number: 10, startTime: "18:20", endTime: "19:05" },
  331. { number: 11, startTime: "19:10", endTime: "19:55" },
  332. { number: 12, startTime: "20:00", endTime: "20:45" }
  333. ];
  334. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(slots)).catch(() => {});
  335. }
  336. /**
  337. * 主导入流程
  338. */
  339. async function runImportFlow() {
  340. try {
  341. const currentUrl = window.location.href;
  342. // 1. 检查是否在教务管理页面环境中
  343. if (!currentUrl.includes("zjgsjw")) {
  344. await window.shiguangBridgePromise.showAlert(
  345. "教务导入指引",
  346. "请先在 WebVPN 中点击进入「教务管理」,登录进入系统后再点击执行导入",
  347. "我知道了"
  348. );
  349. return;
  350. }
  351. window.shiguangBridge.showToast("已连接教务管理,正在获取学期信息...");
  352. const baseUrl = getJwBaseUrl();
  353. // 2. 选择学年学期
  354. const termParams = await getYearAndSemester(baseUrl);
  355. if (!termParams) return;
  356. // 3. 获取并解析课程数据
  357. const rawCourses = await fetchCourses(baseUrl, termParams.xn, termParams.xq);
  358. if (!rawCourses || rawCourses.length === 0) {
  359. window.shiguangBridge.showToast("未找到有效课程数据,请确认所选学期是否有课");
  360. return;
  361. }
  362. // 4. 合并去重
  363. const finalCourses = mergeAndDistinctCourses(rawCourses);
  364. // 5. 自动获取开学日期并让用户确认
  365. const autoStartDate = await fetchSemesterStartDate(baseUrl, termParams.xn, termParams.xq);
  366. const confirmedDate = await window.shiguangBridgePromise.showPrompt(
  367. "确认开学日期",
  368. "请确认本学期第一周周一的日期(格式 YYYY-MM-DD):",
  369. autoStartDate || "",
  370. "validateDateInput"
  371. );
  372. if (confirmedDate === null) {
  373. window.shiguangBridge.showToast("导入已取消");
  374. return;
  375. }
  376. // 6. 推导学期总周数并保存配置
  377. let maxWeek = 0;
  378. finalCourses.forEach(c => {
  379. c.weeks.forEach(w => {
  380. if (w > maxWeek) maxWeek = w;
  381. });
  382. });
  383. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
  384. semesterStartDate: confirmedDate,
  385. semesterTotalWeeks: maxWeek > 0 ? maxWeek : 20,
  386. defaultClassDuration: 45,
  387. defaultBreakDuration: 10
  388. })).catch(() => {});
  389. // 7. 保存课程数据与作息时间
  390. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(finalCourses));
  391. await importPresetTimeSlots();
  392. window.shiguangBridge.showToast(`导入成功:共 ${finalCourses.length} 门课程`);
  393. window.shiguangBridge.notifyTaskCompletion();
  394. } catch (error) {
  395. window.shiguangBridge.showToast("导入异常: " + error.message);
  396. console.error("ZJBTI Import Flow Error:", error);
  397. }
  398. }
  399. runImportFlow();