hbguhx_01.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. // 河北地质大学华信学院强智教务 (61.182.88.214:8090) 拾光课程表适配脚本
  2. // 本校 25 届开发者适配,无法覆盖所有边界情况
  3. // 出现问题请联系 4831884790@qq.com 或提交 pr,pr 更加快速
  4. const BASE_URL = "http://61.182.88.214:8090";
  5. /**
  6. * 带错误检查的 fetch,返回响应文本
  7. */
  8. async function fetchText(url, options = {}) {
  9. const response = await fetch(url, { credentials: "include", ...options });
  10. if (!response.ok) {
  11. throw new Error(`HTTP error! status: ${response.status}`);
  12. }
  13. return response.text();
  14. }
  15. /**
  16. * 解析周次字符串为数组
  17. * 支持 "1-17"、"1-17(周)"、"(单周)"/"(双周)" 标记(按段生效)、中英文逗号与顿号分隔
  18. */
  19. function parseWeeks(weekStr) {
  20. const weeks = [];
  21. if (!weekStr) return weeks;
  22. // 移除节次信息(防御性),保留周次段
  23. const pureWeekData = weekStr.replace(/\[.*?节\]/g, '').trim();
  24. if (!pureWeekData) return weeks;
  25. // 分割并处理每个段
  26. const segments = pureWeekData.split(/[,,、]/);
  27. segments.forEach(seg => {
  28. seg = seg.trim();
  29. if (!seg) return;
  30. // 检测本段的单双周标记(如 "1-17(单周)"、"6(双)"),需在剥离标记前记录
  31. const onlyOdd = /单/.test(seg);
  32. const onlyEven = /双/.test(seg);
  33. seg = seg.replace(/单|双|周|\(.*?\)/g, '').trim();
  34. if (!seg) return;
  35. if (seg.includes('-')) {
  36. const [start, end] = seg.split('-').map(Number);
  37. if (!isNaN(start) && !isNaN(end)) {
  38. for (let i = start; i <= end; i++) {
  39. if (onlyOdd && i % 2 === 0) continue;
  40. if (onlyEven && i % 2 === 1) continue;
  41. weeks.push(i);
  42. }
  43. }
  44. } else {
  45. const w = parseInt(seg, 10);
  46. if (!isNaN(w)) {
  47. if (onlyOdd && w % 2 === 0) return;
  48. if (onlyEven && w % 2 === 1) return;
  49. weeks.push(w);
  50. }
  51. }
  52. });
  53. return [...new Set(weeks)].sort((a, b) => a - b);
  54. }
  55. /**
  56. * 合并连续节次的相同课程
  57. */
  58. function mergeAndDistinctCourses(courses) {
  59. if (courses.length <= 1) return courses;
  60. // 排序以便合并
  61. courses.sort((a, b) => {
  62. if (a.name !== b.name) return a.name.localeCompare(b.name);
  63. if (a.day !== b.day) return a.day - b.day;
  64. if (a.startSection !== b.startSection) return a.startSection - b.startSection;
  65. if (a.teacher !== b.teacher) return a.teacher.localeCompare(b.teacher);
  66. if (a.position !== b.position) return a.position.localeCompare(b.position);
  67. return a.weeks.join(',').localeCompare(b.weeks.join(','));
  68. });
  69. const merged = [];
  70. let current = courses[0];
  71. for (let i = 1; i < courses.length; i++) {
  72. const next = courses[i];
  73. // 判断是否为同一门课程
  74. const isSameCourse =
  75. current.name === next.name &&
  76. current.teacher === next.teacher &&
  77. current.position === next.position &&
  78. current.day === next.day &&
  79. current.weeks.join(',') === next.weeks.join(',');
  80. // 判断节次是否连续
  81. const isContinuous = (current.endSection + 1 === next.startSection);
  82. if (isSameCourse && isContinuous) {
  83. // 合并连续节次
  84. current.endSection = next.endSection;
  85. } else if (!(isSameCourse && current.startSection === next.startSection && current.endSection === next.endSection)) {
  86. // 完全重复的条目直接丢弃,其余推入结果
  87. merged.push(current);
  88. current = next;
  89. }
  90. }
  91. merged.push(current);
  92. return merged;
  93. }
  94. /**
  95. * 从课程块中提取周次和节次
  96. * 匹配 "1-17(周)[01-02节]" / "1-17(周)[01-02 节]" 格式,空格均可选
  97. */
  98. function extractWeekAndSection(weekFull) {
  99. const match = weekFull.match(/(.+?)\(周\)\s*\[\s*(\d+)-(\d+)\s*节\s*\]/);
  100. if (match) {
  101. return {
  102. weekStr: match[1],
  103. startSection: parseInt(match[2], 10),
  104. endSection: parseInt(match[3], 10)
  105. };
  106. }
  107. // 兜底:只匹配周次范围,节次按 1-2 节假设
  108. const altMatch = weekFull.match(/(\d+)-(\d+)/);
  109. if (altMatch) {
  110. return {
  111. weekStr: altMatch[0],
  112. startSection: 1,
  113. endSection: 2
  114. };
  115. }
  116. return null;
  117. }
  118. /**
  119. * 将 HTML 源码解析为课程模型
  120. */
  121. function parseTimetableToModel(htmlString) {
  122. const doc = new DOMParser().parseFromString(htmlString, "text/html");
  123. const timetable = doc.getElementById('kbtable');
  124. if (!timetable) {
  125. return [];
  126. }
  127. const rawCourses = [];
  128. // 行 = 大节,列 = 星期;只处理含 td 的行(跳过表头),备注/中午行会因缺少周次标记被自然过滤
  129. const rows = Array.from(timetable.querySelectorAll('tr')).filter(r => r.querySelector('td'));
  130. rows.forEach((row) => {
  131. const cells = row.querySelectorAll('td');
  132. cells.forEach((cell, dayIndex) => {
  133. const day = dayIndex + 1; // 星期几(1-7)
  134. // 获取所有课程详情 div,包括所有状态的
  135. const detailDivs = Array.from(cell.querySelectorAll('div.kbcontent'));
  136. detailDivs.forEach((detailDiv) => {
  137. const rawHtml = detailDiv.innerHTML.trim();
  138. const innerText = detailDiv.innerText.trim();
  139. if (!rawHtml || rawHtml === "&nbsp;" || innerText.length < 2) return;
  140. // 分割同一个格子内的多门课程(10 个以上连字符即为分隔线)
  141. const blocks = rawHtml.split(/-{10,}/);
  142. blocks.forEach((block) => {
  143. if (!block.trim()) return;
  144. const tempDiv = document.createElement('div');
  145. tempDiv.innerHTML = block;
  146. // 1. 提取课程名(跳过开头的空行,处理 <br> 开头的分隔块)
  147. // 注意:innerHTML 中的 &nbsp; 等实体不会自动解码,需手动处理
  148. let name = "";
  149. const htmlLines = tempDiv.innerHTML.split('<br>');
  150. for (const line of htmlLines) {
  151. const text = line
  152. .replace(/<[^>]*>/g, '')
  153. .replace(/&nbsp;/gi, ' ')
  154. .replace(/\s+/g, ' ')
  155. .trim();
  156. if (text) {
  157. name = text;
  158. break;
  159. }
  160. }
  161. if (!name) return;
  162. // 2. 提取周次和节次信息
  163. const weekFont = tempDiv.querySelector('font[title="周次(节次)"]');
  164. const weekFull = weekFont?.innerText || "";
  165. const weekSection = extractWeekAndSection(weekFull);
  166. if (!weekSection) return;
  167. // 3. 提取教师信息
  168. const teacher = tempDiv.querySelector('font[title="老师"]')?.innerText.trim() || "未知教师";
  169. // 4. 提取教室地点
  170. const position = tempDiv.querySelector('font[title="教室"]')?.innerText.trim() || "未知地点";
  171. rawCourses.push({
  172. "name": name,
  173. "teacher": teacher,
  174. "weeks": parseWeeks(weekSection.weekStr),
  175. "position": position,
  176. "day": day,
  177. "startSection": weekSection.startSection,
  178. "endSection": weekSection.endSection
  179. });
  180. });
  181. });
  182. });
  183. });
  184. return mergeAndDistinctCourses(rawCourses);
  185. }
  186. /**
  187. * 从网页中提取学期选项列表
  188. */
  189. function extractSemesterOptions(htmlString) {
  190. const doc = new DOMParser().parseFromString(htmlString, "text/html");
  191. const semesterSelect = doc.getElementById('xnxq01id');
  192. if (!semesterSelect) {
  193. return [];
  194. }
  195. return Array.from(semesterSelect.querySelectorAll('option'))
  196. .filter(opt => opt.value)
  197. .map(opt => ({
  198. value: opt.value,
  199. text: opt.text
  200. }));
  201. }
  202. /**
  203. * 时间字符串加分钟数,"08:30" + 45 → "09:15"
  204. */
  205. function addMinutes(hhmm, minutes) {
  206. const parts = hhmm.split(':').map(Number);
  207. const total = parts[0] * 60 + parts[1] + minutes;
  208. const h = Math.floor(total / 60) % 24;
  209. const m = total % 60;
  210. return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
  211. }
  212. /**
  213. * 从网页中提取作息时间
  214. * 课表页 #kbtable 中每个大节行头 th 形如 "第一大节\n08:30-10:05"
  215. * 拆分规则:第 1 小节 = 开始 ~ 开始+45 分钟;第 2 小节 = 开始+55 分钟 ~ 大节结束
  216. */
  217. function extractTimeSlots(htmlString) {
  218. const doc = new DOMParser().parseFromString(htmlString, "text/html");
  219. const timetable = doc.getElementById('kbtable');
  220. if (!timetable) return null;
  221. const timeSlots = [];
  222. const rows = Array.from(timetable.querySelectorAll('tr'));
  223. rows.forEach((row) => {
  224. const th = row.querySelector('th');
  225. if (!th) return;
  226. // 提取时间范围,如 "08:30-10:05"
  227. const timeText = th.innerText.trim();
  228. const timeMatch = timeText.match(/(\d{2}:\d{2})-(\d{2}:\d{2})/);
  229. if (!timeMatch) return;
  230. const startTime = timeMatch[1];
  231. const endTime = timeMatch[2];
  232. // 判断是否是有效的时间段(排除"中午"等)
  233. const sectionName = timeText.split('\n')[0].trim();
  234. if (sectionName === '中午') return;
  235. // 为每个大节创建两个小节(时间加法,勿用分钟硬替换)
  236. timeSlots.push(
  237. {
  238. number: timeSlots.length + 1,
  239. startTime: startTime,
  240. endTime: addMinutes(startTime, 45)
  241. },
  242. {
  243. number: timeSlots.length + 2,
  244. startTime: addMinutes(startTime, 55),
  245. endTime: endTime
  246. }
  247. );
  248. });
  249. return timeSlots.length > 0 ? timeSlots : null;
  250. }
  251. /**
  252. * 根据学期字符串估算学期第一周周一日期
  253. * 学期格式如 "2025-2026-2"
  254. * 春季学期:3月第二个周一,秋季学期:9月1日所在周的周一
  255. * 仅作兜底,优先使用教学周历解析出的真实日期
  256. */
  257. function getSemesterStartMonday(semesterId) {
  258. if (!semesterId) return null;
  259. const parts = semesterId.split('-');
  260. if (parts.length < 3) return null;
  261. const year1 = parseInt(parts[0], 10);
  262. const year2 = parseInt(parts[1], 10);
  263. const semester = parseInt(parts[2], 10);
  264. if (isNaN(year1) || isNaN(year2) || isNaN(semester)) return null;
  265. // 估算学期开始日期
  266. let startDate;
  267. if (semester === 1) {
  268. startDate = new Date(year1, 8, 1); // 秋季学期:约9月1日
  269. } else {
  270. // 春季学期:3月第二个周一
  271. const march1Day = new Date(year2, 2, 1).getDay();
  272. const firstMondayDate = march1Day === 1 ? 1 : march1Day === 0 ? 2 : 9 - march1Day;
  273. startDate = new Date(year2, 2, firstMondayDate + 7); // 第二个周一
  274. }
  275. // 找到学期开始日期所在周的周一
  276. const startDay = startDate.getDay(); // 0=周日
  277. const monday = new Date(startDate);
  278. monday.setDate(startDate.getDate() + (startDay === 0 ? -6 : 1 - startDay));
  279. return monday;
  280. }
  281. /**
  282. * 从教学周历页面 HTML 中解析第一周周一的日期
  283. * 表格结构:<tr height='28'><td>1</td><td title='2026年08月31'>31</td>...
  284. * 第 1 个 td 为周次,第 2 个 td 为星期一,title 为完整日期
  285. */
  286. function extractWeekCalendarStartDate(htmlString) {
  287. const doc = new DOMParser().parseFromString(htmlString, "text/html");
  288. const timetable = doc.getElementById('kbtable');
  289. if (!timetable) return null;
  290. const rows = Array.from(timetable.querySelectorAll('tr'));
  291. for (const row of rows) {
  292. const tds = row.querySelectorAll('td');
  293. if (tds.length < 2) continue;
  294. if (tds[0].innerText.trim() !== '1') continue; // 只看第 1 周
  295. const title = tds[1].getAttribute('title') || '';
  296. const match = title.match(/(\d{4})年(\d{1,2})月(\d{1,2})/);
  297. if (match) {
  298. const date = new Date(
  299. parseInt(match[1], 10),
  300. parseInt(match[2], 10) - 1,
  301. parseInt(match[3], 10)
  302. );
  303. if (!isNaN(date.getTime())) return date;
  304. }
  305. return null; // 找到第 1 周行但解析失败,不再继续扫
  306. }
  307. return null;
  308. }
  309. /**
  310. * 请求教学周历页面,获取准确的学期第一周周一日期
  311. * 失败时返回 null,由调用方回退到估算
  312. */
  313. async function fetchSemesterStartMonday(semesterId) {
  314. try {
  315. // 注意:必须使用完整 URL 或前导 "/",否则相对路径会被教务框架错误拼接
  316. const html = await fetchText(`${BASE_URL}/jsxsd/jxzl/jxzl_query`, {
  317. method: "POST",
  318. headers: { "Content-Type": "application/x-www-form-urlencoded" },
  319. body: `xnxq01id=${encodeURIComponent(semesterId)}`
  320. });
  321. return extractWeekCalendarStartDate(html);
  322. } catch (error) {
  323. console.error('获取教学周历失败,将回退到估算学期起点:', error);
  324. return null;
  325. }
  326. }
  327. /**
  328. * 格式化日期为 YYYY-MM-DD
  329. */
  330. function formatDate(date) {
  331. const y = date.getFullYear();
  332. const m = String(date.getMonth() + 1).padStart(2, '0');
  333. const d = String(date.getDate()).padStart(2, '0');
  334. return `${y}-${m}-${d}`;
  335. }
  336. /**
  337. * 根据学期字符串计算当前周数
  338. * 优先使用从教学周历解析到的真实起始日期,否则回退到估算
  339. */
  340. function calculateCurrentWeek(semesterId, startMondayOverride) {
  341. const monday = startMondayOverride || getSemesterStartMonday(semesterId);
  342. if (!monday) return 1;
  343. const now = new Date();
  344. const diffMs = now.getTime() - monday.getTime();
  345. const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
  346. return Math.max(1, Math.floor(diffDays / 7) + 1);
  347. }
  348. /**
  349. * 显示欢迎提示
  350. */
  351. async function showWelcomeAlert() {
  352. return await window.shiguangBridgePromise.showAlert(
  353. "导入提示",
  354. "请先进入学期理论课表页面,首页周课表无法导入",
  355. "开始导入"
  356. );
  357. }
  358. /**
  359. * 获取用户选择的学期参数
  360. */
  361. async function getSemesterParamsFromUser(semesterOptions) {
  362. if (!semesterOptions || semesterOptions.length === 0) {
  363. window.shiguangBridge.showToast("未获取到学期列表");
  364. return null;
  365. }
  366. // 直接显示所有学期选项,让用户一次性选择
  367. const semesterLabels = semesterOptions.map(opt => opt.text);
  368. const semesterIndex = await window.shiguangBridgePromise.showSingleSelection(
  369. "选择学期",
  370. JSON.stringify(semesterLabels),
  371. 0 // 默认选择第一个(最新学期)
  372. );
  373. if (semesterIndex == null) return null; // 兼容 null / undefined(用户取消)
  374. return semesterOptions[semesterIndex].value;
  375. }
  376. /**
  377. * 请求课表页面 HTML
  378. */
  379. async function fetchCourseHtml() {
  380. // 浏览器禁止通过 fetch 设置 User-Agent,无需伪装请求头,Cookie 跟随即可
  381. return fetchText(`${BASE_URL}/jsxsd/xskb/xskb_list.do`);
  382. }
  383. /**
  384. * 根据选择的学期请求课表页面 HTML
  385. */
  386. async function fetchCourseHtmlBySemester(semesterId) {
  387. return fetchText(`${BASE_URL}/jsxsd/xskb/xskb_list.do`, {
  388. method: "POST",
  389. headers: { "Content-Type": "application/x-www-form-urlencoded" },
  390. body: `cj0701id=&zc=&demo=&xnxq01id=${encodeURIComponent(semesterId)}`
  391. });
  392. }
  393. /**
  394. * 保存课程数据到 App
  395. */
  396. async function saveCourseDataToApp(courses, timeSlots, semesterId, startMondayOverride) {
  397. // 计算学期开始日期和当前周数(优先使用教学周历解析出的真实日期)
  398. const startMonday = startMondayOverride || getSemesterStartMonday(semesterId);
  399. const currentWeek = calculateCurrentWeek(semesterId, startMondayOverride);
  400. // 学期总周数:从课程数据中的最大周次推导,最小 20 周
  401. const maxCourseWeek = courses.reduce((max, c) => Math.max(max, ...(c.weeks || [])), 0);
  402. // 保存学期配置
  403. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
  404. "semesterStartDate": startMonday ? formatDate(startMonday) : null,
  405. "currentWeek": currentWeek,
  406. "semesterTotalWeeks": Math.max(20, maxCourseWeek),
  407. "firstDayOfWeek": 1
  408. }));
  409. // 保存作息时间(从网页提取)
  410. if (timeSlots && timeSlots.length > 0) {
  411. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  412. }
  413. // 保存课程数据
  414. return await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  415. }
  416. /**
  417. * 主流程控制
  418. */
  419. async function runImportFlow() {
  420. try {
  421. // 1. 显示欢迎提示
  422. const start = await showWelcomeAlert();
  423. if (!start) return;
  424. // 2. 获取课表 HTML(包含学期选项和作息时间)
  425. const html = await fetchCourseHtml();
  426. // 3. 从网页中提取学期选项
  427. const semesterOptions = extractSemesterOptions(html);
  428. // 4. 从网页中提取作息时间,失败则回退到内置默认值(按本校实际作息)
  429. const timeSlots = extractTimeSlots(html) || [
  430. { number: 1, startTime: "08:30", endTime: "09:15" },
  431. { number: 2, startTime: "09:25", endTime: "10:05" },
  432. { number: 3, startTime: "10:15", endTime: "11:00" },
  433. { number: 4, startTime: "11:10", endTime: "11:50" },
  434. { number: 5, startTime: "14:30", endTime: "15:15" },
  435. { number: 6, startTime: "15:25", endTime: "16:05" },
  436. { number: 7, startTime: "16:15", endTime: "17:00" },
  437. { number: 8, startTime: "17:10", endTime: "17:50" },
  438. { number: 9, startTime: "19:00", endTime: "19:45" },
  439. { number: 10, startTime: "19:55", endTime: "20:40" }
  440. ];
  441. // 5. 让用户选择学期
  442. const semesterId = await getSemesterParamsFromUser(semesterOptions);
  443. if (!semesterId) return;
  444. // 6. 根据选择的学期重新请求课表数据
  445. const courseHtml = await fetchCourseHtmlBySemester(semesterId);
  446. // 7. 从教学周历获取准确的学期第一周周一日期(失败则自动回退到估算)
  447. const realStartMonday = await fetchSemesterStartMonday(semesterId);
  448. // 8. 解析课程数据
  449. const finalCourses = parseTimetableToModel(courseHtml);
  450. if (finalCourses.length === 0) {
  451. // 兜底:直接从初始页面解析,成功不额外提示,仅在失败时提示
  452. const initialCourses = parseTimetableToModel(html);
  453. if (initialCourses.length > 0) {
  454. await saveCourseDataToApp(initialCourses, timeSlots, semesterId, realStartMonday);
  455. window.shiguangBridge.notifyTaskCompletion();
  456. } else {
  457. window.shiguangBridge.showToast("未获取到课程,请确认已登录且所选学期有课");
  458. }
  459. return;
  460. }
  461. // 9. 保存课程数据
  462. await saveCourseDataToApp(finalCourses, timeSlots, semesterId, realStartMonday);
  463. const currentWeek = calculateCurrentWeek(semesterId, realStartMonday);
  464. window.shiguangBridge.showToast(`成功导入 ${finalCourses.length} 门课程(当前第 ${currentWeek} 周)`);
  465. window.shiguangBridge.notifyTaskCompletion();
  466. } catch (error) {
  467. console.error('导入异常:', error);
  468. window.shiguangBridge.showToast("导入异常:" + error.message);
  469. }
  470. }
  471. // 启动执行
  472. runImportFlow();