// ==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("用户取消运行脚本。");
}
})();