zhku_lc6464.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. // 仲恺农业工程学院拾光课程表适配脚本
  2. // https://edu-admin.zhku.edu.cn/
  3. // 教务平台:强智教务
  4. // 适配开发者:lc6464
  5. const PRESET_TIME_CONFIG = {
  6. campuses: {
  7. haizhu: {
  8. startTimes: {
  9. morning: "08:00",
  10. noon: "11:30",
  11. afternoon: "14:30",
  12. evening: "19:30"
  13. }
  14. },
  15. baiyun: {
  16. startTimes: {
  17. morning: "08:40",
  18. noon: "12:20",
  19. afternoon: "13:30",
  20. evening: "19:00"
  21. }
  22. }
  23. },
  24. common: {
  25. sectionCounts: {
  26. morning: 4,
  27. noon: 1,
  28. afternoon: 4,
  29. evening: 3
  30. },
  31. durations: {
  32. classMinutes: 40,
  33. shortBreakMinutes: 10,
  34. longBreakMinutes: 20
  35. },
  36. longBreakAfter: {
  37. morning: 2,
  38. noon: 0, // 午间课程无大课间
  39. afternoon: 2,
  40. evening: 0 // 晚间课程无大课间
  41. }
  42. }
  43. };
  44. const CAMPUS_OPTIONS = [
  45. { id: "haizhu", label: "海珠校区" },
  46. { id: "baiyun", label: "白云校区" }
  47. ];
  48. // 统一做文本清洗,避免 DOM 中换行与多空格干扰匹配
  49. function cleanText(value) {
  50. return (value ?? "").replace(/\s+/g, " ").trim();
  51. }
  52. // HH:mm -> 当天分钟数
  53. function parseTimeToMinutes(hhmm) {
  54. const [h, m] = hhmm.split(":").map(Number);
  55. return h * 60 + m;
  56. }
  57. // 当天分钟数 -> HH:mm
  58. function formatMinutesToTime(totalMinutes) {
  59. const h = Math.floor(totalMinutes / 60);
  60. const m = totalMinutes % 60;
  61. return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
  62. }
  63. // 将“2026年03月09”这类中文日期转换为“2026-03-09”
  64. function normalizeCnDateToIso(cnDateText) {
  65. const match = (cnDateText ?? "").match(/(\d{4})年(\d{1,2})月(\d{1,2})/);
  66. if (match == null) {
  67. throw new Error(`无法解析日期:${cnDateText}`);
  68. }
  69. // 这里使用 Number 而不是 parseInt
  70. // 输入来自正则捕获组,已是纯数字,不需要 parseInt 的截断语义
  71. const y = Number(match[1]);
  72. const m = Number(match[2]);
  73. const d = Number(match[3]);
  74. return `${String(y).padStart(4, "0")}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
  75. }
  76. // 通过首页时间模式标签识别校区
  77. // 规则:name="kbjcmsid" 的 li 中,带 layui-this 的若是第一个则海珠,第二个则白云
  78. async function detectCampusFromMainPage() {
  79. const url = "https://edu-admin.zhku.edu.cn/jsxsd/framework/xsMain_new.htmlx";
  80. const response = await fetch(url, {
  81. method: "GET",
  82. credentials: "include"
  83. });
  84. if (!response.ok) {
  85. throw new Error(`获取首页时间模式失败:HTTP ${response.status}`);
  86. }
  87. const html = await response.text();
  88. const parser = new DOMParser();
  89. const doc = parser.parseFromString(html, "text/html");
  90. const nodes = Array.from(doc.querySelectorAll('li[name="kbjcmsid"]'));
  91. if (nodes.length < 2) {
  92. return null;
  93. }
  94. const activeIndex = nodes.findIndex((node) => {
  95. return node.classList.contains("layui-this");
  96. });
  97. if (activeIndex === 0) {
  98. return "haizhu";
  99. }
  100. if (activeIndex === 1) {
  101. return "baiyun";
  102. }
  103. // 兜底:若索引异常,按文本再次判断
  104. const activeNode = activeIndex >= 0 ? nodes[activeIndex] : null;
  105. const activeText = cleanText(activeNode?.textContent ?? "");
  106. if (activeText.includes("白云")) {
  107. return "baiyun";
  108. }
  109. if (activeText.includes("默认")) {
  110. return "haizhu";
  111. }
  112. return null;
  113. }
  114. // 获取最终校区
  115. // 先尝试自动识别,识别失败再让用户选择
  116. async function chooseCampus() {
  117. // 按 xsMain_new.htmlx 的时间模式标签判断
  118. try {
  119. const campusFromMain = await detectCampusFromMainPage();
  120. if (campusFromMain != null) {
  121. console.log("通过首页时间模式识别到校区:", campusFromMain);
  122. return campusFromMain;
  123. }
  124. } catch (error) {
  125. console.warn("通过首页时间模式识别校区失败,将回退到页面文本识别:", error);
  126. }
  127. const labels = CAMPUS_OPTIONS.map((item) => item.label);
  128. let selectedIndex = null;
  129. do {
  130. selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
  131. "校区检测失败,请选择校区",
  132. JSON.stringify(labels),
  133. -1
  134. );
  135. } while (selectedIndex == null || selectedIndex < 0 || selectedIndex >= CAMPUS_OPTIONS.length);
  136. return CAMPUS_OPTIONS[selectedIndex].id;
  137. }
  138. // 按规则动态生成节次时间
  139. // 这样后续学校调整作息时,只需要改 PRESET_TIME_CONFIG
  140. function buildPresetTimeSlots(campusId) {
  141. const campus = PRESET_TIME_CONFIG.campuses[campusId] ?? PRESET_TIME_CONFIG.campuses.baiyun;
  142. const common = PRESET_TIME_CONFIG.common;
  143. const segments = ["morning", "noon", "afternoon", "evening"];
  144. const slots = [];
  145. let sectionNumber = 1;
  146. for (const segment of segments) {
  147. // 每个时段从配置中的起始时间开始滚动推导
  148. let cursor = parseTimeToMinutes(campus.startTimes[segment]);
  149. const count = common.sectionCounts[segment];
  150. const longBreakAfter = common.longBreakAfter[segment] ?? 0;
  151. for (let i = 1; i <= count; i += 1) {
  152. const start = cursor;
  153. const end = start + common.durations.classMinutes;
  154. slots.push({
  155. number: sectionNumber,
  156. startTime: formatMinutesToTime(start),
  157. endTime: formatMinutesToTime(end)
  158. });
  159. sectionNumber += 1;
  160. cursor = end;
  161. if (i < count) {
  162. // 当 longBreakAfter 为 0 时,该时段不会触发大课间
  163. const longBreakApplies = longBreakAfter > 0 && i === longBreakAfter;
  164. cursor += longBreakApplies
  165. ? common.durations.longBreakMinutes
  166. : common.durations.shortBreakMinutes;
  167. }
  168. }
  169. }
  170. return slots;
  171. }
  172. // 解析周次与节次
  173. // 示例:"3-4,6-8(周)[01-02节]"、"1-16(单周)[03-04节]"
  174. function parseWeeksAndSections(rawText) {
  175. const text = cleanText(rawText);
  176. const match = text.match(/^(.*?)\(([^)]*周)\)\[(.*?)节\]$/);
  177. if (match == null) {
  178. throw new Error(`无法解析课程时间:${text}`);
  179. }
  180. const weeksPart = match[1];
  181. const weekFlag = match[2];
  182. const sectionsPart = match[3];
  183. // 先把周次范围展开成完整数组
  184. const weeks = [];
  185. const weekRanges = weeksPart.match(/\d+(?:-\d+)?/g) ?? [];
  186. for (const rangeText of weekRanges) {
  187. if (rangeText.includes("-")) {
  188. const [start, end] = rangeText.split("-").map(Number);
  189. for (let w = start; w <= end; w += 1) {
  190. weeks.push(w);
  191. }
  192. } else {
  193. weeks.push(Number(rangeText));
  194. }
  195. }
  196. // 去重并排序后,再根据单双周标记过滤
  197. let normalizedWeeks = [...new Set(weeks)].sort((a, b) => a - b);
  198. if (weekFlag.includes("单")) {
  199. normalizedWeeks = normalizedWeeks.filter((w) => w % 2 === 1);
  200. }
  201. if (weekFlag.includes("双")) {
  202. normalizedWeeks = normalizedWeeks.filter((w) => w % 2 === 0);
  203. }
  204. const sections = (sectionsPart.match(/\d+/g) ?? []).map(Number).sort((a, b) => a - b);
  205. if (sections.length === 0) {
  206. throw new Error(`无法解析节次:${text}`);
  207. }
  208. return {
  209. weeks: normalizedWeeks,
  210. startSection: sections[0],
  211. endSection: sections[sections.length - 1]
  212. };
  213. }
  214. // 从当前位置向前查找满足条件的 font 节点
  215. function findPreviousFont(fonts, startIndex, predicate) {
  216. for (let i = startIndex - 1; i >= 0; i -= 1) {
  217. if (predicate(fonts[i])) {
  218. return fonts[i];
  219. }
  220. }
  221. return null;
  222. }
  223. // 从当前位置向后查找满足条件的 font 节点
  224. function findNextFont(fonts, startIndex, predicate) {
  225. for (let i = startIndex + 1; i < fonts.length; i += 1) {
  226. if (predicate(fonts[i])) {
  227. return fonts[i];
  228. }
  229. }
  230. return null;
  231. }
  232. // 从课表文档中解析课程
  233. // 输出为扁平数组,不做同名课程合并
  234. function parseCoursesFromIframeDocument(iframeDoc) {
  235. const courses = [];
  236. const cells = iframeDoc.querySelectorAll(".kbcontent[id$='2']");
  237. cells.forEach((cell) => {
  238. // id 形如 xxxxx-<day>-2,day 为 1~7
  239. const idParts = (cell.id ?? "").split("-");
  240. const day = Number(idParts[idParts.length - 2]);
  241. if (!Number.isInteger(day) || day < 1 || day > 7) {
  242. return;
  243. }
  244. // 同一个 cell 里可能存在多个课程,因此要逐个锚点拆解
  245. const fonts = Array.from(cell.querySelectorAll("font"));
  246. fonts.forEach((font, idx) => {
  247. const title = cleanText(font.getAttribute("title") ?? "");
  248. if (!title.includes("周次")) {
  249. return;
  250. }
  251. const weekText = cleanText(font.textContent);
  252. if (weekText === "") {
  253. return;
  254. }
  255. // 以“周次(节次)”行为锚点,向前找教师和课程名,向后找教室
  256. const teacherFont = findPreviousFont(fonts, idx, (candidate) => {
  257. const candidateTitle = cleanText(candidate.getAttribute("title") ?? "");
  258. return candidateTitle.includes("教师");
  259. });
  260. const teacherIndex = teacherFont == null ? idx : fonts.indexOf(teacherFont);
  261. const nameFont = findPreviousFont(fonts, teacherIndex, (candidate) => {
  262. const candidateTitle = cleanText(candidate.getAttribute("title") ?? "");
  263. const candidateNameAttr = cleanText(candidate.getAttribute("name") ?? "");
  264. const text = cleanText(candidate.textContent);
  265. return (
  266. candidateTitle === "" &&
  267. candidateNameAttr === "" &&
  268. text !== ""
  269. );
  270. });
  271. const locationFont = findNextFont(fonts, idx, (candidate) => {
  272. const candidateTitle = cleanText(candidate.getAttribute("title") ?? "");
  273. return candidateTitle.includes("教室");
  274. });
  275. const courseName = cleanText(nameFont?.textContent ?? "");
  276. const teacher = cleanText(teacherFont?.textContent ?? "");
  277. let position = cleanText(locationFont?.textContent ?? "");
  278. // 过滤空课程名、网络课和不存在的虚拟位置
  279. if (courseName === ""
  280. || position.includes("网络学时,不排时间教室")
  281. || position.includes("经典研读")
  282. || /^(?网络课)?/.test(courseName)) {
  283. return;
  284. }
  285. // 移除教室中的“(白)”“(白云)”“(白)实”等字样,该信息对于学生而言无意义
  286. position = position.replace(/[((]白云?[))](?:实(?![A-Za-z]))?/g, "");
  287. // 移除教室末尾的“xxxx实验室”字样,这个信息对于学生而言无意义
  288. position = position.replace(/(?:(?<=\d|\d[A-Za-z])(?:[^A-Za-z\d))]|(?<!\d)[A-Za-z])*实验室(?:[((]?[\d一二三四五六七八九十甲乙丙丁]+[))]?|(物理实验室)|(机房))*)+$/, "");
  289. // 补全开头的“英东楼”
  290. position = position.replace(/^英\s*(\d{3,4})/, "英东楼$1");
  291. // 移除掉生科楼的“实”
  292. position = position.replace(/(?<=生科[AB])实/, "");
  293. const parsed = parseWeeksAndSections(weekText);
  294. // 查重
  295. const existingCourse = courses.find((c) => c.name === courseName
  296. && c.teacher === teacher
  297. && c.position === position
  298. && c.day === day
  299. && c.startSection === parsed.startSection
  300. && c.endSection === parsed.endSection
  301. && JSON.stringify(c.weeks) === JSON.stringify(parsed.weeks));
  302. if (existingCourse != null) {
  303. return;
  304. }
  305. courses.push({
  306. name: courseName,
  307. teacher,
  308. position,
  309. day,
  310. startSection: parsed.startSection,
  311. endSection: parsed.endSection,
  312. weeks: parsed.weeks
  313. });
  314. });
  315. });
  316. return courses;
  317. }
  318. // 获取课表 iframe 的文档对象
  319. function getScheduleIframeDocument() {
  320. const iframe = document.querySelector("iframe[src*='/jsxsd/xskb/xskb_list.do']");
  321. if (iframe == null) {
  322. return null;
  323. }
  324. if (iframe.contentDocument == null) {
  325. throw new Error("课表 iframe 内容尚未加载完成");
  326. }
  327. return iframe.contentDocument;
  328. }
  329. // 获取列表中的全部以及当前选择的学年学期 ID
  330. function getSemesterIds(iframeDoc) {
  331. const select = iframeDoc.querySelector("#xnxq01id");
  332. if (select == null) {
  333. throw new Error("未找到学年学期选择框 #xnxq01id,请确认已登录教务系统");
  334. }
  335. const options = Array.from(select.querySelectorAll("option"))
  336. .map((opt) => cleanText(opt.value))
  337. .filter((value) => value !== "");
  338. const selectedOption = cleanText(select.value);
  339. if (selectedOption === "") {
  340. throw new Error("未找到当前激活的学年学期,请确认已登录教务系统");
  341. }
  342. return { options, selectedOption };
  343. }
  344. // 拉取学期理论课表并解析为文档对象
  345. // 未指定学年学期时,教务系统返回当前激活学期的课表
  346. async function fetchScheduleDocument(semesterId = "") {
  347. const query = semesterId === "" ? "" : `?xnxq01id=${encodeURIComponent(semesterId)}`;
  348. const url = `https://edu-admin.zhku.edu.cn/jsxsd/xskb/xskb_list.do${query}`;
  349. const response = await fetch(url, {
  350. method: "GET",
  351. credentials: "include"
  352. });
  353. if (!response.ok) {
  354. throw new Error(`获取学期理论课表失败:HTTP ${response.status}`);
  355. }
  356. const html = await response.text();
  357. const parser = new DOMParser();
  358. const doc = parser.parseFromString(html, "text/html");
  359. return doc;
  360. }
  361. // 获取学年学期列表并让用户选择,默认选中当前激活学期
  362. async function chooseScheduleDocument() {
  363. const cachedDoc = await fetchScheduleDocument();
  364. const { options, selectedOption } = getSemesterIds(cachedDoc);
  365. const selection = await window.shiguangBridgePromise.showSingleSelection(
  366. "选择导入的学年学期",
  367. JSON.stringify(options),
  368. options.indexOf(selectedOption)
  369. );
  370. if (selection == null) {
  371. throw new Error("已取消选择学年学期");
  372. }
  373. const selectedIndex = Number(selection);
  374. if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= options.length) {
  375. throw new Error("未选择有效的学年学期");
  376. }
  377. const semesterId = options[selectedIndex];
  378. // 激活学期直接复用首次获取的课表,其它学期再按 ID 拉取
  379. let scheduleDoc = semesterId === selectedOption
  380. ? cachedDoc
  381. : await fetchScheduleDocument(semesterId);
  382. return { scheduleDoc, semesterId };
  383. }
  384. // 拉取教学周历并提取开学日期与总周数
  385. async function fetchSemesterCalendarInfo(semesterId) {
  386. const url = `https://edu-admin.zhku.edu.cn/jsxsd/jxzl/jxzl_query?xnxq01id=${encodeURIComponent(semesterId)}`;
  387. const response = await fetch(url, {
  388. method: "GET",
  389. credentials: "include"
  390. });
  391. if (!response.ok) {
  392. throw new Error(`获取教学周历失败:HTTP ${response.status}`);
  393. }
  394. const html = await response.text();
  395. const parser = new DOMParser();
  396. const doc = parser.parseFromString(html, "text/html");
  397. const rows = Array.from(doc.querySelectorAll("#kbtable tr"));
  398. // 周次行特征:第一列是纯数字
  399. const weekRows = rows.filter((row) => {
  400. const firstCell = row.querySelector("td");
  401. return /^\d+$/.test(cleanText(firstCell?.textContent ?? ""));
  402. });
  403. if (weekRows.length === 0) {
  404. throw new Error("教学周历中未找到周次行");
  405. }
  406. // 学期起始日按“第一周周一”计算
  407. const firstWeekRow = weekRows[0];
  408. const mondayCell = firstWeekRow.querySelectorAll("td")[1];
  409. const mondayTitle = mondayCell?.getAttribute("title") ?? "";
  410. if (mondayTitle === "") {
  411. throw new Error("教学周历中未找到第一周周一日期");
  412. }
  413. return {
  414. semesterStartDate: normalizeCnDateToIso(mondayTitle),
  415. semesterTotalWeeks: weekRows.length
  416. };
  417. }
  418. // 单独区分未登录错误,入口处使用弹窗提示
  419. class LoginRequiredError extends Error {}
  420. // 通过登录表单识别登录页面
  421. function checkLoginDocument(doc) {
  422. const loginForm = doc.querySelector('#loginForm[action="/Logon.do?method=logon"]');
  423. if (loginForm != null) {
  424. throw new LoginRequiredError("请先登录教务系统,再点击“执行导入”。");
  425. }
  426. }
  427. // 主流程:读取课表 -> 选择校区 -> 拉周历 -> 生成节次 -> 调桥接导入
  428. // 传统流程:从页面中读取用户打开的课表 iframe
  429. // 便捷流程:若未打开课表,则让用户选择学年学期,直接拉取课表
  430. async function importSchedule() {
  431. // 未登录时直接提示,不继续读取课表
  432. checkLoginDocument(document);
  433. window.shiguangBridge.showToast("开始读取教务课表……");
  434. // 读取 iframe,未打开课表时则走便捷导入流程
  435. const iframeDoc = getScheduleIframeDocument();
  436. let scheduleDoc = iframeDoc;
  437. let semesterId;
  438. if (iframeDoc == null) {
  439. const selectedSchedule = await chooseScheduleDocument();
  440. scheduleDoc = selectedSchedule.scheduleDoc;
  441. semesterId = selectedSchedule.semesterId;
  442. } else {
  443. semesterId = getSemesterIds(iframeDoc).selectedOption;
  444. }
  445. // 解析课程信息
  446. const courses = parseCoursesFromIframeDocument(scheduleDoc);
  447. if (courses.length === 0) {
  448. throw new Error("未解析到任何课程,请确认当前课表页面已加载完成");
  449. }
  450. // 拉取周历信息,获取开学日期与总周数
  451. const calendarInfo = await fetchSemesterCalendarInfo(semesterId);
  452. // 选择校区并生成预设上课时间配置
  453. const campusId = await chooseCampus();
  454. const campusLabel = CAMPUS_OPTIONS.find((item) => item.id === campusId)?.label ?? "白云校区";
  455. const presetTimeSlots = buildPresetTimeSlots(campusId);
  456. // 构建上课预设时间配置
  457. const config = {
  458. semesterStartDate: calendarInfo.semesterStartDate,
  459. semesterTotalWeeks: calendarInfo.semesterTotalWeeks,
  460. defaultClassDuration: PRESET_TIME_CONFIG.common.durations.classMinutes,
  461. defaultBreakDuration: PRESET_TIME_CONFIG.common.durations.shortBreakMinutes,
  462. // 每周按周一起始计算,因此固定为 1
  463. firstDayOfWeek: 1
  464. };
  465. // 通知课表软件进行导入,传递课程与预设时间配置
  466. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  467. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(presetTimeSlots));
  468. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  469. window.shiguangBridge.showToast(`导入成功:${campusLabel},课程 ${courses.length} 条`);
  470. window.shiguangBridge.notifyTaskCompletion();
  471. }
  472. // 自执行入口
  473. (async () => {
  474. try {
  475. await importSchedule();
  476. } catch (error) {
  477. if (error instanceof LoginRequiredError) {
  478. // 弹窗强提醒,同时也在控制台输出并展示 Toast
  479. await window.shiguangBridgePromise.showAlert("未登录教务系统", error.message, "好的");
  480. }
  481. console.error("课表导入失败:", error);
  482. // 失败原因直接提示给用户,便于在移动端快速定位问题
  483. window.shiguangBridge.showToast(`导入失败:${error.message}`);
  484. }
  485. })();