ynufe.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. // 云南财经大学教务系统课程表导入脚本
  2. // 基于时光课表官方适配规范开发
  3. // ========== 工具函数 ==========
  4. /**
  5. * 解析周数字符串
  6. * @param {string} Str 如:1-6,7-13周(单)
  7. * @returns {Array} 返回数组 [1,3,5,7,9,11,13]
  8. */
  9. function getWeeks(Str) {
  10. function range(con, tag) {
  11. let retWeek = [];
  12. con.slice(0, -1).split(',').forEach(w => {
  13. let tt = w.split('-');
  14. let start = parseInt(tt[0]);
  15. let end = parseInt(tt[tt.length - 1]);
  16. if (tag === 1 || tag === 2) {
  17. retWeek.push(...Array(end + 1 - start).fill(start).map((x, y) => x + y).filter(f => {
  18. return f % tag === 0;
  19. }));
  20. } else {
  21. retWeek.push(...Array(end + 1 - start).fill(start).map((x, y) => x + y).filter(v => {
  22. return v % 2 !== 0;
  23. }));
  24. }
  25. });
  26. return retWeek;
  27. }
  28. Str = Str.replace(/[(){}|第\[\]]/g, "").replace(/到/g, "-");
  29. let reWeek = [];
  30. let week1 = [];
  31. while (Str.search(/周|\s/) !== -1) {
  32. let index = Str.search(/周|\s/);
  33. if (Str[index + 1] === '单' || Str[index + 1] === '双') {
  34. week1.push(Str.slice(0, index + 2).replace(/周|\s/g, ""));
  35. index += 2;
  36. } else {
  37. week1.push(Str.slice(0, index + 1).replace(/周|\s/g, ""));
  38. index += 1;
  39. }
  40. Str = Str.slice(index);
  41. index = Str.search(/\d/);
  42. if (index !== -1) Str = Str.slice(index);
  43. else Str = "";
  44. }
  45. if (Str.length !== 0) week1.push(Str);
  46. week1.forEach(v => {
  47. if (v.slice(-1) === "双") {
  48. reWeek.push(...range(v, 2));
  49. } else if (v.slice(-1) === "单") {
  50. reWeek.push(...range(v, 3));
  51. } else {
  52. reWeek.push(...range(v + "全", 1));
  53. }
  54. });
  55. return reWeek;
  56. }
  57. /**
  58. * 解析节次字符串
  59. * @param {string} Str 如: 1-4节 或 1-2-3-4节
  60. * @returns {Array} [1,2,3,4]
  61. */
  62. function getSection(Str) {
  63. let reJc = [];
  64. let strArr = Str.replace("节", "").trim().split("-");
  65. if (strArr.length <= 2) {
  66. for (let i = Number(strArr[0]); i <= Number(strArr[strArr.length - 1]); i++) {
  67. reJc.push(Number(i));
  68. }
  69. } else {
  70. strArr.forEach(v => {
  71. reJc.push(Number(v));
  72. });
  73. }
  74. return reJc;
  75. }
  76. /**
  77. * 检查是否在登录页面
  78. * @returns {boolean}
  79. */
  80. function isLoginPage() {
  81. const url = window.location.href;
  82. // 检查URL是否包含登录页面特征
  83. return url.includes('login') || url.includes('Login') ||
  84. document.querySelector('input[type="password"]') !== null;
  85. }
  86. /**
  87. * 获取课程表HTML
  88. * @returns {string} 课程表HTML内容
  89. */
  90. function getScheduleHtml() {
  91. try {
  92. let html = '';
  93. let found = false;
  94. // 首先尝试从iframe中获取
  95. let iframes = document.getElementsByTagName('iframe');
  96. for (const iframe of iframes) {
  97. if (iframe.src && iframe.src.search('/jsxsd/xskb/xskb_list.do') !== -1) {
  98. const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
  99. if (iframeDoc) {
  100. const kbtable = iframeDoc.getElementById('kbtable');
  101. if (kbtable) {
  102. html = kbtable.outerHTML;
  103. found = true;
  104. break;
  105. }
  106. const contentBox = iframeDoc.getElementsByClassName('content_box')[0];
  107. if (contentBox) {
  108. html = contentBox.outerHTML;
  109. found = true;
  110. break;
  111. }
  112. }
  113. }
  114. }
  115. // 如果iframe中没找到,尝试直接从主文档获取
  116. if (!found) {
  117. const kbtable = document.getElementById('kbtable');
  118. if (kbtable) {
  119. html = kbtable.outerHTML;
  120. found = true;
  121. }
  122. }
  123. if (!found || !html) {
  124. throw new Error('未找到课表元素');
  125. }
  126. return html;
  127. } catch (error) {
  128. console.error('获取课程表HTML失败:', error);
  129. throw error;
  130. }
  131. }
  132. /**
  133. * 解析课程HTML数据
  134. * @param {string} html 课程表HTML
  135. * @returns {Array} 课程数组
  136. */
  137. function parseScheduleHtml(html) {
  138. let result = [];
  139. let uniqueCourses = []; // 移到外部作用域
  140. try {
  141. // 创建临时div来解析HTML
  142. const tempDiv = document.createElement('div');
  143. tempDiv.innerHTML = html;
  144. console.log('开始解析课程表HTML...');
  145. // 查找课程表格
  146. const table = tempDiv.querySelector('#kbtable') || tempDiv.querySelector('table');
  147. if (!table) {
  148. throw new Error('未找到课程表格');
  149. }
  150. // 遍历所有行(每行是一个大节时间段)
  151. const rows = table.querySelectorAll('tr');
  152. console.log(`找到 ${rows.length} 行`);
  153. // 用于记录已处理的div,避免重复(跨大节课程会在多行出现)
  154. const processedDivs = new Set();
  155. rows.forEach((tr, rowIdx) => {
  156. const tds = tr.querySelectorAll('td');
  157. // 遍历这一行的所有td列
  158. // 注意: querySelectorAll('td')只选择td元素,不包括th
  159. // 所以td[0]就是星期一, td[1]是星期二, ..., td[6]是星期日
  160. tds.forEach((td, colIdx) => {
  161. // 查找这个单元格里的课程内容div
  162. const hiddenDiv = td.querySelector('div.kbcontent');
  163. // 如果没有隐藏div或内容为空,跳过
  164. if (!hiddenDiv) {
  165. return;
  166. }
  167. // 检查是否已经处理过这个div(根据name属性去重)
  168. const divName = hiddenDiv.getAttribute('name') || hiddenDiv.getAttribute('id');
  169. if (divName && processedDivs.has(divName)) {
  170. return; // 已处理过,跳过
  171. }
  172. const divText = hiddenDiv.textContent.trim();
  173. if (!divText || divText.length <= 6) {
  174. return;
  175. }
  176. // 从div的name属性提取星期信息
  177. // name格式: "hash-星期-序号" 例如 "EBC6F96389D143DC9C53084617F9C7D2-2-1"
  178. // 其中第二部分的数字: 1=星期一, 2=星期二, ..., 7=星期日
  179. let day = colIdx + 1; // 默认使用列索引
  180. if (divName) {
  181. const nameParts = divName.split('-');
  182. if (nameParts.length >= 3) {
  183. const dayFromName = parseInt(nameParts[1]);
  184. if (!isNaN(dayFromName) && dayFromName >= 1 && dayFromName <= 7) {
  185. day = dayFromName;
  186. }
  187. }
  188. }
  189. // 标记为已处理
  190. if (divName) {
  191. processedDivs.add(divName);
  192. }
  193. console.log(`\n[行${rowIdx} 列${colIdx} 星期${day}]`);
  194. console.log(`内容预览: ${divText.substring(0, 50)}...`);
  195. // 可能包含多个课程,用 ----- 分隔
  196. const courseSections = hiddenDiv.innerHTML.split(/-----+/);
  197. console.log(`分割成 ${courseSections.length} 个课程段`);
  198. // 用于课程段去重(避免完全相同的课程段被重复添加)
  199. const processedSections = new Set();
  200. // 遍历每个课程段
  201. courseSections.forEach((section, sectionIdx) => {
  202. const sectionText = section.replace(/<[^>]*>/g, '').trim();
  203. if (!sectionText || sectionText.length < 3) {
  204. return;
  205. }
  206. // 检查是否已经处理过完全相同的课程段(内容去重)
  207. if (processedSections.has(sectionText)) {
  208. console.log(` 跳过重复课程段 ${sectionIdx + 1}`);
  209. return;
  210. }
  211. processedSections.add(sectionText);
  212. console.log(` 课程段 ${sectionIdx + 1}:`);
  213. console.log(` 原始HTML:`, section.substring(0, 200));
  214. let course = {
  215. day: day, // 星期几(1=周一, 2=周二, ..., 7=周日)
  216. weeks: [],
  217. sections: [],
  218. name: '',
  219. teacher: '',
  220. position: ''
  221. };
  222. // 解析HTML,按br分割成行
  223. const lines = section.split(/<br\s*\/?>/i);
  224. console.log(` 分割成 ${lines.length} 行`);
  225. let firstTextLine = true; // 标记是否是第一个有效文本行
  226. lines.forEach((line, lineIdx) => {
  227. // 跳过空行
  228. const plainText = line.replace(/<[^>]*>/g, '').trim();
  229. if (!plainText || plainText === '&nbsp;') {
  230. return;
  231. }
  232. console.log(` 行${lineIdx}: ${line.substring(0, 100)}`);
  233. console.log(` 纯文本: ${plainText}`);
  234. // 第一个有效文本行就是课程名(没有title属性)
  235. if (firstTextLine && !course.name) {
  236. // 移除span标签(包含调课标记如&nbspO)但保留其他内容
  237. let courseName = line.replace(/<span[^>]*>.*?<\/span>/gi, '').trim();
  238. // 提取纯文本
  239. courseName = courseName.replace(/<[^>]*>/g, '').trim();
  240. // 清理HTML实体
  241. courseName = courseName.replace(/&nbsp;/g, ' ').trim();
  242. course.name = courseName;
  243. console.log(` ✓ 第一行作为课程名: ${course.name}`);
  244. firstTextLine = false;
  245. return;
  246. }
  247. firstTextLine = false;
  248. // 检查这一行的title属性(使用双引号)
  249. if (line.includes('title="老师"')) {
  250. course.teacher = plainText;
  251. console.log(` ✓ 匹配老师: ${course.teacher}`);
  252. }
  253. else if (line.includes('title="教室"')) {
  254. // 对于教室,需要先移除隐藏的font标签,再提取文本
  255. const cleanLine = line.replace(/<font[^>]*style="display:none;"[^>]*>.*?<\/font>/gi, '');
  256. const cleanText = cleanLine.replace(/<[^>]*>/g, '').trim();
  257. // 再移除可能残留的前导数字
  258. const finalPosition = cleanText.replace(/^[\d-]+/, '').trim();
  259. course.position = finalPosition;
  260. console.log(` ✓ 匹配教室: ${course.position}`);
  261. }
  262. else if (line.includes('title="周次(节次)"')) {
  263. console.log(` ✓ 匹配周次节次: ${plainText}`);
  264. // 解析周次: "1-18(周)[06-07节]"
  265. const weekMatch = plainText.match(/^(.+?)\(周\)/);
  266. if (weekMatch) {
  267. const weekStr = weekMatch[1];
  268. course.weeks = getWeeks(weekStr + '周');
  269. console.log(` -> 周: ${course.weeks}`);
  270. }
  271. // 解析节次: "[06-07节]"
  272. const sectionMatch = plainText.match(/\[(.+?)节?\]/);
  273. if (sectionMatch) {
  274. const sectionStr = sectionMatch[1];
  275. course.sections = getSection(sectionStr + '节');
  276. console.log(` -> 节: ${course.sections}`);
  277. }
  278. }
  279. // 如果没有找到教室,尝试从包含隐藏font的行提取
  280. // 这行可能格式如: <font style="display:none;">01-02</font><font style="display:none;">20</font>北院卓媒220
  281. else if (!course.position && line.includes('style="display:none;"')) {
  282. // 移除所有隐藏的font标签
  283. const visibleText = line.replace(/<font[^>]*style="display:none;"[^>]*>.*?<\/font>/gi, '')
  284. .replace(/<[^>]*>/g, '')
  285. .trim();
  286. if (visibleText && visibleText.length > 0) {
  287. // 移除所有前导的数字和连字符(如 "01-0220" 或 "06-0722")
  288. // 匹配模式:开头的数字-数字组合
  289. const cleanPosition = visibleText.replace(/^[\d-]+/, '').trim();
  290. if (cleanPosition.length > 0) {
  291. course.position = cleanPosition;
  292. console.log(` ✓ 提取教室(清理后): ${course.position}`);
  293. }
  294. }
  295. }
  296. });
  297. // 验证并添加课程
  298. if (course.name && course.weeks.length > 0 && course.sections.length > 0) {
  299. course.teacher = course.teacher || "未知教师";
  300. course.position = course.position || "未知地点";
  301. console.log(` ✓ 完整课程:`, {
  302. name: course.name,
  303. teacher: course.teacher,
  304. position: course.position,
  305. day: course.day,
  306. weeks: `${course.weeks.length}周`,
  307. sections: course.sections
  308. });
  309. result.push(course);
  310. } else {
  311. console.warn(` ✗ 信息不完整:`, {
  312. name: course.name || '无',
  313. teacher: course.teacher || '无',
  314. weeks: course.weeks.length,
  315. sections: course.sections.length
  316. });
  317. }
  318. });
  319. });
  320. });
  321. console.log(`\n解析完成,共得到 ${result.length} 条课程记录(去重前)`);
  322. // 合并完全相同的课程(去重)
  323. const courseKeys = new Set();
  324. result.forEach(course => {
  325. // 生成课程唯一标识: 名称+老师+地点+星期+节次+周次
  326. const key = `${course.name}|${course.teacher}|${course.position}|${course.day}|${course.sections.join(',')}|${course.weeks.join(',')}`;
  327. if (!courseKeys.has(key)) {
  328. courseKeys.add(key);
  329. uniqueCourses.push(course);
  330. } else {
  331. console.log(` 跳过重复课程: ${course.name} (${course.teacher})`);
  332. }
  333. });
  334. console.log(`去重后剩余 ${uniqueCourses.length} 条课程记录`);
  335. } catch (err) {
  336. console.error('解析课程表出错:', err);
  337. throw new Error('解析课程表失败: ' + err.message);
  338. }
  339. return uniqueCourses;
  340. }
  341. /**
  342. * 转换课程数据格式以符合时光课表规范
  343. * @param {Array} rawCourses 原始课程数据
  344. * @returns {Array} 转换后的课程数据
  345. */
  346. function convertCoursesToStandardFormat(rawCourses) {
  347. const validCourses = [];
  348. rawCourses.forEach((course, index) => {
  349. try {
  350. // 处理节次:将原始格式转换为startSection和endSection
  351. let startSection = 1;
  352. let endSection = 1;
  353. if (course.sections && course.sections.length > 0) {
  354. const sections = course.sections.sort((a, b) => a - b);
  355. startSection = sections[0];
  356. endSection = sections[sections.length - 1];
  357. }
  358. // 验证必需字段
  359. if (!startSection || !endSection || startSection < 1 || endSection < 1) {
  360. console.error(`课程 ${index + 1} 缺少有效的节次信息:`, course);
  361. throw new Error(`课程节次信息无效: startSection=${startSection}, endSection=${endSection}`);
  362. }
  363. if (!course.day || course.day < 1 || course.day > 7) {
  364. console.error(`课程 ${index + 1} 星期数据无效:`, course);
  365. throw new Error(`课程星期数据无效: day=${course.day}`);
  366. }
  367. if (!course.weeks || course.weeks.length === 0) {
  368. console.error(`课程 ${index + 1} 缺少周次信息:`, course);
  369. throw new Error(`课程周次信息缺失`);
  370. }
  371. const convertedCourse = {
  372. name: course.name || "未知课程",
  373. teacher: course.teacher || "未知教师",
  374. position: course.position || "未知地点",
  375. day: course.day,
  376. startSection: startSection,
  377. endSection: endSection,
  378. weeks: course.weeks
  379. };
  380. validCourses.push(convertedCourse);
  381. } catch (err) {
  382. console.error(`转换课程 ${index + 1} 时出错:`, err.message);
  383. // 如果任何课程转换失败,抛出错误
  384. throw new Error(`课程数据验证失败: ${err.message}`);
  385. }
  386. });
  387. return validCourses;
  388. }
  389. /**
  390. * 生成时间段配置
  391. * @returns {Array} 时间段数组
  392. */
  393. function generateTimeSlots() {
  394. // 云南财经大学默认时间配置
  395. return [
  396. { "number": 1, "startTime": "08:00", "endTime": "08:40" },
  397. { "number": 2, "startTime": "08:50", "endTime": "09:30" },
  398. { "number": 3, "startTime": "10:00", "endTime": "10:40" },
  399. { "number": 4, "startTime": "10:50", "endTime": "11:30" },
  400. { "number": 5, "startTime": "11:40", "endTime": "12:20" },
  401. { "number": 6, "startTime": "14:30", "endTime": "15:10" },
  402. { "number": 7, "startTime": "15:20", "endTime": "16:00" },
  403. { "number": 8, "startTime": "16:30", "endTime": "17:10" },
  404. { "number": 9, "startTime": "17:20", "endTime": "18:00" },
  405. { "number": 10, "startTime": "18:10", "endTime": "18:30" },
  406. { "number": 11, "startTime": "19:00", "endTime": "19:40" },
  407. { "number": 12, "startTime": "19:50", "endTime": "20:30" },
  408. { "number": 13, "startTime": "20:50", "endTime": "21:30" },
  409. { "number": 14, "startTime": "21:40", "endTime": "22:20" }
  410. ];
  411. }
  412. // ========== 主要功能函数 ==========
  413. /**
  414. * 获取和解析课程数据
  415. * @returns {Array|null} 课程数组或null
  416. */
  417. async function fetchAndParseCourses() {
  418. try {
  419. console.log('正在获取课程表数据...');
  420. // 获取课程表HTML
  421. const html = getScheduleHtml();
  422. if (!html) {
  423. console.warn('未获取到课程表HTML');
  424. return null;
  425. }
  426. console.log('成功获取课程表HTML,开始解析...');
  427. // 解析课程数据
  428. const rawCourses = parseScheduleHtml(html);
  429. if (!rawCourses || rawCourses.length === 0) {
  430. console.warn('未解析到课程数据');
  431. return null;
  432. }
  433. console.log(`原始解析到 ${rawCourses.length} 条课程记录`);
  434. // 转换为标准格式
  435. const courses = convertCoursesToStandardFormat(rawCourses);
  436. console.log(`转换为标准格式后有 ${courses.length} 门课程`);
  437. return courses;
  438. } catch (error) {
  439. console.error('获取或解析课程数据失败:', error);
  440. return null;
  441. }
  442. }
  443. /**
  444. * 保存课程数据到时光课表
  445. * @param {Array} courses 课程数组
  446. * @returns {boolean} 保存是否成功
  447. */
  448. async function saveCourses(courses) {
  449. try {
  450. console.log(`正在保存 ${courses.length} 门课程...`);
  451. await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
  452. console.log('课程数据保存成功');
  453. return true;
  454. } catch (error) {
  455. console.error('保存课程失败:', error);
  456. return false;
  457. }
  458. }
  459. /**
  460. * 导入预设时间段到时光课表
  461. * @returns {boolean} 导入是否成功
  462. */
  463. async function importPresetTimeSlots() {
  464. try {
  465. console.log('正在导入时间段配置...');
  466. const presetTimeSlots = generateTimeSlots();
  467. await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(presetTimeSlots));
  468. console.log('时间段配置导入成功');
  469. return true;
  470. } catch (error) {
  471. console.error('导入时间段失败:', error);
  472. return false;
  473. }
  474. }
  475. // ========== 主执行流程 ==========
  476. /**
  477. * 主导入函数:云南财经大学课程表导入
  478. */
  479. async function importYnufeCourseSchedule() {
  480. // 检查是否在登录页面
  481. if (isLoginPage()) {
  482. console.log('检测到在登录页面,终止导入');
  483. AndroidBridge.showToast('请先登录教务系统!');
  484. return; // 直接返回,不抛出错误,不调用notifyTaskCompletion
  485. }
  486. try {
  487. console.log('云南财经大学课程导入开始...');
  488. // 获取和解析课程数据
  489. let courses = await fetchAndParseCourses();
  490. // 如果没有获取到任何课程
  491. if (!courses || courses.length === 0) {
  492. console.log('未获取到课程数据');
  493. // 检查是否真的是空课表(已登录且能找到课表元素但没有课程)
  494. const html = getScheduleHtml();
  495. if (html && html.includes('kbtable')) {
  496. // 找到了课表元素但没有课程,是真的空课表
  497. console.log('检测到空课表');
  498. AndroidBridge.showToast('当前课表为空');
  499. courses = []; // 返回空数组
  500. } else {
  501. // 找不到课表元素,解析失败
  502. AndroidBridge.showToast('获取课表失败,请检查网络和页面状态');
  503. throw new Error('未找到课表数据');
  504. }
  505. } else {
  506. console.log(`成功解析 ${courses.length} 门课程`);
  507. }
  508. // 保存课程数据
  509. const saveResult = await saveCourses(courses);
  510. if (!saveResult) {
  511. AndroidBridge.showToast('保存课程失败');
  512. throw new Error('保存课程数据失败');
  513. }
  514. // 导入时间段配置
  515. const timeSlotResult = await importPresetTimeSlots();
  516. if (!timeSlotResult) {
  517. AndroidBridge.showToast('导入时间段配置失败');
  518. throw new Error('导入时间段失败');
  519. }
  520. // 成功
  521. if (courses.length > 0) {
  522. AndroidBridge.showToast(`成功导入 ${courses.length} 门课程!`);
  523. }
  524. console.log('课程导入完成');
  525. return true;
  526. } catch (error) {
  527. console.error('导入过程出错:', error);
  528. AndroidBridge.showToast('导入失败: ' + error.message);
  529. return false;
  530. }
  531. }
  532. /**
  533. * 启动导入流程并处理完成信号
  534. */
  535. async function runImportFlow() {
  536. const success = await importYnufeCourseSchedule();
  537. // 只有成功导入时才发送完成信号
  538. if (success) {
  539. AndroidBridge.notifyTaskCompletion();
  540. }
  541. return success;
  542. }
  543. // 启动导入流程
  544. runImportFlow();