Pārlūkot izejas kodu

新增信阳农林学院(XYAFU)青果教务系统适配器

glxgo 1 mēnesi atpakaļ
vecāks
revīzija
f673dd4dbc
3 mainītis faili ar 449 papildinājumiem un 0 dzēšanām
  1. 5 0
      index/root_index.yaml
  2. 9 0
      resources/XYAFU/adapters.yaml
  3. 435 0
      resources/XYAFU/xyafu_01.js

+ 5 - 0
index/root_index.yaml

@@ -517,3 +517,8 @@ schools:
     initial: "W"
     resource_folder: "WENHUA"
 
+  - id: "XYAFU"
+    name: "信阳农林学院"
+    initial: "X"
+    resource_folder: "XYAFU"
+

+ 9 - 0
resources/XYAFU/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/XYAFU/adapters.yaml
+adapters:
+  - adapter_id: "XYAFU_01"
+    adapter_name: "信阳农林学院青果教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "xyafu_01.js"
+    import_url: "https://sso.xyafu.edu.cn/sso/login?service=https%3A%2F%2Fjwgl.xyafu.edu.cn%2Fcaslogin"
+    maintainer: "glxgo"
+    description: "信阳农林学院教务,登录后需要进入教务系统才可以点击导入,非本校开发者适配如果有误建议提交issues"

+ 435 - 0
resources/XYAFU/xyafu_01.js

@@ -0,0 +1,435 @@
+// 文件: XYAFU_01.js
+// 功能:从信阳农林学院青果教务系统获取课程表,解析后导入到拾光课程表
+// 适配:信阳农林学院青果教务系统
+// 维护者:glxgo
+
+const BASE = `${window.location.origin}`;
+const CONTROL_PAGE = '/student/xkjg.wdkb.jsp?menucode=S20301';
+const TIMETABLE_PAGE = '/student/wsxk.xskcb10319.jsp?params=';
+const TIME_SLOTS_SPRING_SUMMER = [
+  { number: 1, startTime: '08:00', endTime: '08:45' },
+  { number: 2, startTime: '08:50', endTime: '09:35' },
+  { number: 3, startTime: '10:00', endTime: '10:45' },
+  { number: 4, startTime: '10:50', endTime: '11:35' },
+  { number: 5, startTime: '14:30', endTime: '15:15' },
+  { number: 6, startTime: '15:20', endTime: '16:05' },
+  { number: 7, startTime: '16:30', endTime: '17:15' },
+  { number: 8, startTime: '17:20', endTime: '18:05' },
+  { number: 9, startTime: '19:00', endTime: '19:45' },
+  { number: 10, startTime: '19:50', endTime: '20:35' }
+];
+const TIME_SLOTS_AUTUMN_WINTER = [
+  { number: 1, startTime: '08:00', endTime: '08:45' },
+  { number: 2, startTime: '08:50', endTime: '09:35' },
+  { number: 3, startTime: '10:00', endTime: '10:45' },
+  { number: 4, startTime: '10:50', endTime: '11:35' },
+  { number: 5, startTime: '14:00', endTime: '14:45' },
+  { number: 6, startTime: '14:50', endTime: '15:35' },
+  { number: 7, startTime: '16:00', endTime: '16:45' },
+  { number: 8, startTime: '16:50', endTime: '17:35' },
+  { number: 9, startTime: '19:00', endTime: '19:45' },
+  { number: 10, startTime: '19:50', endTime: '20:35' }
+];
+
+function cleanText(value) {
+  return String(value || '')
+    .replace(/[​-‍]/g, '')
+    .replace(/ /g, ' ')
+    .trim();
+}
+
+function encodeParams(xn, xq, xh) {
+  return btoa(`xn=${xn}&xq=${xq}&xh=${xh}`);
+}
+
+function parseWeeks(weekStr) {
+  const weeks = [];
+  String(weekStr || '')
+    .replace(/\s+/g, '')
+    .split(/[,,]/)
+    .forEach((part) => {
+      if (!part) return;
+      const isSingle = part.includes('单');
+      const isDouble = part.includes('双');
+      const rangeMatch = part.match(/(\d+)-(\d+)/);
+      if (rangeMatch) {
+        const start = parseInt(rangeMatch[1], 10);
+        const end = parseInt(rangeMatch[2], 10);
+        for (let i = start; i <= end; i++) {
+          if (isSingle && i % 2 === 0) continue;
+          if (isDouble && i % 2 !== 0) continue;
+          weeks.push(i);
+        }
+      } else {
+        const num = parseInt(part.replace(/[^\d]/g, ''), 10);
+        if (!Number.isNaN(num)) weeks.push(num);
+      }
+    });
+  return [...new Set(weeks)].sort((a, b) => a - b);
+}
+
+function decodeParams(encoded) {
+  try {
+    return atob(encoded);
+  } catch (_) {
+    return '';
+  }
+}
+
+function parseXhFromEncodedParams(encoded) {
+  const decoded = decodeParams(encoded);
+  if (!decoded) return '';
+  const search = new URLSearchParams(decoded);
+  return String(search.get('xh') || '').trim();
+}
+
+function extractParamsFromHtml(html) {
+  const match = String(html || '').match(/wsxk\.xskcb10319\.jsp\?params=([^"'&\s>]+)/);
+  return match ? decodeURIComponent(match[1]) : '';
+}
+
+function findControlFrame(win) {
+  try {
+    if (win.document.querySelector('#xnxq')) return win;
+  } catch (_) {}
+  for (let i = 0; i < win.frames.length; i++) {
+    try {
+      const found = findControlFrame(win.frames[i]);
+      if (found) return found;
+    } catch (_) {}
+  }
+  return null;
+}
+
+function findTimetableFrame(win) {
+  try {
+    if (win.document.getElementById('mytable')) return win;
+  } catch (_) {}
+  for (let i = 0; i < win.frames.length; i++) {
+    try {
+      const found = findTimetableFrame(win.frames[i]);
+      if (found) return found;
+    } catch (_) {}
+  }
+  return null;
+}
+
+async function fetchControlDoc() {
+  const res = await fetch(`${BASE}${CONTROL_PAGE}`, { credentials: 'include' });
+  if (!res.ok) throw new Error(`课表控制页请求失败: ${res.status}`);
+  const html = await res.text();
+  return new DOMParser().parseFromString(html, 'text/html');
+}
+
+async function fetchTimetableDoc(xn, xq, xh) {
+  const params = encodeParams(xn, xq, xh);
+  const res = await fetch(`${BASE}${TIMETABLE_PAGE}${encodeURIComponent(params)}`, {
+    method: 'GET',
+    credentials: 'include'
+  });
+  if (!res.ok) throw new Error(`课表页面请求失败: ${res.status}`);
+  const buffer = await res.arrayBuffer();
+  let html = '';
+  try {
+    html = new TextDecoder('gbk').decode(buffer);
+  } catch (_) {
+    html = new TextDecoder('utf-8').decode(buffer);
+  }
+  return new DOMParser().parseFromString(html, 'text/html');
+}
+
+function parseSelectOptions(selectEl) {
+  if (!selectEl) return { options: [], defaultIndex: 0 };
+  const options = [];
+  let defaultIndex = 0;
+  Array.from(selectEl.querySelectorAll('option')).forEach((opt) => {
+    const value = String(opt.value || '').trim();
+    if (!value) return;
+    const text = cleanText(opt.textContent) || value;
+    if (opt.selected) defaultIndex = options.length;
+    options.push({ value, text });
+  });
+  return { options, defaultIndex };
+}
+
+function isSpringSummerTerm(selectedText) {
+  return /第二学期|春|夏/.test(selectedText || '');
+}
+
+async function resolveTermSelection() {
+  let controlDoc = null;
+  let controlFrame = findControlFrame(window);
+
+  if (controlFrame) {
+    controlDoc = controlFrame.document;
+  } else {
+    controlDoc = await fetchControlDoc();
+  }
+
+  const select = controlDoc.querySelector('#xnxq');
+  if (!select) {
+    throw new Error('未找到学期选择器,请先登录并打开“学生个人课表”页面');
+  }
+
+  const { options, defaultIndex } = parseSelectOptions(select);
+  if (!options.length) {
+    throw new Error('未读取到学期列表,请先进入“学生个人课表”页面');
+  }
+
+  const selectedIndex = await window.AndroidBridgePromise.showSingleSelection(
+    '选择学期',
+    JSON.stringify(options.map(item => item.text)),
+    defaultIndex
+  );
+  if (selectedIndex === null || selectedIndex === -1) {
+    throw new Error('已取消学期选择');
+  }
+
+  const selected = options[selectedIndex];
+  const [xn, xq] = String(selected.value).split('-');
+  if (!xn || typeof xq === 'undefined') {
+    throw new Error(`学期值解析失败: ${selected.value}`);
+  }
+
+  let encodedParams = extractParamsFromHtml(controlDoc.documentElement.outerHTML);
+  if (!encodedParams) {
+    const timetableFrame = findTimetableFrame(window);
+    if (timetableFrame) {
+      const url = new URL(timetableFrame.location.href);
+      encodedParams = url.searchParams.get('params') || '';
+    }
+  }
+
+  const xh = parseXhFromEncodedParams(encodedParams);
+  if (!xh) {
+    throw new Error('未读取到学号参数,请先点击进入“学生个人课表”后再导入');
+  }
+
+  return {
+    xn,
+    xq,
+    xh,
+    selectedValue: selected.value,
+    selectedText: selected.text,
+    isSpringSummer: isSpringSummerTerm(selected.text)
+  };
+}
+
+function findTable(doc) {
+  return doc.getElementById('mytable')
+    || Array.from(doc.querySelectorAll('table')).find(table => {
+      const text = cleanText(table.innerText);
+      return text.includes('星期一') && text.includes('[');
+    })
+    || null;
+}
+
+function parseCellByDivBlocks(cell, day) {
+  const blocks = Array.from(cell.querySelectorAll('div[style*="padding-bottom:5px"], div[style*="padding-bottom: 5px"]'));
+  if (!blocks.length) return [];
+
+  const items = [];
+  blocks.forEach((block) => {
+    const lines = block.innerText
+      .split(/\n+/)
+      .map(cleanText)
+      .filter(Boolean);
+    const joined = lines.join('\n');
+    const match = joined.match(/([\d,-单双]+)\[(\d+)-(\d+)\]/);
+    if (!match) return;
+
+    const before = joined.slice(0, match.index).split(/\n+/).map(cleanText).filter(Boolean);
+    const after = joined.slice(match.index + match[0].length).split(/\n+/).map(cleanText).filter(Boolean);
+
+    const name = before[0] || '';
+    const teacher = before[1] || '';
+    if (!name) return;
+
+    const weeks = parseWeeks(match[1]);
+    const startSection = parseInt(match[2], 10);
+    const endSection = parseInt(match[3], 10);
+    if (!weeks.length) return;
+
+    items.push({
+      name,
+      teacher,
+      position: after.join(' '),
+      day,
+      startSection,
+      endSection,
+      weeks
+    });
+  });
+  return items;
+}
+
+function parseCellByTextFallback(cell, day) {
+  const lines = cell.innerText
+    .split(/\n+/)
+    .map(cleanText)
+    .filter(Boolean);
+  if (!lines.length) return [];
+
+  const items = [];
+  const textLines = lines.map(line => {
+    const m = line.match(/^(.+?)\s+([\d,\-单双]+)\[(\d+)-(\d+)\]\s+(.+)$/);
+    if (m) {
+      return { raw: line, match: { nameTeacher: m[1], weeks: parseWeeks(m[2]), startSection: parseInt(m[3], 10), endSection: parseInt(m[4], 10), position: m[5] } };
+    }
+    return { raw: line, match: null };
+  });
+
+  const timeBlocks = textLines.filter(tl => tl.match);
+  timeBlocks.forEach((tl) => {
+    const m = tl.match;
+    const name = cleanText(m.nameTeacher).replace(/\s+/g, '');
+    items.push({
+      name,
+      teacher: '',
+      position: m.position,
+      day,
+      startSection: m.startSection,
+      endSection: m.endSection,
+      weeks: m.weeks
+    });
+  });
+
+  return items;
+}
+
+function parseAndMergeQingguoTable(doc) {
+  const table = findTable(doc);
+  if (!table) {
+    throw new Error('未找到课表表格,请先进入“学生个人课表”页面');
+  }
+
+  const rawItems = [];
+  Array.from(table.rows).forEach((row) => {
+    const cells = Array.from(row.cells);
+    if (cells.length < 7) return;
+
+    cells.forEach((cell, colIndex) => {
+      const distanceToLast = cells.length - 1 - colIndex;
+      if (distanceToLast > 6) return;
+      const day = 7 - distanceToLast;
+      const rawText = cleanText(cell.innerText);
+      if (!rawText.includes('[')) return;
+
+      const divParsed = parseCellByDivBlocks(cell, day);
+      if (divParsed.length) {
+        rawItems.push(...divParsed);
+        return;
+      }
+
+      const textParsed = parseCellByTextFallback(cell, day);
+      if (textParsed.length) {
+        rawItems.push(...textParsed);
+        return;
+      }
+    });
+  });
+
+  const groupMap = new Map();
+  rawItems.forEach((item) => {
+    if (!item || !item.name || !item.weeks.length) return;
+    const key = `${item.name}|${item.teacher}|${item.position}|${item.day}`;
+    if (!groupMap.has(key)) groupMap.set(key, {});
+    const weekMap = groupMap.get(key);
+    item.weeks.forEach((week) => {
+      if (!weekMap[week]) weekMap[week] = new Set();
+      for (let section = item.startSection; section <= item.endSection; section++) {
+        weekMap[week].add(section);
+      }
+    });
+  });
+
+  const finalCourses = [];
+  groupMap.forEach((weekMap, key) => {
+    const [name, teacher, position, day] = key.split('|');
+    const patternMap = new Map();
+
+    Object.keys(weekMap).forEach((weekStr) => {
+      const week = parseInt(weekStr, 10);
+      const sections = Array.from(weekMap[week]).sort((a, b) => a - b);
+      if (!sections.length) return;
+      let start = sections[0];
+      for (let i = 0; i < sections.length; i++) {
+        if (i === sections.length - 1 || sections[i + 1] !== sections[i] + 1) {
+          const pKey = `${start}-${sections[i]}`;
+          if (!patternMap.has(pKey)) patternMap.set(pKey, []);
+          patternMap.get(pKey).push(week);
+          if (i < sections.length - 1) start = sections[i + 1];
+        }
+      }
+    });
+
+    patternMap.forEach((weeks, patternKey) => {
+      const [startSection, endSection] = patternKey.split('-').map(Number);
+      finalCourses.push({
+        name,
+        teacher,
+        position,
+        day: parseInt(day, 10),
+        startSection,
+        endSection,
+        weeks: weeks.sort((a, b) => a - b)
+      });
+    });
+  });
+
+  return finalCourses;
+}
+
+async function loadTimetableDoc(term) {
+  const currentTableFrame = findTimetableFrame(window);
+  if (currentTableFrame && currentTableFrame.document.getElementById('mytable')) {
+    const currentUrl = new URL(currentTableFrame.location.href);
+    const currentParams = currentUrl.searchParams.get('params') || '';
+    const decoded = decodeParams(currentParams);
+    if (decoded.includes(`xn=${term.xn}`) && decoded.includes(`xq=${term.xq}`)) {
+      return currentTableFrame.document;
+    }
+  }
+  return await fetchTimetableDoc(term.xn, term.xq, term.xh);
+}
+
+async function runImportFlow() {
+  try {
+    const confirmed = await window.AndroidBridgePromise.showAlert(
+      '信阳农林学院教务导入',
+      '请确认你已登录教务系统,并且最好已经打开“学生个人课表”页面。',
+      '确定,开始导入'
+    );
+    if (!confirmed) return;
+
+    const term = await resolveTermSelection();
+    AndroidBridge.showToast('正在提取青果课表数据...');
+
+    const timeSlots = term.isSpringSummer ? TIME_SLOTS_SPRING_SUMMER : TIME_SLOTS_AUTUMN_WINTER;
+
+    const doc = await loadTimetableDoc(term);
+    const courses = parseAndMergeQingguoTable(doc);
+    if (!courses.length) {
+      throw new Error('未找到有效课程,请确认当前学期课表已正常显示');
+    }
+
+    const allWeeks = courses.flatMap(course => course.weeks);
+    const semesterTotalWeeks = allWeeks.length ? Math.max(...allWeeks) : 20;
+
+    await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify({
+      semesterTotalWeeks,
+      semesterStartDate: null,
+      firstDayOfWeek: 1
+    }));
+    await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
+    await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
+
+    AndroidBridge.showToast(`导入成功:共 ${courses.length} 门课程`);
+    AndroidBridge.notifyTaskCompletion();
+  } catch (error) {
+    console.error(error);
+    AndroidBridge.showToast(`导入失败: ${error.message}`);
+  }
+}
+
+runImportFlow();