您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
自动化预订南京大学羽毛球场地,仅供学习使用。
// ==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("用户取消运行脚本。"); } })();