lzu.js 26 KB

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