lzu.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. // 兰州大学本科生教务系统 拾光课程表适配
  2. // 适配对象: 兰州大学本科生教务管理系统 (jwk.lzu.edu.cn/academic)
  3. // 使用方式: 登录教务后, 在【学生课表】网格视图 或【课程列表】视图 点击"执行导入"
  4. //
  5. // 说明(依次尝试, 取第一个成功的):
  6. // - 策略C: 行列网格课表解析 (兰大 showTimetable 学生课表页)
  7. // - 策略B: 课程列表表格解析 (含多时段、跨框架递归, 支持课程列表/选课结果视图)
  8. // - 策略A: URP 标准网格课表解析 (td[id*="_"] + .class_div, 兼容其他 URP 视图)
  9. // - 策略B2: 纯文本兜底解析 (innerText 行)
  10. // - 作息时间: 页面网格表头动态读取, 否则回退到兰大预设作息(秋季)
  11. // - 递归穿透 <iframe>/<frame> 框架, 课程列表页(index_frame.jsp)也可导入
  12. // ========== 兰大预设作息时间 (秋季学期, 校教〔2014〕4号) ==========
  13. // 1-2节 8:30-10:10 / 3-4节 10:30-12:10 / 5-6节 14:30-16:10 /
  14. // 7-8节 16:30-18:10 / 9-10节 19:00-20:40 / 第11节 20:50-21:35
  15. const LZU_TIME_SLOTS = [
  16. { number: 1, startTime: "08:30", endTime: "09:15" },
  17. { number: 2, startTime: "09:25", endTime: "10:10" },
  18. { number: 3, startTime: "10:30", endTime: "11:15" },
  19. { number: 4, startTime: "11:25", endTime: "12:10" },
  20. { number: 5, startTime: "14:30", endTime: "15:15" },
  21. { number: 6, startTime: "15:25", endTime: "16:10" },
  22. { number: 7, startTime: "16:30", endTime: "17:15" },
  23. { number: 8, startTime: "17:25", endTime: "18:10" },
  24. { number: 9, startTime: "19:00", endTime: "19:45" },
  25. { number: 10, startTime: "19:55", endTime: "20:40" },
  26. { number: 11, startTime: "20:50", endTime: "21:35" }
  27. ];
  28. // ========== 周次解析 ==========
  29. // 支持: "1-18周" / "1-18周全周" / "1-17周单周" / "2-18周双周" /
  30. // "第10,11周" / "1-8,10-17周" / "1-5,7-8,10-12周双周,13-17周"
  31. function parseWeeks(weekStr) {
  32. const weeks = new Set();
  33. if (!weekStr) return [];
  34. const text = String(weekStr).replace(/[第周]/g, "").replace(/[,;;]/g, ",");
  35. const segments = text.split(",");
  36. segments.forEach(seg => {
  37. const isOdd = seg.includes("单");
  38. const isEven = seg.includes("双");
  39. const clean = seg.replace(/[单双全]/g, "").trim();
  40. if (clean.includes("-")) {
  41. const [s, e] = clean.split("-").map(Number);
  42. if (!isNaN(s) && !isNaN(e)) {
  43. for (let w = s; w <= e; w++) {
  44. if (isOdd && w % 2 === 0) continue;
  45. if (isEven && w % 2 !== 0) continue;
  46. weeks.add(w);
  47. }
  48. }
  49. } else if (clean) {
  50. const n = Number(clean);
  51. if (!isNaN(n) && n > 0) weeks.add(n);
  52. }
  53. });
  54. return Array.from(weeks).sort((a, b) => a - b);
  55. }
  56. // ========== 节次解析 ==========
  57. // 支持: "上午12节"(1-2) "上午34节"(3-4) "下午56节"(5-6) "下午78节"(7-8)
  58. // "晚9-10节"(9-10) "晚9-11节"(9-11) "第3节"(3-3)
  59. function parseSections(sectionStr) {
  60. if (!sectionStr) return null;
  61. const text = String(sectionStr).replace(/[节第上午下午晚上]/g, "");
  62. const dashed = text.match(/(\d+)-(\d+)/);
  63. if (dashed) {
  64. return { startSection: parseInt(dashed[1]), endSection: parseInt(dashed[2]) };
  65. }
  66. const numMatch = text.match(/(\d+)/);
  67. if (!numMatch) return null;
  68. const numStr = numMatch[1];
  69. // 形如 "12"/"34"/"56"/"78" 的连续两位 → 拆成两个节次; 单个数字 → 单节
  70. if (numStr.length === 2 && Number(numStr[1]) === Number(numStr[0]) + 1) {
  71. return { startSection: Number(numStr[0]), endSection: Number(numStr[1]) };
  72. }
  73. const n = parseInt(numStr);
  74. return { startSection: n, endSection: n };
  75. }
  76. // ========== 框架穿透辅助 ==========
  77. // URP 老教务可能把课表放在 <iframe> 或 <frameset><frame> 里, 需要递归遍历所有同源子文档
  78. function allDocuments() {
  79. const docs = [];
  80. const seen = new Set();
  81. const walk = (doc) => {
  82. if (!doc || seen.has(doc)) return;
  83. seen.add(doc);
  84. docs.push(doc);
  85. try {
  86. doc.querySelectorAll("iframe, frame").forEach(f => {
  87. try {
  88. if (f.contentDocument) walk(f.contentDocument);
  89. } catch (e) { /* 跨域框架无法访问, 跳过 */ }
  90. });
  91. } catch (e) { /* ignore */ }
  92. };
  93. walk(document);
  94. return docs;
  95. }
  96. function queryAllInFrames(selector) {
  97. let nodes = [];
  98. allDocuments().forEach(doc => {
  99. try { nodes = nodes.concat(Array.from(doc.querySelectorAll(selector))); } catch (e) { /* ignore */ }
  100. });
  101. return nodes;
  102. }
  103. // ========== 策略A: URP 网格课表解析 ==========
  104. function parseGridSchedule() {
  105. const courses = [];
  106. let timeSlots = null;
  107. const allTds = queryAllInFrames('td[id*="_"]');
  108. allTds.forEach(td => {
  109. const idParts = td.id.split("_");
  110. if (idParts.length !== 2) return;
  111. const day = parseInt(idParts[0]);
  112. if (isNaN(day) || day < 1 || day > 7) return;
  113. const classDivs = td.querySelectorAll(".class_div");
  114. classDivs.forEach(div => {
  115. const pTags = div.querySelectorAll("p");
  116. if (pTags.length < 5) return;
  117. // 兼容两种 DOM 结构: 教师/周次/节次/地点 在 p[1..4] 或 p[2..5]
  118. let teacherIdx = 2, weekIdx = 3, sectionIdx = 4, posIdx = 5;
  119. const p1Text = pTags[1] ? pTags[1].textContent.trim() : "";
  120. if (p1Text && !/\d/.test(p1Text) && !p1Text.includes("周") && p1Text.length < 10) {
  121. teacherIdx = 1; weekIdx = 2; sectionIdx = 3; posIdx = 4;
  122. }
  123. const name = pTags[0].textContent.trim();
  124. const teacher = (pTags[teacherIdx] ? pTags[teacherIdx].textContent : "").trim().replace(/\*+/g, " ").replace(/\s+/g, " ");
  125. const weeks = parseWeeks(pTags[weekIdx] ? pTags[weekIdx].textContent : "");
  126. const sec = parseSections(pTags[sectionIdx] ? pTags[sectionIdx].textContent : "");
  127. const position = pTags[posIdx] ? pTags[posIdx].textContent.trim() : "未知地点";
  128. if (name && weeks.length && sec) {
  129. courses.push({
  130. name, teacher, position, day,
  131. startSection: sec.startSection, endSection: sec.endSection, weeks
  132. });
  133. }
  134. });
  135. });
  136. // 尝试从网格表头动态读取作息时间: <th id="0_1">... (08:30-09:15)
  137. const ths = queryAllInFrames('th[id^="0_"]');
  138. const slots = [];
  139. ths.forEach(th => {
  140. const n = parseInt(th.id.split("_")[1]);
  141. const m = (th.textContent || "").match(/\((\d{2}:\d{2})-(\d{2}:\d{2})\)/);
  142. if (!isNaN(n) && m) slots.push({ number: n, startTime: m[1], endTime: m[2] });
  143. });
  144. if (slots.length) timeSlots = slots.sort((a, b) => a.number - b.number);
  145. return { courses, timeSlots };
  146. }
  147. // ========== 策略B: 列表/选课结果课表解析 ==========
  148. // 适配"个人课表/选课结果"列表视图:
  149. // - 表格行结构: 课程信息行(课程号/课序号/课程名/教师...) 之后可跟 1 行或多行"排课行"
  150. // - 或 div 布局: innerText 中课程信息行与排课行相邻
  151. // 排课字符串形如: "1-18周全周 星期一 下午56节 天山堂A504"
  152. const WEEK_DAY_PATTERN = /([第]?[0-9,\-,]+周(?:全周|单周|双周)?)\s*(星期[一二三四五六日天])\s*((?:上午|下午|晚上|晚)?\s*\d+(?:-\d+)?节)\s*(\S+)/;
  153. const WEEK_DAY_PATTERN_NO_POS = /([第]?[0-9,\-,]+周(?:全周|单周|双周)?)\s*(星期[一二三四五六日天])\s*((?:上午|下午|晚上|晚)?\s*\d+(?:-\d+)?节)/;
  154. const DAY_MAP = { "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "日": 7, "天": 7 };
  155. function dayNum(dayText) {
  156. return DAY_MAP[String(dayText).replace("星期", "")] || 0;
  157. }
  158. // 从文本片段数组中提取课程信息 (课程号 + 课程名 + 教师)
  159. function findCourseInfo(tokens) {
  160. // 课程号特征: 形如 2402003 / 1402401b / 101402001(3)
  161. const codeIdx = tokens.findIndex(c => /^\d{3,}[a-zA-Z]*(\(\d+\))?$/.test(c) && c.length <= 12);
  162. if (codeIdx === -1) return null;
  163. // 课程名 = 课程号之后第一个非纯数字且不含"周"的片段; 教师 = 再下一个片段
  164. for (let i = codeIdx + 1; i < tokens.length; i++) {
  165. if (!/^[\d.\-]+$/.test(tokens[i]) && !tokens[i].includes("周")) {
  166. return {
  167. name: tokens[i],
  168. teacher: (tokens[i + 1] || "").replace(/\*+/g, " ").replace(/\s+/g, " ")
  169. };
  170. }
  171. }
  172. return null;
  173. }
  174. // 从文本中提取排课信息 (周次/星期/节次/地点); nextText 用于"地点在下一段"的兜底
  175. function extractSchedule(text, nextText) {
  176. let m = text.match(WEEK_DAY_PATTERN);
  177. if (m) {
  178. return { weeks: parseWeeks(m[1]), day: dayNum(m[2]), sec: parseSections(m[3]), position: m[4] };
  179. }
  180. m = text.match(WEEK_DAY_PATTERN_NO_POS);
  181. if (m) {
  182. const rest = text.slice(text.indexOf(m[3]) + m[3].length).trim();
  183. const position = rest.split(/\s+/)[0] || (nextText || "").split(/\s+/)[0] || "";
  184. return { weeks: parseWeeks(m[1]), day: dayNum(m[2]), sec: parseSections(m[3]), position };
  185. }
  186. return null;
  187. }
  188. function pushCourse(courses, info, sched) {
  189. if (!info || !sched || !info.name || !sched.weeks.length || !sched.sec || !sched.day) return;
  190. courses.push({
  191. name: info.name,
  192. teacher: info.teacher,
  193. position: sched.position,
  194. day: sched.day,
  195. startSection: sched.sec.startSection,
  196. endSection: sched.sec.endSection,
  197. weeks: sched.weeks
  198. });
  199. }
  200. // 从文本中提取全部排课信息 (一个单元格内可能有多个时段)
  201. function extractAllSchedules(text) {
  202. const out = [];
  203. const re = new RegExp(WEEK_DAY_PATTERN.source, "g");
  204. let m;
  205. while ((m = re.exec(text)) !== null) {
  206. const weeks = parseWeeks(m[1]);
  207. const day = dayNum(m[2]);
  208. const sec = parseSections(m[3]);
  209. const position = m[4] || "";
  210. if (weeks.length && day && sec) {
  211. out.push({ weeks, day, sec: { startSection: sec.startSection, endSection: sec.endSection }, position });
  212. }
  213. }
  214. return out;
  215. }
  216. // 解析表格行结构 (兰大列表视图: 每门课一行, 上课时间地点在同一格内可含多个时段;
  217. // 也兼容"课程信息行 + 后续排课行"的两行结构)
  218. function parseListSchedule() {
  219. const courses = [];
  220. const rows = queryAllInFrames("tr");
  221. let pendingInfo = null;
  222. rows.forEach(row => {
  223. // 单元格内 <br> 转成空格再拼接, 避免多时段粘连(如 "...B604 1-17周单周 ...")
  224. const cells = Array.from(row.querySelectorAll("td, th")).map(c => {
  225. const parts = brTextLines(c).join(" ") || (c.textContent || "").trim();
  226. return parts;
  227. }).filter(Boolean);
  228. const rowText = cells.join(" ");
  229. const info = findCourseInfo(cells);
  230. if (info) pendingInfo = info;
  231. if (pendingInfo) {
  232. const scheds = extractAllSchedules(rowText);
  233. scheds.forEach(s => pushCourse(courses, pendingInfo, s));
  234. }
  235. });
  236. return { courses: dedupeAndMergeBlocks(courses), timeSlots: null };
  237. }
  238. // 解析整页文本行 (innerText), 适配 div 布局 / 复制的文本结构
  239. function parseTextSchedule() {
  240. const courses = [];
  241. const allLines = [];
  242. allDocuments().forEach(doc => {
  243. const bodyText = (doc.body && doc.body.innerText) || "";
  244. const ls = bodyText.replace(/ /g, " ").split(/\r?\n/).map(l => l.trim()).filter(Boolean);
  245. ls.forEach(l => allLines.push(l));
  246. });
  247. let pendingInfo = null;
  248. allLines.forEach((line, idx) => {
  249. const tokens = line.split(/\s+/).filter(Boolean);
  250. const info = findCourseInfo(tokens);
  251. const nextLine = allLines[idx + 1] || "";
  252. if (info) {
  253. pendingInfo = info;
  254. const sched = extractSchedule(line, nextLine);
  255. if (sched) pushCourse(courses, info, sched);
  256. } else if (pendingInfo) {
  257. const sched = extractSchedule(line, nextLine);
  258. if (sched) pushCourse(courses, pendingInfo, sched);
  259. }
  260. });
  261. return { courses, timeSlots: null };
  262. }
  263. // ========== 策略C: 行列网格解析 (兰大 showTimetable.do 学生课表) ==========
  264. // 结构: 表头行含"周一..周日"; 每门课是一个 td(id 形如 "1-2268", 首位数字=真实星期几),
  265. // 内容按 <br> 分隔多行: 课程名<< >>;序号/地点/教师/周次/自身节次(如"晚9-11节")/备注
  266. function cleanCourseName(s) {
  267. // "&lt;&lt;数学物理方法Ⅰ&gt;&gt;;2" → "数学物理方法Ⅰ"; 兼容无 <</>> 情况
  268. let n = String(s || "").replace(/&lt;|&gt;/g, "").replace(/<</g, "").split(">>")[0];
  269. n = n.replace(/;[0-9]+$/, "").replace(/[;0-9]+$/, "").trim();
  270. return n;
  271. }
  272. function isBuildingLike(t) {
  273. return /(堂|楼|馆|室|区|操场)/.test(t) || /[A-Za-z]+\s*\d+/.test(t);
  274. }
  275. // 按 <br> 分行提取单元格纯文本 (兼容 textContent 不产生换行的问题)
  276. function brTextLines(cell) {
  277. const raw = cell.innerHTML || "";
  278. const segs = raw.split(/<br\s*\/?\s*>/i);
  279. const out = [];
  280. segs.forEach(seg => {
  281. const clean = seg.replace(/<[^>]+>/g, "")
  282. .replace(/&lt;/g, "<").replace(/&gt;/g, ">")
  283. .replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").trim();
  284. if (clean) out.push(clean);
  285. });
  286. return out;
  287. }
  288. // 从文本中识别节次区间 (如 "晚9-11节" "上午34节" "第1-2节")
  289. function parseSectionFromText(text) {
  290. if (!text) return null;
  291. const m = String(text).match(/((?:上午|下午|晚上|晚|第)?\s*\d{1,2}\s*[-—~至]\s*\d{1,2}\s*节|(?:上午|下午|晚上|晚|第)?\s*\d{1,2}\s*节)/);
  292. if (m) return parseSections(m[1]);
  293. return null;
  294. }
  295. // 从课程格子 id (如 "5-2268") 首位取真实星期
  296. function cellDay(cell, fallback) {
  297. const m = (cell.id || "").match(/^([1-7])-/);
  298. return m ? parseInt(m[1]) : fallback;
  299. }
  300. // 解析单个课程格子 → 返回课程块数组
  301. function parseCourseCell(cell, day, fallbackSection) {
  302. const results = [];
  303. const lines = brTextLines(cell);
  304. if (!lines.length) return results;
  305. let cur = null;
  306. const flush = () => {
  307. if (cur) results.push(cur);
  308. cur = null;
  309. };
  310. lines.forEach(line => {
  311. const secL = /周/.test(line) ? null : parseSectionFromText(line);
  312. const isNameLine = line.includes(">>") || line.includes("<<")
  313. || (!cur && !/周|学时|讲课|堂|楼|馆|节/.test(line) && line.length <= 40 && !/^\d/.test(line));
  314. if (isNameLine) {
  315. flush();
  316. cur = { name: cleanCourseName(line), teacher: "", position: "", weeks: [], day: day, startSection: null, endSection: null };
  317. } else if (cur) {
  318. if (secL) {
  319. cur.startSection = secL.startSection;
  320. cur.endSection = secL.endSection;
  321. } else if (/[0-9]+周/.test(line) || /全周|单周|双周/.test(line)) {
  322. cur.weeks = parseWeeks(line);
  323. } else if (/学时|讲课|备注|实践/.test(line) || /^\d{1,2}:\d{2}/.test(line)) {
  324. // 备注/时间行忽略
  325. } else if (isBuildingLike(line)) {
  326. cur.position = line;
  327. } else {
  328. cur.teacher = (cur.teacher ? cur.teacher + " " : "") + line;
  329. }
  330. }
  331. });
  332. flush();
  333. // 自身文本未给出节次时, 用行标签节次
  334. results.forEach(b => {
  335. if (!b.startSection && fallbackSection) {
  336. b.startSection = fallbackSection.startSection;
  337. b.endSection = fallbackSection.endSection;
  338. }
  339. });
  340. return results;
  341. }
  342. function parsePeriodGrid() {
  343. const blocks = [];
  344. const tables = queryAllInFrames("table");
  345. for (const table of tables) {
  346. const dayInfo = tableDayColumns(table);
  347. if (!dayInfo) continue;
  348. const trs = Array.from(table.querySelectorAll("tr")).filter(r => r.querySelectorAll("td,th").length);
  349. let carriedSection = null;
  350. for (let r = dayInfo.headerRow + 1; r < trs.length; r++) {
  351. const cells = Array.from(trs[r].querySelectorAll("td,th"));
  352. // 行节次: 找不含"周"、内容短、含 节/时间 的标签单元格 (课程格都含周次, 不会误判)
  353. let rowSection = null;
  354. for (const c of cells) {
  355. const t = (c.textContent || "").trim();
  356. if (!t || t.length > 60 || /周/.test(t)) continue;
  357. if (!/节|^\d{1,2}:\d{2}/.test(t)) continue;
  358. rowSection = parseSectionFromText(t);
  359. if (rowSection) break;
  360. }
  361. if (!rowSection) rowSection = carriedSection;
  362. else carriedSection = rowSection;
  363. if (!rowSection) continue;
  364. // 逐格取课程
  365. for (let ci = 0; ci < cells.length; ci++) {
  366. const cell = cells[ci];
  367. const txt = (cell.textContent || "").trim();
  368. if (!txt || txt.length < 4 || /^(&nbsp;|\s|-|—|・|\.)*$/.test(txt)) continue;
  369. const looksCourse = /<<|>>/.test(txt) || (/周/.test(txt) && !dayFromText(txt));
  370. if (!looksCourse) continue;
  371. const day = cellDay(cell, dayInfo.colDay[ci] || 0);
  372. parseCourseCell(cell, day, rowSection).forEach(b => blocks.push(b));
  373. }
  374. }
  375. }
  376. return { courses: dedupeAndMergeBlocks(blocks), timeSlots: null };
  377. }
  378. // 归一化 + 同课相邻节次合并 + 去重
  379. function dedupeAndMergeBlocks(blocks) {
  380. const slotCount = LZU_TIME_SLOTS.length; // 兰大最多11节
  381. const norm = [];
  382. blocks.forEach(b => {
  383. let s = b.startSection, e = b.endSection;
  384. if (!s || !e || e < s || !b.name || !b.weeks.length || !b.day) return;
  385. if (s > slotCount) return;
  386. if (e > slotCount) e = slotCount;
  387. norm.push({ name: b.name, teacher: (b.teacher || "").trim(), position: b.position || "", day: b.day, startSection: s, endSection: e, weeks: b.weeks });
  388. });
  389. const groups = {};
  390. const wkKey = w => w.join(",");
  391. norm.forEach(b => {
  392. const g = b.name + "|" + b.teacher + "|" + b.position + "|" + b.day + "|" + wkKey(b.weeks);
  393. (groups[g] = groups[g] || []).push(b);
  394. });
  395. const out = [];
  396. Object.keys(groups).forEach(g => {
  397. const grp = groups[g];
  398. const first = grp[0];
  399. if (grp.every(b => b.startSection === first.startSection && b.endSection === first.endSection)) {
  400. out.push(first); // 完全重复 → 保留一条
  401. return;
  402. }
  403. grp.sort((a, b) => a.startSection - b.startSection);
  404. let cur = { ...grp[0] };
  405. for (let i = 1; i < grp.length; i++) {
  406. const b = grp[i];
  407. if (b.startSection <= cur.endSection + 1) {
  408. cur.endSection = Math.max(cur.endSection, b.endSection);
  409. const ws = new Set(cur.weeks.concat(b.weeks));
  410. cur.weeks = Array.from(ws).sort((x, y) => x - y);
  411. } else {
  412. out.push(cur);
  413. cur = { ...b };
  414. }
  415. }
  416. out.push(cur);
  417. });
  418. return out;
  419. }
  420. // ========== 诊断收集 (全部策略失败时输出表格 DOM 骨架, 便于定位结构) ==========
  421. // 短探针: 纯数字概览, 放在第一行, 抗复制乱码
  422. function collectProbe() {
  423. const docs = allDocuments();
  424. let frTotal = 0, frOk = 0;
  425. try {
  426. document.querySelectorAll("iframe, frame").forEach(f => {
  427. frTotal++;
  428. try { if (f.contentDocument) frOk++; } catch (e) {}
  429. });
  430. } catch (e) {}
  431. let tables = 0, trs = 0, tds = 0, hdrTbl = 0, codeCells = 0, schedCells = 0;
  432. docs.forEach(doc => {
  433. try {
  434. tables += doc.querySelectorAll("table").length;
  435. trs += doc.querySelectorAll("tr").length;
  436. tds += doc.querySelectorAll("td, th").length;
  437. doc.querySelectorAll("table").forEach(t => {
  438. if ((t.innerText || t.textContent || "").indexOf("课程号") > -1) hdrTbl++;
  439. });
  440. } catch (e) {}
  441. try {
  442. doc.querySelectorAll("td").forEach(cd => {
  443. const t = (cd.textContent || "").trim();
  444. if (/^\d{3,}[a-zA-Z]*(\(\d+\))?$/.test(t) && t.length <= 12) codeCells++;
  445. if (/[0-9]+周/.test(t) && /星期[一二三四五六日天]/.test(t)) schedCells++;
  446. });
  447. } catch (e) {}
  448. });
  449. return "PROBE: 文档=" + docs.length
  450. + " 框架=" + frTotal + "/" + frOk
  451. + " 表=" + tables + " 行=" + trs + " 格=" + tds
  452. + " 含课程号表=" + hdrTbl
  453. + " 课程代码格=" + codeCells
  454. + " 排课格=" + schedCells;
  455. }
  456. function dayFromText(t) {
  457. // 支持 "星期一" / "周一" / "礼拜一" 等表头
  458. const s = String(t || "").replace(/星期|礼拜|周/g, "");
  459. return DAY_MAP[s] || 0;
  460. }
  461. // 找出"周一..周日"表头所在行及列→星期映射
  462. function tableDayColumns(table) {
  463. const trs = Array.from(table.querySelectorAll("tr")).filter(r => r.querySelectorAll("td,th").length);
  464. for (let i = 0; i < trs.length; i++) {
  465. const cells = Array.from(trs[i].querySelectorAll("td,th"));
  466. const map = {};
  467. cells.forEach((c, ci) => {
  468. const d = dayFromText(c.textContent);
  469. if (d) map[ci] = d;
  470. });
  471. if (Object.keys(map).length >= 5) return { headerRow: i, colDay: map };
  472. }
  473. return null;
  474. }
  475. // 单元格摘要: 标签/id/class + innerHTML 开头(暴露嵌套结构)
  476. function cellBrief(cell) {
  477. const id = cell.id || "";
  478. const cls = (typeof cell.className === "string" ? cell.className : (cell.className && cell.className.baseVal) || "").toString();
  479. const tag = cell.tagName || "";
  480. let s = "<" + tag + (id ? "#" + id : "") + (cls ? "." + String(cls).slice(0, 14) : "") + ">";
  481. s += (cell.innerHTML || "").replace(/\s+/g, " ").trim().slice(0, 36);
  482. return s;
  483. }
  484. function collectDiagnostics() {
  485. const L = [collectProbe()];
  486. try { L.push("URL尾: " + (window.location.href || "").slice(-90)); } catch (e) {}
  487. const tables = queryAllInFrames("table");
  488. L.push("tables=" + tables.length);
  489. tables.forEach((tb, ti) => {
  490. const dayInfo = tableDayColumns(tb);
  491. const trs = Array.from(tb.querySelectorAll("tr")).filter(r => r.querySelectorAll("td,th").length);
  492. if (!dayInfo) return; // 只展示含"周一..周日"的课表表格
  493. L.push("== 表T" + ti + " tr=" + trs.length + " 表头行=" + dayInfo.headerRow + " 列→星期: " + JSON.stringify(dayInfo.colDay));
  494. const start = Math.max(0, dayInfo.headerRow);
  495. let dumped = false;
  496. for (let r = start; r < Math.min(trs.length, start + 12); r++) {
  497. const cells = Array.from(trs[r].querySelectorAll("td,th"));
  498. const bs = cells.map(cellBrief);
  499. L.push("R" + r + "(" + cells.length + "): " + bs.join(" ⏐ "));
  500. // 找第一个疑似"课程格子"(文本含"周"且长度适中), 输出其完整 innerHTML
  501. if (!dumped) {
  502. const courseCell = cells.find((c) => {
  503. const t = (c.textContent || "").trim();
  504. return t.length > 6 && t.length < 500 && /周/.test(t);
  505. });
  506. if (courseCell) {
  507. L.push(" [首个课程格子innerHTML] " + (courseCell.innerHTML || "").replace(/\s+/g, " ").slice(0, 500));
  508. dumped = true;
  509. }
  510. }
  511. }
  512. });
  513. return L.join("\n");
  514. }
  515. // ========== 流程控制 ==========
  516. async function runImportFlow() {
  517. try {
  518. const confirmed = await window.shiguangBridgePromise.showAlert(
  519. "兰州大学教务导入",
  520. "请先登录教务系统并进入【学生课表】页面(网格视图),再点击确定开始导入。",
  521. "开始导入"
  522. );
  523. if (!confirmed) return;
  524. // 依次尝试各解析策略, 取第一个成功的
  525. let result = null;
  526. let usedStrategy = "";
  527. const strategies = [
  528. { name: "行列网格课表", fn: parsePeriodGrid },
  529. { name: "网格课表", fn: parseGridSchedule },
  530. { name: "列表课表(表格)", fn: parseListSchedule },
  531. { name: "列表课表(文本)", fn: parseTextSchedule }
  532. ];
  533. for (const s of strategies) {
  534. const r = s.fn();
  535. if (r.courses.length) {
  536. result = r;
  537. usedStrategy = s.name;
  538. break;
  539. }
  540. }
  541. if (!result || !result.courses.length) {
  542. try {
  543. const diag = collectDiagnostics();
  544. await window.shiguangBridgePromise.showAlert(
  545. "未检测到课表数据",
  546. diag + "\n\n----\n请确认处于【学生课表】网格视图。若仍有问题, 可将以上信息截图反馈给维护者, 便于改进适配",
  547. "知道了"
  548. );
  549. } catch (e) {
  550. window.shiguangBridge.showToast("未检测到课表数据,请确认当前页面是个人课表后重试");
  551. }
  552. return;
  553. }
  554. const timeSlots = result.timeSlots && result.timeSlots.length ? result.timeSlots : LZU_TIME_SLOTS;
  555. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(result.courses));
  556. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  557. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
  558. semesterStartDate: null,
  559. semesterTotalWeeks: 20,
  560. defaultClassDuration: 45,
  561. defaultBreakDuration: 10,
  562. firstDayOfWeek: 1
  563. }));
  564. window.shiguangBridge.showToast(`兰州大学课表导入成功(${usedStrategy}): 共 ${result.courses.length} 条安排`);
  565. window.shiguangBridge.notifyTaskCompletion();
  566. } catch (error) {
  567. console.error("兰州大学课表导入失败:", error);
  568. window.shiguangBridge.showToast("课表导入失败: " + (error.message || error));
  569. }
  570. }
  571. runImportFlow();