zhku_lc6464.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  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.AndroidBridgePromise.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. // 教务页面会用 display:none 隐藏辅助节点,这里只保留可见信息
  233. function isVisibleFont(font) {
  234. const styleText = (font.getAttribute("style") ?? "").replace(/\s+/g, "").toLowerCase();
  235. return !styleText.includes("display:none");
  236. }
  237. // 从课表 iframe 中解析课程
  238. // 输出为扁平数组,不做同名课程合并
  239. function parseCoursesFromIframeDocument(iframeDoc) {
  240. const courses = [];
  241. const cells = iframeDoc.querySelectorAll(".kbcontent[id$='2']");
  242. cells.forEach((cell) => {
  243. // id 形如 xxxxx-<day>-2,day 为 1~7
  244. const idParts = (cell.id ?? "").split("-");
  245. const day = Number(idParts[idParts.length - 2]);
  246. if (!Number.isInteger(day) || day < 1 || day > 7) {
  247. return;
  248. }
  249. // 同一个 cell 里可能存在多个课程,因此要逐个锚点拆解
  250. const fonts = Array.from(cell.querySelectorAll("font"));
  251. fonts.forEach((font, idx) => {
  252. const title = cleanText(font.getAttribute("title") ?? "");
  253. if (!title.includes("周次")) {
  254. return;
  255. }
  256. if (!isVisibleFont(font)) {
  257. return;
  258. }
  259. const weekText = cleanText(font.textContent);
  260. if (weekText === "") {
  261. return;
  262. }
  263. // 以“周次(节次)”行为锚点,向前找教师和课程名,向后找教室
  264. const teacherFont = findPreviousFont(fonts, idx, (candidate) => {
  265. const candidateTitle = cleanText(candidate.getAttribute("title") ?? "");
  266. return candidateTitle.includes("教师") && isVisibleFont(candidate);
  267. });
  268. const teacherIndex = teacherFont == null ? idx : fonts.indexOf(teacherFont);
  269. const nameFont = findPreviousFont(fonts, teacherIndex, (candidate) => {
  270. const candidateTitle = cleanText(candidate.getAttribute("title") ?? "");
  271. const candidateNameAttr = cleanText(candidate.getAttribute("name") ?? "");
  272. const text = cleanText(candidate.textContent);
  273. return (
  274. candidateTitle === "" &&
  275. candidateNameAttr === "" &&
  276. isVisibleFont(candidate) &&
  277. text !== ""
  278. );
  279. });
  280. const locationFont = findNextFont(fonts, idx, (candidate) => {
  281. const candidateTitle = cleanText(candidate.getAttribute("title") ?? "");
  282. return candidateTitle.includes("教室") && isVisibleFont(candidate);
  283. });
  284. const courseName = cleanText(nameFont?.textContent ?? "");
  285. const teacher = cleanText(teacherFont?.textContent ?? "");
  286. let position = cleanText(locationFont?.textContent ?? "");
  287. // 过滤空课程名、网络课和不存在的虚拟位置
  288. if (courseName === ""
  289. || position.includes("网络学时,不排时间教室")
  290. || position.includes("经典研读")
  291. || /^(?网络课)?/.test(courseName)) {
  292. return;
  293. }
  294. // 移除教室中的“(白)”“(白云)”“(白)实”等字样,该信息对于学生而言无意义
  295. position = position.replace(/[((]白云?[))](?:实(?![A-Za-z]))?/g, "");
  296. // 移除教室末尾的“xxxx实验室”字样,这个信息对于学生而言无意义
  297. position = position.replace(/(?:(?<=\d|\d[A-Za-z])(?:[^A-Za-z\d))]|(?<!\d)[A-Za-z])*实验室(?:[((]?[\d一二三四五六七八九十甲乙丙丁]+[))]?|(物理实验室)|(机房))*)+$/, "");
  298. // 补全开头的“英东楼”
  299. position = position.replace(/^英\s*(\d{3,4})/, "英东楼$1");
  300. // 移除掉生科楼的“实”
  301. position = position.replace(/(?<=生科[AB])实/, "");
  302. const parsed = parseWeeksAndSections(weekText);
  303. // 查重
  304. const existingCourse = courses.find((c) => c.name === courseName
  305. && c.teacher === teacher
  306. && c.position === position
  307. && c.day === day
  308. && c.startSection === parsed.startSection
  309. && c.endSection === parsed.endSection
  310. && JSON.stringify(c.weeks) === JSON.stringify(parsed.weeks));
  311. if (existingCourse != null) {
  312. return;
  313. }
  314. courses.push({
  315. name: courseName,
  316. teacher,
  317. position,
  318. day,
  319. startSection: parsed.startSection,
  320. endSection: parsed.endSection,
  321. weeks: parsed.weeks
  322. });
  323. });
  324. });
  325. return courses;
  326. }
  327. // 获取课表 iframe 的文档对象
  328. function getScheduleIframeDocument() {
  329. const iframe = document.querySelector("iframe[src*='/jsxsd/xskb/xskb_list.do']");
  330. if (iframe == null || iframe.contentDocument == null) {
  331. throw new Error("未找到课表 iframe,或 iframe 内容尚未加载完成");
  332. }
  333. return iframe.contentDocument;
  334. }
  335. // 获取当前学年学期 ID,例如 2025-2026-2
  336. function getSemesterId(iframeDoc) {
  337. const select = iframeDoc.querySelector("#xnxq01id");
  338. if (select == null) {
  339. throw new Error("未找到学年学期选择框 #xnxq01id");
  340. }
  341. // 优先读取 option[selected],读取失败再回退到 select.value
  342. const selectedOption = select.querySelector("option[selected]");
  343. return cleanText(selectedOption?.value ?? select.value);
  344. }
  345. // 拉取教学周历并提取开学日期与总周数
  346. async function fetchSemesterCalendarInfo(semesterId) {
  347. const url = `https://edu-admin.zhku.edu.cn/jsxsd/jxzl/jxzl_query?xnxq01id=${encodeURIComponent(semesterId)}`;
  348. const response = await fetch(url, {
  349. method: "GET",
  350. credentials: "include"
  351. });
  352. if (!response.ok) {
  353. throw new Error(`获取教学周历失败:HTTP ${response.status}`);
  354. }
  355. const html = await response.text();
  356. const parser = new DOMParser();
  357. const doc = parser.parseFromString(html, "text/html");
  358. const rows = Array.from(doc.querySelectorAll("#kbtable tr"));
  359. // 周次行特征:第一列是纯数字
  360. const weekRows = rows.filter((row) => {
  361. const firstCell = row.querySelector("td");
  362. return /^\d+$/.test(cleanText(firstCell?.textContent ?? ""));
  363. });
  364. if (weekRows.length === 0) {
  365. throw new Error("教学周历中未找到周次行");
  366. }
  367. // 学期起始日按“第一周周一”计算
  368. const firstWeekRow = weekRows[0];
  369. const mondayCell = firstWeekRow.querySelectorAll("td")[1];
  370. const mondayTitle = mondayCell?.getAttribute("title") ?? "";
  371. if (mondayTitle === "") {
  372. throw new Error("教学周历中未找到第一周周一日期");
  373. }
  374. return {
  375. semesterStartDate: normalizeCnDateToIso(mondayTitle),
  376. semesterTotalWeeks: weekRows.length
  377. };
  378. }
  379. // 主流程:读取课表 -> 选择校区 -> 拉周历 -> 生成节次 -> 调桥接导入
  380. async function importSchedule() {
  381. AndroidBridge.showToast("开始读取教务课表……");
  382. // 读取 iframe 并获取当前学年学期 ID
  383. const iframeDoc = getScheduleIframeDocument();
  384. const semesterId = getSemesterId(iframeDoc);
  385. // 解析课程信息
  386. const courses = parseCoursesFromIframeDocument(iframeDoc);
  387. if (courses.length === 0) {
  388. throw new Error("未解析到任何课程,请确认当前课表页面已加载完成");
  389. }
  390. // 拉取周历信息,获取开学日期与总周数
  391. const calendarInfo = await fetchSemesterCalendarInfo(semesterId);
  392. // 选择校区并生成预设上课时间配置
  393. const campusId = await chooseCampus();
  394. const campusLabel = CAMPUS_OPTIONS.find((item) => item.id === campusId)?.label ?? "白云校区";
  395. const presetTimeSlots = buildPresetTimeSlots(campusId);
  396. // 构建上课预设时间配置
  397. const config = {
  398. semesterStartDate: calendarInfo.semesterStartDate,
  399. semesterTotalWeeks: calendarInfo.semesterTotalWeeks,
  400. defaultClassDuration: PRESET_TIME_CONFIG.common.durations.classMinutes,
  401. defaultBreakDuration: PRESET_TIME_CONFIG.common.durations.shortBreakMinutes,
  402. // 每周按周一起始计算,因此固定为 1
  403. firstDayOfWeek: 1
  404. };
  405. // 通知课表软件进行导入,传递课程与预设时间配置
  406. await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
  407. await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(presetTimeSlots));
  408. await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify(config));
  409. AndroidBridge.showToast(`导入成功:${campusLabel},课程 ${courses.length} 条`);
  410. AndroidBridge.notifyTaskCompletion();
  411. }
  412. // 自执行入口
  413. (async () => {
  414. try {
  415. await importSchedule();
  416. } catch (error) {
  417. console.error("课表导入失败:", error);
  418. // 失败原因直接提示给用户,便于在移动端快速定位问题
  419. AndroidBridge.showToast(`导入失败:${error.message}`);
  420. }
  421. })();