// ==UserScript==
// @name 图书馆抢座助手北工
// @name:en 新Library Seat Sniper - Full Featured Edition
// @namespace https://github.com/seat-sniper
// @version 4.1.0
// @description 图书馆自动抢座助手,高并发抢座
// @description:en Complete library seat reservation assistant for BTBU, full Python feature parity
// @author SeatSniper
// @match http://libreservation.btbu.edu.cn/*
// @match https://libreservation.btbu.edu.cn/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @grant GM_notification
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/forge.min.js
// @license MIT
// @supportURL https://github.com/seat-sniper/library-seat-sniper/issues
// @homepageURL https://github.com/seat-sniper/library-seat-sniper
// @compatible chrome 支持Chrome浏览器,推荐使用
// @compatible firefox 支持Firefox浏览器
// @compatible edge 支持Edge浏览器
// @compatible safari 支持Safari浏览器
// ==/UserScript==
(function() {
'use strict';
// ========================== 配置参数 ==========================
const CONFIG = {
BASE_URL: "http://libreservation.btbu.edu.cn/ic-web",
REQ_TIMEOUT: 500, // 单个请求超时(ms)
CONCURRENT_TIMEOUT: 200, // 并发等待超时(ms) - 快速切换到下一轮
ROUND_INTERVAL: 200, // 轮次间隔(ms) - 进一步压缩时间
DEFAULT_CONCURRENT_REQUESTS: 30, // 默认并发数 - 优化为30
MAX_CONCURRENT_REQUESTS: 200, // 最大并发数限制
RETRY_DELAY: 50, // 重试延迟(ms)
PER_ATTEMPT_GAP_MS: 0, // 座位间隔时间
UI_UPDATE_INTERVAL: 100 // UI更新间隔(ms)
};
// ========================== 全局状态 ==========================
let globalState = {
token: null,
accNo: null,
isRunning: false,
stopFlag: false,
currentConcurrentRequests: CONFIG.DEFAULT_CONCURRENT_REQUESTS,
currentMaxWorkers: CONFIG.DEFAULT_CONCURRENT_REQUESTS,
flashInterval: null,
timeUpdateInterval: null,
// ★ 会话池 - 对标Python版本 ★
sessionPool: [],
mainSession: null,
// ★ 性能优化:座位ID缓存 ★
seatIdCache: new Map(),
roomDataCache: null
};
// ========================== 工具函数 ==========================
// 解析座位名称列表 - 对标Python parse_target_seat_names
function parseTargetSeatNames(raw) {
if (typeof raw === 'string') {
const parts = raw.trim().split(/[,\s,、;;]+/);
const seen = new Set();
const result = [];
for (const part of parts) {
if (part && !seen.has(part)) {
seen.add(part);
result.push(part);
}
}
return result;
}
return raw || [];
}
// 收集座位输入框的值
function collectSeatNames() {
const seatInputs = document.querySelectorAll('.seat-input');
const seats = [];
seatInputs.forEach(input => {
const value = input.value.trim();
if (value) {
seats.push(value);
}
});
return seats;
}
// 时间格式标准化 - 对标Python ensure_hhmmss
function ensureHHMMSS(timeStr) {
timeStr = timeStr.trim();
if (/^\d{2}:\d{2}:\d{2}$/.test(timeStr)) return timeStr;
if (/^\d{2}:\d{2}$/.test(timeStr)) return timeStr + ":00";
const match = timeStr.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
if (match) {
const hour = String(parseInt(match[1])).padStart(2, '0');
const minute = match[2];
const second = match[3] || '00';
return `${hour}:${minute}:${second}`;
}
return timeStr;
}
// 日期格式化 - 对标Python ymd_no_dash
function ymdNoDash(dateStr) {
return dateStr.replace(/\D/g, '');
}
// 格式化当前时间
function formatCurrentTime() {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const ms = String(now.getMilliseconds()).padStart(3, '0');
return `${hours}:${minutes}:${seconds}.${ms}`;
}
// 添加日志 - 对标Python _append_log
function addLog(message, type = 'info') {
const logArea = document.getElementById('seat-sniper-log');
if (!logArea) return;
const timestamp = formatCurrentTime();
const logEntry = document.createElement('div');
logEntry.className = `log-entry log-${type}`;
logEntry.innerHTML = `<span class="timestamp">[${timestamp}]</span> ${message}`;
logArea.appendChild(logEntry);
logArea.scrollTop = logArea.scrollHeight;
// 限制日志条数
if (logArea.children.length > 500) {
logArea.removeChild(logArea.firstChild);
}
}
// 显示通知
function showNotification(title, message, type = 'info') {
if (typeof GM_notification !== 'undefined') {
GM_notification({
title: title,
text: message,
timeout: 5000
});
}
addLog(`${title}: ${message}`, type);
}
// ========================== 会话池管理 - 对标Python ========================
// 初始化会话池 - 对标Python _init_session_pool
function initSessionPool() {
addLog(`🔧 初始化 ${globalState.currentConcurrentRequests} 个并发会话...`);
globalState.sessionPool = [];
for (let i = 0; i < globalState.currentConcurrentRequests; i++) {
// 创建会话对象(模拟requests.Session)
const session = {
id: i + 1,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
},
cookies: new Map(), // 模拟cookies存储
// 添加请求方法
request: function(options) {
return makeRequest({
...options,
headers: {
...this.headers,
...options.headers
}
});
}
};
globalState.sessionPool.push(session);
}
addLog("✅ 会话池初始化完成");
}
// 同步登录状态到所有会话 - 对标Python _sync_login_to_all_sessions
function syncLoginToAllSessions() {
if (!globalState.token || !globalState.accNo) {
return false;
}
addLog("🔄 同步登录状态到所有会话...");
globalState.sessionPool.forEach((session, index) => {
try {
// 将token添加到每个session的headers中
session.headers['token'] = globalState.token;
session.headers['lan'] = '1';
// 如果主session有cookies,同步到所有session
if (globalState.mainSession && globalState.mainSession.cookies) {
session.cookies = new Map(globalState.mainSession.cookies);
}
} catch (error) {
addLog(`⚠️ 会话${index + 1}同步失败: ${error.message}`, 'warning');
}
});
return true;
}
// ========================== 网络请求封装 ==========================
// 发送HTTP请求 - 增强版
function makeRequest(options) {
return new Promise((resolve, reject) => {
const requestOptions = {
method: options.method || 'GET',
url: options.url,
headers: options.headers || {},
data: options.data,
timeout: CONFIG.REQ_TIMEOUT,
onload: (response) => {
try {
const data = JSON.parse(response.responseText);
resolve(data);
} catch (e) {
resolve({ responseText: response.responseText, status: response.status });
}
},
onerror: (error) => reject(error),
ontimeout: () => reject(new Error('Request timeout'))
};
if (typeof GM_xmlhttpRequest !== 'undefined') {
GM_xmlhttpRequest(requestOptions);
} else {
// 降级到普通 fetch
const controller = new AbortController();
setTimeout(() => controller.abort(), CONFIG.REQ_TIMEOUT);
fetch(options.url, {
method: options.method || 'GET',
headers: options.headers,
body: options.data,
signal: controller.signal
})
.then(response => response.json())
.then(resolve)
.catch(reject);
}
});
}
// ========================== 登录模块 - 对标Python ==========================
// RSA加密密码 - 严格按后端返回的Base64 DER公钥处理,明文为 password;nonce
async function encryptPassword(password, publicKeyStr, nonce) {
try {
addLog("🔐 开始RSA密码加密(Base64→DER→ASN.1→PKCS1_v1_5)...");
if (!publicKeyStr || typeof publicKeyStr !== 'string') {
throw new Error('无效的publicKey');
}
// 1) Base64 → DER 原始字节
let derBinary;
try {
derBinary = forge.util.decode64(publicKeyStr);
} catch (e) {
throw new Error('publicKey Base64 解码失败');
}
// 2) DER → ASN.1 → PublicKey
let publicKey;
try {
const derBuffer = forge.util.createBuffer(derBinary, 'raw');
const asn1 = forge.asn1.fromDer(derBuffer);
publicKey = forge.pki.publicKeyFromAsn1(asn1);
} catch (e) {
throw new Error('DER/ASN.1 公钥解析失败');
}
// 3) 明文拼接:password;nonce(参照Python版本格式)
const plaintext = `${password};${nonce}`;
addLog(`🔐 加密明文格式: ${password.substring(0,3)}***;${nonce.substring(0,8)}...`);
// 4) PKCS#1 v1.5 加密
let encryptedBytes;
try {
encryptedBytes = publicKey.encrypt(plaintext, 'RSAES-PKCS1-v1_5');
} catch (e) {
throw new Error('PKCS1_v1_5 加密失败');
}
// 5) 输出Base64
const result = forge.util.encode64(encryptedBytes);
addLog('✅ 密码加密成功', 'success');
return result;
} catch (error) {
addLog(`❌ 密码加密失败: ${error.message}`, 'error');
throw new Error(`密码加密失败: ${error.message}`);
}
}
// 执行登录 - 对标Python _login_thread
async function performLogin() {
try {
addLog("ℹ️ 正在登录...");
// 初始化主会话
if (!globalState.mainSession) {
globalState.mainSession = {
headers: {},
cookies: new Map()
};
}
// 1. 获取公钥和随机数 - 对标Python第240-245行
addLog("🔐 正在获取登录公钥...");
const keyData = await makeRequest({
url: `${CONFIG.BASE_URL}/login/publicKey`,
method: 'GET'
});
addLog(`🔐 公钥接口响应: code=${keyData.code}`);
if (keyData.code !== 0) {
throw new Error(`获取公钥失败: ${keyData.message}`);
}
const { publicKey, nonceStr } = keyData.data;
addLog(`🔐 获取到公钥长度: ${publicKey ? publicKey.length : 'null'}, nonce长度: ${nonceStr ? nonceStr.length : 'null'}`);
// 2. 加密密码 - 对标Python第247-249行
const username = document.getElementById('seat-sniper-username').value;
const password = document.getElementById('seat-sniper-password').value;
if (!username || !password) {
throw new Error("请输入学号和密码");
}
addLog("🔐 正在加密密码...");
const encryptedPassword = await encryptPassword(password, publicKey, nonceStr);
// 3. 执行登录 - 对标Python第251-266行
addLog("🔐 正在登录...");
const loginPayload = {
logonName: username,
password: encryptedPassword,
captcha: "",
privacy: true,
consoleType: 16
};
const loginData = await makeRequest({
url: `${CONFIG.BASE_URL}/login/user`,
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=UTF-8'
},
data: JSON.stringify(loginPayload)
});
addLog(`🔐 登录接口响应: code=${loginData.code}, message=${loginData.message || 'none'}`);
// 4. 处理登录结果 - 对标Python第268-273行
if (loginData.code === 0) {
globalState.token = loginData.data.token;
globalState.accNo = loginData.data.accNo;
// 保存登录信息
GM_setValue('saved_username', username);
GM_setValue('saved_password', password);
GM_setValue('saved_token', globalState.token);
GM_setValue('saved_accNo', globalState.accNo);
addLog("✅ 登录成功", 'success');
showNotification("登录成功", `欢迎 ${username}`, 'success');
updateLoginButton(true);
return true;
} else {
throw new Error(loginData.message || '登录失败');
}
} catch (error) {
addLog(`❌ 登录失败: ${error.message}`, 'error');
showNotification("登录失败", error.message, 'error');
return false;
}
}
// 更新登录按钮状态
function updateLoginButton(isLoggedIn) {
const loginBtn = document.getElementById('seat-sniper-login-btn');
if (!loginBtn) return;
if (isLoggedIn) {
loginBtn.textContent = '已登录';
loginBtn.style.background = '#4CAF50';
loginBtn.disabled = true;
} else {
loginBtn.textContent = '登录';
loginBtn.style.background = '#2196F3';
loginBtn.disabled = false;
}
}
// ========================== 抢座核心逻辑 - 对标Python ==========================
// 单个预约请求 - 对标Python _single_reserve_request
async function singleReserveRequest(seatName, session, workerId) {
try {
const roomId = document.getElementById('seat-sniper-room-id').value;
const dateStr = ymdNoDash(document.getElementById('seat-sniper-date').value);
// ★ 性能优化:使用缓存的座位ID ★
const cacheKey = `${roomId}_${dateStr}_${seatName}`;
let deviceId = globalState.seatIdCache.get(cacheKey);
if (!deviceId) {
// 1. 查询座位ID - 对标Python第440-451行
const reserveData = await session.request({
url: `${CONFIG.BASE_URL}/reserve?roomIds=${roomId}&resvDates=${dateStr}&sysKind=8`,
method: 'GET'
});
if (reserveData.code !== 0) {
throw new Error(`查询座位失败: ${reserveData.message}`);
}
// 查找座位ID - 对标Python第453-457行
const devices = reserveData.data;
const device = devices.find(d => d.devName === seatName);
if (!device) {
addLog(`❌ 工作线程${workerId}: 找不到座位 ${seatName}`, 'error');
return false;
}
deviceId = device.devId;
// 缓存座位ID,避免重复查询
globalState.seatIdCache.set(cacheKey, deviceId);
}
// 2. 执行预约 - 对标Python第459-475行
const beginTime = ensureHHMMSS(document.getElementById('seat-sniper-begin-time').value);
const endTime = ensureHHMMSS(document.getElementById('seat-sniper-end-time').value);
const dateValue = document.getElementById('seat-sniper-date').value.trim();
const payload = {
sysKind: 8,
appAccNo: globalState.accNo,
memberKind: 1,
resvMember: [globalState.accNo],
resvBeginTime: `${dateValue} ${beginTime}`,
resvEndTime: `${dateValue} ${endTime}`,
captcha: "",
resvProperty: 0,
resvDev: [deviceId],
memo: ""
};
const result = await session.request({
url: `${CONFIG.BASE_URL}/reserve`,
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=UTF-8'
},
data: JSON.stringify(payload)
});
// 3. 处理预约结果 - 对标Python第489-501行
if (result.code === 0) {
addLog(`🎉 工作线程${workerId}: ${seatName} 预约成功!`, 'success');
showNotification("抢座成功", `${seatName} 预约成功!`, 'success');
return true;
} else {
const message = result.message || '未知错误';
if (message.includes('当前设备正在被预约')) {
addLog(`🔒 工作线程${workerId}: ${seatName} - ${message}`, 'warning');
} else if (message.includes('请在22:00后开始预约')) {
addLog(`⏰ 工作线程${workerId}: ${seatName} - ${message}`, 'warning');
} else {
addLog(`❌ 工作线程${workerId}: ${seatName} 失败 - ${message}`, 'error');
}
return false;
}
} catch (error) {
if (error.message === 'Request timeout') {
addLog(`⏱️ 工作线程${workerId}: ${seatName} 请求超时`, 'warning');
} else {
addLog(`❌ 工作线程${workerId}: ${seatName} 异常 - ${error.message}`, 'error');
}
return false;
}
}
// 并发预约单个座位 - 优化版:快速超时切换
async function concurrentReserveSeat(seatName) {
const promises = [];
// 提交并发任务 - 30个线程同时发起
for (let i = 0; i < globalState.currentConcurrentRequests; i++) {
if (globalState.stopFlag) break;
// 每个并发请求使用不同的session
const session = globalState.sessionPool[i % globalState.sessionPool.length];
promises.push(singleReserveRequest(seatName, session, i + 1));
}
try {
// ★ 关键优化:200ms超时,快速切换到下一轮 ★
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('轮次超时')), CONFIG.CONCURRENT_TIMEOUT);
});
const raceResult = await Promise.race([
Promise.allSettled(promises),
timeoutPromise
]);
// 检查是否有成功的请求
if (raceResult && Array.isArray(raceResult)) {
const success = raceResult.some(result => result.status === 'fulfilled' && result.value === true);
if (success) {
addLog(`🎉 ${seatName} 预约成功!`, 'success');
globalState.stopFlag = true;
return true;
}
}
// 没有成功,记录本轮结果
addLog(`⏱️ ${seatName} 本轮未成功,${CONFIG.CONCURRENT_TIMEOUT}ms后继续下一轮`, 'info');
return false;
} catch (error) {
if (error.message === '轮次超时') {
addLog(`⏱️ ${seatName} 轮次超时(${CONFIG.CONCURRENT_TIMEOUT}ms),切换下一轮`, 'warning');
} else {
addLog(`⚠️ ${seatName} 并发请求异常: ${error.message}`, 'error');
}
return false;
}
}
// 抢座主循环 - 优化版:快速轮次切换
async function sniperMainLoop() {
const seatNames = collectSeatNames();
if (seatNames.length === 0) {
addLog("❌ 没有配置座位名称", 'error');
return;
}
addLog("🚀 开始高速循环尝试!(快速轮次切换模式)", 'info');
addLog(`📊 策略:每轮${globalState.currentConcurrentRequests}个并发,${CONFIG.CONCURRENT_TIMEOUT}ms超时,${CONFIG.ROUND_INTERVAL}ms间隔`, 'info');
addLog(`🎯 目标座位: ${seatNames.join(', ')}`, 'info');
let roundCount = 0;
// 主循环 - 快速轮次模式
while (!globalState.stopFlag) {
roundCount++;
let success = false;
// 遍历每个座位
for (const seatName of seatNames) {
if (globalState.stopFlag) break;
addLog(`🎯 第${roundCount}轮 尝试座位:${seatName}`, 'info');
// 并发请求同一个座位(200ms超时)
if (await concurrentReserveSeat(seatName)) {
addLog("✅ 抢到座位,停止任务", 'success');
success = true;
globalState.stopFlag = true;
break;
}
// 座位间无间隔(CONFIG.PER_ATTEMPT_GAP_MS = 0)
if (CONFIG.PER_ATTEMPT_GAP_MS > 0 && !globalState.stopFlag) {
await new Promise(resolve => setTimeout(resolve, CONFIG.PER_ATTEMPT_GAP_MS));
}
}
if (success) break;
// ★ 关键优化:轮次间隔400ms,避免过于频繁 ★
if (!globalState.stopFlag) {
addLog(`⏸️ 第${roundCount}轮结束,${CONFIG.ROUND_INTERVAL}ms后开始第${roundCount + 1}轮`, 'info');
await new Promise(resolve => setTimeout(resolve, CONFIG.ROUND_INTERVAL));
}
}
stopSniper();
}
// ========================== 性能优化函数 ==========================
// 预热座位查询,提前缓存座位ID
async function preloadSeatIds(seatNames) {
if (seatNames.length === 0) return;
try {
addLog("🔥 预热座位查询缓存...", 'info');
const roomId = document.getElementById('seat-sniper-room-id').value;
const dateStr = ymdNoDash(document.getElementById('seat-sniper-date').value);
// 使用第一个session查询座位数据
const session = globalState.sessionPool[0];
const reserveData = await session.request({
url: `${CONFIG.BASE_URL}/reserve?roomIds=${roomId}&resvDates=${dateStr}&sysKind=8`,
method: 'GET'
});
if (reserveData.code === 0) {
const devices = reserveData.data;
// 缓存所有目标座位的ID
let cachedCount = 0;
seatNames.forEach(seatName => {
const device = devices.find(d => d.devName === seatName);
if (device) {
const cacheKey = `${roomId}_${dateStr}_${seatName}`;
globalState.seatIdCache.set(cacheKey, device.devId);
cachedCount++;
}
});
addLog(`✅ 预热完成,缓存了${cachedCount}个座位ID`, 'success');
} else {
addLog(`⚠️ 预热失败: ${reserveData.message}`, 'warning');
}
} catch (error) {
addLog(`⚠️ 预热异常: ${error.message}`, 'warning');
}
}
// ========================== 准备开始抢座 - 对标Python _prepare_sniper ==========================
async function prepareSniper() {
// 对标Python第283-286行
if (globalState.isRunning) {
globalState.stopFlag = true;
stopSniper();
return;
}
globalState.stopFlag = false;
// ★ 解析并发数配置 - 对标Python第291-305行 ★
try {
globalState.currentConcurrentRequests = parseInt(document.getElementById('seat-sniper-concurrent').value);
if (globalState.currentConcurrentRequests <= 0) {
addLog("❌ 并发数必须大于0", 'error');
return;
}
if (globalState.currentConcurrentRequests > CONFIG.MAX_CONCURRENT_REQUESTS) {
addLog(`❌ 并发数不能超过${CONFIG.MAX_CONCURRENT_REQUESTS}(系统保护)`, 'error');
return;
}
globalState.currentMaxWorkers = globalState.currentConcurrentRequests + Math.max(10, Math.floor(globalState.currentConcurrentRequests / 5));
addLog(`🔧 配置并发数: ${globalState.currentConcurrentRequests}, 线程池大小: ${globalState.currentMaxWorkers}`, 'info');
} catch (error) {
addLog("❌ 请输入有效的并发数", 'error');
return;
}
// 检查登录状态 - 对标Python第307-314行
if (!globalState.token || !globalState.accNo) {
addLog("ℹ️ 尚未登录,正在自动登录…", 'info');
const loginSuccess = await performLogin();
if (!loginSuccess) {
addLog("❌ 登录失败,无法开始抢座", 'error');
return;
}
}
// ★ 解析座位列表 - 对标Python第316-320行 ★
const seats = collectSeatNames();
if (seats.length === 0) {
addLog("⚠️ 无座位名,终止", 'warning');
return;
}
// ★ 初始化线程池和会话池 - 对标Python第322-327行 ★
initSessionPool();
syncLoginToAllSessions();
// ★ 性能优化:预热座位查询 ★
await preloadSeatIds(seats);
// 计算等待时间 - 对标Python第329-347行
const targetTime = ensureHHMMSS(document.getElementById('seat-sniper-start-time').value);
const today = new Date().toISOString().split('T')[0];
const targetDateTime = new Date(`${today} ${targetTime}`);
// 如果目标时间已过,则设置为明天
if (targetDateTime < new Date()) {
targetDateTime.setDate(targetDateTime.getDate() + 1);
}
const delay = Math.max(0, targetDateTime.getTime() - new Date().getTime());
globalState.isRunning = true;
addLog(`⏳ 等待到 ${targetTime} 开始抢座...`, 'info');
addLog(`🚀 将使用 ${globalState.currentConcurrentRequests} 个并发请求同时抢座!`, 'info');
addLog(`🎯 目标座位: ${seats.join(', ')}`, 'info');
updateStartButton(true);
startFlashing();
if (delay > 0) {
addLog(`⏰ 距离开始还有 ${Math.round(delay / 1000)} 秒`, 'info');
setTimeout(() => {
if (!globalState.stopFlag) {
sniperMainLoop();
}
}, delay);
} else {
// 立即开始
sniperMainLoop();
}
}
// 停止抢座 - 对标Python _stop_flashing
function stopSniper() {
globalState.isRunning = false;
globalState.stopFlag = true;
stopFlashing();
updateStartButton(false);
addLog("⏹️ 抢座已停止", 'info');
}
// ========================== UI 控制 - 简化版 ==========================
function updateStartButton(isRunning) {
const startBtn = document.getElementById('seat-sniper-start-btn');
if (!startBtn) return;
if (isRunning) {
startBtn.textContent = '停止抢座';
startBtn.style.background = '#f44336';
startBtn.onclick = stopSniper;
} else {
startBtn.textContent = `开始抢座 (并发数:${globalState.currentConcurrentRequests})`;
startBtn.style.background = '#E53935';
startBtn.onclick = prepareSniper;
}
}
function startFlashing() {
const colors = ['#E53935', '#2E7D32', '#1565C0'];
let colorIndex = 0;
globalState.flashInterval = setInterval(() => {
const startBtn = document.getElementById('seat-sniper-start-btn');
if (startBtn && globalState.isRunning) {
startBtn.style.background = colors[colorIndex % colors.length];
colorIndex++;
}
}, 220);
}
function stopFlashing() {
if (globalState.flashInterval) {
clearInterval(globalState.flashInterval);
globalState.flashInterval = null;
}
const startBtn = document.getElementById('seat-sniper-start-btn');
if (startBtn) {
startBtn.style.background = '#E53935';
}
}
function updateCurrentTime() {
const timeLabel = document.getElementById('seat-sniper-current-time');
if (timeLabel) {
timeLabel.textContent = formatCurrentTime();
}
}
// ========================== UI 创建 - 可收缩展开版 ==========================
function createUI() {
if (document.getElementById('seat-sniper-container')) {
return;
}
// 加载保存的配置
const savedUsername = GM_getValue('saved_username', '');
const savedPassword = GM_getValue('saved_password', '');
const savedRoomId = GM_getValue('saved_room_id', '1');
const savedSeats = JSON.parse(GM_getValue('saved_seats', '["", "", ""]'));
const savedToken = GM_getValue('saved_token', '');
const savedAccNo = GM_getValue('saved_accNo', '');
const savedExpanded = GM_getValue('ui_expanded', false); // 记住展开状态
if (savedToken && savedAccNo) {
globalState.token = savedToken;
globalState.accNo = savedAccNo;
}
// 创建主容器
const container = document.createElement('div');
container.id = 'seat-sniper-container';
container.className = savedExpanded ? 'expanded' : 'collapsed';
container.innerHTML = `
<style>
#seat-sniper-container {
position: fixed;
top: 20px;
right: 20px;
background: #fff;
border: 2px solid #ddd;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
z-index: 10000;
font-family: Arial, sans-serif;
font-size: 14px;
transition: all 0.3s ease;
cursor: move;
user-select: none;
}
/* 收缩状态 */
#seat-sniper-container.collapsed {
width: 250px;
height: auto;
}
/* 展开状态 */
#seat-sniper-container.expanded {
width: 420px;
height: auto;
}
.sniper-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 15px;
border-radius: 8px 8px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
font-size: 16px;
cursor: move;
position: relative;
}
.sniper-title {
flex: 1;
}
.toggle-size-btn {
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.3s;
}
.toggle-size-btn:hover {
background: rgba(255,255,255,0.3);
}
.sniper-content {
padding: 15px;
cursor: default;
}
/* 收缩状态下隐藏部分内容 */
.collapsed .expandable-content {
display: none;
}
.collapsed .sniper-content {
padding: 10px 15px;
}
.form-row {
display: flex;
align-items: center;
margin-bottom: 12px;
gap: 8px;
}
.form-row.compact {
margin-bottom: 8px;
}
.form-row label {
min-width: 60px;
font-weight: bold;
color: #333;
font-size: 13px;
}
.form-row input, .form-row textarea {
flex: 1;
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
}
/* 收缩状态下输入框宽度限制 */
.collapsed .form-row input[type="text"],
.collapsed .form-row input[type="password"] {
max-width: 120px;
}
.seat-grid {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.seat-input {
width: 60px !important;
flex: none !important;
padding: 4px 6px !important;
text-align: center;
border: 2px solid #ddd !important;
border-radius: 4px !important;
font-size: 11px !important;
font-weight: bold;
}
.seat-input:focus {
border-color: #4CAF50 !important;
box-shadow: 0 0 3px rgba(76, 175, 80, 0.3);
outline: none;
}
.btn-primary {
background: #2196F3;
color: white;
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
font-size: 12px;
}
.btn-danger {
background: #E53935;
color: white;
width: 100%;
padding: 10px;
font-size: 14px;
margin-top: 8px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.current-time {
font-family: 'Courier New', monospace;
font-weight: bold;
color: #1976D2;
font-size: 12px;
}
.log-area {
height: 150px;
overflow-y: auto;
background: #0b1020;
color: #cde0ff;
padding: 8px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 11px;
}
.expanded .log-area {
height: 200px;
}
.log-entry {
margin-bottom: 3px;
line-height: 1.3;
}
.log-entry .timestamp {
color: #888;
}
.log-success {
color: #4CAF50;
}
.log-error {
color: #f44336;
}
.log-warning {
color: #ff9800;
}
.separator {
height: 1px;
background: #eee;
margin: 10px 0;
}
.highlight-input {
border: 2px solid #4CAF50 !important;
font-weight: bold;
}
.dragging {
opacity: 0.8;
transform: scale(1.02);
}
/* 状态指示 */
.status-indicator {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #4CAF50;
border: 2px solid white;
}
/* 切换按钮容器 */
.toggle-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.toggle-hint {
font-size: 8px;
color: rgba(255,255,255,0.8);
line-height: 1;
pointer-events: none;
}
</style>
<div class="sniper-header" id="sniper-header">
<div class="sniper-title">🎯 抢座助手</div>
<div class="toggle-container">
<button class="toggle-size-btn" id="toggle-size-btn" title="展开/收缩">🎯</button>
<span class="toggle-hint" id="toggle-hint">小</span>
</div>
<div class="status-indicator"></div>
</div>
<div class="sniper-content">
<!-- 基本信息(始终显示) -->
<div class="form-row compact">
<label>学号:</label>
<input type="text" id="seat-sniper-username" value="${savedUsername}" placeholder="请输入学号">
<button id="seat-sniper-login-btn" class="btn-primary">登录</button>
</div>
<div class="form-row compact">
<label>密码:</label>
<input type="password" id="seat-sniper-password" value="${savedPassword}" placeholder="请输入密码">
</div>
<!-- 可展开内容 -->
<div class="expandable-content">
<div class="form-row compact">
<label>房间ID:</label>
<input type="text" id="seat-sniper-room-id" value="${savedRoomId}" placeholder="房间ID">
</div>
<div class="separator"></div>
<div class="form-row compact">
<label>当前时间:</label>
<span id="seat-sniper-current-time" class="current-time">${formatCurrentTime()}</span>
</div>
<div class="form-row compact">
<label>抢座日期:</label>
<input type="date" id="seat-sniper-date" value="${new Date(Date.now() + 24*60*60*1000).toISOString().split('T')[0]}">
</div>
<div class="form-row compact">
<label>预约时段:</label>
<input type="time" id="seat-sniper-begin-time" value="08:00" style="width: 60px;">
<span>-</span>
<input type="time" id="seat-sniper-end-time" value="20:00" style="width: 60px;">
</div>
<div class="form-row compact">
<label>开始时间:</label>
<input type="time" id="seat-sniper-start-time" value="21:59:39" step="1">
</div>
<div class="separator"></div>
<div class="form-row compact">
<label>并发数:</label>
<input type="number" id="seat-sniper-concurrent" value="${CONFIG.DEFAULT_CONCURRENT_REQUESTS}"
min="1" max="${CONFIG.MAX_CONCURRENT_REQUESTS}" class="highlight-input" style="width: 80px;">
</div>
</div>
<!-- 座位配置(始终显示) -->
<div class="form-row compact">
<label>座位:</label>
<div class="seat-grid">
<input type="text" class="seat-input" value="${savedSeats[0] || ''}" placeholder="1">
<input type="text" class="seat-input" value="${savedSeats[1] || ''}" placeholder="2">
<input type="text" class="seat-input" value="${savedSeats[2] || ''}" placeholder="3">
</div>
</div>
<!-- 开始按钮(始终显示) -->
<button id="seat-sniper-start-btn" class="btn-danger">开始抢座 (并发:${CONFIG.DEFAULT_CONCURRENT_REQUESTS})</button>
<!-- 日志区域(始终显示,但高度会变化) -->
<div style="margin-top: 10px;">
<label><strong>📋 日志:</strong></label>
<div id="seat-sniper-log" class="log-area"></div>
</div>
</div>
`;
document.body.appendChild(container);
// 绑定事件
document.getElementById('seat-sniper-login-btn').onclick = performLogin;
document.getElementById('seat-sniper-start-btn').onclick = prepareSniper;
// 展开/收缩切换
document.getElementById('toggle-size-btn').onclick = function(e) {
e.stopPropagation(); // 防止触发拖拽
const isExpanded = container.classList.contains('expanded');
if (isExpanded) {
container.classList.remove('expanded');
container.classList.add('collapsed');
GM_setValue('ui_expanded', false);
document.getElementById('toggle-hint').textContent = '小';
} else {
container.classList.remove('collapsed');
container.classList.add('expanded');
GM_setValue('ui_expanded', true);
document.getElementById('toggle-hint').textContent = '大';
}
};
// 设置初始提示文字
document.getElementById('toggle-hint').textContent = savedExpanded ? '大' : '小';
// 添加拖拽功能
makeDraggable(container);
// 自动保存配置
const usernameInput = document.getElementById('seat-sniper-username');
const passwordInput = document.getElementById('seat-sniper-password');
const roomIdInput = document.getElementById('seat-sniper-room-id');
usernameInput.addEventListener('input', function() {
GM_setValue('saved_username', this.value);
});
passwordInput.addEventListener('input', function() {
GM_setValue('saved_password', this.value);
});
roomIdInput.addEventListener('input', function() {
GM_setValue('saved_room_id', this.value);
});
// 座位配置自动保存
const seatInputs = document.querySelectorAll('.seat-input');
seatInputs.forEach(input => {
input.addEventListener('input', function() {
const seats = [];
seatInputs.forEach(s => seats.push(s.value.trim()));
GM_setValue('saved_seats', JSON.stringify(seats));
});
});
// 并发数更新
const concurrentInput = document.getElementById('seat-sniper-concurrent');
concurrentInput.addEventListener('input', function() {
globalState.currentConcurrentRequests = parseInt(this.value) || CONFIG.DEFAULT_CONCURRENT_REQUESTS;
updateStartButton(false);
});
// 开始时间更新
globalState.timeUpdateInterval = setInterval(updateCurrentTime, CONFIG.UI_UPDATE_INTERVAL);
// 检查登录状态
updateLoginButton(globalState.token && globalState.accNo);
addLog("🚀 图书馆抢座助手已启动 - 功能完整版", 'success');
addLog(`📊 默认并发数: ${CONFIG.DEFAULT_CONCURRENT_REQUESTS}`, 'info');
addLog(`⏱️ 请求超时: ${CONFIG.REQ_TIMEOUT}ms`, 'info');
}
// 拖拽功能
function makeDraggable(element) {
let isDragging = false;
let currentX;
let currentY;
let initialX;
let initialY;
let xOffset = 0;
let yOffset = 0;
const header = element.querySelector('.sniper-header');
header.addEventListener('mousedown', dragStart);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', dragEnd);
function dragStart(e) {
if (e.target.closest('.toggle-size-btn') || e.target.closest('.toggle-container')) {
return; // 不拖拽切换按钮和容器
}
initialX = e.clientX - xOffset;
initialY = e.clientY - yOffset;
if (e.target === header || header.contains(e.target)) {
isDragging = true;
element.classList.add('dragging');
}
}
function drag(e) {
if (isDragging) {
e.preventDefault();
currentX = e.clientX - initialX;
currentY = e.clientY - initialY;
xOffset = currentX;
yOffset = currentY;
// 限制拖拽范围在窗口内
const rect = element.getBoundingClientRect();
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
currentX = Math.max(0, Math.min(currentX, maxX));
currentY = Math.max(0, Math.min(currentY, maxY));
xOffset = currentX;
yOffset = currentY;
setTranslate(currentX, currentY, element);
}
}
function dragEnd() {
if (isDragging) {
initialX = currentX;
initialY = currentY;
isDragging = false;
element.classList.remove('dragging');
// 保存位置
GM_setValue('container_position_x', currentX);
GM_setValue('container_position_y', currentY);
}
}
function setTranslate(xPos, yPos, el) {
el.style.transform = `translate3d(${xPos}px, ${yPos}px, 0)`;
}
// 恢复保存的位置
const savedX = GM_getValue('container_position_x', null);
const savedY = GM_getValue('container_position_y', null);
// 如果有保存的位置,则恢复;否则使用默认位置
if (savedX !== null && savedY !== null) {
xOffset = savedX;
yOffset = savedY;
setTranslate(savedX, savedY, element);
} else {
// 首次使用时,保存当前默认位置
GM_setValue('container_position_x', 0);
GM_setValue('container_position_y', 0);
}
}
// ========================== 初始化 ==========================
function init() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createUI);
} else {
createUI();
}
}
// 页面卸载时清理
window.addEventListener('beforeunload', function() {
if (globalState.timeUpdateInterval) {
clearInterval(globalState.timeUpdateInterval);
}
if (globalState.flashInterval) {
clearInterval(globalState.flashInterval);
}
globalState.stopFlag = true;
});
// 启动
init();
})();