Greasy Fork

来自缓存

Greasy Fork is available in English.

BJTU 教室空闲查询助手

一个用于北京交通大学教学服务平台 (aa.bjtu.edu.cn) 的油猴脚本。它能在你浏览教室查询页面时,自动抓取所有分页数据,并筛选出当前可用的自习教室。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         BJTU 教室空闲查询助手
// @namespace    http://tampermonkey.net/
// @version      3.4
// @license     MIT
// @description  一个用于北京交通大学教学服务平台 (aa.bjtu.edu.cn) 的油猴脚本。它能在你浏览教室查询页面时,自动抓取所有分页数据,并筛选出当前可用的自习教室。
// @author       Lxl
// @match        https://aa.bjtu.edu.cn/classroomtimeholdresult/room_view/*
// @grant        GM_addStyle
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    const CONFIG = {
        blocks: [
            { name: "🌟 全天空闲", slots: [0, 1, 3, 4, 6], rooms: [] },
            { name: "☀️ 上午空闲 (1-2节)",      slots: [0, 1],           rooms: [] },
            { name: "☕ 下午空闲 (4-5节)",      slots: [3, 4],           rooms: [] },
            { name: "🌙 晚上空闲 (7节)",       slots: [6],              rooms: [] }
        ]
    };

    const PRIORITY_MAP = {
        "YF": 1, "SY": 2, "SX": 3, "SD": 4, "OTHER": 99
    };

    const COLOR_MAP = {
        "YF": "rgba(64, 192, 87, 0.25)",
        "SY": "rgba(77, 171, 247, 0.25)",
        "SX": "rgba(255, 107, 107, 0.25)",
        "SD": "rgba(255, 169, 77, 0.25)",
        "OTHER": "rgba(255, 255, 255, 0.1)"
    };

    const BORDER_MAP = {
        "YF": "#40c057", "SY": "#4dabf7", "SX": "#ff6b6b", "SD": "#ffa94d", "OTHER": "transparent"
    };

    const STYLES = `
        #bjtu-helper-panel {
            position: fixed; top: 100px; right: 20px; width: 340px;
            background: rgba(33, 37, 41, 0.98); color: #fff;
            border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.4);
            z-index: 9999; font-family: system-ui, -apple-system, sans-serif;
            font-size: 14px; border: 1px solid rgba(255,255,255,0.1);
            display: flex; flex-direction: column; max-height: 85vh;
        }
        #bjtu-helper-header {
            padding: 12px 15px; border-bottom: 1px solid rgba(255,255,255,0.1);
            display: flex; justify-content: space-between; align-items: center;
            font-weight: bold; background: rgba(255,255,255,0.05);
            border-radius: 8px 8px 0 0; cursor: move;
        }
        #bjtu-helper-content {
            padding: 15px; overflow-y: auto; flex: 1;
        }
        .bjtu-status-bar {
            padding: 0 15px 10px; font-size: 12px; color: #adb5bd;
            border-bottom: 1px solid rgba(255,255,255,0.05); margin-bottom: 10px;
        }
        .bjtu-progress {
            height: 3px; background: #343a40; margin-top: 5px;
            border-radius: 2px; overflow: hidden;
        }
        .bjtu-progress-bar {
            height: 100%; background: #4dabf7; width: 0%; transition: width 0.3s;
        }
        .bjtu-block-group { margin-bottom: 18px; }
        .bjtu-block-title {
            color: #74c0fc; font-size: 13px; margin-bottom: 8px;
            font-weight: 600; display: flex; justify-content: space-between; align-items: center;
            cursor: pointer; user-select: none;
        }
        /* 折叠指示箭头 */
        .bjtu-block-title::after {
            content: '▼'; font-size: 10px; margin-left: 8px; transition: transform 0.2s;
        }
        .bjtu-block-title.collapsed::after {
            transform: rotate(-90deg);
        }
        .bjtu-room-list { display: flex; flex-wrap: wrap; gap: 6px; }
        .bjtu-room-list.hidden { display: none; }

        .bjtu-room-tag {
            padding: 4px 8px; border-radius: 4px; font-size: 13px;
            border: 1px solid transparent; color: #e9ecef;
            transition: all 0.2s; font-family: Consolas, Monaco, monospace;
        }
        .bjtu-room-tag:hover { filter: brightness(1.2); cursor: default; }
        #bjtu-refresh-icon { cursor: pointer; opacity: 0.7; transition: 0.3s; }
        #bjtu-refresh-icon:hover { opacity: 1; transform: rotate(180deg); }
    `;

    GM_addStyle(STYLES);

    function getTodayColumnIndex() {
        let day = new Date().getDay();
        let dayIndex = (day === 0) ? 6 : day - 1;
        return 1 + (dayIndex * 7);
    }

    function getPrefix(roomName) {
        const match = roomName.match(/^([A-Za-z]+)/);
        return match ? match[1].toUpperCase() : "OTHER";
    }

    function sortRooms(rooms) {
        return rooms.sort((a, b) => {
            const prefixA = getPrefix(a);
            const prefixB = getPrefix(b);
            const pA = PRIORITY_MAP[prefixA] || PRIORITY_MAP["OTHER"];
            const pB = PRIORITY_MAP[prefixB] || PRIORITY_MAP["OTHER"];
            if (pA !== pB) return pA - pB;
            return a.localeCompare(b, 'en', { numeric: true });
        });
    }

    function getMaxPage() {
        const bodyText = document.body.innerText;
        const cnMatch = bodyText.match(/共\s*(\d+)\s*页/);
        if (cnMatch) return parseInt(cnMatch[1]);
        const enMatch = bodyText.match(/Page[::]\s*\d+\s*\/\s*(\d+)/i);
        if (enMatch) return parseInt(enMatch[1]);
        const pageLinks = document.querySelectorAll('ul.pagination a');
        let max = 1;
        const currentParams = new URLSearchParams(window.location.search);
        if(currentParams.has('page')) max = Math.max(max, parseInt(currentParams.get('page')));
        pageLinks.forEach(link => {
            if (/^\d+$/.test(link.innerText.trim())) max = Math.max(max, parseInt(link.innerText));
            if (link.href && link.href.match(/page=(\d+)/)) max = Math.max(max, parseInt(link.href.match(/page=(\d+)/)[1]));
        });
        return max;
    }

    function isSlotFreeStatic(td) {
        const styleText = (td.getAttribute('style') || '').toLowerCase();
        if (!styleText.includes('background-color')) return true;
        return /#fff|white|rgb\(\s*255\s*,\s*255\s*,\s*255\s*\)|transparent/.test(styleText);
    }

    function isSlotFreeLive(td) {
        const style = window.getComputedStyle(td);
        const bg = style.backgroundColor;
        return bg === 'rgb(255, 255, 255)' || bg === 'transparent' || bg === 'rgba(0, 0, 0, 0)';
    }

    function extractData(rows, storage, isLive) {
        rows.forEach(row => {
            const cols = row.querySelectorAll("td");
            if (cols.length < 50) return;
            const roomName = cols[0].innerText.split('(')[0].trim();
            const slots = [];
            cols.forEach(col => slots.push(isLive ? isSlotFreeLive(col) : isSlotFreeStatic(col)));
            storage[roomName] = slots;
        });
    }

    function analyze(allDataMap) {
        CONFIG.blocks.forEach(b => b.rooms = []);
        const startCol = getTodayColumnIndex();

        for (const [roomName, slots] of Object.entries(allDataMap)) {
            CONFIG.blocks.forEach(block => {
                let allFree = true;
                for (let offset of block.slots) {
                    if (startCol + offset >= slots.length || !slots[startCol + offset]) {
                        allFree = false;
                        break;
                    }
                }
                if (allFree) block.rooms.push(roomName);
            });
        }

        CONFIG.blocks.forEach(b => {
            sortRooms(b.rooms);
        });

        renderResults();
    }

    async function runFullScan() {
        const maxPage = getMaxPage();
        const allDataMap = {};
        const statusText = document.getElementById('bjtu-status-text');
        const progressBar = document.getElementById('bjtu-progress-bar');

        statusText.innerText = `正在扫描全楼层 (共 ${maxPage} 页)...`;
        const currentParams = new URLSearchParams(window.location.search);
        let currentPage = parseInt(currentParams.get('page')) || 1;
        const promises = [];

        for (let i = 1; i <= maxPage; i++) {
            if (i === currentPage) {
                const rows = Array.from(document.querySelectorAll("table.table-bordered tr")).slice(2);
                extractData(rows, allDataMap, true);
                progressBar.style.width = `${(i / maxPage) * 100}%`;
                continue;
            }
            const targetUrl = new URL(window.location.href);
            targetUrl.searchParams.set('page', i);
            const p = fetch(targetUrl.toString())
                .then(r => r.text())
                .then(html => {
                    const doc = new DOMParser().parseFromString(html, "text/html");
                    const rows = Array.from(doc.querySelectorAll("table.table-bordered tr")).slice(2);
                    extractData(rows, allDataMap, false);
                })
                .catch(e => console.error(e))
                .finally(() => {
                    const currentWidth = parseFloat(progressBar.style.width) || 0;
                    progressBar.style.width = `${Math.min(currentWidth + (100/maxPage), 100)}%`;
                });
            promises.push(p);
        }

        await Promise.all(promises);
        statusText.innerText = `已合并 ${maxPage} 页数据`;
        progressBar.style.background = '#40c057';
        analyze(allDataMap);
    }

    function createPanel() {
        const panel = document.createElement('div');
        panel.id = 'bjtu-helper-panel';
        panel.innerHTML = `
            <div id="bjtu-helper-header">
                <span>教室空闲助手</span>
                <span id="bjtu-refresh-icon" title="重新扫描">↻</span>
            </div>
            <div class="bjtu-status-bar">
                <div id="bjtu-status-text">初始化中...</div>
                <div class="bjtu-progress">
                    <div id="bjtu-progress-bar" class="bjtu-progress-bar"></div>
                </div>
            </div>
            <div id="bjtu-helper-content"></div>
        `;
        document.body.appendChild(panel);

        document.getElementById('bjtu-refresh-icon').onclick = () => {
             document.getElementById('bjtu-progress-bar').style.width = '0%';
             document.getElementById('bjtu-progress-bar').style.background = '#4dabf7';
             runFullScan();
        };

        const header = document.getElementById('bjtu-helper-header');
        let isDragging = false, startX, startY, initialLeft, initialTop;
        header.onmousedown = (e) => {
            isDragging = true; startX = e.clientX; startY = e.clientY;
            const rect = panel.getBoundingClientRect();
            initialLeft = rect.left; initialTop = rect.top;
            panel.style.right = 'auto'; panel.style.left = initialLeft + 'px'; panel.style.top = initialTop + 'px';
        };
        document.onmousemove = (e) => {
            if(!isDragging) return;
            panel.style.left = (initialLeft + (e.clientX - startX)) + 'px';
            panel.style.top = (initialTop + (e.clientY - startY)) + 'px';
        };
        document.onmouseup = () => isDragging = false;
    }

    function renderResults() {
        const container = document.getElementById('bjtu-helper-content');
        let html = '';

        CONFIG.blocks.forEach(block => {
            html += `<div class="bjtu-block-group">`;
            html += `<div class="bjtu-block-title">
                        <span>${block.name}</span>
                        <div style="display:flex; align-items:center;">
                            <span style="opacity:0.6; font-size:12px; margin-right:5px;">${block.rooms.length}间</span>
                        </div>
                     </div>`;
            html += `<div class="bjtu-room-list">`;

            if (block.rooms.length === 0) {
                html += `<span style="color:#868e96; font-size:12px; padding:5px;">暂无空闲</span>`;
            } else {
                block.rooms.forEach(room => {
                    const prefix = getPrefix(room);
                    const bgColor = COLOR_MAP[prefix] || COLOR_MAP["OTHER"];
                    const borderColor = BORDER_MAP[prefix] || "transparent";
                    html += `<span class="bjtu-room-tag" style="background:${bgColor}; border-color:${borderColor}">${room}</span>`;
                });
            }
            html += `</div></div>`;
        });
        container.innerHTML = html;

        // 绑定折叠事件
        container.querySelectorAll('.bjtu-block-title').forEach(title => {
            title.addEventListener('click', function() {
                this.classList.toggle('collapsed');
                const list = this.nextElementSibling;
                list.classList.toggle('hidden');
            });
        });
    }

    window.addEventListener('load', () => {
        createPanel();
        setTimeout(runFullScan, 600);
    });

})();