JXNU.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. // 文件: JXNU.js
  2. // 功能:从江西师范大学系统获取课程表, 解析后导入到拾光课程表
  3. // 适配:江西师大教务系统
  4. // 维护者:glxgo
  5. // ---------- 常量配置 ----------
  6. const UNIT_COUNT = 13; // 每天最大节次数
  7. // ---------- 全局验证函数 ----------
  8. function validateYearInput(input) {
  9. if (/^\d{4}$/.test(input)) return false;
  10. return "请输入四位数字的年份(例如 2024)";
  11. }
  12. function validateWeeksInput(input) {
  13. const num = parseInt(input, 10);
  14. if (isNaN(num) || num < 1 || num > 55) return "请输入 1-55 之间的有效周数";
  15. return false;
  16. }
  17. // ---------- 单元格文本解析 ----------
  18. /**
  19. * 从课程格子文本中提取课程名、教师、教室
  20. * 输入例如:"军事理论(W7103)合班吕俊.4班"
  21. */
  22. function parseCellText(line) {
  23. line = line.trim();
  24. if (line.length < 3) return null;
  25. let room = '', name = '', teacher = '';
  26. const rmMatch = line.match(/\(([^)]+)\)/);
  27. if (rmMatch) {
  28. room = rmMatch[1].trim();
  29. name = line.substring(0, line.indexOf('(' + rmMatch[1] + ')')).trim();
  30. const after = line.substring(
  31. line.indexOf('(' + rmMatch[1] + ')') + rmMatch[1].length + 2
  32. ).trim();
  33. const hbMatch = after.match(/合班(.+?)(?:$|\.\d+班|\s)/);
  34. if (hbMatch) teacher = hbMatch[1].trim();
  35. if (!teacher) {
  36. const jgMatch = after.match(/教工(.+?)(?:#|$|\s)/);
  37. if (jgMatch) teacher = jgMatch[1].trim();
  38. }
  39. if (!teacher && after.length > 0 && !after.match(/^\d+班$/)) {
  40. teacher = after.replace(/\.\d+班$/, '').trim();
  41. }
  42. } else {
  43. name = line;
  44. }
  45. name = name.replace(/\s+/g, '');
  46. if (!/[\u4e00-\u9fa5a-zA-Z]/.test(name)) return null;
  47. if (name.length > 50) name = name.substring(0, 50);
  48. return { name, teacher, room };
  49. }
  50. /**
  51. * 判断单元格文本是否是"节次标签"
  52. */
  53. function isPeriodLabel(text) {
  54. const t = text.trim();
  55. if (!t) return false;
  56. const cleaned = t.replace(/\s+/g, '');
  57. const normalized = cleaned.replace(/^第/, '').replace(/节$/, '');
  58. if (/^[1-9]$/.test(normalized)) return true;
  59. if (/^(1[0-3]?)$/.test(normalized) && parseInt(normalized) <= UNIT_COUNT) return true;
  60. const rangeMatch = normalized.match(/^(\d{1,2})(?:[-–—~~,]|至)(\d{1,2})$/);
  61. if (rangeMatch) {
  62. const s = parseInt(rangeMatch[1]);
  63. const e = parseInt(rangeMatch[2]);
  64. if (s >= 1 && e <= UNIT_COUNT && s <= e) return true;
  65. }
  66. const rawTokens = (t.match(/\d{1,2}/g) || []).map(n => parseInt(n, 10));
  67. if (rawTokens.length >= 2 && rawTokens.every(n => n >= 1 && n <= UNIT_COUNT)) {
  68. const stripped = t.replace(/\d{1,2}/g, '').replace(/[第节\s]/g, '');
  69. if (!stripped) return true;
  70. }
  71. if (t.includes('晚') && (t.includes('上') || t.includes('自'))) return true;
  72. return false;
  73. }
  74. /**
  75. * 从节次标签文本解析出节次数字数组
  76. * "1" → [1], "3-4" → [3,4], "第6-7节" → [6,7], "6 7" → [6,7], "晚上" → [10,11,12]
  77. */
  78. function parsePeriodText(text) {
  79. const t = text.trim();
  80. if (!t) return [];
  81. if (t.includes('晚') && (t.includes('上') || t.includes('自'))) return [10, 11, 12];
  82. const rawTokens = (t.match(/\d{1,2}/g) || [])
  83. .map(n => parseInt(n, 10))
  84. .filter(n => n >= 1 && n <= UNIT_COUNT);
  85. if (rawTokens.length >= 2) {
  86. const stripped = t.replace(/\d{1,2}/g, '').replace(/[第节\s]/g, '');
  87. if (!stripped) {
  88. const nums = [...new Set(rawTokens)].sort((a, b) => a - b);
  89. if (nums[nums.length - 1] - nums[0] + 1 === nums.length) return nums;
  90. }
  91. }
  92. // 优先检查原始文本中是否有换行或空白分隔符(如 "1\n2")
  93. // 按 \n 或空白分割,每个部分独立解析为单个节次
  94. if (t.includes('\n') || t.includes('\r') || /\s{2,}/.test(t)) {
  95. const parts = t.split(/[\n\r]+/).map(s => s.trim()).filter(s => s.length > 0);
  96. if (parts.length >= 2) {
  97. const nums = [];
  98. for (const p of parts) {
  99. const n = parseInt(p, 10);
  100. if (n >= 1 && n <= UNIT_COUNT && !nums.includes(n)) nums.push(n);
  101. }
  102. if (nums.length >= 2) { nums.sort((a, b) => a - b); return nums; }
  103. }
  104. }
  105. const cleaned = t.replace(/\s+/g, '');
  106. const normalized = cleaned.replace(/^第/, '').replace(/节$/, '');
  107. const rangeMatch = normalized.match(/^(\d{1,2})(?:[-–—~~,]|至)(\d{1,2})$/);
  108. if (rangeMatch) {
  109. const start = parseInt(rangeMatch[1], 10);
  110. const end = parseInt(rangeMatch[2], 10);
  111. if (start >= 1 && end <= UNIT_COUNT && start <= end) {
  112. const result = [];
  113. for (let i = start; i <= end; i++) result.push(i);
  114. return result;
  115. }
  116. }
  117. const singleMatch = normalized.match(/^(\d{1,2})$/);
  118. if (singleMatch) {
  119. const num = parseInt(singleMatch[1], 10);
  120. if (num >= 1 && num <= UNIT_COUNT) return [num];
  121. }
  122. const nums = (normalized.match(/\d{1,2}/g) || [])
  123. .map(n => parseInt(n, 10))
  124. .filter(n => n >= 1 && n <= UNIT_COUNT);
  125. if (nums.length > 0) {
  126. return [...new Set(nums)].sort((a, b) => a - b);
  127. }
  128. return [];
  129. }
  130. // ---------- DOM 解析(当前页面) ----------
  131. /**
  132. * 在指定 window 对象中递归查找课表表格。
  133. * 匹配条件:表格第一行(表头)必须包含"星期一"且至少有 7 列(周一到周日)。
  134. * 支持表格位于 iframe 内的情况。
  135. */
  136. function findCourseTable(win) {
  137. try {
  138. for (const t of win.document.querySelectorAll('table')) {
  139. // 初级筛选:表格内容要包含"星期一"
  140. if (!t.textContent.includes('星期一')) continue;
  141. // 中级筛选:表格第一行必须有至少 7 列
  142. if (t.rows.length === 0) continue;
  143. const firstRow = t.rows[0];
  144. if (firstRow.cells.length < 7) continue;
  145. // 高级筛选:第一行某列确实包含"星期一"文本
  146. let hasMondayHeader = false;
  147. for (let c = 0; c < firstRow.cells.length; c++) {
  148. if (firstRow.cells[c].textContent.includes('星期一')) {
  149. hasMondayHeader = true;
  150. break;
  151. }
  152. }
  153. if (hasMondayHeader) return t;
  154. }
  155. } catch (e) {
  156. // 跨域 iframe 无法访问,跳过
  157. }
  158. // 递归查找 iframe
  159. try {
  160. for (let i = 0; i < win.frames.length; i++) {
  161. const found = findCourseTable(win.frames[i]);
  162. if (found) return found;
  163. }
  164. } catch (e) {
  165. // 跨域 iframe 无法访问,跳过
  166. }
  167. return null;
  168. }
  169. /**
  170. * 从当前页面的 DOM 中,用 table.rows API 提取课程数据。
  171. *
  172. * 核心差异(对比旧脚本的 querySelectorAll 平铺方案):
  173. * 旧脚本用 gridTable.querySelectorAll('td, th') 获取所有格子的扁平列表,
  174. * 在有 rowspan 时索引会错位,导致课程落到错误的星期列。
  175. *
  176. * 本方案用 table.rows[r].cells[c]——浏览器原生处理 rowspan,
  177. * rowspan 覆盖的格在下一行的 cells 中自动消失,不需要手动追踪。
  178. */
  179. function parseCourseTableFromCurrentPage() {
  180. const courses = [];
  181. const dayNames = ['星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日'];
  182. const table = findCourseTable(window);
  183. if (!table) {
  184. console.warn("[JXNU] 未找到课表表格");
  185. return { courses, html: '' };
  186. }
  187. const pageHtml = document.documentElement.outerHTML;
  188. const rows = table.rows;
  189. if (rows.length < 3) {
  190. console.warn("[JXNU] 课表格行数不足:", rows.length);
  191. return { courses, html: pageHtml };
  192. }
  193. let headerRowIdx = -1;
  194. for (let i = 0; i < Math.min(rows.length, 3); i++) {
  195. const rowText = rows[i].textContent.trim();
  196. if (dayNames.some(d => rowText.includes(d))) {
  197. headerRowIdx = i;
  198. break;
  199. }
  200. }
  201. if (headerRowIdx < 0) {
  202. console.warn("[JXNU] 未找到课表表头行");
  203. return { courses, html: pageHtml };
  204. }
  205. const headerRow = rows[headerRowIdx];
  206. const totalCols = headerRow.cells.length;
  207. console.log(`[JXNU] 表头共 ${totalCols} 列`);
  208. // 定位"星期一"所在列
  209. let firstDayCol = -1;
  210. for (let c = 0; c < totalCols; c++) {
  211. const t = headerRow.cells[c].textContent.trim().replace(/\s+/g, '');
  212. console.log(`[JXNU] 列 ${c}: "${t}"`);
  213. if (t.includes('星期一')) firstDayCol = c;
  214. }
  215. if (firstDayCol < 0) {
  216. console.warn("[JXNU] 未找到星期一的表头列");
  217. return { courses, html: pageHtml };
  218. }
  219. // dayColMap: 表头列索引 → 星期几
  220. const dayColMap = {};
  221. for (let c = firstDayCol; c < totalCols && c - firstDayCol < 7; c++) {
  222. dayColMap[c] = c - firstDayCol + 1;
  223. }
  224. console.log(`[JXNU] 星期一在列 ${firstDayCol},dayColMap:`, JSON.stringify(dayColMap));
  225. // periodLabelCol = 星期一的前一列
  226. const periodLabelCol = firstDayCol - 1;
  227. console.log(`[JXNU] periodLabelCol=${periodLabelCol}`);
  228. // rowspan 追踪
  229. const rowspanActive = new Array(totalCols).fill(0);
  230. let totalPeriodRows = 0;
  231. for (let r = headerRowIdx + 1; r < rows.length; r++) {
  232. const row = rows[r];
  233. const cells = row.cells;
  234. if (cells.length < 2) {
  235. console.log(`[JXNU] 行 ${r}: 只有 ${cells.length} 格(分隔行)`);
  236. }
  237. // ---- 用 Column-pass 算法遍历 ----
  238. // 必须完整扫过每一列表头位,哪怕当前行可见单元格已经用完,
  239. // 否则末尾列的 rowspan 不会在这一行递减,后续行会整体错位。
  240. let periodText = '', foundPeriod = false;
  241. let periods = [], courseData = [];
  242. let cellIdx = 0;
  243. for (let col = 0; col < totalCols; col++) {
  244. if (rowspanActive[col] > 0) {
  245. rowspanActive[col]--;
  246. continue;
  247. }
  248. if (cellIdx >= cells.length) continue;
  249. const cell = cells[cellIdx++];
  250. if (cell.rowSpan > 1) rowspanActive[col] = cell.rowSpan - 1;
  251. const text = cell.textContent.trim();
  252. // 方案A:如果 col == periodLabelCol,用列位置判断
  253. // 方案B:同时检查 col > periodLabelCol 的短表格情况
  254. if (col === periodLabelCol) {
  255. if (text && isPeriodLabel(text)) {
  256. periodText = text; periods = parsePeriodText(text);
  257. foundPeriod = periods.length > 0;
  258. }
  259. if (foundPeriod) console.log(`[JXNU] 行${r} period: "${text.replace(/\s+/g,' ')}" → [${periods}]`);
  260. }
  261. // 如果是课程列,尝试从中提取课程名(用 isPeriodLabel 排除误判)
  262. if (dayColMap[col] && text && text.length >= 2 && text !== '\u00a0') {
  263. const cleanText = text.replace(/\s+/g, '');
  264. const skipWords = ['上午', '下午', '晚上', '中午', '中 午', '午休', '节次'];
  265. if (!skipWords.includes(cleanText) && text.length > 2) {
  266. courseData.push({ cell, text, day: dayColMap[col] });
  267. console.log(`[JXNU] 行${r} col${col}(周${dayColMap[col]}): "${text.replace(/\s+/g,' ').substring(0,60)}"`);
  268. }
  269. }
  270. }
  271. // 如果没找到 period → 尝试方案C:扫描当前行所有格找 period
  272. if (!foundPeriod) {
  273. for (let ci = 0; ci < cells.length; ci++) {
  274. const t = cells[ci].textContent.trim();
  275. if (t && isPeriodLabel(t)) {
  276. periodText = t; periods = parsePeriodText(t);
  277. foundPeriod = periods.length > 0;
  278. console.log(`[JXNU] 行${r} [方案C] 扫描找到 period: "${t.replace(/\s+/g,' ')}" 在 cell[${ci}]`);
  279. break;
  280. }
  281. }
  282. }
  283. if (!foundPeriod) {
  284. console.log(`[JXNU] 行${r}: 未找到节次标签`);
  285. continue;
  286. }
  287. totalPeriodRows++;
  288. let rowCount = 0;
  289. for (const { cell, text, day } of courseData) {
  290. let name = '', teacher = '', room = '';
  291. const titleEl = cell.querySelector('.title font, .title');
  292. if (titleEl) {
  293. name = titleEl.textContent.trim();
  294. if (/[\u4e00-\u9fa5a-zA-Z]/.test(name)) {
  295. const pEls = cell.querySelectorAll('p font');
  296. if (pEls.length >= 2) room = pEls[1].textContent.trim();
  297. if (pEls.length >= 3) teacher = pEls[2].textContent.trim();
  298. if (!room && pEls.length >= 1) {
  299. const t = pEls[0].textContent.trim();
  300. if (/[楼馆教栋区斋轩堂室]/.test(t)) room = t;
  301. }
  302. } else { name = ''; }
  303. }
  304. if (!name) {
  305. const parsed = parseCellText(text);
  306. if (!parsed || !parsed.name) {
  307. console.log(`[JXNU] parseCellText失败: "${text.replace(/\s+/g,' ').substring(0,60)}"`);
  308. continue;
  309. }
  310. name = parsed.name; teacher = parsed.teacher || teacher; room = parsed.room || room;
  311. }
  312. name = name.replace(/\s+/g, '');
  313. if (!/[\u4e00-\u9fa5a-zA-Z]/.test(name)) continue;
  314. courses.push({ name, teacher: teacher || '', position: room || '',
  315. day, startSection: periods[0], endSection: periods[periods.length - 1], weeks: [] });
  316. rowCount++;
  317. }
  318. console.log(`[JXNU] 行${r}: 解析到 ${rowCount} 门课`);
  319. }
  320. console.log(`[JXNU] 共 ${totalPeriodRows} 行数据`);
  321. const seen = new Set();
  322. const deduped = [];
  323. for (const c of courses) {
  324. const key = `${c.name}|${c.day}|${c.startSection}|${c.endSection}|${c.teacher}|${c.position}`;
  325. if (!seen.has(key)) { seen.add(key); deduped.push(c); }
  326. }
  327. console.log(`[JXNU] 完成: ${courses.length} → ${deduped.length} 条`);
  328. return { courses: deduped, html: pageHtml };
  329. }
  330. // ---------- 时间段配置 ----------
  331. function getTimeSlots() {
  332. return [
  333. { number: 1, startTime: "08:00", endTime: "08:40" },
  334. { number: 2, startTime: "08:50", endTime: "09:30" },
  335. { number: 3, startTime: "09:40", endTime: "10:20" },
  336. { number: 4, startTime: "10:30", endTime: "11:10" },
  337. { number: 5, startTime: "11:20", endTime: "12:00" },
  338. { number: 6, startTime: "14:00", endTime: "14:40" },
  339. { number: 7, startTime: "14:50", endTime: "15:30" },
  340. { number: 8, startTime: "15:40", endTime: "16:20" },
  341. { number: 9, startTime: "16:30", endTime: "17:10" },
  342. { number: 10, startTime: "19:00", endTime: "19:40" },
  343. { number: 11, startTime: "19:50", endTime: "20:30" },
  344. { number: 12, startTime: "20:40", endTime: "21:20" },
  345. ];
  346. }
  347. // ---------- 用户交互 ----------
  348. async function promptUserToStart() {
  349. return await window.AndroidBridgePromise.showAlert(
  350. "江西师范大学 课表导入",
  351. "请确认:\n1. 已在浏览器中登录教务系统\n2. 已进入学生课表查询页面并选择了正确的学年学期\n3. 已点击【查询】按钮,课表已正常显示\n\n点击确定开始导入。",
  352. "确定,开始导入"
  353. );
  354. }
  355. async function getTotalWeeks() {
  356. return await window.AndroidBridgePromise.showPrompt(
  357. "设置本学期总周数",
  358. "请输入本学期总周数(默认 20,范围 1-55):",
  359. "20",
  360. "validateWeeksInput"
  361. );
  362. }
  363. // ---------- 主流程 ----------
  364. async function run() {
  365. try {
  366. const confirmed = await promptUserToStart();
  367. if (!confirmed) { AndroidBridge.showToast("用户取消了导入。"); return; }
  368. const weeksInput = await getTotalWeeks();
  369. if (weeksInput === null) { AndroidBridge.showToast("导入已取消。"); return; }
  370. const totalWeeks = parseInt(weeksInput, 10);
  371. if (isNaN(totalWeeks) || totalWeeks < 1) { AndroidBridge.showToast("周数设置无效。"); return; }
  372. const defaultWeeks = Array.from({ length: totalWeeks }, (_, i) => i + 1);
  373. AndroidBridge.showToast("正在解析课表数据...");
  374. const { courses: parsedCourses, html: pageHtml } = parseCourseTableFromCurrentPage();
  375. if (parsedCourses.length === 0) {
  376. // 解析失败时给出更具体的提示
  377. let detail = "";
  378. if (!pageHtml.includes('星期一')) {
  379. detail = '当前页面不包含课表表格。请确认:\n' +
  380. '1. 已进入学生课表查询页面\n' +
  381. '2. 已选择学年学期并点击【查询】\n' +
  382. '3. 课表已正常显示(页面中能看到"星期一"表头)';
  383. } else if (pageHtml.includes('星期一') && document.querySelectorAll('table').length > 0) {
  384. detail = '已找到包含"星期一"的课表表格,但未能从中解析出有效的课程数据。\n' +
  385. '请确认已从下拉菜单中选择了正确的学年学期,并点击了【查询/确定】按钮。\n\n' +
  386. '常见问题:\n' +
  387. '1. 页面加载后未点击【查询】按钮\n' +
  388. '2. 当前学期没有课程安排(课表空白)\n' +
  389. '3. 选错了学年或学期';
  390. } else {
  391. detail = '未能在当前页面中找到课表数据。\n' +
  392. '请确认已在学生课表查询页面正确选择了学期并点击了【查询】。';
  393. }
  394. await window.AndroidBridgePromise.showAlert("未解析到课程", detail, "确定");
  395. return;
  396. }
  397. const coursesWithWeeks = parsedCourses.map(c => ({
  398. name: c.name, teacher: c.teacher, position: c.position,
  399. day: c.day, startSection: c.startSection, endSection: c.endSection,
  400. weeks: c.weeks.length > 0 ? c.weeks : defaultWeeks,
  401. }));
  402. try {
  403. await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(coursesWithWeeks));
  404. AndroidBridge.showToast(`课程数据已导入(共 ${coursesWithWeeks.length} 条)`);
  405. } catch (saveErr) {
  406. console.error("[JXNU] 保存课程失败:", saveErr);
  407. await window.AndroidBridgePromise.showAlert("保存课程失败", saveErr.message || String(saveErr), "确定");
  408. return;
  409. }
  410. const timeSlots = getTimeSlots();
  411. try {
  412. await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  413. AndroidBridge.showToast("时间段数据已导入");
  414. } catch (slotErr) {
  415. console.error("[JXNU] 保存时间段失败:", slotErr);
  416. AndroidBridge.showToast(`时间段保存失败:${slotErr.message}`);
  417. }
  418. AndroidBridge.showToast("导入完成!");
  419. AndroidBridge.notifyTaskCompletion();
  420. } catch (err) {
  421. console.error("[JXNU] 导入流程出错:", err);
  422. try {
  423. await window.AndroidBridgePromise.showAlert("导入失败", `未知错误:${err.message || err}\n\n请联系开发者。`, "确定");
  424. } catch (_) {}
  425. AndroidBridge.notifyTaskCompletion();
  426. }
  427. }
  428. run();