浏览代码

add: 重庆交通大学教务系统适配 (#325)

Charles 1 月之前
父节点
当前提交
8dc35e9dde
共有 3 个文件被更改,包括 435 次插入11 次删除
  1. 16 11
      index/root_index.yaml
  2. 9 0
      resources/CQJTU/adapters.yaml
  3. 410 0
      resources/CQJTU/cqjtu.js

+ 16 - 11
index/root_index.yaml

@@ -3,10 +3,10 @@
 # CI/CD 构建脚本会读取此文件,以确定需要处理哪些学校的适配器资源。
 
 schools:
-  - id: "GLOBAL_TOOLS"    # id 唯一值
+  - id: "GLOBAL_TOOLS" # id 唯一值
     name: "通用工具与服务" # 名称
-    initial: "G"          # 排序首字母
-    resource_folder: "GLOBAL_TOOLS"   # 资源文件夹名称
+    initial: "G" # 排序首字母
+    resource_folder: "GLOBAL_TOOLS" # 资源文件夹名称
 
   - id: "zhengfang_jiaowu"
     name: "正方教务-通用教务"
@@ -38,6 +38,11 @@ schools:
     initial: "C"
     resource_folder: "CQU"
 
+  - id: "CQJTU"
+    name: "重庆交通大学"
+    initial: "C"
+    resource_folder: "CQJTU"
+
   - id: "CQCST"
     name: "重庆城市科技学院"
     initial: "C"
@@ -411,7 +416,7 @@ schools:
     name: "宁波工程学院"
     initial: "N"
     resource_folder: "NBUT"
-  
+
   - id: "HUAYU"
     name: "山东华宇工学院"
     initial: "S"
@@ -436,7 +441,7 @@ schools:
     name: "枣庄学院"
     initial: "Z"
     resource_folder: "UZZ"
-  
+
   - id: "CMC"
     name: "成都医学院"
     initial: "C"
@@ -445,18 +450,18 @@ schools:
   - id: "SDIPCT"
     name: "山东石油化工学院"
     initial: "S"
-    resource_folder: "SDIPCT"  
+    resource_folder: "SDIPCT"
 
   - id: "GDUST"
     name: "广东科技学院"
     initial: "G"
     resource_folder: "GDUST"
-  
+
   - id: "UPC"
     name: "中国石油大学(华东)"
     initial: "Z"
-    resource_folder: "UPC"   
-    
+    resource_folder: "UPC"
+
   - id: "TJAU"
     name: "天津农学院"
     initial: "T"
@@ -465,7 +470,7 @@ schools:
   - id: "CCIT"
     name: "长春工程学院"
     initial: "C"
-    resource_folder: "CCIT"       
+    resource_folder: "CCIT"
 
   - id: "IMNC"
     name: "呼和浩特民族学院"
@@ -476,7 +481,7 @@ schools:
     name: "北方工业大学"
     initial: "B"
     resource_folder: "NCUT"
-    
+
   - id: "JXNU"
     name: "江西师范大学"
     initial: "J"

+ 9 - 0
resources/CQJTU/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/CQJTU/adapters.yaml
+adapters:
+  - adapter_id: "CQJTU"
+    adapter_name: "重庆交通大学教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "cqjtu.js"
+    import_url: "https://jwgln.cqjtu.edu.cn/jsxsd/sso.jsp"
+    maintainer: "烛谛"
+    description: "适配重庆交通大学教务系统"

+ 410 - 0
resources/CQJTU/cqjtu.js

@@ -0,0 +1,410 @@
+// ===== 登录检查 =====
+const checkLogin = () => {
+  const hostnameOk = window.location.hostname === "jwgln.cqjtu.edu.cn";
+  const nameEl = document.querySelector(".userInfo span:last-child");
+  const nameOk = nameEl && nameEl.innerText.trim().length > 0;
+  return hostnameOk && nameOk;
+};
+
+const getUserName = () => {
+  const el = document.querySelector(".userInfo span:last-child");
+  return el ? el.innerText.trim() : "";
+};
+
+// ===== 工具函数 =====
+
+// 周次展开: "1-9,11-16(单周)" → [1,3,5,7,9,11,13,15]
+function parseWeeks(weekStr) {
+  weekStr = weekStr.replace(/\[\d+(?:-\d+)?节\]$/, "");
+  const typeMatch = weekStr.match(/\(([^)]+)\)$/);
+  const weekType = typeMatch ? typeMatch[1] : "周";
+  const pureWeekStr = weekStr.replace(/\([^)]+\)$/, "");
+  const weekRanges = pureWeekStr.split(",");
+
+  let weeks = [];
+  for (const range of weekRanges) {
+    const parts = range.split("-");
+    const start = Number(parts[0]);
+    const end = parts.length > 1 ? Number(parts[1]) : start;
+    for (let i = start; i <= end; i++) {
+      weeks.push(i);
+    }
+  }
+
+  if (weekType === "单周") {
+    weeks = weeks.filter((w) => w % 2 === 1);
+  } else if (weekType === "双周") {
+    weeks = weeks.filter((w) => w % 2 === 0);
+  }
+
+  return weeks;
+}
+
+// 提取节次: "1-9,11-16(周)[01-02节]" → { start: 1, end: 2 }
+function parseSections(weekStr) {
+  const match = weekStr.match(/\[(\d+)(?:-(\d+))?节\]/);
+  if (!match) return null;
+  const start = Number(match[1]);
+  const end = match[2] ? Number(match[2]) : start;
+  return { start, end };
+}
+
+// 格式化分钟为 HH:MM
+function formatTime(minutes) {
+  const h = Math.floor(minutes / 60);
+  const m = minutes % 60;
+  return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
+}
+
+// 计算时间槽
+function calculateTimeSlots(sectionNumbers, startH, startM, endH, endM) {
+  const n = sectionNumbers.length;
+  const totalMinutes = endH * 60 + endM - (startH * 60 + startM);
+  const rawDuration = Math.floor(totalMinutes / n);
+  const z = Math.floor(rawDuration / 5) * 5;
+  const interval = n > 1 ? Math.floor((totalMinutes - z * n) / (n - 1)) : 0;
+
+  return sectionNumbers.map((num, idx) => {
+    const offset = idx * (z + interval);
+    const start = startH * 60 + startM + offset;
+    const end = start + z;
+    return {
+      number: num,
+      startTime: formatTime(start),
+      endTime: formatTime(end),
+    };
+  });
+}
+
+// 根据学期值计算开学日期, 就是个简单计算,谁还不能自己手动调了,鬼知道教务处要怎么安排后续的学期
+function getSemesterStartDate(semesterValue) {
+  const year = parseInt(semesterValue.substring(0, 4));
+  const termType = semesterValue.slice(-1);
+  if (termType === "1") {
+    return `${year}-09-08`;
+  } else {
+    return `${year + 1}-03-02`;
+  }
+}
+
+// 从timeSlots计算课程配置
+function getSemesterConfig(timeSlots) {
+  if (timeSlots.length === 0) {
+    return {};
+  }
+
+  const [sH, sM] = timeSlots[0].startTime.split(":").map(Number);
+  const [eH, eM] = timeSlots[0].endTime.split(":").map(Number);
+  const classDuration = eH * 60 + eM - (sH * 60 + sM);
+  const defaultClassDuration = Math.round(classDuration / 5) * 5;
+
+  let defaultBreakDuration = 5;
+  if (timeSlots.length >= 2) {
+    const [e1H, e1M] = timeSlots[0].endTime.split(":").map(Number);
+    const [s2H, s2M] = timeSlots[1].startTime.split(":").map(Number);
+    defaultBreakDuration = s2H * 60 + s2M - (e1H * 60 + e1M);
+  }
+
+  return {
+    defaultClassDuration,
+    defaultBreakDuration,
+    semesterTotalWeeks: 18,
+    firstDayOfWeek: 1,
+  };
+}
+
+// 在主文档和frames中查找元素
+function findElementInFrames(selector) {
+  let el = document.querySelector(selector);
+  if (el) return el;
+
+  for (let i = 0; i < window.frames.length; i++) {
+    try {
+      const frameDoc = window.frames[i].document;
+      if (frameDoc) {
+        el = frameDoc.querySelector(selector);
+        if (el) return el;
+      }
+    } catch (e) {}
+  }
+  return null;
+}
+
+// ===== 主解析函数 =====
+
+function parseSchedule() {
+  let table = findElementInFrames("#timetable");
+
+  if (!table) {
+    AndroidBridge.showToast("请去往学期理论课表界面");
+    return [];
+  }
+
+  const courses = [];
+  const timeSlots = [];
+  const tbody = table.querySelector("tbody");
+  if (!tbody) {
+    const rows = table.querySelectorAll("tr");
+    if (rows.length > 0) {
+      return parseRowsDirectly(rows);
+    }
+    AndroidBridge.showToast("课表结构异常");
+    return [];
+  }
+
+  const rows = tbody.querySelectorAll("tr");
+
+  for (let i = 1; i < rows.length; i++) {
+    const row = rows[i];
+    const th = row.querySelector("th");
+    if (!th) continue;
+
+    const thMatch = th.innerText.match(/\(([\d,]+)小节\)/);
+    if (!thMatch) continue;
+
+    const sectionNumbers = thMatch[1].split(",").map(Number);
+    const baseStart = sectionNumbers[0];
+    const baseEnd = sectionNumbers[sectionNumbers.length - 1];
+
+    // 提取时间范围并计算时间槽
+    const timeMatch = th.innerText.match(/(\d+):(\d+)-(\d+):(\d+)/);
+    if (timeMatch) {
+      const startH = Number(timeMatch[1]);
+      const startM = Number(timeMatch[2]);
+      const endH = Number(timeMatch[3]);
+      const endM = Number(timeMatch[4]);
+      const slots = calculateTimeSlots(
+        sectionNumbers,
+        startH,
+        startM,
+        endH,
+        endM,
+      );
+      timeSlots.push(...slots);
+    }
+
+    const tds = row.querySelectorAll("td");
+
+    for (let day = 1; day <= 7 && day <= tds.length; day++) {
+      const td = tds[day - 1];
+      const allKbDivs = td.querySelectorAll("div.kbcontent");
+      const kbDivs = Array.from(allKbDivs).filter((div) => {
+        const style = div.getAttribute("style") || "";
+        return !style.includes("display:none");
+      });
+
+      if (kbDivs.length === 0) continue;
+
+      for (const kbDiv of kbDivs) {
+        const html = kbDiv.innerHTML;
+        const parts = html.split("</font>---------------------<br>");
+
+        for (const part of parts) {
+          const firstFontMatch = part.match(
+            /<font onmouseover="kbtc\(this\)" onmouseout="kbot\(this\)">([^<]*)<\/font>/,
+          );
+          if (!firstFontMatch) continue;
+
+          const name = firstFontMatch[1].trim();
+          if (!name) continue;
+
+          const teacherMatch = part.match(
+            /<font title="教师"[^>]*>([^<]*)<\/font>/,
+          );
+          const teacher = teacherMatch ? teacherMatch[1].trim() : "未知";
+
+          const weeksMatch = part.match(
+            /<font title="周次\(节次\)"[^>]*>([^<]*)<\/font>/,
+          );
+          if (!weeksMatch) continue;
+
+          const weeksStr = weeksMatch[1].trim();
+          const posMatch = part.match(
+            /<font title="教室"[^>]*>([^<]*)<\/font>/,
+          );
+          const position = posMatch ? posMatch[1].trim() : "";
+
+          let startSection = baseStart;
+          let endSection = baseEnd;
+          const sectionInfo = parseSections(weeksStr);
+          if (sectionInfo) {
+            startSection = sectionInfo.start;
+            endSection = sectionInfo.end;
+          }
+
+          const weeks = parseWeeks(weeksStr);
+          if (weeks.length === 0) continue;
+
+          courses.push({
+            name,
+            teacher,
+            position,
+            day,
+            startSection,
+            endSection,
+            weeks,
+          });
+        }
+      }
+    }
+  }
+
+  return { courses, timeSlots };
+}
+
+// 直接解析table的rows(无tbody的情况)
+function parseRowsDirectly(rows) {
+  const courses = [];
+  const timeSlots = [];
+
+  for (let i = 1; i < rows.length; i++) {
+    const row = rows[i];
+    const th = row.querySelector("th");
+    if (!th) continue;
+
+    const thMatch = th.innerText.match(/\(([\d,]+)小节\)/);
+    if (!thMatch) continue;
+
+    const sectionNumbers = thMatch[1].split(",").map(Number);
+    const baseStart = sectionNumbers[0];
+    const baseEnd = sectionNumbers[sectionNumbers.length - 1];
+
+    const timeMatch = th.innerText.match(/(\d+):(\d+)-(\d+):(\d+)/);
+    if (timeMatch) {
+      const startH = Number(timeMatch[1]);
+      const startM = Number(timeMatch[2]);
+      const endH = Number(timeMatch[3]);
+      const endM = Number(timeMatch[4]);
+      const slots = calculateTimeSlots(
+        sectionNumbers,
+        startH,
+        startM,
+        endH,
+        endM,
+      );
+      timeSlots.push(...slots);
+    }
+
+    const tds = row.querySelectorAll("td");
+
+    for (let day = 1; day <= 7 && day <= tds.length; day++) {
+      const td = tds[day - 1];
+      const allKbDivs = td.querySelectorAll("div.kbcontent");
+      const kbDivs = Array.from(allKbDivs).filter((div) => {
+        const style = div.getAttribute("style") || "";
+        return !style.includes("display:none");
+      });
+
+      if (kbDivs.length === 0) continue;
+
+      for (const kbDiv of kbDivs) {
+        const html = kbDiv.innerHTML;
+        const parts = html.split("</font>---------------------<br>");
+
+        for (const part of parts) {
+          const firstFontMatch = part.match(
+            /<font onmouseover="kbtc\(this\)" onmouseout="kbot\(this\)">([^<]*)<\/font>/,
+          );
+          if (!firstFontMatch) continue;
+
+          const name = firstFontMatch[1].trim();
+          if (!name) continue;
+
+          const teacherMatch = part.match(
+            /<font title="教师"[^>]*>([^<]*)<\/font>/,
+          );
+          const teacher = teacherMatch ? teacherMatch[1].trim() : "未知";
+
+          const weeksMatch = part.match(
+            /<font title="周次\(节次\)"[^>]*>([^<]*)<\/font>/,
+          );
+          if (!weeksMatch) continue;
+
+          const weeksStr = weeksMatch[1].trim();
+          const posMatch = part.match(
+            /<font title="教室"[^>]*>([^<]*)<\/font>/,
+          );
+          const position = posMatch ? posMatch[1].trim() : "";
+
+          let startSection = baseStart;
+          let endSection = baseEnd;
+          const sectionInfo = parseSections(weeksStr);
+          if (sectionInfo) {
+            startSection = sectionInfo.start;
+            endSection = sectionInfo.end;
+          }
+
+          const weeks = parseWeeks(weeksStr);
+          if (weeks.length === 0) continue;
+
+          courses.push({
+            name,
+            teacher,
+            position,
+            day,
+            startSection,
+            endSection,
+            weeks,
+          });
+        }
+      }
+    }
+  }
+
+  return { courses, timeSlots };
+}
+
+// ===== 保存函数 =====
+
+async function saveCourses(courses) {
+  await window.AndroidBridgePromise.saveImportedCourses(
+    JSON.stringify(courses),
+  );
+}
+
+async function saveTimeSlots(timeSlots) {
+  await window.AndroidBridgePromise.savePresetTimeSlots(
+    JSON.stringify(timeSlots),
+  );
+}
+
+async function saveConfig(config) {
+  await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify(config));
+}
+
+// ===== 主流程 =====
+
+(async () => {
+  if (!checkLogin()) {
+    AndroidBridge.showToast("尚未登录,请先登录!");
+    return;
+  }
+
+  const { courses, timeSlots } = parseSchedule();
+
+  if (courses.length === 0) {
+    AndroidBridge.showToast("未解析到任何课程");
+    return;
+  }
+
+  // 获取学期配置
+  const semesterSelect = findElementInFrames("#xnxq01id");
+  const courseConfigData = getSemesterConfig(timeSlots);
+
+  if (semesterSelect) {
+    courseConfigData.semesterStartDate = getSemesterStartDate(
+      semesterSelect.value,
+    );
+  }
+
+  console.log("准备保存课程:", courses.length, "门");
+  console.log("准备保存时间槽:", timeSlots.length, "个");
+  console.log("准备保存配置:", JSON.stringify(courseConfigData));
+
+  await saveCourses(courses);
+  await saveTimeSlots(timeSlots);
+  await saveConfig(courseConfigData);
+
+  AndroidBridge.showToast(`导入成功!${courses.length}门课程`);
+  AndroidBridge.notifyTaskCompletion();
+})();