Greasy Fork

Greasy Fork is available in English.

南京大学羽毛球场地预订自动化工具(手机版)

自动化预订南京大学羽毛球场地,仅供学习使用。

// ==UserScript==
// @name         南京大学羽毛球场地预订自动化工具(手机版)
// @namespace    http://tampermonkey.net/
// @version      2.2
// @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 URLS = {
        home: "https://ggtypt.nju.edu.cn/venue/home",
        reservationBase: "https://ggtypt.nju.edu.cn/venue/venue-reservation/"
    };

    const SELECTORS = {
        // 主页
        home_reservationEntryButton: "/html/body/div[1]/div[2]/div/div/div/div[1]/div[2]/div/div[1]",
        // 场馆选择页面
        venue_fangZhaozhou: "/html/body/div[1]/div[2]/div/div/div/div/div/div[1]/div[3]/div[1]", // 方肇周羽毛球馆
        venue_siZuTuan: "/html/body/div[1]/div[2]/div/div/div/div/div/div[3]/div[3]/div[1]",     // 四组团羽毛球馆
        venue_guLou: "/html/body/div[1]/div[2]/div/div/div/div/div/div[4]/div[3]/div[1]",        // 鼓楼羽毛球馆
        venue_testPingPong: "/html/body/div[1]/div[2]/div/div/div/div/div/div[1]/div[3]/div[3]", // 方肇周乒乓球,由于基本没人预约,这里拿来做测试用
        // 预订详情页
        reservation_firstTimeSlotHeader: `//*[@id="scrollTable"]/div/table/thead/tr/td[2]/div`,                                        // 第一个时间段的头部,用于计算偏移
        reservation_courtRowAndCell: (trIndex, tdIndex) => `//*[@id="scrollTable"]/div/table/tbody/tr[${trIndex}]/td[${tdIndex}]/div`, // 场地单元格
        reservation_agreementCheckboxLabel: "/html/body/div[1]/div[2]/div/div/div/div/div[1]/div[5]/div[1]/div[1]/label",              // 同意协议的标签
        reservation_confirmInfoButton: "/html/body/div[1]/div[2]/div/div/div/div/div[1]/div[5]/div[1]/div[2]",                         // 确认预约信息按钮
        reservation_addPartnerButton: "/html/body/div[1]/div[2]/div/div/div/div/div[1]/div[1]/div[3]",                                 // 选择同伴按钮
        reservation_firstPartnerInList: "//div[@class='buddyList']//li[1]//input",                                                     // 同伴列表中的第一个
        reservation_confirmPartnerButton: "/html/body/div[1]/div[2]/div/div/div/div/div[1]/div[2]/div/div[5]/div",                     // 同伴选择弹窗的确认按钮
        reservation_submitButton: "/html/body/div[1]/div[2]/div/div/div/div/div[1]/div[2]/div[1]"                                      // 提交预约按钮
    };


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

    async function waitForElement(selector, timeout = 15000, isXpath = true) {
        const start = Date.now();
        while (Date.now() - start < timeout) {
            const el = isXpath ? document.evaluate(selector, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
                               : document.querySelector(selector);
            if (el && (el.offsetParent !== null || el.getClientRects().length > 0)) { // 检查是否可见
                 console.log(`元素 "${selector}" 已找到。`);
                 return el;
            }
            await sleep(333);
        }
        throw new Error(`等待元素 ${selector} 超时或元素不可见`);
    }

    async function waitForElementAndClick(selector, timeout = 15000, isXpath = true, preClickDelay = 0, postClickDelay = 0) {
        const element = await waitForElement(selector, timeout, isXpath);
        if (preClickDelay > 0) await sleep(preClickDelay);
        element.click();
        console.log(`点击了元素: ${selector}`);
        if (postClickDelay > 0) await sleep(postClickDelay);
        return element;
    }

    function fetchServerTime() {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "HEAD",
                url: "https://ggtypt.nju.edu.cn/venue/venue-reservation/", // 这个URL用于获取Date头,可能需要是主站或任意有效链接
                onload: function(response) {
                    const header = response.responseHeaders;
                    const match = header.match(/Date:\s*(.+)/i);
                    if (match && match[1]) {
                        resolve(new Date(match[1].trim()));
                    } else {
                        console.warn("响应头中未找到Date字段,将使用本地时间作为后备。Headers:", header);
                        resolve(new Date());
                    }
                },
                onerror: function(error) {
                    console.error("获取服务器时间网络请求失败:", error);
                    console.warn("将使用本地时间作为后备。");
                    resolve(new Date());
                }
            });
        });
    }

    async function waitForServerTime(targetTime) {
        console.log(`目标服务器时间: ${targetTime.toLocaleString()}`);
        let lastLoggedTime = 0;
        while (true) {
            try {
                const serverTime = await fetchServerTime();
                if (serverTime >= targetTime) {
                    console.log(`已达到或超过目标服务器时间! 服务器时间: ${serverTime.toLocaleString()}`);
                    break;
                }
            } catch (err) {
                console.error("检查服务器时间时出错:", err);
            }
            await sleep(333); // 检查频率,选择333毫秒不至于增加太多服务器负担
        }
    }

    function timeToInt(t) {
        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 getUserPreferences() {
        let venueKey = prompt("请选择场馆: A.方肇周 B.四组团 C.鼓楼");
        if (!venueKey) return null; // 用户取消
        venueKey = venueKey.toLowerCase();
        while (!['a', 'b', 'c', 'z'].includes(venueKey)) {
            alert("无效输入!");
            venueKey = prompt("请选择场馆: A.方肇周 B.四组团 C.鼓楼");
            if (!venueKey) return null;
            venueKey = venueKey.toLowerCase();
        }

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

        let courtNumberUserStr;
        let courtNumberUser;
        let courtNumberSystem; // 用于 XPath 的 tr 索引

        const venueDetails = {
            'a': { name: '方肇周羽毛球', min: 7, max: 18, offset: 6 }, // 场地7对应tr[1]
            'b': { name: '四组团羽毛球', min: 1, max: 12, offset: 0 }, // 场地1对应tr[1]
            'c': { name: '鼓楼羽毛球', min: 1, max: 12, offset: 0 },   // 场地1对应tr[1]
            'z': { name: '方肇周乒乓球', min: 1, max: 30, offset: 0 }  // 场地1对应tr[1]
        };
        const detail = venueDetails[venueKey];
        courtNumberUserStr = prompt(`场馆: ${detail.name}\n请选择场地号 (${detail.min}-${detail.max}):`);
        if (!courtNumberUserStr) return null; // 用户取消
        courtNumberUser = parseInt(courtNumberUserStr);
        while (isNaN(courtNumberUser) || courtNumberUser < detail.min || courtNumberUser > detail.max) {
            alert("无效场地号!");
            courtNumberUserStr = prompt(`场馆: ${detail.name}\n请选择场地号 (${detail.min}-${detail.max}):`);
            if (!courtNumberUserStr) return null; // 用户取消
            courtNumberUser = parseInt(courtNumberUserStr);
        }
        courtNumberSystem = courtNumberUser - detail.offset;

        const useTimerStr = prompt("是否开启定时抢票?Y/N (若输入N或无效输入,则为默认时间UTC-8;若输入Y,则可设定具体时间)", "N");
        let targetTime = new Date(); // 默认立即执行
        if (useTimerStr && useTimerStr.toLowerCase() === 'y') {
            const hourStr = prompt("请输入目标小时 (0-23),例如明天早上8点则输入8:", "8");
            const minuteStr = prompt("请输入目标分钟 (0-59):", "0");
            const secondStr = prompt("请输入目标秒数 (0-59):", "0");
            const millisecondStr = prompt("请输入目标毫秒数 (0-999):", "0");
            targetTime.setHours(parseInt(hourStr), parseInt(minuteStr), parseInt(secondStr), parseInt(millisecondStr));
        } else {
             targetTime.setHours(8,0,0,0);
        }

        return { venueKey, desiredHour, courtNumberUser, courtNumberSystem, targetTime };
    }


    /********** 主逻辑 **********/
    async function main() {
        try {
            console.log("南京大学羽毛球场地预订自动化工具(手机版)开始运行...");

            const prefs = await getUserPreferences();
            if (!prefs) {
                console.log("用户取消了输入或未提供完整信息,脚本终止。");
                alert("用户取消了输入或未提供完整信息,脚本终止。");
                return;
            }
            console.log("用户偏好设定:", prefs);


            // 1. 如果在主页,点击场地预约入口
            if (location.href.startsWith(URLS.home)) {
                await waitForElementAndClick(SELECTORS.home_reservationEntryButton);
                console.log("已点击场地预约入口。");
            }


            // 2. 等待服务器时间到达目标时间 (如果设置了定时)
            // 如果 prefs.targetTime 是已经过去的时间,这个等待会立刻通过
            await waitForServerTime(prefs.targetTime);


            // 3. 选择场馆
            // 此时应该已经跳转到了场馆选择页面,或者已经在该页面(如果之前刷新)
            // 需要确保场馆选择的元素是可见的
            let venueSelectorXPath;
            switch (prefs.venueKey) {
                case 'a': venueSelectorXPath = SELECTORS.venue_fangZhaozhou; break;
                case 'b': venueSelectorXPath = SELECTORS.venue_siZuTuan; break;
                case 'c': venueSelectorXPath = SELECTORS.venue_guLou; break;
                case 'z': venueSelectorXPath = SELECTORS.venue_testPingPong; break;
                default: throw new Error("无效的场馆选择Key");
            }
            console.log(`尝试选择场馆,XPath: ${venueSelectorXPath}`);
            await waitForElementAndClick(venueSelectorXPath);
            console.log(`场馆 "${prefs.venueKey}" 选择成功。`);


            // 4. 选择场地和时间
            console.log("开始选择场地和时间...");
            let firstTimeSlotElement;
            let currentTimeSlotText;
            let currentTimeSlotInt;
            while (true) {
                firstTimeSlotElement = await waitForElement(SELECTORS.reservation_firstTimeSlotHeader);
                currentTimeSlotText = firstTimeSlotElement.textContent.trim();
                currentTimeSlotInt = timeToInt(currentTimeSlotText);
                console.log(`页面上第一个显示的时间段: ${currentTimeSlotText} (解析为: ${currentTimeSlotInt})`);

                if (currentTimeSlotInt === -1) {
                    await sleep(333); // 表格还没加载好
                } else {
                    break;
                }
            }

            const tdIndex = (prefs.desiredHour - currentTimeSlotInt + 2);
            console.log(`目标时段: ${prefs.desiredHour}, 计算得到的 tdIndex: ${tdIndex}`);
            if (tdIndex <= 0) {
                throw new Error(`计算的表格列索引 (tdIndex) 无效: ${tdIndex}。目标时段 ${prefs.desiredHour} 可能早于页面显示的第一个时段 ${currentTimeSlotText}。`);
            }

            const courtCellXPath = SELECTORS.reservation_courtRowAndCell(prefs.courtNumberSystem, tdIndex);
            console.log(`尝试选择场地单元格,XPath: ${courtCellXPath}`);
            await waitForElementAndClick(courtCellXPath);
            console.log(`场地 ${prefs.courtNumberUser} (系统行号 ${prefs.courtNumberSystem}), 时段 ${prefs.desiredHour}:00 (列号 ${tdIndex}) 选择成功。`);


            // 5. 勾选同意预约协议
            await waitForElementAndClick(SELECTORS.reservation_agreementCheckboxLabel);
            console.log("已勾选同意预约协议。");


            // 6. 点击 "确认预约信息"
            await waitForElementAndClick(SELECTORS.reservation_confirmInfoButton);
            console.log("已点击“确认预约信息”。");


            // 7. 选择同伴
            console.log("准备选择同伴...");
            await waitForElementAndClick(SELECTORS.reservation_addPartnerButton);
            console.log("已点击“选择同伴”按钮。");

            await waitForElementAndClick(SELECTORS.reservation_firstPartnerInList);
            console.log("已选择列表中的第一个同伴。");

            await waitForElementAndClick(SELECTORS.reservation_confirmPartnerButton);
            console.log("已确认同伴选择。");


            // 8. 提交预约
            console.log("准备最终提交预约...");
            await waitForElementAndClick(SELECTORS.reservation_submitButton);
            console.log("已点击“提交预约”按钮。");

            console.log("预订流程执行完毕。脚本将在1小时后关闭。");
            await sleep(3600000); // 等待1小时

        } catch (error) {
            console.error("自动化预订过程中发生严重错误:", error);
            alert(`预订失败: ${error.message}\n请打开浏览器控制台 (F12) 查看详细错误信息并反馈。`);
        }
    }


    /********** 脚本启动 **********/
    if (confirm("来自开发者:如果程序出现问题,请发邮件到[email protected]。")) {
        main();
    } else {
        alert("脚本退出运行。");
        console.log("用户取消运行脚本。");
    }

})();