Procházet zdrojové kódy

Merge pull request #319 from XingHeYuZhuan/pending

Pending
星河欲转 před 1 měsícem
rodič
revize
e1cf6ea159

+ 21 - 1
index/root_index.yaml

@@ -505,4 +505,24 @@ schools:
   - id: "CAUC"
     name: "中国民航大学"
     initial: "Z"
-    resource_folder: "CAUC"  
+    resource_folder: "CAUC"
+
+  - id: "ECJTU"
+    name: "华东交通大学"
+    initial: "H"
+    resource_folder: "ECJTU"
+
+  - id: "WENHUA"
+    name: "文华学院"
+    initial: "W"
+    resource_folder: "WENHUA"
+
+  - id: "XYAFU"
+    name: "信阳农林学院"
+    initial: "X"
+    resource_folder: "XYAFU"
+
+  - id: "ZJUT"
+    name: "浙江工业大学"
+    initial: "Z"
+    resource_folder: "ZJUT"

+ 9 - 0
resources/ECJTU/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/ECJTU/adapters.yaml
+adapters:
+  - adapter_id: "ECJTU_01"
+    adapter_name: "华东交通大学教务系统"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "ecjtu_01.js"
+    import_url: "https://jwxt.ecjtu.edu.cn/index.action"
+    maintainer: "glxgo"
+    description: "华东交通大学教务,登录后需要进入教务系统才可以点击导入,非本校开发者适配如果有误建议提交issues"

+ 265 - 0
resources/ECJTU/ecjtu_01.js

@@ -0,0 +1,265 @@
+// 文件: ECJTU_01.js
+// 功能:从华东交通大学系统获取课程表,解析后导入到拾光课程表
+// 适配:华东交通大学教务系统
+// 维护者:glxgo
+
+const BASE = window.location.origin;
+const SCHEDULE_PATHS = [
+  '/Schedule/Schedule_getUserSchedume.action?item=0207',
+  '/Schedule/Schedule_getUserSchedume.action?item=0205',
+  '/Schedule/Schedule_getUserSchedume.action'
+];
+
+function cleanText(value) {
+  return String(value || '')
+    .replace(/[​-‍]/g, '')
+    .replace(/ /g, ' ')
+    .trim();
+}
+
+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 parseSections(sectionStr) {
+  const sections = String(sectionStr || '')
+    .split(',')
+    .map(s => parseInt(s.trim(), 10))
+    .filter(n => !Number.isNaN(n));
+  if (!sections.length) return null;
+  return {
+    startSection: sections[0],
+    endSection: sections[sections.length - 1]
+  };
+}
+
+function parseTeacherPosition(line) {
+  const raw = cleanText(line);
+  const atIndex = raw.indexOf('@');
+  if (atIndex === -1) {
+    return { teacher: raw, position: '' };
+  }
+  return {
+    teacher: cleanText(raw.slice(0, atIndex)),
+    position: cleanText(raw.slice(atIndex + 1))
+  };
+}
+
+function parseCourseLines(lines, day) {
+  const items = [];
+  for (let i = 2; i < lines.length; i++) {
+    const line = cleanText(lines[i]);
+    const match = line.match(/^([\d,,\-单双()]+)\s+(\d+(?:,\d+)*)$/);
+    if (!match) continue;
+
+    const name = cleanText(lines[i - 2]);
+    const teacherPosition = parseTeacherPosition(lines[i - 1]);
+    const weekText = match[1];
+    const sectionText = match[2];
+    const weeks = parseWeeks(weekText);
+    const sections = parseSections(sectionText);
+
+    if (!name || !weeks.length || !sections) continue;
+
+    items.push({
+      name,
+      teacher: teacherPosition.teacher || '未知教师',
+      position: teacherPosition.position || '未排地点',
+      day,
+      startSection: sections.startSection,
+      endSection: sections.endSection,
+      weeks
+    });
+  }
+  return items;
+}
+
+function mergeCourses(rawItems) {
+  const groupMap = new Map();
+  rawItems.forEach((item) => {
+    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.sort((a, b) => (
+    a.day - b.day
+    || a.startSection - b.startSection
+    || a.name.localeCompare(b.name, 'zh-CN')
+  ));
+}
+
+function parseScheduleTable(doc) {
+  const table = doc.getElementById('courseSche');
+  if (!table) return [];
+
+  const rows = Array.from(table.rows);
+  if (rows.length < 2) return [];
+
+  const rawItems = [];
+  for (let r = 1; r < rows.length; r++) {
+    const cells = Array.from(rows[r].cells);
+    if (cells.length < 2) continue;
+
+    const dayCells = cells.slice(1, 8);
+    dayCells.forEach((cell, index) => {
+      const rawText = cleanText(cell.innerText);
+      if (!rawText || !rawText.includes('@')) return;
+      const lines = cell.innerText
+        .split(/\n+/)
+        .map(cleanText)
+        .filter(Boolean);
+      rawItems.push(...parseCourseLines(lines, index + 1));
+    });
+  }
+
+  return mergeCourses(rawItems);
+}
+
+function getCurrentTermInfo(doc) {
+  const select = doc.querySelector('#term');
+  if (!select) return null;
+  const selected = select.querySelector('option:checked') || select.options[select.selectedIndex];
+  return {
+    value: String(select.value || '').trim(),
+    text: selected ? cleanText(selected.textContent) : ''
+  };
+}
+
+function isScheduleDoc(doc) {
+  return !!(doc && (doc.getElementById('courseSche') || doc.querySelector('#term')));
+}
+
+function findScheduleDoc(win) {
+  try {
+    if (isScheduleDoc(win.document)) return win.document;
+  } catch (_) {}
+  for (let i = 0; i < win.frames.length; i++) {
+    try {
+      const found = findScheduleDoc(win.frames[i]);
+      if (found) return found;
+    } catch (_) {}
+  }
+  return null;
+}
+
+async function fetchScheduleDoc() {
+  for (const path of SCHEDULE_PATHS) {
+    try {
+      const res = await fetch(`${BASE}${path}`, { credentials: 'include' });
+      if (!res.ok) continue;
+      const html = await res.text();
+      const doc = new DOMParser().parseFromString(html, 'text/html');
+      if (isScheduleDoc(doc)) return doc;
+    } catch (_) {}
+  }
+  return null;
+}
+
+async function loadScheduleDoc() {
+  const currentDoc = findScheduleDoc(window);
+  if (currentDoc && currentDoc.getElementById('courseSche')) return currentDoc;
+  const fetched = await fetchScheduleDoc();
+  if (fetched) return fetched;
+  throw new Error('未找到课表页面,请先登录后进入“我的课表/个人课表”页面');
+}
+
+async function runImportFlow() {
+  try {
+    const confirmed = await window.AndroidBridgePromise.showAlert(
+      '华东交通大学教务导入',
+      '请确认你已经登录教务系统;如需导入其他学期,请先在页面上切换到目标学期后再导入。',
+      '确定,开始导入'
+    );
+    if (!confirmed) return;
+
+    const doc = await loadScheduleDoc();
+    const termInfo = getCurrentTermInfo(doc);
+    AndroidBridge.showToast(termInfo?.text ? `正在导入 ${termInfo.text} 课表...` : '正在解析课表数据...');
+
+    const courses = parseScheduleTable(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.saveImportedCourses(JSON.stringify(courses));
+
+    AndroidBridge.showToast(`导入成功:共 ${courses.length} 门课程`);
+    AndroidBridge.notifyTaskCompletion();
+  } catch (error) {
+    console.error(error);
+    AndroidBridge.showToast(`导入失败: ${error.message}`);
+  }
+}
+
+runImportFlow();

+ 9 - 0
resources/WENHUA/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/WENHUA/adapters.yaml
+adapters:
+  - adapter_id: "WENHUA_01"
+    adapter_name: "文华学院正方教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "wenhua_01.js"
+    import_url: "https://jw1.hustwenhua.net/jwglxt/xtgl/login_slogin.html"
+    maintainer: "glxgo"
+    description: "文华学院教务,登录后需要进入教务系统才可以点击导入,非本校开发者适配如果有误建议提交issues"

+ 211 - 0
resources/WENHUA/wenhua_01.js

@@ -0,0 +1,211 @@
+// 文件: WENHUA_01.js
+// 功能:从文华学院正方教务系统获取课程表,解析后导入到拾光课程表
+// 适配:文华学院正方教务系统
+// 维护者:glxgo
+
+const BASE = `${window.location.origin}/jwglxt`;
+const INDEX_PATH = '/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151&layout=default';
+const COURSE_API_PATH = '/kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151';
+const TIME_API_PATH = '/kbcx/xskbcx_cxRjc.html?gnmkdm=N2151';
+
+async function req(url, method = 'GET', body) {
+  const res = await fetch(url, {
+    method,
+    credentials: 'include',
+    headers: {
+      'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
+      'x-requested-with': 'XMLHttpRequest',
+      'accept': '*/*'
+    },
+    body
+  });
+  if (!res.ok) throw new Error(`请求失败: ${res.status}`);
+  return await res.text();
+}
+
+function isOnTimetablePage() {
+  return window.location.pathname === '/jwglxt/kbcx/xskbcx_cxXskbcxIndex.html';
+}
+
+function readCurrentPageTerm() {
+  const xnmEl = document.querySelector('#xnm');
+  const xqmEl = document.querySelector('#xqm');
+  const xnm = xnmEl ? String(xnmEl.value || '').trim() : '';
+  const xqm = xqmEl ? String(xqmEl.value || '').trim() : '';
+  if (!xnm || !xqm) throw new Error('当前课表页未读到学年学期,请先选择后再导入');
+  return { xnm, xqm };
+}
+
+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 = String(opt.textContent || '').trim() || value;
+    if (opt.selected) defaultIndex = options.length;
+    options.push({ value, text });
+  });
+  return { options, defaultIndex };
+}
+
+function parseTermOptionsFromDoc(doc) {
+  const yearData = parseSelectOptions(doc.querySelector('#xnm'));
+  const semesterData = parseSelectOptions(doc.querySelector('#xqm'));
+  if (!yearData.options.length || !semesterData.options.length) {
+    throw new Error('课表页学年学期选项解析失败');
+  }
+  return { yearData, semesterData };
+}
+
+async function fetchIndexDoc() {
+  const html = await fetch(`${BASE}${INDEX_PATH}`, { credentials: 'include' }).then(res => {
+    if (!res.ok) throw new Error(`课表页请求失败: ${res.status}`);
+    return res.text();
+  });
+  return new DOMParser().parseFromString(html, 'text/html');
+}
+
+async function selectTermByUserFromDoc(doc) {
+  const { yearData, semesterData } = parseTermOptionsFromDoc(doc);
+
+  const yearIndex = await window.AndroidBridgePromise.showSingleSelection(
+    '选择学年',
+    JSON.stringify(yearData.options.map(item => item.text)),
+    yearData.defaultIndex
+  );
+  if (yearIndex === null || yearIndex === -1) throw new Error('已取消学年选择');
+
+  const semesterIndex = await window.AndroidBridgePromise.showSingleSelection(
+    '选择学期',
+    JSON.stringify(semesterData.options.map(item => item.text)),
+    semesterData.defaultIndex
+  );
+  if (semesterIndex === null || semesterIndex === -1) throw new Error('已取消学期选择');
+
+  return {
+    xnm: yearData.options[yearIndex].value,
+    xqm: semesterData.options[semesterIndex].value
+  };
+}
+
+async function resolveTerm() {
+  if (isOnTimetablePage()) {
+    return readCurrentPageTerm();
+  }
+  const doc = await fetchIndexDoc();
+  return await selectTermByUserFromDoc(doc);
+}
+
+function parseWeeks(zcd) {
+  if (!zcd) return [];
+  const result = new Set();
+  String(zcd).replace(/\s+/g, '').split(/[,,]/).forEach((seg) => {
+    const odd = seg.includes('单');
+    const even = seg.includes('双');
+    const normalized = seg.replace(/周|\(|\)|单|双/g, '');
+    const match = normalized.match(/(\d+)(?:-(\d+))?/);
+    if (!match) return;
+    const start = Number(match[1]);
+    const end = Number(match[2] || match[1]);
+    for (let week = start; week <= end; week++) {
+      if (odd && week % 2 === 0) continue;
+      if (even && week % 2 !== 0) continue;
+      result.add(week);
+    }
+  });
+  return [...result].sort((a, b) => a - b);
+}
+
+function parseCourses(data) {
+  if (!data || !Array.isArray(data.kbList)) {
+    return { courses: [], xqhId: '1' };
+  }
+
+  const courses = [];
+  let xqhId = '1';
+
+  data.kbList.forEach((course) => {
+    if (course.xqh_id) xqhId = String(course.xqh_id).trim() || xqhId;
+
+    const day = Number(course.xqj);
+    const secRaw = String(course.jcs || course.jc || '').replace(/节/g, '').trim();
+    const sectionNums = (secRaw.match(/\d+/g) || []).map(Number).filter(n => !Number.isNaN(n));
+    const weeks = parseWeeks(course.zcd);
+
+    if (!course.kcmc || !sectionNums.length || !weeks.length || !(day >= 1 && day <= 7)) return;
+
+    courses.push({
+      name: String(course.kcmc).trim(),
+      teacher: String(course.xm || '未知').trim(),
+      position: String(course.cdmc || course.cdbh || '未排地点').trim(),
+      day,
+      startSection: sectionNums[0],
+      endSection: sectionNums[sectionNums.length - 1],
+      weeks
+    });
+  });
+
+  const deduped = new Map();
+  courses.forEach((course) => {
+    const key = `${course.name}|${course.teacher}|${course.position}|${course.day}|${course.startSection}|${course.endSection}|${course.weeks.join(',')}`;
+    if (!deduped.has(key)) deduped.set(key, course);
+  });
+
+  return { courses: [...deduped.values()], xqhId };
+}
+
+function parseTimeSlots(data) {
+  if (!Array.isArray(data) || !data.length) throw new Error('未获取到节次时间数据');
+  return data.map((item) => ({
+    number: Number(item.jcmc),
+    startTime: String(item.qssj || '').trim(),
+    endTime: String(item.jssj || '').trim()
+  })).filter(item => item.number > 0 && item.startTime && item.endTime);
+}
+
+async function fetchCourses(xnm, xqm) {
+  const body = `xnm=${encodeURIComponent(xnm)}&xqm=${encodeURIComponent(xqm)}&kzlx=ck&xsdm=&kclbdm=&kclxdm=`;
+  const text = await req(`${BASE}${COURSE_API_PATH}`, 'POST', body);
+  return JSON.parse(text);
+}
+
+async function fetchTimeSlots(xnm, xqm) {
+  const body = `xnm=${encodeURIComponent(xnm)}&xqm=${encodeURIComponent(xqm)}`;
+  const text = await req(`${BASE}${TIME_API_PATH}`, 'POST', body);
+  return parseTimeSlots(JSON.parse(text));
+}
+
+async function run() {
+  try {
+    const { xnm, xqm } = await resolveTerm();
+    AndroidBridge.showToast('正在解析课表数据...');
+
+    const rawData = await fetchCourses(xnm, xqm);
+    const { courses, xqhId } = parseCourses(rawData);
+    if (!courses.length) throw new Error('未获取到课表数据');
+    const timeSlots = await fetchTimeSlots(xnm, xqm).catch(() => null);
+
+    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
+    }));
+    if (timeSlots && timeSlots.length) {
+      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}`);
+  }
+}
+
+run();

+ 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();

+ 9 - 0
resources/ZJUT/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/ZJUT/adapters.yaml
+adapters:
+  - adapter_id: "ZJUT_01"
+    adapter_name: "浙江工业大学正方教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "zjut_01.js"
+    import_url: "http://www.gdjw.zjut.edu.cn/jwglxt/xtgl/login_slogin.html?kickout=1"
+    maintainer: "glxgo"
+    description: "浙江工业大学教务,登录后需要进入教务系统才可以点击导入,非本校开发者适配如果有误建议提交issues"

+ 212 - 0
resources/ZJUT/zjut_01.js

@@ -0,0 +1,212 @@
+// 文件: ZJUT_01.js
+// 功能:从浙江工业大学正方教务系统获取课程表,解析后导入到拾光课程表
+// 适配:浙江工业大学正方教务系统
+// 维护者:glxgo
+
+const BASE = `${window.location.origin}/jwglxt`;
+const GNMKDM = 'N253508';
+const INDEX_PATH = `/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=${GNMKDM}&layout=default`;
+const COURSE_API_PATH = `/kbcx/xskbcx_cxXsgrkb.html?gnmkdm=${GNMKDM}`;
+const TIME_API_PATH = `/kbcx/xskbcx_cxRjc.html?gnmkdm=${GNMKDM}`;
+
+async function req(url, method = 'GET', body) {
+  const res = await fetch(url, {
+    method,
+    credentials: 'include',
+    headers: {
+      'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
+      'x-requested-with': 'XMLHttpRequest',
+      'accept': '*/*'
+    },
+    body
+  });
+  if (!res.ok) throw new Error(`请求失败: ${res.status}`);
+  return await res.text();
+}
+
+function isOnTimetablePage() {
+  return window.location.pathname === '/jwglxt/kbcx/xskbcx_cxXskbcxIndex.html';
+}
+
+function readCurrentPageTerm() {
+  const xnmEl = document.querySelector('#xnm');
+  const xqmEl = document.querySelector('#xqm');
+  const xnm = xnmEl ? String(xnmEl.value || '').trim() : '';
+  const xqm = xqmEl ? String(xqmEl.value || '').trim() : '';
+  if (!xnm || !xqm) throw new Error('当前课表页未读到学年学期,请先选择后再导入');
+  return { xnm, xqm };
+}
+
+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 = String(opt.textContent || '').trim() || value;
+    if (opt.selected) defaultIndex = options.length;
+    options.push({ value, text });
+  });
+  return { options, defaultIndex };
+}
+
+function parseTermOptionsFromDoc(doc) {
+  const yearData = parseSelectOptions(doc.querySelector('#xnm'));
+  const semesterData = parseSelectOptions(doc.querySelector('#xqm'));
+  if (!yearData.options.length || !semesterData.options.length) {
+    throw new Error('课表页学年学期选项解析失败');
+  }
+  return { yearData, semesterData };
+}
+
+async function fetchIndexDoc() {
+  const html = await fetch(`${BASE}${INDEX_PATH}`, { credentials: 'include' }).then(res => {
+    if (!res.ok) throw new Error(`课表页请求失败: ${res.status}`);
+    return res.text();
+  });
+  return new DOMParser().parseFromString(html, 'text/html');
+}
+
+async function selectTermByUserFromDoc(doc) {
+  const { yearData, semesterData } = parseTermOptionsFromDoc(doc);
+
+  const yearIndex = await window.AndroidBridgePromise.showSingleSelection(
+    '选择学年',
+    JSON.stringify(yearData.options.map(item => item.text)),
+    yearData.defaultIndex
+  );
+  if (yearIndex === null || yearIndex === -1) throw new Error('已取消学年选择');
+
+  const semesterIndex = await window.AndroidBridgePromise.showSingleSelection(
+    '选择学期',
+    JSON.stringify(semesterData.options.map(item => item.text)),
+    semesterData.defaultIndex
+  );
+  if (semesterIndex === null || semesterIndex === -1) throw new Error('已取消学期选择');
+
+  return {
+    xnm: yearData.options[yearIndex].value,
+    xqm: semesterData.options[semesterIndex].value
+  };
+}
+
+async function resolveTerm() {
+  if (isOnTimetablePage()) {
+    return readCurrentPageTerm();
+  }
+  const doc = await fetchIndexDoc();
+  return await selectTermByUserFromDoc(doc);
+}
+
+function parseWeeks(zcd) {
+  if (!zcd) return [];
+  const result = new Set();
+  String(zcd).replace(/\s+/g, '').split(/[,,]/).forEach((seg) => {
+    const odd = seg.includes('单');
+    const even = seg.includes('双');
+    const normalized = seg.replace(/周|\(|\)|单|双/g, '');
+    const match = normalized.match(/(\d+)(?:-(\d+))?/);
+    if (!match) return;
+    const start = Number(match[1]);
+    const end = Number(match[2] || match[1]);
+    for (let week = start; week <= end; week++) {
+      if (odd && week % 2 === 0) continue;
+      if (even && week % 2 !== 0) continue;
+      result.add(week);
+    }
+  });
+  return [...result].sort((a, b) => a - b);
+}
+
+function parseCourses(data) {
+  if (!data || !Array.isArray(data.kbList)) {
+    return { courses: [], xqhId: '01' };
+  }
+
+  const courses = [];
+  let xqhId = '01';
+
+  data.kbList.forEach((course) => {
+    if (course.xqh_id) xqhId = String(course.xqh_id).trim() || xqhId;
+
+    const day = Number(course.xqj);
+    const secRaw = String(course.jcs || course.jc || '').replace(/节/g, '').trim();
+    const sectionNums = (secRaw.match(/\d+/g) || []).map(Number).filter(n => !Number.isNaN(n));
+    const weeks = parseWeeks(course.zcd);
+
+    if (!course.kcmc || !sectionNums.length || !weeks.length || !(day >= 1 && day <= 7)) return;
+
+    courses.push({
+      name: String(course.kcmc).trim(),
+      teacher: String(course.xm || '未知').trim(),
+      position: String(course.cdmc || course.cdbh || '未排地点').trim(),
+      day,
+      startSection: sectionNums[0],
+      endSection: sectionNums[sectionNums.length - 1],
+      weeks
+    });
+  });
+
+  const deduped = new Map();
+  courses.forEach((course) => {
+    const key = `${course.name}|${course.teacher}|${course.position}|${course.day}|${course.startSection}|${course.endSection}|${course.weeks.join(',')}`;
+    if (!deduped.has(key)) deduped.set(key, course);
+  });
+
+  return { courses: [...deduped.values()], xqhId };
+}
+
+function parseTimeSlots(data) {
+  if (!Array.isArray(data) || !data.length) throw new Error('未获取到节次时间数据');
+  return data.map((item) => ({
+    number: Number(item.jcmc),
+    startTime: String(item.qssj || '').trim(),
+    endTime: String(item.jssj || '').trim()
+  })).filter(item => item.number > 0 && item.startTime && item.endTime);
+}
+
+async function fetchCourses(xnm, xqm) {
+  const body = `xnm=${encodeURIComponent(xnm)}&xqm=${encodeURIComponent(xqm)}&kzlx=ck&xsdm=&kclbdm=&kclxdm=`;
+  const text = await req(`${BASE}${COURSE_API_PATH}`, 'POST', body);
+  return JSON.parse(text);
+}
+
+async function fetchTimeSlots(xnm, xqm) {
+  const body = `xnm=${encodeURIComponent(xnm)}&xqm=${encodeURIComponent(xqm)}`;
+  const text = await req(`${BASE}${TIME_API_PATH}`, 'POST', body);
+  return parseTimeSlots(JSON.parse(text));
+}
+
+async function run() {
+  try {
+    const { xnm, xqm } = await resolveTerm();
+    AndroidBridge.showToast('正在解析课表数据...');
+
+    const rawData = await fetchCourses(xnm, xqm);
+    const { courses, xqhId } = parseCourses(rawData);
+    if (!courses.length) throw new Error('未获取到课表数据');
+    const timeSlots = await fetchTimeSlots(xnm, xqm).catch(() => null);
+
+    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
+    }));
+    if (timeSlots && timeSlots.length) {
+      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}`);
+  }
+}
+
+run();