capadap.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. // 文件: capadap.js
  2. //后期可加入接口-获取校区 https://jwxt.cap.edu.cn/jwapp/sys/kbapp/api/wdkbcx/getMyScheduledCampus.do
  3. // 新版适配 - 接口新增了每个字段,可以直接使用无需再做正则提取
  4. /**
  5. * 显示导入提示
  6. */
  7. async function promptUserToStart() {
  8. const confirmed = await window.AndroidBridgePromise.showAlert(
  9. "导入确认",
  10. "请确保您已经登录咯~",
  11. "开始导入"
  12. );
  13. if (!confirmed) {
  14. AndroidBridge.showToast("用户取消了导入");
  15. return false;
  16. }
  17. AndroidBridge.showToast("开始流程咯~");
  18. return true;
  19. }
  20. /**
  21. * 请求工具
  22. */
  23. async function api(url, options = {}) {
  24. const method = options.method || (options.data ? "POST" : "GET");
  25. const headers = {
  26. "accept": "application/json, text/javascript, */*; q=0.01",
  27. "x-requested-with": "XMLHttpRequest",
  28. "Referer": "https://jwxt.cap.edu.cn/jwapp/sys/kbapp/*default/index.do",
  29. ...(options.data && { "content-type": "application/x-www-form-urlencoded; charset=UTF-8" }),
  30. ...options.headers
  31. };
  32. const res = await fetch(url, {
  33. method: method,
  34. headers: headers,
  35. body: options.data || null,
  36. credentials: "include"
  37. });
  38. return res.json();
  39. }
  40. // ========== 共享变量 ==========
  41. const AppConfig = {
  42. currentSemester: null,
  43. postData: null,
  44. };
  45. // ========== 1. 提取上课时间 & 学期信息 ==========
  46. async function extractCourseTime() {
  47. try {
  48. // 1. 获取当前学期
  49. const userRes = await api(
  50. "https://jwxt.cap.edu.cn/jwapp/sys/homeapp/api/home/currentUser.do"
  51. );
  52. AppConfig.currentSemester = userRes.datas.welcomeInfo.xnxqdm;
  53. console.log("检测到当前学期:", AppConfig.currentSemester);
  54. AppConfig.postData = `XNXQDM=${AppConfig.currentSemester}&XQDM=01`;
  55. // 2. 获取节次时间表(小节),这里原来被 return 挡在后面了
  56. const sectionRes = await api(
  57. "https://jwxt.cap.edu.cn/jwapp/sys/kbapp/api/wdkbcx/getMySectionList.do",
  58. { data: AppConfig.postData }
  59. );
  60. const rawSections = sectionRes.datas.getMySectionList;
  61. const cleanSections = rawSections
  62. .filter(item => item.name.includes("第"))
  63. .map(item => ({
  64. number: parseInt(item.name.replace(/[^0-9]/g, "")),
  65. startTime: item.startTime,
  66. endTime: item.endTime
  67. }))
  68. .sort((a, b) => a.number - b.number);
  69. console.log("节次时间表:", cleanSections);
  70. // 3. 获取学期周次
  71. const weekRes = await api(
  72. "https://jwxt.cap.edu.cn/jwapp/sys/homeapp/api/home/getTermWeeks.do",
  73. { data: `termCode=${AppConfig.currentSemester}` }
  74. );
  75. const finalWeeks = weekRes.datas.map(item => ({
  76. week: item.serialNumber,
  77. startTime: item.startDate.split(' ')[0],
  78. endTime: item.endDate.split(' ')[0],
  79. isCurrent: item.curWeek
  80. }));
  81. const totalWeeks = finalWeeks.length;
  82. const startDate = finalWeeks[0].startTime;
  83. console.log("学期信息:", {
  84. semester: AppConfig.currentSemester,
  85. totalWeeks,
  86. startDate
  87. });
  88. // 把 cleanSections 带出去
  89. return {
  90. currentSemester: AppConfig.currentSemester,
  91. totalWeeks,
  92. startDate,
  93. cleanSections
  94. };
  95. } catch (error) {
  96. console.error('解析基础信息时出错:', error);
  97. AndroidBridge.showToast(`解析失败: ${error.message}`);
  98. return null;
  99. }
  100. }
  101. // ========== 2. 获取课表原始数据 ==========
  102. async function getCourseData(totalWeeks) {
  103. const allRaw = [];
  104. const seen = new Set();
  105. const weekRequests = [];
  106. for (let zc = 1; zc <= totalWeeks; zc++) {
  107. weekRequests.push(
  108. api("https://jwxt.cap.edu.cn/jwapp/sys/kbapp/api/wdkbcx/getMyScheduleDetail.do", {
  109. data: `${AppConfig.postData}&ZC=${zc}`,
  110. }).then(res => {
  111. const list = res?.datas?.getMyScheduleDetail?.arrangedList || [];
  112. list.forEach(item => {
  113. // 更精确的去重键:课程名+教学班ID+星期+节次+周次字符串
  114. // 加上周次字符串可以避免同一门课在不同周被误判为重复
  115. const key = `${item.courseCode || item.courseName}|${item.teachClassId}|${item.dayOfWeek}|${item.beginSection}`;
  116. if (!seen.has(key)) {
  117. seen.add(key);
  118. allRaw.push(item);
  119. }
  120. });
  121. }).catch(e => {
  122. console.warn(`第${zc}周请求失败:`, e.message);
  123. })
  124. );
  125. }
  126. await Promise.all(weekRequests);
  127. console.log(`获取到 ${allRaw.length} 条课程数据(含短期实验/实习)`);
  128. return allRaw;
  129. }
  130. // ========== 3. 辅助周次解析函数 ==========
  131. function parseWeekString(weekStr) {
  132. // "101101011111111111" -> [1,3,4,6,8,9,...]
  133. if (!weekStr) return [];
  134. const weeks = [];
  135. for (let i = 0; i < weekStr.length; i++) {
  136. if (weekStr[i] === '1') {
  137. weeks.push(i + 1);
  138. }
  139. }
  140. return weeks;
  141. }
  142. /**
  143. * 解析学期间的周次描述,例如 "14-15周"、"3周"、"1-3周(单),7周,11-17周(单)"
  144. * 返回周次数组
  145. */
  146. function parseWeeksDescription(desc) {
  147. if (!desc) return [];
  148. const weeks = [];
  149. // 预处理:去掉“周”字、空格,中文逗号变英文
  150. let clean = desc.replace(/\s+/g, '').replace(/,/g, ',').replace(/周/g, '');
  151. const segments = clean.split(',');
  152. segments.forEach(seg => {
  153. // 检测单双周标记
  154. const isOdd = seg.includes('(单)');
  155. const isEven = seg.includes('(双)');
  156. seg = seg.replace(/\(单\)|\(双\)/g, '');
  157. if (seg.includes('-')) {
  158. const [start, end] = seg.split('-').map(Number);
  159. for (let i = start; i <= end; i++) {
  160. if (isOdd && i % 2 === 0) continue;
  161. if (isEven && i % 2 !== 0) continue;
  162. weeks.push(i);
  163. }
  164. } else {
  165. const num = parseInt(seg);
  166. if (!isNaN(num)) {
  167. if (isOdd && num % 2 === 0) return;
  168. if (isEven && num % 2 !== 0) return;
  169. weeks.push(num);
  170. }
  171. }
  172. });
  173. return [...new Set(weeks)].sort((a, b) => a - b);
  174. }
  175. // ========== 4. 从HTML片段中提取教师姓名 ==========
  176. function extractTeacherFromHTML(html) {
  177. if (!html) return null;
  178. const match = html.match(/<a[^>]*>([^<]+)<\/a>/);
  179. return match ? match[1].trim() : null;
  180. }
  181. /**
  182. * 从描述文本中提取非教师、非校区的备注地点
  183. */
  184. function extractExtraLocation(htmlText, campusName, teacher) {
  185. if (!htmlText) return '';
  186. // 去掉所有HTML标签
  187. let clean = htmlText.replace(/<[^>]+>/g, ' ').trim();
  188. // 去掉已知校区名
  189. if (campusName) clean = clean.replace(new RegExp(campusName, 'g'), '');
  190. // 去掉开头的周次描述
  191. clean = clean.replace(/^\d+(-\d+)?周\s*/, '');
  192. // 去掉教师姓名(如果传入)
  193. if (teacher) clean = clean.replace(new RegExp(teacher, 'g'), '');
  194. // 清理多余空格
  195. clean = clean.replace(/\s+/g, ' ').trim();
  196. return clean;
  197. }
  198. // ========== 5. 核心解析函数:将单条 raw item 解析为多个 course 片段 ==========
  199. function parseCourseItem(item) {
  200. const courseName = item.courseName;
  201. const day = item.dayOfWeek;
  202. const beginSection = item.beginSection;
  203. const endSection = item.endSection;
  204. const campusName = item.campusName || '';
  205. const placeName = item.placeName || '';
  206. const tags = item.tags || [];
  207. // 优先使用 cellWeekTeacherClassroomDetail,如果为空则用 multiCourseTitleDetail 或 titleDetail
  208. let segmentsSource = [];
  209. if (item.cellWeekTeacherClassroomDetail && item.cellWeekTeacherClassroomDetail.length > 0) {
  210. segmentsSource = item.cellWeekTeacherClassroomDetail.map(cell => cell.text);
  211. } else if (item.multiCourseTitleDetail && item.multiCourseTitleDetail.length > 1) {
  212. segmentsSource = item.multiCourseTitleDetail
  213. .slice(1)
  214. .filter(line => {
  215. const plainText = line.replace(/<[^>]+>/g, '').trim();
  216. // 过滤掉纯数字/逗号/空格组成的行(班级列表)
  217. if (/^[\d,\s]+$/.test(plainText)) return false;
  218. // 过滤掉空行
  219. return plainText.length > 0;
  220. })
  221. .map(line => line.trim())
  222. .filter(line => line !== '');
  223. } else if (item.titleDetail && item.titleDetail.length > 1) {
  224. // ✅ 兜底:只有 titleDetail 时,用其中第一行教师/地点信息
  225. segmentsSource = [item.titleDetail[1]];
  226. }
  227. const courses = [];
  228. // 保存总周次作为兜底
  229. const totalWeeks = parseWeekString(item.week || '');
  230. segmentsSource.forEach(segText => {
  231. const teacher = extractTeacherFromHTML(segText) || '未知教师';
  232. let weeks;
  233. // 尝试从文本中提取周次描述
  234. const weekDescMatch = segText.match(/^([\d\-\(\),周单双\s]+?)\s*</);
  235. if (weekDescMatch && weekDescMatch[1]) {
  236. const wd = weekDescMatch[1].trim();
  237. weeks = parseWeeksDescription(wd);
  238. if (weeks.length === 0) weeks = totalWeeks;
  239. } else {
  240. weeks = totalWeeks;
  241. }
  242. // 确定地点
  243. let position;
  244. if (placeName) {
  245. position = (campusName && !placeName.includes(campusName)) ? `${campusName} ${placeName}` : placeName;
  246. } else {
  247. // placeName 为空时,从描述提取备注
  248. const extra = extractExtraLocation(segText, campusName, teacher);
  249. position = campusName ? `${campusName} ${extra}`.trim() : extra;
  250. }
  251. position = position || campusName || '未知地点';
  252. courses.push({
  253. name: courseName,
  254. teacher: teacher,
  255. position: position.trim(),
  256. day: day,
  257. startSection: beginSection,
  258. endSection: endSection,
  259. weeks: weeks,
  260. campusName: campusName,
  261. rawPlaceName: placeName,
  262. hasExperimentTag: tags.some(t => t.text === '实')
  263. });
  264. });
  265. return courses;
  266. }
  267. // ========== 6. 聚合所有课程并映射小节编号 ==========
  268. function parseAllCourses(rawArrangedList, sectionList) {
  269. const allCourses = [];
  270. if (!rawArrangedList || !Array.isArray(rawArrangedList)) {
  271. return { courses: [], timeSlots: [] };
  272. }
  273. // 构建时间 -> 小节编号 的映射
  274. const startTimeToSection = {};
  275. const endTimeToSection = {};
  276. sectionList.forEach(slot => {
  277. startTimeToSection[slot.startTime] = slot.number;
  278. endTimeToSection[slot.endTime] = slot.number;
  279. });
  280. rawArrangedList.forEach(item => {
  281. if (item.dayOfWeek === null || item.beginSection === null) return;
  282. // 根据 beginTime 和 endTime 查找正确的小节区间
  283. const realStart = startTimeToSection[item.beginTime];
  284. const realEnd = endTimeToSection[item.endTime];
  285. if (realStart === undefined || realEnd === undefined) {
  286. // 时间无法匹配,丢弃该课程(或使用原始值,但不推荐)
  287. console.warn(`课程 ${item.courseName} 时间无法匹配时间槽: ${item.beginTime}-${item.endTime}`);
  288. return;
  289. }
  290. // 用正确的小节编号覆盖原始 beginSection/endSection
  291. const correctedItem = {
  292. ...item,
  293. beginSection: realStart,
  294. endSection: realEnd
  295. };
  296. const courses = parseCourseItem(correctedItem);
  297. allCourses.push(...courses);
  298. });
  299. // 时间槽直接使用 sectionList,编号保持 1,2,3...
  300. const timeSlots = sectionList.map(sec => ({
  301. number: sec.number,
  302. startTime: sec.startTime,
  303. endTime: sec.endTime
  304. }));
  305. console.log(`解析完成,共 ${allCourses.length} 个课程片段,${timeSlots.length} 个时间段`);
  306. return { courses: allCourses, timeSlots };
  307. }
  308. // ========== 7. 获取所有数据 ==========
  309. async function fetchAllRawData() {
  310. const baseInfo = await extractCourseTime();
  311. if (!baseInfo) return null;
  312. const rawArrangedList = await getCourseData(baseInfo.totalWeeks);
  313. if (!rawArrangedList || rawArrangedList.length === 0) {
  314. AndroidBridge.showToast("未检测到当前学期的课程数据");
  315. return null;
  316. }
  317. return { baseInfo, rawArrangedList };
  318. }
  319. // ========== 8. 保存配置 ==========
  320. async function saveConfig(baseInfo) {
  321. const configData = {
  322. semesterStartDate: baseInfo.startDate,
  323. semesterTotalWeeks: baseInfo.totalWeeks || 20,
  324. };
  325. try {
  326. const configSuccess = await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify(configData));
  327. if (!configSuccess) {
  328. AndroidBridge.showToast("学期保存失败");
  329. return false;
  330. }
  331. return true;
  332. } catch (error) {
  333. AndroidBridge.showToast("保存配置失败: " + error.message);
  334. return false;
  335. }
  336. }
  337. // ========== 9. 主导入流程 ==========
  338. async function runImportFlow() {
  339. try {
  340. const isReady = await promptUserToStart();
  341. if (!isReady) return;
  342. const dataBundle = await fetchAllRawData();
  343. if (!dataBundle) return;
  344. const { courses: finalCourses, timeSlots } = parseAllCourses(dataBundle.rawArrangedList, dataBundle.baseInfo.cleanSections);
  345. if (finalCourses.length === 0) {
  346. AndroidBridge.showToast("解析失败:未能提取到有效课程");
  347. return;
  348. }
  349. // 保存学期配置
  350. const configSaveResult = await saveConfig(dataBundle.baseInfo);
  351. if (!configSaveResult) return;
  352. // 保存时间段 (基于实际课程生成的大节)
  353. try {
  354. const slotJson = JSON.stringify(timeSlots);
  355. console.log("写入时间段数据:", slotJson);
  356. await window.AndroidBridgePromise.savePresetTimeSlots(slotJson);
  357. } catch (e) {
  358. console.error("时间段写入失败:", e);
  359. AndroidBridge.showToast("时间段保存失败");
  360. return;
  361. }
  362. // 保存课程数据
  363. const saveResult = await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(finalCourses));
  364. if (!saveResult) {
  365. AndroidBridge.showToast("课程数据保存失败");
  366. return;
  367. }
  368. AndroidBridge.showToast("Hi ~ 课表导入成功!");
  369. AndroidBridge.notifyTaskCompletion();
  370. } catch (error) {
  371. console.error("主流程异常:", error);
  372. AndroidBridge.showToast("意外错误: " + error.message);
  373. }
  374. }
  375. // 启动导入流程
  376. runImportFlow();