فهرست منبع

Merge pull request #891 from ShiGuangSchedule/pending

星河欲转 1 هفته پیش
والد
کامیت
0e040306b9
3فایلهای تغییر یافته به همراه291 افزوده شده و 0 حذف شده
  1. 5 0
      index/root_index.yaml
  2. 9 0
      resources/GZTCME/adapters.yaml
  3. 277 0
      resources/GZTCME/gztcme.js

+ 5 - 0
index/root_index.yaml

@@ -1327,3 +1327,8 @@ schools:
     name: "浙江科技大学"
     initial: "Z"
     resource_folder: "ZUST"
+
+  - id: "GZTCME"
+    name: "贵州机电职业技术学院"
+    initial: "G"
+    resource_folder: "GZTCME"

+ 9 - 0
resources/GZTCME/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/GZTCME/adapters.yaml
+adapters:
+  - adapter_id: "GZTCME"
+    adapter_name: "贵州机电职业技术学院"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "gztcme.js"
+    import_url: "https://jwxt.gztcme.cn/jwglxt/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151&layout=default"
+    maintainer: "Panda君"
+    description: "点开直达“个人课表查询”;未登录会先跳登录页,登录后回到后台首页直接点导入即可,不用再翻菜单。可选择学年学期,自动读取校历、校区作息与周次,未排定时间的课程会提示。"

+ 277 - 0
resources/GZTCME/gztcme.js

@@ -0,0 +1,277 @@
+// 贵州机电职业技术学院(gztcme.cn)正方 V9 教务适配
+// 入口:登录后进入「个人课表查询」,再执行导入。
+
+function gztcmeText(value) {
+    return String(value == null ? '' : value).replace(/^\s+|\s+$/g, '');
+}
+
+function gztcmeConfirmed(value) {
+    return value === true || value === 'true';
+}
+
+function gztcmeCheckPage() {
+    if (window.location.pathname.indexOf('/jwglxt/') < 0) {
+        throw new Error('请先在教务系统(jwxt.gztcme.cn)登录并进入“个人课表查询”,再执行导入。');
+    }
+}
+
+async function gztcmeFetchText(path, body) {
+    const init = { method: body ? 'POST' : 'GET', credentials: 'include' };
+    if (body) {
+        init.headers = { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' };
+        init.body = body;
+    }
+    let response;
+    try {
+        response = await fetch('/jwglxt/' + path, init);
+    } catch (error) {
+        throw new Error('请求教务失败,请确认网络通畅并且已经登录教务系统。');
+    }
+    const text = await response.text();
+    if ((response.url || '').indexOf('login_slogin') >= 0 || text.indexOf('login_slogin') >= 0) {
+        throw new Error('登录已过期,请重新登录教务系统后再导入。');
+    }
+    if (!response.ok) throw new Error('教务返回异常(HTTP ' + response.status + '),请稍后重试。');
+    return text;
+}
+
+async function gztcmeFetchJson(path, body) {
+    const text = await gztcmeFetchText(path, body);
+    try {
+        return JSON.parse(text);
+    } catch (error) {
+        throw new Error('教务未返回有效数据,请重新登录后重试。');
+    }
+}
+
+function gztcmeOptions(doc, selector) {
+    const list = [];
+    const nodes = doc.querySelectorAll(selector + ' option');
+    for (let i = 0; i < nodes.length; i++) {
+        const value = gztcmeText(nodes[i].getAttribute('value'));
+        if (!value) continue;
+        list.push({
+            value: value,
+            label: gztcmeText(nodes[i].textContent) || value,
+            selected: nodes[i].selected === true || nodes[i].hasAttribute('selected')
+        });
+    }
+    return list;
+}
+
+async function gztcmePick(title, items, defaultIndex) {
+    const labels = [];
+    for (let i = 0; i < items.length; i++) labels.push(items[i].label);
+    const picked = await window.shiguangBridgePromise.showSingleSelection(title, JSON.stringify(labels), defaultIndex);
+    if (picked === null || picked === undefined) return null;
+    const text = String(picked).toLowerCase();
+    if (text === 'null' || text === '') return null;
+    const index = Number(picked);
+    if (!(index >= 0 && index < items.length)) throw new Error('所选项目无效,请重新导入。');
+    return items[index];
+}
+
+async function gztcmeChooseTerm() {
+    const html = await gztcmeFetchText('kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151&layout=default');
+    const doc = new DOMParser().parseFromString(html, 'text/html');
+    const years = gztcmeOptions(doc, '#xnm');
+    const terms = gztcmeOptions(doc, '#xqm');
+    if (years.length === 0 || terms.length === 0) throw new Error('未取得学年学期列表,请确认已经登录教务系统。');
+
+    let yearIndex = 0, termIndex = 0;
+    for (let i = 0; i < years.length; i++) if (years[i].selected) yearIndex = i;
+    for (let i = 0; i < terms.length; i++) if (terms[i].selected) termIndex = i;
+
+    const year = await gztcmePick('选择学年', years, yearIndex);
+    if (!year) return null;
+    const term = await gztcmePick('选择学期', terms, termIndex);
+    if (!term) return null;
+    return { year: year.value, term: term.value, label: year.label + ' 第 ' + term.label + ' 学期' };
+}
+
+function gztcmeWeeks(value) {
+    const text = gztcmeText(value).replace(/[第周\s]/g, '').replace(/(/g, '(').replace(/)/g, ')').replace(/[,、]/g, ',');
+    const weeks = [], seen = {};
+    const parts = text.split(',');
+    for (let i = 0; i < parts.length; i++) {
+        if (!parts[i]) continue;
+        const match = parts[i].match(/^(\d+)(?:-(\d+))?(?:\(([单双])\))?$/);
+        if (!match) throw new Error('无法识别上课周次「' + value + '」,请核对教务课表。');
+        const from = Number(match[1]), to = Number(match[2] || match[1]);
+        if (from < 1 || to < from || to > 60) throw new Error('上课周次超出有效范围。');
+        for (let week = from; week <= to; week++) {
+            if (match[3] === '单' && week % 2 === 0) continue;
+            if (match[3] === '双' && week % 2 === 1) continue;
+            if (!seen[week]) { seen[week] = true; weeks.push(week); }
+        }
+    }
+    weeks.sort(function (a, b) { return a - b; });
+    if (weeks.length === 0) throw new Error('课程周次为空,请核对教务课表。');
+    return weeks;
+}
+
+function gztcmeCourses(data, term) {
+    if (!data || !Array.isArray(data.kbList) || !data.xsxx) throw new Error('教务未返回课表数据,请重新进入个人课表页面。');
+    if (gztcmeText(data.xsxx.XNM) !== term.year || gztcmeText(data.xsxx.XQM) !== term.term) {
+        throw new Error('课表与所选学期不一致,请重新导入。');
+    }
+    const courses = [], campuses = [];
+    for (let i = 0; i < data.kbList.length; i++) {
+        const row = data.kbList[i];
+        const name = gztcmeText(row.kcmc);
+        if (!name) continue;
+        const day = Number(row.xqj);
+        const section = gztcmeText(row.jcs).replace(/节/g, '').match(/^(\d+)(?:-(\d+))?$/);
+        if (!(day >= 1 && day <= 7) || !section) throw new Error('课程「' + name + '」的星期或节次异常,请核对教务课表。');
+        const startSection = Number(section[1]), endSection = Number(section[2] || section[1]);
+        if (startSection < 1 || endSection < startSection) throw new Error('课程「' + name + '」的节次异常,请核对教务课表。');
+        courses.push({
+            name: name,
+            teacher: gztcmeText(row.xm),
+            position: gztcmeText(row.cdmc),
+            day: day,
+            startSection: startSection,
+            endSection: endSection,
+            weeks: gztcmeWeeks(row.zcd)
+        });
+        const campusId = gztcmeText(row.xqh_id);
+        if (campusId) {
+            let known = false;
+            for (let j = 0; j < campuses.length; j++) if (campuses[j].value === campusId) known = true;
+            if (!known) campuses.push({ value: campusId, label: gztcmeText(row.xqmc) || '校区' });
+        }
+    }
+    if (courses.length === 0) throw new Error('该学期没有可导入的课程,请确认所选学期。');
+    return { courses: courses, campuses: campuses, unscheduled: Array.isArray(data.sjkList) ? data.sjkList : [] };
+}
+
+function gztcmeCalendar(rows) {
+    if (!Array.isArray(rows) || rows.length === 0) throw new Error('未取得所选学期的校历,请确认学期选择。');
+    const weeks = [];
+    for (let i = 0; i < rows.length; i++) {
+        const number = Number(rows[i].zs);
+        const match = gztcmeText(rows[i].rq).match(/^(\d{4}-\d{2}-\d{2})\/(\d{4}-\d{2}-\d{2})$/);
+        if (!(number >= 1) || !match) throw new Error('校历格式异常,无法确定开学日期。');
+        weeks.push({ number: number, start: match[1], end: match[2] });
+    }
+    weeks.sort(function (a, b) { return a.number - b.number; });
+    if (weeks.length > 60) throw new Error('校历周数超出有效范围。');
+
+    const first = new Date(weeks[0].start + 'T00:00:00Z');
+    if (isNaN(first.getTime()) || first.getUTCDay() !== 1) throw new Error('校历第一周不是周一,无法确定开学日期。');
+    for (let i = 0; i < weeks.length; i++) {
+        const start = new Date(weeks[i].start + 'T00:00:00Z');
+        const end = new Date(weeks[i].end + 'T00:00:00Z');
+        if (weeks[i].number !== i + 1) throw new Error('校历周次不连续。');
+        if (isNaN(start.getTime()) || isNaN(end.getTime()) || end < start) throw new Error('校历日期格式异常。');
+        if (start.getTime() - first.getTime() !== i * 7 * 86400000) throw new Error('校历日期与教学周不对应。');
+    }
+    return { semesterStartDate: weeks[0].start, semesterTotalWeeks: weeks.length, firstDayOfWeek: 1 };
+}
+
+function gztcmeClock(value) {
+    const match = gztcmeText(value).match(/^(\d{1,2}):(\d{2})/);
+    if (!match) throw new Error('作息时间格式异常。');
+    return (match[1].length === 1 ? '0' + match[1] : match[1]) + ':' + match[2];
+}
+
+function gztcmeTimeSlots(rows) {
+    const slots = [], seen = {};
+    for (let i = 0; i < rows.length; i++) {
+        const number = Number(rows[i].jcmc);
+        if (!(number >= 1) || seen[number]) continue;
+        seen[number] = true;
+        slots.push({ number: number, startTime: gztcmeClock(rows[i].qssj), endTime: gztcmeClock(rows[i].jssj) });
+    }
+    slots.sort(function (a, b) { return a.number - b.number; });
+    for (let i = 0; i < slots.length; i++) {
+        if (slots[i].number !== i + 1) throw new Error('作息节次编号不连续,请核对教务作息。');
+        if (slots[i].startTime >= slots[i].endTime) throw new Error('作息起止时间异常,请核对教务作息。');
+    }
+    return slots;
+}
+
+async function gztcmeSave(method, value, label) {
+    const result = await window.shiguangBridgePromise[method](JSON.stringify(value));
+    if (!gztcmeConfirmed(result)) throw new Error(label + '保存未成功,请重新导入。');
+}
+
+async function gztcmeRunImport() {
+    let savedStage = '';
+    try {
+        gztcmeCheckPage();
+        window.shiguangBridge.showToast('正在读取学年学期…');
+        const term = await gztcmeChooseTerm();
+        if (!term) { window.shiguangBridge.showToast('导入已取消'); return; }
+
+        window.shiguangBridge.showToast('正在读取课表、校历和作息…');
+        const fetched = await Promise.all([
+            gztcmeFetchJson('kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151', 'xnm=' + term.year + '&xqm=' + term.term + '&kzlx=ck'),
+            gztcmeFetchJson('kbcx/xskbcxZccx_cxZcByXnxq.html?gnmkdm=N2154', 'xnm=' + term.year + '&xqm=' + term.term)
+        ]);
+        const parsed = gztcmeCourses(fetched[0], term);
+        const config = gztcmeCalendar(fetched[1]);
+
+        let campus = parsed.campuses.length > 0 ? parsed.campuses[0] : null;
+        if (parsed.campuses.length > 1) {
+            campus = await gztcmePick('选择课表作息校区', parsed.campuses, 0);
+            if (!campus) { window.shiguangBridge.showToast('导入已取消'); return; }
+        }
+
+        let slots = [];
+        if (campus) {
+            const rows = await gztcmeFetchJson('kbcx/xskbcx_cxRjc.html?gnmkdm=N2151',
+                'xnm=' + term.year + '&xqm=' + term.term + '&xqh_id=' + campus.value);
+            slots = gztcmeTimeSlots(Array.isArray(rows) ? rows : []);
+        }
+
+        let maxSection = 0;
+        const names = [];
+        for (let i = 0; i < parsed.courses.length; i++) {
+            const course = parsed.courses[i];
+            if (course.endSection > maxSection) maxSection = course.endSection;
+            for (let j = 0; j < course.weeks.length; j++) {
+                if (course.weeks[j] > config.semesterTotalWeeks) {
+                    throw new Error('课程「' + course.name + '」周次超出本学期校历(' + config.semesterTotalWeeks + ' 周)。');
+                }
+            }
+            let known = false;
+            for (let j = 0; j < names.length; j++) if (names[j] === course.name) known = true;
+            if (!known) names.push(course.name);
+        }
+        if (slots.length > 0 && maxSection > slots.length) throw new Error('课表节次超出作息节数,请核对教务课表。');
+
+        let note = term.label + '\n' +
+            '课程时段:' + parsed.courses.length + ' 条 / ' + names.length + ' 门课\n' +
+            '开学日期:' + config.semesterStartDate + '(第 1 周周一)\n' +
+            '校历周数:' + config.semesterTotalWeeks + ' 周\n' +
+            '作息节数:' + (slots.length > 0 ? slots.length + ' 节(' + (campus ? campus.label : '') + ')' : '未取到,使用应用默认作息');
+        if (parsed.unscheduled.length > 0) {
+            let pending = '';
+            for (let i = 0; i < parsed.unscheduled.length; i++) {
+                const row = parsed.unscheduled[i];
+                pending += '\n· ' + (gztcmeText(row.kcmc) || '未命名课程') + '(' + (gztcmeText(row.qsjsz) || '周次未知') + ')';
+            }
+            note += '\n\n以下 ' + parsed.unscheduled.length + ' 门课尚未排定上课时间,本次不导入:' + pending;
+        }
+
+        const confirmed = await window.shiguangBridgePromise.showAlert('核对导入信息', note, '确认导入');
+        if (!gztcmeConfirmed(confirmed)) { window.shiguangBridge.showToast('导入已取消'); return; }
+
+        await gztcmeSave('saveCourseConfig', config, '学期配置');
+        savedStage = '学期配置';
+        if (slots.length > 0) {
+            await gztcmeSave('savePresetTimeSlots', slots, '作息');
+            savedStage = '学期配置和作息';
+        }
+        await gztcmeSave('saveImportedCourses', parsed.courses, '课程');
+        window.shiguangBridge.showToast('已导入 ' + parsed.courses.length + ' 条课程时段');
+        window.shiguangBridge.notifyTaskCompletion();
+    } catch (error) {
+        const message = error && error.message ? error.message : String(error);
+        await window.shiguangBridgePromise.showAlert('导入未完成',
+            (savedStage ? savedStage + '已保存,后续步骤未完成,请重新导入。\n' : '') + message, '知道了');
+    }
+}
+
+gztcmeRunImport();