Greasy Fork

来自缓存

Greasy Fork is available in English.

SEU劳动教育课程推送助手

东南大学劳动教育选课神器!实时监控新增课程并微信推送,移动端后台稳定运行,当然你也可以选择在电脑上安装

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         SEU劳动教育课程推送助手
// @namespace    http://tampermonkey.net/
// @version      2.6
// @license      MIT
// @description  东南大学劳动教育选课神器!实时监控新增课程并微信推送,移动端后台稳定运行,当然你也可以选择在电脑上安装
// @author       zz6zz666@github with AI support
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_openInTab
// @run-at       document-end
// @connect      www.pushplus.plus
// ==/UserScript==

(function () {
    'use strict';

    // ==================== 全局配置区(用户可自定义)====================
    // 【登录与推送配置】
    const USERNAME = '12345678';       // 替换为你的一卡通号
    const PASSWORD = 'abc123456';      // 替换为你的密码
    const PUSHPLUS_TOKEN = 'ce0**********************************11'; // 替换为你的PushPlus Token
    const PUSH_TITLE = '劳动教育课程推送'; // 微信推送标题
    const LOCATION_FILTERS = [];       // 校区筛选,如['四牌楼校区', '九龙湖校区'],为空则不筛选
    const CATEGORY_FILTERS = [];       // 劳动类别筛选,如['服务劳动'],为空则不筛选(完全匹配)
    const REFRESH_INTERVAL = 3 * 60 * 1000; // 选课页自动刷新间隔(单位:毫秒)
    const LOGIN_TIMEOUT = 10 * 1000;  // 登录超时检测时间(单位:毫秒)
    const LOGIN_DISABLE_DURATION = 15 * 60 * 1000; // 登录失败后禁用自动登录时长(单位:毫秒)

    // 【后台页面活性维持配置】
    const COOLDOWN = 180 * 1000;        // 冷却时间(单位:毫秒)
    const HEARTBEAT_INTERVAL = 15 * 1000;   // 心跳间隔
    const CHECK_INTERVAL = 60 * 1000;       // 检查间隔
    const HEARTBEAT_URL = 'https://labor.seu.edu.cn/favicon.ico'; // 用于心跳的轻量资源
    // =================================================================

    // ==================== 工具函数 ====================
    function pushToWechat(title, content) {
        if (!PUSHPLUS_TOKEN) {
            console.error('【错误】请填写PushPlus Token');
            return;
        }
        GM_xmlhttpRequest({
            method: "POST",
            url: 'http://www.pushplus.plus/send',
            headers: {'Content-Type': 'application/json'},
            data: JSON.stringify({
                'token': PUSHPLUS_TOKEN,
                'title': title,
                'content': content,
                'template': 'markdown'
            }),
            onload: function(response) {
                console.log('%c【推送成功】', 'color:green; font-weight:bold;');
            },
            onerror: function(error) {
                console.error('【推送失败】', error);
            }
        });
    }

    function shouldDisableLogin() {
        const isLoginFailed = GM_getValue('loginFailStatus', true);
        if (!isLoginFailed) {
            return false;
        }
        
        // 有失败标志时,再判断是否在冷却期内
        const lastFailTime = GM_getValue('loginFailTime', 0);
        const now = Date.now();
        return (now - lastFailTime) < LOGIN_DISABLE_DURATION;
    }
    // =================================================

    // ==================== 移动端活性维持增强逻辑 ====================
    const currentUrl = window.location.href;
    const LOGIN_LABOR_URL = "https://auth.seu.edu.cn/dist/#/dist/main/login?service=https://labor.seu.edu.cn/UnifiedAuth/CASLogin";

    /**
     * 发送轻量心跳请求(兼容移动端后台)
     * 利用浏览器对同域请求的优先级优待,减少被休眠的概率
     */
    function sendHeartbeat() {
        // 带时间戳避免缓存,确保请求实际发送
        const url = `${HEARTBEAT_URL}?ts=${Date.now()}`;
        return fetch(url, {
            method: 'HEAD', // 只请求头,减少数据传输
            keepalive: true, // 确保页面隐藏时仍能发送
            cache: 'no-store'
        }).then(() => {
            const now = Date.now();
            GM_setValue('lastTargetActive', now);
            console.log(`[心跳成功] ${new Date(now).toLocaleTimeString()}`);
        }).catch(err => {
            console.log(`[心跳失败] 重试中... ${err.message}`);
            // 失败时立即重试一次
            setTimeout(sendHeartbeat, 3000);
        });
    }

    /**
     * 增强版目标页面活性维持
     * 结合心跳请求+定时器策略,对抗移动端后台休眠
     */
    function handleTargetPage() {
        // 立即发送一次心跳初始化
        sendHeartbeat();

        // 核心:使用不等间隔的定时器,避免浏览器识别为周期性任务而延迟
        let intervalOffset = 0; // 动态偏移量,避免固定间隔被优化
        const startHeartbeatLoop = () => {
            // 每次间隔在基础时间上±10%波动,减少规律性
            const randomOffset = Math.floor(HEARTBEAT_INTERVAL * (0.9 + Math.random() * 0.2));
            intervalOffset = (intervalOffset + 1) % 5; // 避免偏移累积

            const timer = setTimeout(() => {
                // 检测页面可见性,可见时额外更新一次状态
                if (document.visibilityState === 'visible') {
                    GM_setValue('lastTargetActive', Date.now());
                }
                sendHeartbeat();
                startHeartbeatLoop(); // 递归调用,保持循环
            }, randomOffset + intervalOffset * 100);

            // 监听页面可见性变化,立即发送心跳
            document.addEventListener('visibilitychange', () => {
                if (document.visibilityState === 'visible') {
                    clearTimeout(timer);
                    sendHeartbeat();
                    startHeartbeatLoop();
                }
            }, { once: true });
        };

        startHeartbeatLoop();
    }

    /**
     * 非目标页面检查逻辑
     */
    function handleNonTargetPage() {
        if (shouldDisableLogin()) {
            console.log('[页面活性] 登录失败冷却期内,暂不创建新标签页');
            return;
        }
        function checkAndCreate() {
            const lastActive = GM_getValue('lastTargetActive', 0);
            const now = Date.now();
            const timeSinceLast = now - lastActive;

            if (timeSinceLast > COOLDOWN) {
                console.log(`[页面活性] 超过${COOLDOWN/1000}秒无目标页面活跃,创建新标签页`);
                GM_openInTab(LOGIN_LABOR_URL, {
                    active: false,
                    insert: true
                });
                GM_setValue('lastTargetActive', now);
            } else {
                console.log(`[页面活性] 目标页面活跃,剩余冷却:${Math.floor((COOLDOWN - timeSinceLast)/1000)}秒`);
            }
        }
        checkAndCreate();
        setInterval(checkAndCreate, CHECK_INTERVAL);
    }

    // 启动活性维持逻辑
    if (currentUrl.includes('labor.seu.edu.cn')) {
        handleTargetPage();
    } else {
        handleNonTargetPage();
    }
    // ==============================================================

    // ==================== 自动登录与选课跳转逻辑 ====================
    let loginTimer = null;
    function handleLoginPage() {
        if (shouldDisableLogin()) {
            const lastFailTime = new Date(GM_getValue('loginFailTime', 0)).toLocaleString();
            console.log(`[自动登录] 登录失败后禁用期内(上次失败时间:${lastFailTime}),暂不执行自动登录`);
            return;
        }

        console.log('[自动登录] 检测到登录页,开始自动登录...');

        let loginSuccess = false;

        if (loginTimer) {
            clearTimeout(loginTimer);
            loginTimer = null;
        }
        loginTimer = setTimeout(() => {
            // 超时后先检测是否停留在登录成功哈希页
            if (window.location.hash === '#/dist/LoginSuccess') {
                loginSuccess = true;
                GM_setValue('loginFailStatus', false);
                GM_setValue('loginFailTime', 0);
                console.log('%c[登录成功] 检测到登录成功页,未超时', 'color: green; font-weight: bold');
            }

            if (!loginSuccess) {
                console.error('[自动登录] 登录超时,未检测到跳转或成功页');
                GM_setValue('loginFailStatus', true);
                GM_setValue('loginFailTime', Date.now());
                pushToWechat('课程推送登录失效提醒',
                             `## 统一身份认证登录超时\n\n⚠️ 登录尝试超过${LOGIN_TIMEOUT/1000}秒未跳转,可能是以下原因:\n1. 需要短信验证码\n2. 账号密码错误\n3. 系统临时故障\n\n请手动登录检查状态\n时间:${new Date().toLocaleString()}`);
            }
        }, LOGIN_TIMEOUT);

        // 监听页面跳转判断登录成功
        const originalPushState = history.pushState;
        history.pushState = function(...args) {
            loginSuccess = true;
            clearTimeout(loginTimer);
            loginTimer = null;
            return originalPushState.apply(this, args);
        };

        window.addEventListener('beforeunload', () => {
            loginSuccess = true;
            clearTimeout(loginTimer);
            loginTimer = null;
        });

        // 等待登录元素加载
        const waitForElements = () => {
            // 适配PC和移动端的用户名输入框
            const usernameInput = document.querySelector('input.input-username-pc[type="text"]') ||
                                document.querySelector('input.input-username-mobile[type="text"]') ||
                                document.querySelector('input[type="text"][placeholder*="一卡通号"], input[type="text"][placeholder*="学号"]');

            // 适配PC和移动端的密码输入框
            const passwordInput = document.querySelector('input[type="password"]') ||
                                document.querySelector('input.input-password-pc') ||
                                document.querySelector('input.input-password-mobile input.ant-input');

            // 适配PC和移动端的登录按钮
            const loginButton = document.querySelector('button.login-button-pc') ||
                                document.querySelector('button[type="button"].ant-btn-primary') ||
                                document.querySelector('button[type="button"]');

            if (!usernameInput || !passwordInput || !loginButton) {
                console.log('[自动登录] 元素未找到,500ms 后重试...');
                setTimeout(waitForElements, 500);
                return;
            }

            console.log('[自动登录] 找到输入框,等待 1 秒后输入信息...');

            setTimeout(() => {
                // 强制设置输入值(兼容React等框架)
                const forceSetValue = (input, value) => {
                    const lastValue = input.value;
                    input.value = value;
                    const event = new Event('input', { bubbles: true });
                    event.simulated = true;
                    const tracker = input._valueTracker;
                    if (tracker) tracker.setValue(lastValue);
                    input.dispatchEvent(event);
                };

                forceSetValue(usernameInput, USERNAME);
                forceSetValue(passwordInput, PASSWORD);

                setTimeout(() => {
                    if (!loginButton.disabled) {
                        console.log('[自动登录] 点击登录按钮...');
                        loginButton.click();
                    } else {
                        console.log('[自动登录] 登录按钮禁用,1秒后重试点击...');
                        setTimeout(() => loginButton.click(), 1000);
                    }
                }, 500);
            }, 1000);
        };

        setTimeout(waitForElements, 1000);
    }

    function handleLaborHomePage() {
        console.log('[页面跳转] 检测到劳动教育首页,准备跳转选课页...');

        // 清除登录失败状态
        GM_setValue('loginFailStatus', false);
        GM_setValue('loginFailTime', 0);

        const targetUrl = 'https://labor.seu.edu.cn/SJItemKaiKe/XuanKe/Index';
        setTimeout(() => {
            console.log('[页面跳转] 正在跳转到选课页:', targetUrl);
            window.location.href = targetUrl;
        }, 1000);
    }
    // ==============================================================

    // ==================== 课程监控与推送逻辑 ====================
    function getWeekday(dateStr) {
        if (!dateStr) return '';
        const dateMatch = dateStr.match(/\d{4}-\d{2}-\d{2}/);
        if (!dateMatch) return '';
        const date = new Date(dateMatch[0]);
        return isNaN(date.getTime()) ? '' : ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][date.getDay()];
    }

    const cleanText = (text) => text ? text.trim().replace(/\s+/g, ' ') : '无';

    function extractCourseInfo(row) {
        const isPureNumber = (text) => /^\d+$/.test(text.trim());
        const col1Text = cleanText(row.querySelector('td:nth-child(1)')?.textContent || '');
        const col2Text = cleanText(row.querySelector('td:nth-child(2)')?.textContent || '');
        const isIndexInCol1 = isPureNumber(col1Text) && !isPureNumber(col2Text);
        const offset = isIndexInCol1 ? 0 : 1;

        const originalTime = cleanText(row.querySelector(`td:nth-child(${8 + offset})`).textContent);
        const weekday = getWeekday(originalTime);
        const 选课状态 = cleanText(row.querySelector(`td:nth-child(${10 + offset})`).textContent);
        const 截止状态 = cleanText(row.querySelector(`td:nth-child(${9 + offset})`).textContent);
        const 开课地点 = cleanText(row.querySelector(`td:nth-child(${7 + offset}) .limit-line`)?.textContent);
        const 项目名称 = cleanText(row.querySelector(`td:nth-child(${3 + offset})`).textContent);
        const 实施时间 = weekday ? `${originalTime}(${weekday})` : originalTime;

        const uniqueId = `${项目名称}|${实施时间}`;
        const isFull = 选课状态.includes('已满');
        const isExpired = 截止状态.includes('已截止');
        const isInvalid = isFull || isExpired;

        const locationMatch = LOCATION_FILTERS.length === 0
            ? true
            : LOCATION_FILTERS.some(filter => 开课地点?.includes(filter));
        const categoryMatch = CATEGORY_FILTERS.length === 0
            ? true
            : CATEGORY_FILTERS.some(filter => 项目类别 === filter);

        return {
            uniqueId,
            序号: isIndexInCol1 ? col1Text : col2Text,
            项目名称,
            项目类别: cleanText(row.querySelector(`td:nth-child(${4 + offset})`).textContent),
            开课地点,
            实施时间,
            选课截止时间: 截止状态,
            选课人数_容纳人数: 选课状态,
            授课教师: cleanText(row.querySelector(`td:nth-child(${15 + offset})`).textContent),
            isInvalid,
            locationMatch,
            categoryMatch
        };
    }

    function handleCoursePage() {
        console.log('[课程监控] 检测到选课页面,开始处理选课信息...');

        GM_setValue('loginFailStatus', false);
        GM_setValue('loginFailTime', 0);

        window.addEventListener('load', function() {
            const courseTable = document.getElementById('c_app_page_index_XuanKe_table');
            if (!courseTable) {
                console.error('[课程监控] 错误:未找到课程表格,请先登录系统');
                return;
            }
            const courseRows = courseTable.querySelectorAll('tbody .c--tr');
            if (courseRows.length === 0) {
                console.log('[课程监控] 提示:当前无课程数据或页面未加载完成');
                return;
            }

            let allCourses = Array.from(courseRows).map(row => extractCourseInfo(row));
            const validCourses = allCourses.filter(
                course => course.locationMatch && course.categoryMatch && !course.isInvalid);

            console.log('%c[课程监控] 符合推送条件的课程', 'color:#2E86AB; font-weight:bold;');
            console.log(`共 ${validCourses.length} 门`, validCourses);

            const storedUniqueIds = GM_getValue('pushedCourseUniqueIds', []);
            let pushedUniqueIds = new Set(storedUniqueIds);
            const newCourses = validCourses.filter(course => !pushedUniqueIds.has(course.uniqueId));

            // 清理过期课程
            const allCurrentUniqueIds = new Set(allCourses.map(c => c.uniqueId));
            const expiredUniqueIds = Array.from(pushedUniqueIds).filter(id => !allCurrentUniqueIds.has(id) ||
                allCourses.find(c => c.uniqueId === id)?.isInvalid ||
                !allCourses.find(c => c.uniqueId === id)?.locationMatch
            );
            expiredUniqueIds.forEach(id => pushedUniqueIds.delete(id));
            GM_setValue('pushedCourseUniqueIds', Array.from(pushedUniqueIds));

            // 推送新课程
            if (newCourses.length > 0) {
                console.log(`%c[课程监控] 发现 ${newCourses.length} 门新课程,准备推送`, 'color:green;');
                const formatToMarkdown = (courses) => {
                    let md = '| 序号 | 项目名称 | 项目类别 | 实施时间 | 开课地点 | 选课情况 | 教师 |\n';
                    md += '|------|----------|----------|----------|----------|----------|------|\n';
                    courses.forEach(course => {
                        md += `| ${course.序号} | ${course.项目名称} | ${course.项目类别} | ${course.实施时间} | ${course.开课地点} | ${course.选课人数_容纳人数} | ${course.授课教师} |\n`;
                    });
                    return md + `\n提取时间:${new Date().toLocaleString()}`;
                };
                pushToWechat(PUSH_TITLE, formatToMarkdown(newCourses));
                newCourses.forEach(course => pushedUniqueIds.add(course.uniqueId));
                GM_setValue('pushedCourseUniqueIds', Array.from(pushedUniqueIds));
            } else {
                console.log('[课程监控] 无新增课程');
            }

            // 定时刷新
            setTimeout(() => {
                console.log(`\n[课程监控] ${REFRESH_INTERVAL/1000/60}分钟后自动刷新...`);
                window.location.reload();
            }, REFRESH_INTERVAL);
        });
    }
    // ==============================================================

    // ==================== 主流程分发 ====================
    function handleMainLogic() {
        const currentUrl = window.location.href;
        if (currentUrl === "https://labor.seu.edu.cn/AuthServer/Login") {
            console.log('[登录重定向] 检测到旧登录页,跳转至统一身份认证...');
            window.location.href = LOGIN_LABOR_URL;
        } else if (currentUrl.includes('auth.seu.edu.cn/dist')) {
            if (window.location.hash === '#/dist/LoginSuccess') {
                console.log('%c[登录成功检测] 已匹配登录成功页面哈希路径,登录冷却标志(如果有)已清除', 'color: #4CAF50; font-weight: bold');
                clearTimeout(loginTimer);
                loginTimer = null;
                GM_setValue('loginFailStatus', false);
                GM_setValue('loginFailTime', 0);
            } else {
                handleLoginPage();
            }
        } else if (/^https:\/\/labor\.seu\.edu\.cn\/System\/Home/.test(currentUrl)) {
            handleLaborHomePage();
        } else if (currentUrl.includes('labor.seu.edu.cn/SJItemKaiKe/XuanKe/Index')) {
            handleCoursePage();
        }
    }

    // 初始加载时执行一次
    handleMainLogic();

    // 监听 hash 变化(前端路由切换时触发),重新执行主流程
    window.addEventListener('hashchange', () => {
        // 6. 当hash从LoginSuccess切换到登录页时,清除计时器
        if (!window.location.hash.includes('LoginSuccess') && loginTimer) {
            clearTimeout(loginTimer);
            loginTimer = null;
            console.log('[hash变化] 检测到离开登录成功页,已清除计时器');
        }
        handleMainLogic();
    });
    // ====================================================
})();