Greasy Fork

Greasy Fork is available in English.

南京大学羽毛球场地预订自动化工具

仅供学习使用

// ==UserScript==
// @name         南京大学羽毛球场地预订自动化工具
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  仅供学习使用
// @author       严宇恒
// @license      MIT
// @match        https://ggtypt.nju.edu.cn/venue/home
// @grant        GM_xmlhttpRequest
// @connect      ggtypt.nju.edu.cn
// ==/UserScript==

(function() {
    'use strict';

    /********** 工具函数 **********/
    // 延时等待
    const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

    // 使用选择器等待页面中元素出现(基于 querySelector)
    async function waitForElementBySelector(selector, timeout = 10000) {
        const start = Date.now();
        while (Date.now() - start < timeout) {
            const el = document.querySelector(selector);
            if (el) return el;
            await sleep(300);
        }
        throw new Error(`等待元素 ${selector} 超时`);
    }

    // 使用 XPath 等待页面中元素出现
    async function waitForElementByXpath(xpath, timeout = 10000) {
        const start = Date.now();
        while (Date.now() - start < timeout) {
            let result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
            if (result.singleNodeValue) return result.singleNodeValue;
            await sleep(300);
        }
        throw new Error(`等待 XPath 元素 ${xpath} 超时`);
    }

    // 通过 GM_xmlhttpRequest 请求获取服务器时间(响应头中的 Date 字段)
    function fetchServerTime() {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "HEAD",
                url: "https://ggtypt.nju.edu.cn/venue/venue-reservation/",
                onload: function(response) {
                    let header = response.responseHeaders;
                    let match = header.match(/Date:\s*(.+)/i);
                    if (match && match[1]) {
                        let serverTime = new Date(match[1].trim());
                        resolve(serverTime);
                    } else {
                        reject("无法获取服务器时间");
                    }
                },
                onerror: function() {
                    reject("网络请求失败");
                }
            });
        });
    }

    // 等待直到服务器时间达到目标时间
    async function waitForServerTime(targetTime) {
        while (true) {
            try {
                let serverTime = await fetchServerTime();
                console.log(`服务器时间: ${serverTime}`);
                if (serverTime >= targetTime) {
                    console.log("达到预约时间!");
                    break;
                }
            } catch (err) {
                console.error(err);
            }
            await sleep(300);
        }
    }

    // 将时间段文本转换为整数(用于比较时段)
    function timeToInt(t) {
        // 可能的最早时间为上午9点,可能的最晚时间为晚上22点
        const mapping = {
            '09:00-10:00': 9,
            '10:00-11:00': 10,
            '11:00-12:00': 11,
            '12:00-13:00': 12,
            '13:00-14:00': 13,
            '14:00-15:00': 14,
            '15:00-16:00': 15,
            '16:00-17:00': 16,
            '17:00-18:00': 17,
            '18:00-19:00': 18,
            '19:00-20:00': 19,
            '20:00-21:00': 20,
            '21:00-22:00': 21
        };
        return mapping[t] || -1;
    }

    /********** 主逻辑 **********/
    (async function main() {
        try {

            /********** 用户设定 **********/
            // 选择场馆
            let place = prompt("请选择场馆: A.方肇周 B.四组团 C.鼓楼");
            while (place.toLowerCase() !== 'a' && place.toLowerCase() !== 'b' && place.toLowerCase() !== 'c' && place.toLowerCase() !== 'z') { // z是方肇周乒乓球,由于基本没人预约,这里拿来做测试用
                alert("无效输入!");
                place = prompt("请选择场馆: A.方肇周 B.四组团 C.鼓楼");
            }
            // 根据选择构造对应场馆的 XPath
            let hallXpath = "";
            if (place.toLowerCase() === 'a') {
                hallXpath = "/html/body/div[1]/div/div/div[3]/div[2]/div/div[2]/div[1]/div[2]/div[2]/div[1]";
            } else if (place.toLowerCase() === 'b') {
                hallXpath = "/html/body/div[1]/div/div/div[3]/div[2]/div/div[2]/div[3]/div[2]/div[2]/div[1]";
            } else if (place.toLowerCase() === 'c'){
                hallXpath = "/html/body/div[1]/div/div/div[3]/div[2]/div/div[2]/div[4]/div[2]/div[2]/div[1]";
            } else {
                hallXpath = "/html/body/div[1]/div/div/div[3]/div[2]/div/div[2]/div[1]/div[2]/div[2]/div[3]";
            }

            // 选择时段
            let courtHour = prompt("请输入预约时段(例如:17表示17:00-18:00):");
            while (isNaN(courtHour) || courtHour < 9 || courtHour > 21) {
                alert("无效输入!");
                courtHour = prompt("请输入预约时段(例如:17表示17:00-18:00):");
            }
            const desiredHour = parseInt(courtHour);

            // 根据场馆,选择具体场地号
            let courtNumber;
            if (place.toLowerCase() === 'a') {
                courtNumber = prompt("请选择场地: 可输入的场地号有 7 8 9 10 11 12 13 14 15 16 17 18");
                while (isNaN(courtNumber) || courtNumber < 7 || courtNumber > 18) {
                    alert("无效输入!");
                    courtNumber = prompt("请选择场地: 可输入的场地号有 7 8 9 10 11 12 13 14 15 16 17 18");
                }
                courtNumber = parseInt(courtNumber) - 6; // 方肇周的场地从7开始编号,故这里减去6。
            } else if (place.toLowerCase() === 'b') {
                courtNumber = prompt("请选择场地: 可输入的场地号有 1 2 3 4 5 6 7 8 9 10 11 12");
                while (isNaN(courtNumber) || courtNumber < 1 || courtNumber > 12) {
                    alert("无效输入!");
                    courtNumber = prompt("请选择场地: 可输入的场地号有 1 2 3 4 5 6 7 8 9 10 11 12");
                }
                courtNumber = parseInt(courtNumber);
            } else if (place.toLowerCase() === 'c') {
                courtNumber = prompt("请选择场地: 可输入的场地号有 1 2 3 4 5 6 7 8 9(中1) 10(中2) 11(中3) 12(中4)");
                while (isNaN(courtNumber) || courtNumber < 1 || courtNumber > 12) {
                    alert("无效输入!");
                    courtNumber = prompt("请选择场地: 可输入的场地号有 1 2 3 4 5 6 7 8 9(中1) 10(中2) 11(中3) 12(中4)");
                }
                courtNumber = parseInt(courtNumber);
            } else {
                courtNumber = prompt("请选择场地: 可输入的场地号有 1-30");
                while (isNaN(courtNumber) || courtNumber < 1 || courtNumber > 30) {
                    alert("无效输入!");
                    courtNumber = prompt("请选择场地: 可输入的场地号有 1-30");
                }
                courtNumber = parseInt(courtNumber);
            }

            // 设置预约定时目标时间:默认当天 08:00:00,或用户自定义
            let useTimer = prompt("是否开启定时?Y/n(如不开启默认08:00:00)");
            let targetTime = new Date();
            if (useTimer.toLowerCase() === 'y') {
                let hour = prompt("请输入预约时的小时(0-23):");
                let minute = prompt("请输入预约时的分钟(0-59):");
                let second = prompt("请输入预约时的秒数(0-59):");
                let millisecond = prompt("请输入预约时的毫秒数(0-999):");
                targetTime.setHours(parseInt(hour), parseInt(minute), parseInt(second), parseInt(millisecond));
            } else {
                targetTime.setHours(8, 0, 0, 0);
            }
            console.log(`预约目标时间:${targetTime}`);

            /********** 自动化操作流程 **********/
            // 点击预约入口
            let appointmentButton = await waitForElementByXpath("/html/body/div[1]/div/div/div[3]/div/div[3]/div[1]");
            appointmentButton.click();
            console.log("进入预约流程。");

            // 等待服务器时间达到目标时间
            await waitForServerTime(targetTime);

            // 点击对应的场馆入口
            let hallElement = await waitForElementByXpath(hallXpath);
            hallElement.click();
            console.log("选择场馆成功。");

            // 调整预约日期或时间:轮询页面时间段(XPath 获取页面中当前展示的时段)
            let testElement = await waitForElementByXpath("/html/body/div[1]/div/div/div[3]/div[2]/div/div[2]/div[3]/div[1]/div/div/div/div/div/table/thead/tr/td[2]/div");
            let currentTimeSlot = timeToInt(testElement.textContent.trim());
            while (true) {
                let direction = desiredHour - currentTimeSlot;
                if (direction >= 0 && direction <= 4) {
                    break;
                } else if (direction < 0) {
                    // 点击向前调整按钮
                    let beforeButton = await waitForElementByXpath("/html/body/div[1]/div/div/div[3]/div[2]/div/div[2]/div[3]/div[1]/div/div/div/div/div/table/tbody/tr[13]/td[2]/div/span");
                    beforeButton.click();
                } else {
                    // 点击向后调整按钮
                    let afterButton = await waitForElementByXpath("/html/body/div[1]/div/div/div[3]/div[2]/div/div[2]/div[3]/div[1]/div/div/div/div/div/table/tbody/tr[13]/td[6]/div/span");
                    afterButton.click();
                }
                await sleep(300);
                testElement = await waitForElementByXpath("/html/body/div[1]/div/div/div[3]/div[2]/div/div[2]/div[3]/div[1]/div/div/div/div/div/table/thead/tr/td[2]/div");
                currentTimeSlot = timeToInt(testElement.textContent.trim());
            }
            console.log("调整到正确的预约日期/时段。");

            // 计算目标单元格位置并点击对应场地
            // td 索引:根据当前时段与目标时段差值计算
            let tdIndex = (desiredHour - currentTimeSlot + 2); // +2 用于表格定位
            let courtXpath = `/html/body/div[1]/div/div/div[3]/div[2]/div/div[2]/div[3]/div[1]/div/div/div/div/div/table/tbody/tr[${courtNumber}]/td[${tdIndex}]/div`;
            let courtElement = await waitForElementByXpath(courtXpath);
            courtElement.click();
            console.log("选择场地成功。");

            // 勾选同意预约协议
            let agreementElement = await waitForElementByXpath("/html/body/div[1]/div/div/div[3]/div[2]/div/div[2]/div[4]/label");
            agreementElement.click();
            console.log("同意预约协议。");

            // 确认预约信息
            let subscribeElement = await waitForElementByXpath("/html/body/div[1]/div/div/div[3]/div[2]/div/div[2]/div[5]/div/div[2]");
            subscribeElement.click();
            console.log("确认预约信息。");

            // 选择同伴
            let partnerElement = await waitForElementByXpath("/html/body/div[1]/div/div/div[3]/div[2]/div/div[2]/form/div/div[2]/div/div/label[1]");
            partnerElement.click();
            console.log("选择同伴。");

            // 提交预约
            let submitElement = await waitForElementByXpath("/html/body/div[1]/div/div/div[3]/div[2]/div/div[2]/div[1]/div/div[2]");
            submitElement.click();
            console.log("提交预约。");

            await sleep(3600000); // 等待1小时

        } catch (err) {
            console.error("预约失败:", err);
            alert("预约失败,请查看控制台日志了解详细错误信息。");
        }
    })();
})();