zhku_lc6464.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  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. const defaultIndex = 1; // 默认白云校区
  129. const selectedIndex = await window.AndroidBridgePromise.showSingleSelection(
  130. "请选择校区",
  131. JSON.stringify(labels),
  132. defaultIndex
  133. );
  134. if (selectedIndex == null || selectedIndex < 0 || selectedIndex >= CAMPUS_OPTIONS.length) {
  135. return "baiyun";
  136. }
  137. return CAMPUS_OPTIONS[selectedIndex].id;
  138. }
  139. // 按规则动态生成节次时间
  140. // 这样后续学校调整作息时,只需要改 PRESET_TIME_CONFIG
  141. function buildPresetTimeSlots(campusId) {
  142. const campus = PRESET_TIME_CONFIG.campuses[campusId] ?? PRESET_TIME_CONFIG.campuses.baiyun;
  143. const common = PRESET_TIME_CONFIG.common;
  144. const segments = ["morning", "noon", "afternoon", "evening"];
  145. const slots = [];
  146. let sectionNumber = 1;
  147. for (const segment of segments) {
  148. // 每个时段从配置中的起始时间开始滚动推导
  149. let cursor = parseTimeToMinutes(campus.startTimes[segment]);
  150. const count = common.sectionCounts[segment];
  151. const longBreakAfter = common.longBreakAfter[segment] ?? 0;
  152. for (let i = 1; i <= count; i += 1) {
  153. const start = cursor;
  154. const end = start + common.durations.classMinutes;
  155. slots.push({
  156. number: sectionNumber,
  157. startTime: formatMinutesToTime(start),
  158. endTime: formatMinutesToTime(end)
  159. });
  160. sectionNumber += 1;
  161. cursor = end;
  162. if (i < count) {
  163. // 当 longBreakAfter 为 0 时,该时段不会触发大课间
  164. const longBreakApplies = longBreakAfter > 0 && i === longBreakAfter;
  165. cursor += longBreakApplies
  166. ? common.durations.longBreakMinutes
  167. : common.durations.shortBreakMinutes;
  168. }
  169. }
  170. }
  171. return slots;
  172. }
  173. // 解析周次与节次
  174. // 示例:"3-4,6-8(周)[01-02节]"、"1-16(单周)[03-04节]"
  175. function parseWeeksAndSections(rawText) {
  176. const text = cleanText(rawText);
  177. const match = text.match(/^(.*?)\(([^)]*周)\)\[(.*?)节\]$/);
  178. if (match == null) {
  179. throw new Error(`无法解析课程时间:${text}`);
  180. }
  181. const weeksPart = match[1];
  182. const weekFlag = match[2];
  183. const sectionsPart = match[3];
  184. // 先把周次范围展开成完整数组
  185. const weeks = [];
  186. const weekRanges = weeksPart.match(/\d+(?:-\d+)?/g) ?? [];
  187. for (const rangeText of weekRanges) {
  188. if (rangeText.includes("-")) {
  189. const [start, end] = rangeText.split("-").map(Number);
  190. for (let w = start; w <= end; w += 1) {
  191. weeks.push(w);
  192. }
  193. } else {
  194. weeks.push(Number(rangeText));
  195. }
  196. }
  197. // 去重并排序后,再根据单双周标记过滤
  198. let normalizedWeeks = [...new Set(weeks)].sort((a, b) => a - b);
  199. if (weekFlag.includes("单")) {
  200. normalizedWeeks = normalizedWeeks.filter((w) => w % 2 === 1);
  201. }
  202. if (weekFlag.includes("双")) {
  203. normalizedWeeks = normalizedWeeks.filter((w) => w % 2 === 0);
  204. }
  205. const sections = (sectionsPart.match(/\d+/g) ?? []).map(Number).sort((a, b) => a - b);
  206. if (sections.length === 0) {
  207. throw new Error(`无法解析节次:${text}`);
  208. }
  209. return {
  210. weeks: normalizedWeeks,
  211. startSection: sections[0],
  212. endSection: sections[sections.length - 1]
  213. };
  214. }
  215. // 从当前位置向前查找满足条件的 font 节点
  216. function findPreviousFont(fonts, startIndex, predicate) {
  217. for (let i = startIndex - 1; i >= 0; i -= 1) {
  218. if (predicate(fonts[i])) {
  219. return fonts[i];
  220. }
  221. }
  222. return null;
  223. }
  224. // 从当前位置向后查找满足条件的 font 节点
  225. function findNextFont(fonts, startIndex, predicate) {
  226. for (let i = startIndex + 1; i < fonts.length; i += 1) {
  227. if (predicate(fonts[i])) {
  228. return fonts[i];
  229. }
  230. }
  231. return null;
  232. }
  233. // 教务页面会用 display:none 隐藏辅助节点,这里只保留可见信息
  234. function isVisibleFont(font) {
  235. const styleText = (font.getAttribute("style") ?? "").replace(/\s+/g, "").toLowerCase();
  236. return !styleText.includes("display:none");
  237. }
  238. // 从课表 iframe 中解析课程
  239. // 输出为扁平数组,不做同名课程合并
  240. function parseCoursesFromIframeDocument(iframeDoc) {
  241. const courses = [];
  242. const cells = iframeDoc.querySelectorAll(".kbcontent[id$='2']");
  243. cells.forEach((cell) => {
  244. // id 形如 xxxxx-<day>-2,day 为 1~7
  245. const idParts = (cell.id ?? "").split("-");
  246. const day = Number(idParts[idParts.length - 2]);
  247. if (!Number.isInteger(day) || day < 1 || day > 7) {
  248. return;
  249. }
  250. // 同一个 cell 里可能存在多个课程,因此要逐个锚点拆解
  251. const fonts = Array.from(cell.querySelectorAll("font"));
  252. fonts.forEach((font, idx) => {
  253. const title = cleanText(font.getAttribute("title") ?? "");
  254. if (!title.includes("周次")) {
  255. return;
  256. }
  257. if (!isVisibleFont(font)) {
  258. return;
  259. }
  260. const weekText = cleanText(font.textContent);
  261. if (weekText === "") {
  262. return;
  263. }
  264. // 以“周次(节次)”行为锚点,向前找教师和课程名,向后找教室
  265. const teacherFont = findPreviousFont(fonts, idx, (candidate) => {
  266. const candidateTitle = cleanText(candidate.getAttribute("title") ?? "");
  267. return candidateTitle.includes("教师") && isVisibleFont(candidate);
  268. });
  269. const teacherIndex = teacherFont == null ? idx : fonts.indexOf(teacherFont);
  270. const nameFont = findPreviousFont(fonts, teacherIndex, (candidate) => {
  271. const candidateTitle = cleanText(candidate.getAttribute("title") ?? "");
  272. const candidateNameAttr = cleanText(candidate.getAttribute("name") ?? "");
  273. const text = cleanText(candidate.textContent);
  274. return (
  275. candidateTitle === "" &&
  276. candidateNameAttr === "" &&
  277. isVisibleFont(candidate) &&
  278. text !== ""
  279. );
  280. });
  281. const locationFont = findNextFont(fonts, idx, (candidate) => {
  282. const candidateTitle = cleanText(candidate.getAttribute("title") ?? "");
  283. return candidateTitle.includes("教室") && isVisibleFont(candidate);
  284. });
  285. const courseName = cleanText(nameFont?.textContent ?? "");
  286. const teacher = cleanText(teacherFont?.textContent ?? "");
  287. let position = cleanText(locationFont?.textContent ?? "");
  288. // 移除教室中的“(白)”“(白云)”“(白)实”等字样,该信息对于学生而言无意义
  289. position = position.replace(/[((]白云?[))]实?/g, "");
  290. // 过滤空课程名、网络课和不存在的虚拟位置
  291. if (courseName === ""
  292. || position.includes("网络学时,不排时间教室")
  293. || position.includes("经典研读")
  294. || /^(?网络课)?/.test(courseName)) {
  295. return;
  296. }
  297. const parsed = parseWeeksAndSections(weekText);
  298. // 查重
  299. const existingCourse = courses.find((c) => c.name === courseName
  300. && c.teacher === teacher
  301. && c.position === position
  302. && c.day === day
  303. && c.startSection === parsed.startSection
  304. && c.endSection === parsed.endSection
  305. && JSON.stringify(c.weeks) === JSON.stringify(parsed.weeks));
  306. if (existingCourse != null) {
  307. return;
  308. }
  309. courses.push({
  310. name: courseName,
  311. teacher,
  312. position,
  313. day,
  314. startSection: parsed.startSection,
  315. endSection: parsed.endSection,
  316. weeks: parsed.weeks
  317. });
  318. });
  319. });
  320. return courses;
  321. }
  322. // 获取课表 iframe 的文档对象
  323. function getScheduleIframeDocument() {
  324. const iframe = document.querySelector("iframe[src*='/jsxsd/xskb/xskb_list.do']");
  325. if (iframe == null || iframe.contentDocument == null) {
  326. throw new Error("未找到课表 iframe,或 iframe 内容尚未加载完成");
  327. }
  328. return iframe.contentDocument;
  329. }
  330. // 获取当前学年学期 ID,例如 2025-2026-2
  331. function getSemesterId(iframeDoc) {
  332. const select = iframeDoc.querySelector("#xnxq01id");
  333. if (select == null) {
  334. throw new Error("未找到学年学期选择框 #xnxq01id");
  335. }
  336. // 优先读取 option[selected],读取失败再回退到 select.value
  337. const selectedOption = select.querySelector("option[selected]");
  338. return cleanText(selectedOption?.value ?? select.value);
  339. }
  340. // 拉取教学周历并提取开学日期与总周数
  341. async function fetchSemesterCalendarInfo(semesterId) {
  342. const url = `https://edu-admin.zhku.edu.cn/jsxsd/jxzl/jxzl_query?xnxq01id=${encodeURIComponent(semesterId)}`;
  343. const response = await fetch(url, {
  344. method: "GET",
  345. credentials: "include"
  346. });
  347. if (!response.ok) {
  348. throw new Error(`获取教学周历失败:HTTP ${response.status}`);
  349. }
  350. const html = await response.text();
  351. const parser = new DOMParser();
  352. const doc = parser.parseFromString(html, "text/html");
  353. const rows = Array.from(doc.querySelectorAll("#kbtable tr"));
  354. // 周次行特征:第一列是纯数字
  355. const weekRows = rows.filter((row) => {
  356. const firstCell = row.querySelector("td");
  357. return /^\d+$/.test(cleanText(firstCell?.textContent ?? ""));
  358. });
  359. if (weekRows.length === 0) {
  360. throw new Error("教学周历中未找到周次行");
  361. }
  362. // 学期起始日按“第一周周一”计算
  363. const firstWeekRow = weekRows[0];
  364. const mondayCell = firstWeekRow.querySelectorAll("td")[1];
  365. const mondayTitle = mondayCell?.getAttribute("title") ?? "";
  366. if (mondayTitle === "") {
  367. throw new Error("教学周历中未找到第一周周一日期");
  368. }
  369. return {
  370. semesterStartDate: normalizeCnDateToIso(mondayTitle),
  371. semesterTotalWeeks: weekRows.length
  372. };
  373. }
  374. // 主流程:读取课表 -> 选择校区 -> 拉周历 -> 生成节次 -> 调桥接导入
  375. async function importSchedule() {
  376. AndroidBridge.showToast("开始读取教务课表……");
  377. // 读取 iframe 并获取当前学年学期 ID
  378. const iframeDoc = getScheduleIframeDocument();
  379. const semesterId = getSemesterId(iframeDoc);
  380. // 解析课程信息
  381. const courses = parseCoursesFromIframeDocument(iframeDoc);
  382. if (courses.length === 0) {
  383. throw new Error("未解析到任何课程,请确认当前课表页面已加载完成");
  384. }
  385. // 拉取周历信息,获取开学日期与总周数
  386. const calendarInfo = await fetchSemesterCalendarInfo(semesterId);
  387. // 选择校区并生成预设上课时间配置
  388. const campusId = await chooseCampus();
  389. const campusLabel = CAMPUS_OPTIONS.find((item) => item.id === campusId)?.label ?? "白云校区";
  390. const presetTimeSlots = buildPresetTimeSlots(campusId);
  391. // 构建上课预设时间配置
  392. const config = {
  393. semesterStartDate: calendarInfo.semesterStartDate,
  394. semesterTotalWeeks: calendarInfo.semesterTotalWeeks,
  395. defaultClassDuration: PRESET_TIME_CONFIG.common.durations.classMinutes,
  396. defaultBreakDuration: PRESET_TIME_CONFIG.common.durations.shortBreakMinutes,
  397. // 每周按周一起始计算,因此固定为 1
  398. firstDayOfWeek: 1
  399. };
  400. // 通知课表软件进行导入,传递课程与预设时间配置
  401. await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
  402. await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(presetTimeSlots));
  403. await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify(config));
  404. AndroidBridge.showToast(`导入成功:${campusLabel},课程 ${courses.length} 条`);
  405. AndroidBridge.notifyTaskCompletion();
  406. }
  407. // 自执行入口
  408. (async () => {
  409. try {
  410. await importSchedule();
  411. } catch (error) {
  412. console.error("课表导入失败:", error);
  413. // 失败原因直接提示给用户,便于在移动端快速定位问题
  414. AndroidBridge.showToast(`导入失败:${error.message}`);
  415. }
  416. })();