FAFU.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. // 文件: FAFU.js (v3)
  2. // 福建农林大学教务系统课程表导入脚本
  3. // 适配对象: 经典正方教务系统 (jwgl.fafu.edu.cn, default2.aspx)
  4. // 说明: 解析"时间网格"式课表 —— 行为节次、列为星期,
  5. // 课程单元格为分行文本(课程名 / 周X第Y节{第X-Y周} / 教师 / 地点)。
  6. // v3: 支持课表位于同源 iframe 内;用 innerHTML 按 <br> 切行;失败时输出诊断。
  7. // ---------------- 通用工具 ----------------
  8. function cleanText(s) {
  9. return (s || '')
  10. .replace(/\u00a0/g, ' ')
  11. .replace(/\s+/g, ' ')
  12. .trim();
  13. }
  14. // 展开周次数组 "1-16" 及 单/双周 过滤
  15. function expandWeeks(start, end, parity) {
  16. const weeks = [];
  17. for (let i = start; i <= end; i++) {
  18. if (parity === 1 && i % 2 !== 1) continue;
  19. if (parity === 2 && i % 2 !== 0) continue;
  20. weeks.push(i);
  21. }
  22. return weeks;
  23. }
  24. // 解析课程信息行:"周一第1,2节{第1-5周}" / "{第1-17周|2节/周}" / "{第16-16周|双周}"
  25. function parseInfoLine(info) {
  26. let day = 0;
  27. const dayM = /星期?([一二三四五六日])/.exec(info || '');
  28. if (dayM) day = '一二三四五六日'.indexOf(dayM[1]) + 1;
  29. let sections = [];
  30. const secM = /第(\d+(?:,\d+)*)节/.exec(info || '');
  31. if (secM) sections = secM[1].split(',').map(Number);
  32. let weekStart = 0, weekEnd = 0;
  33. const wM = /第(\d+)(?:-(\d+))?周/.exec(info || '');
  34. if (wM) {
  35. weekStart = parseInt(wM[1]);
  36. weekEnd = wM[2] ? parseInt(wM[2]) : weekStart;
  37. }
  38. let parity = 0;
  39. if (/单周/.test(info || '')) parity = 1;
  40. else if (/双周/.test(info || '')) parity = 2;
  41. return { day, sections, weekStart, weekEnd, parity };
  42. }
  43. // ---------------- 若课表在 iframe 中,需递归收集同源文档 ----------------
  44. function getAllDocs() {
  45. const docs = [document];
  46. const seen = new Set([document]);
  47. (function walk(doc) {
  48. try {
  49. const frames = doc.querySelectorAll('iframe, frame');
  50. frames.forEach(f => {
  51. const inner = f.contentDocument;
  52. if (inner && !seen.has(inner)) {
  53. seen.add(inner);
  54. docs.push(inner);
  55. walk(inner);
  56. }
  57. });
  58. } catch (e) { /* 跨域 iframe 无法访问,忽略 */ }
  59. })(document);
  60. return docs;
  61. }
  62. // 在所有文档中定位课表网格
  63. function locateGridTable() {
  64. for (const doc of getAllDocs()) {
  65. const tables = doc.querySelectorAll('table');
  66. for (const t of tables) {
  67. const txt = cleanText(t.textContent || '');
  68. if (txt.includes('上午') && txt.includes('第1节') &&
  69. /星期[一二三四五六日]/.test(txt)) {
  70. return t;
  71. }
  72. }
  73. }
  74. return null;
  75. }
  76. function countTables() {
  77. return getAllDocs().reduce((n, d) => n + d.querySelectorAll('table').length, 0);
  78. }
  79. // 星期列的水平范围 {星期号: {left, right}}
  80. function buildDayColumns(table) {
  81. const headerTr = Array.from(table.querySelectorAll('tr'))
  82. .find(tr => /星期[一二三四五六日]/.test(tr.textContent || ''));
  83. if (!headerTr) return {};
  84. const cols = {};
  85. Array.from(headerTr.querySelectorAll('td, th')).forEach(td => {
  86. const m = /星期([一二三四五六日])/.exec(td.textContent || '');
  87. if (!m) return;
  88. const dayNum = '一二三四五六日'.indexOf(m[1]) + 1;
  89. const r = td.getBoundingClientRect();
  90. if (r.width > 0) cols[dayNum] = { left: r.left, right: r.right };
  91. });
  92. return cols;
  93. }
  94. // 因单元格有 rowspan/colspan,用几何位置判断其属于哪个星期列
  95. function dayFromRect(td, dayCols) {
  96. const r = td.getBoundingClientRect();
  97. const cx = (r.left + r.right) / 2;
  98. for (const dayNum in dayCols) {
  99. const c = dayCols[dayNum];
  100. if (cx >= c.left - 2 && cx <= c.right + 2) return parseInt(dayNum);
  101. }
  102. return 0;
  103. }
  104. // 用单元格 HTML 按 <br> 切行(不依赖 innerText,隐藏表格也能解析)
  105. function cellLines(td) {
  106. const html = td.innerHTML || '';
  107. return html.split(/<br\s*\/?>/i)
  108. .map(s => cleanText(s.replace(/<[^>]*>/g, '')));
  109. }
  110. // 把行按空行切成课程块,每块 4 行:课程名/节次周次/教师/地点
  111. function splitBlocks(lines) {
  112. const blocks = [];
  113. let cur = [];
  114. for (const ln of lines) {
  115. if (ln === '') {
  116. if (cur.length) { blocks.push(cur); cur = []; }
  117. } else {
  118. cur.push(ln);
  119. }
  120. }
  121. if (cur.length) blocks.push(cur);
  122. return blocks;
  123. }
  124. // 解析网格课表
  125. function parseGridTable(table) {
  126. const dayCols = buildDayColumns(table);
  127. const courses = [];
  128. const rows = Array.from(table.querySelectorAll('tr'));
  129. rows.forEach(tr => {
  130. // 该行首个"第X节"标签(用于无节次信息的课程,如体育)
  131. let rowSectionStart = 0;
  132. Array.from(tr.querySelectorAll('td')).forEach(td => {
  133. if (rowSectionStart) return;
  134. const m = /^第(\d+)节$/.exec(cleanText(td.innerText || td.textContent || ''));
  135. if (m) rowSectionStart = parseInt(m[1]);
  136. });
  137. Array.from(tr.querySelectorAll('td')).forEach(td => {
  138. const lines = cellLines(td).map(l => l.trim());
  139. if (!lines.some(l => l)) return; // 全空则跳过
  140. const blocks = splitBlocks(lines);
  141. blocks.forEach(block => {
  142. // 课程块需至少 4 行且末行为地点(可过滤"上午/早晨/第X节"等标签)
  143. if (block.length < 4 || !block[3]) return;
  144. const name = block[0] || '';
  145. const info = block[1] || '';
  146. const teacher = block[2] || '';
  147. const location = block[3] || '';
  148. const parsed = parseInfoLine(info);
  149. const day = parsed.day || dayFromRect(td, dayCols);
  150. let sections = parsed.sections;
  151. if (!sections.length && rowSectionStart) {
  152. const span = td.rowSpan || 1;
  153. sections = Array.from({ length: span }, (_, i) => rowSectionStart + i);
  154. }
  155. const weeks = expandWeeks(parsed.weekStart, parsed.weekEnd, parsed.parity);
  156. if (!day || !sections.length || !weeks.length || !name) return;
  157. courses.push({
  158. name: name,
  159. day: day,
  160. weeks: weeks,
  161. teacher: teacher,
  162. position: location,
  163. startSection: sections[0],
  164. endSection: sections[sections.length - 1]
  165. });
  166. });
  167. });
  168. });
  169. return courses;
  170. }
  171. // ---------------- 主流程 ----------------
  172. async function scrapeAndParseCourses() {
  173. window.shiguangBridge.showToast("正在解析课表数据...");
  174. const table = locateGridTable();
  175. if (!table) {
  176. const ts = countTables();
  177. await window.shiguangBridgePromise.showAlert(
  178. "导入失败",
  179. "未定位到课表网格。\n当前共检测到 " + ts + " 个表格。\n请确认:\n1.已登录教务系统\n2.已在【信息查询-学生个人课表】点击【查询】并显示课程\n3.若课表在 iframe/新窗口,脚本已自动尝试进入同源 iframe" +
  180. (ts === 0 ? "\n\n提示:检测到 0 个表格,可能脚本运行于错误页面/跨域 iframe,请返回课表页面后重试。" : ""),
  181. "确定"
  182. );
  183. return null;
  184. }
  185. const result = parseGridTable(table);
  186. if (result.length === 0) {
  187. await window.shiguangBridgePromise.showAlert(
  188. "导入失败",
  189. "已定位到课表,但未解析出任何课程。\n请点击【查询】让课表显示课程数据后重试。",
  190. "确定"
  191. );
  192. return null;
  193. }
  194. return { courses: result };
  195. }
  196. async function saveCourses(courses) {
  197. window.shiguangBridge.showToast(`正在保存 ${courses.length} 门课程...`);
  198. try {
  199. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  200. return true;
  201. } catch (error) {
  202. window.shiguangBridge.showToast("课程保存失败: " + error.message);
  203. return false;
  204. }
  205. }
  206. // 预设时间段
  207. const presetTimeSlots = [
  208. { number: 1, startTime: "08:00", endTime: "08:45" },
  209. { number: 2, startTime: "08:50", endTime: "09:35" },
  210. { number: 3, startTime: "09:55", endTime: "10:40" },
  211. { number: 4, startTime: "10:45", endTime: "11:30" },
  212. { number: 5, startTime: "11:35", endTime: "12:20" },
  213. { number: 6, startTime: "14:00", endTime: "14:45" },
  214. { number: 7, startTime: "14:50", endTime: "15:35" },
  215. { number: 8, startTime: "15:50", endTime: "16:35" },
  216. { number: 9, startTime: "16:40", endTime: "17:25" },
  217. { number: 10, startTime: "18:25", endTime: "19:10" },
  218. { number: 11, startTime: "19:15", endTime: "20:00" },
  219. { number: 12, startTime: "20:05", endTime: "20:50" }
  220. ];
  221. async function importPresetTimeSlots() {
  222. try {
  223. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(presetTimeSlots));
  224. window.shiguangBridge.showToast("预设时间段导入成功!");
  225. } catch (error) {
  226. window.shiguangBridge.showToast("导入时间段失败: " + error.message);
  227. }
  228. }
  229. async function runImportFlow() {
  230. const confirmed = await window.shiguangBridgePromise.showAlert(
  231. "福建农林大学课表导入",
  232. "导入前请确保已在【学生个人课表】页面点击了【查询】并显示了课程。\n确认后脚本将自动解析当前课表。",
  233. "好的,开始导入"
  234. );
  235. if (!confirmed) {
  236. window.shiguangBridge.showToast("用户取消了导入。");
  237. return;
  238. }
  239. const result = await scrapeAndParseCourses();
  240. if (!result) return;
  241. const ok = await saveCourses(result.courses);
  242. if (!ok) return;
  243. await importPresetTimeSlots();
  244. window.shiguangBridge.showToast(`课程导入成功,共 ${result.courses.length} 门!`);
  245. window.shiguangBridge.notifyTaskCompletion();
  246. }
  247. runImportFlow();