ynufe.js 24 KB

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