// 仓库地址(持续维护、更新中): https://github.com/ceilf6/Auto_courseGrabber // https://github.com/ceilf6 // https://blog.csdn.net/2301_78856868 // 使用方法: // 1. 登录教务系统并进入选课页面 // 2. 配置目标课程列表 TARGET_COURSES(现在已经支持UI界面,所以在UI界面上配置也没事) // 3. 按F12打开控制台,粘贴此脚本并执行 // 4. 输入 grab.start() 开始抢课 // 注意!!! 目标课程得在页面中出现(可以在不是选课时间到目标课程展示的页面并打开定时开抢功能、教学班卡片信息本脚本已经自动实现展开所以不重要),DOM树一定得展开否则无法找到 !!! (function () { // ========= 防止重复粘贴/重复执行 ========= const __CG_GLOBAL__ = typeof window !== "undefined" ? window : globalThis; const __CG_LOADED_KEY__ = "__AUTO_COURSE_GRABBER_LOADED__"; const __CG_INSTANCE_KEY__ = "__AUTO_COURSE_GRABBER_INSTANCE_ID__"; const __CG_REFRESH_LOCK_KEY__ = "__AUTO_COURSE_GRABBER_LAST_REFRESH_AT__"; if (__CG_GLOBAL__[__CG_LOADED_KEY__]) { try { // 尝试停止旧实例(如果旧实例仍挂在 window.grab 上) if (__CG_GLOBAL__.grab && typeof __CG_GLOBAL__.grab.stop === "function") { __CG_GLOBAL__.grab.stop(); } } catch (e) { // 忽略停止失败 } console.warn( "[抢课脚本] 检测到脚本已加载过一次:已尝试停止旧实例,并将覆盖为新实例。", ); } __CG_GLOBAL__[__CG_LOADED_KEY__] = true; __CG_GLOBAL__[__CG_INSTANCE_KEY__] = `${Date.now()}-${Math.random().toString(16).slice(2)}`; ("use strict"); // ========== 防御性地保存原生方法引用 ========== // 在脚本执行前保存原生的 Array.prototype.filter,防止被页面污染 // 假如使用猴子补丁,可能会导致原先系统中的功能出错,还是选择耦合度低、入侵性小的方案 const nativeArrayFilter = Array.prototype.filter; const nativeArrayMap = Array.prototype.map; /** * 安全的数组 filter 函数 * 使用保存的原生 filter 方法,避免被页面污染 * @param {Array} array - 要过滤的数组 * @param {Function} callback - 过滤回调函数 * @returns {Array} - 过滤后的数组 */ function safeFilter(array, callback) { if (!array || !Array.isArray(array)) { return []; } try { return nativeArrayFilter.call(array, callback); } catch (e) { // 如果原生方法也失败,回退到手动循环 const result = []; for (let i = 0; i < array.length; i++) { try { if (callback(array[i], i, array)) { result.push(array[i]); } } catch (err) { // 忽略回调执行失败的项 } } return result; } } /** * 安全的数组 map 函数 * 使用保存的原生 map 方法,避免被页面污染 * @param {Array} array - 要映射的数组 * @param {Function} callback - 映射回调函数 * @returns {Array} - 映射后的数组 */ function safeMap(array, callback) { if (!array || !Array.isArray(array)) { return []; } try { return nativeArrayMap.call(array, callback); } catch (e) { // 如果原生方法也失败,回退到手动循环 const result = []; for (let i = 0; i < array.length; i++) { try { result.push(callback(array[i], i, array)); } catch (err) { result.push(undefined); } } return result; } } // ========== 配置参数 ========== // 支持多门课程同时抢课,格式: [{code: '课程号或课程名称', priority: 优先级, timeFilter: 时间过滤, teacherFilter: 教师过滤}] // code 字段支持两种输入方式: // 1. 课程号(纯数字):如 '23286514' // 2. 课程名称(包含中文):如 '机器学习'、'计算机控制' const TARGET_COURSES = [ // 示例配置: // { code: '23286514', priority: 1 }, // 使用课程号,高优先级,无过滤 // { code: '机器学习', priority: 1 }, // 使用课程名称,高优先级,无过滤 // { code: 'CS102', priority: 2, timeFilter: ['星期一', '星期三'] }, // 只选星期一或星期三的课 // { code: 'CS103', priority: 3, teacherFilter: ['张三', '李四'] } // 只选张三或李四的课 // { code: 'CS104', priority: 4, timeFilter: ['第1-2节'], teacherFilter: ['王五'] } // 同时过滤时间和教师 ]; const CHECK_INTERVAL = 1000; // 检查间隔(毫秒) const MAX_ATTEMPTS = 3000; // 最大尝试次数 const MAX_FAILED_ATTEMPTS = 10; // 最大连续失败次数 const RETRY_DELAY = 3000; // 重试延迟(毫秒) const CONCURRENT_ENABLED = true; // 是否启用并发抢课 const CLICK2EXPEND_ENABLED = true; // 用户设置: 是否在 jQuery 后自动展开目标课程信息,用于时间筛选和教师筛选 let click2expend_enabled = true; // 用于脚本自动关闭 // ========== 过滤器配置 ========== // 全局时间过滤器(可选)- 留空表示不过滤,支持多个关键词,满足任意一个即可 // 示例: ['星期一', '星期三', '第1-2节', '第11-12节'] const GLOBAL_TIME_FILTER = []; // 全局教师过滤器(可选)- 留空表示不过滤,支持多个关键词,满足任意一个即可 // 示例: ['张三', '张三', '讲师'] const GLOBAL_TEACHER_FILTER = []; // ========== 全局状态管理 ========== let attemptCount = 0; let isRunning = false; let intervalId = null; let refreshInProgress = false; // 避免刷新分支在 attemptCount 未变化时被重复触发 let refreshTimeoutId = null; // 刷新后延迟抢课的 timeout // 多课程状态管理 let courseStates = new Map(); // 每门课程的状态: {courseCode: {attempts, failed, tried, conflicted, selecting, success}} let selectedCourses = new Set(); // 已成功选上的课程 let activeCourses = new Set(); // 当前活跃的课程列表 // 全局选课队列 let selectingQueue = []; // 正在处理的选课任务队列 let isProcessingQueue = false; // 是否正在处理队列 // 定时开抢相关 let scheduledTime = null; // 计划开抢时间 let schedulerIntervalId = null; // 定时器ID let isScheduled = false; // 是否已设置定时 // ========== 工具函数 ========== /** * 安全的字符串分割与过滤函数 * 使用安全的 filter 函数避免被页面污染 * @param {string} input - 原始输入字符串 * @param {RegExp} [separatorRegex=/[,,;;]+/] - 分隔符正则 * @returns {string[]} - 过滤后的非空字符串数组 */ function safeParseFilterInput(input, separatorRegex = /[,,;;]+/) { if (!input || typeof input !== "string") { return []; } const raw = input.trim(); if (!raw) { return []; } // 分割字符串 const splitResult = raw.split(separatorRegex); // Trim 每个元素并过滤空字符串 const trimmed = safeMap(splitResult, (item) => { try { return String(item).trim(); } catch (e) { return String(item); } }); // 过滤空字符串 return safeFilter(trimmed, (v) => { try { const str = String(v); return str && str.length > 0; } catch (e) { return false; } }); } /** * 判断输入是否为课程号 * 正方课程号可能是纯数字,也可能是 2328A009 / CS102 这类字母数字混合格式。 * @param {string} input - 用户输入 * @returns {boolean} - 是否为课程号 */ function isCourseCode(input) { const value = String(input).trim(); return /^[A-Za-z0-9_-]+$/.test(value) && /\d/.test(value); } /** * 从教学班名称中提取课程名称 * 教学班名称格式:课程名称-0001 * @param {string} jxbmc - 教学班名称 * @returns {string} - 课程名称 */ function extractCourseNameFromJxbmc(jxbmc) { if (!jxbmc) return ""; // 移除末尾的 -数字 部分 const match = jxbmc.match(/^(.+)-\d+$/); return match ? match[1].trim() : jxbmc.trim(); } function isCourseHeadMatching(head, courseCodeOrName) { const input = String(courseCodeOrName).trim(); const isCode = isCourseCode(input); if (isCode) { const codeInput = head.querySelector('input[name="kch_id"]'); if (codeInput && String(codeInput.value).trim() === input) { return true; } const kcmcText = head.querySelector("span.kcmc")?.textContent || ""; return kcmcText.includes(input); } const kcmcSpan = head.querySelector("span.kcmc"); const courseName = kcmcSpan?.querySelector("a")?.textContent?.trim() || kcmcSpan?.textContent?.trim() || ""; return courseName.toLowerCase().includes(input.toLowerCase()); } function findMatchingCourseHeads(courseCodeOrName) { const matchedHeads = []; const heads = document.querySelectorAll(".panel-heading.kc_head"); for (let head of heads) { if (isCourseHeadMatching(head, courseCodeOrName)) { matchedHeads.push(head); } } return matchedHeads; } function getCourseSectionFromHead(head) { return ( head.closest(".panel") || head.closest(".panel-info") || head.closest(".panel-default") || head.parentElement ); } function getTeachingRowsInCourseSection(head) { const rows = []; const seen = new Set(); const addRows = (rowList) => { for (let row of rowList) { if (!seen.has(row)) { seen.add(row); rows.push(row); } } }; const section = getCourseSectionFromHead(head); if (section) { addRows(section.querySelectorAll("table tbody tr, table tr.body_tr")); } let sibling = head.nextElementSibling; while (sibling && !sibling.matches(".panel-heading.kc_head")) { addRows(sibling.querySelectorAll("table tbody tr, table tr.body_tr")); sibling = sibling.nextElementSibling; } return rows; } /** * 展开课程详情(支持课程号和课程名称) * @param {string} courseCodeOrName - 课程号或课程名称 * @returns {boolean} - 是否成功展开 */ function expandCourseByCode(courseCodeOrName) { const heads = findMatchingCourseHeads(courseCodeOrName); for (let head of heads) { /*
*/ // 直接触发 click(等价于用户点击) head.click(); return true; } return false; } function forceExpandTargetCourses() { const targets = new Set([ ...activeCourses, ...TARGET_COURSES.map((c) => (typeof c === "string" ? c : c.code)), ]); targets.forEach((code) => { expandCourseByCode(code); }); } function forceExpandTargetCoursesAggressive() { let count = 0; const timer = setInterval(() => { forceExpandTargetCourses(); if (++count >= 3) clearInterval(timer); }, 300); } // 彩色日志函数 function log(message, type = "info", courseCode = null) { const timestamp = new Date().toLocaleTimeString(); const courseTag = courseCode ? `[${courseCode}]` : ""; const prefix = `[抢课脚本 ${timestamp}]${courseTag}`; switch (type) { case "success": console.log( `%c${prefix} ✅ ${message}`, "color: #00ff00; font-weight: bold;", ); break; case "error": console.log( `%c${prefix} ❌ ${message}`, "color: #ff0000; font-weight: bold;", ); break; case "warning": console.log( `%c${prefix} ⚠️ ${message}`, "color: #ffa500; font-weight: bold;", ); break; case "info": console.log(`%c${prefix} ℹ️ ${message}`, "color: #0099ff;"); break; } } // 初始化课程状态 function initCourseState(courseCode) { if (!courseStates.has(courseCode)) { courseStates.set(courseCode, { attempts: 0, failed: 0, tried: new Set(), conflicted: new Set(), selecting: false, success: false, lastAttempt: 0, }); } return courseStates.get(courseCode); } // 获取课程状态 function getCourseState(courseCode) { return courseStates.get(courseCode) || initCourseState(courseCode); } // 检查是否所有课程都已完成(成功或失败) function allCoursesCompleted() { for (let courseCode of activeCourses) { const state = getCourseState(courseCode); if (!state.success && state.failed < MAX_FAILED_ATTEMPTS) { return false; } } return true; } // 时间模糊匹配函数 function matchesTimeFilter(timeInfo, timeFilter) { // 如果没有配置过滤器,返回true(不过滤) if (!timeFilter || timeFilter.length === 0) { return true; } // 如果时间信息为空,返回false if (!timeInfo || timeInfo === "未知时间") { return false; } // 检查是否匹配任意一个时间关键词 for (let keyword of timeFilter) { if (timeInfo.includes(keyword)) { return true; } } return false; } // 教师模糊匹配函数 function matchesTeacherFilter(teacher, teacherFilter) { // 如果没有配置过滤器,返回true(不过滤) if (!teacherFilter || teacherFilter.length === 0) { return true; } // 如果教师信息为空,返回false if (!teacher || teacher === "未知教师") { return false; } // 检查是否匹配任意一个教师关键词 for (let keyword of teacherFilter) { if (teacher.includes(keyword)) { return true; } } return false; } // 检查教学班是否匹配过滤条件 function matchesFilters(teachingClass, courseCode) { // 获取该课程的配置 const courseConfig = TARGET_COURSES.find((c) => c.code === courseCode); // 获取时间和教师过滤器(优先使用课程特定配置,否则使用全局配置) const timeFilter = (courseConfig && courseConfig.timeFilter) || GLOBAL_TIME_FILTER; const teacherFilter = (courseConfig && courseConfig.teacherFilter) || GLOBAL_TEACHER_FILTER; // 检查时间过滤 const timeMatch = matchesTimeFilter( teachingClass.info.timeInfo, timeFilter, ); if (!timeMatch) { return { match: false, reason: "时间不匹配过滤条件" }; } // 检查教师过滤 const teacherMatch = matchesTeacherFilter( teachingClass.info.teacher, teacherFilter, ); if (!teacherMatch) { return { match: false, reason: "教师不匹配过滤条件" }; } return { match: true, reason: "通过过滤" }; } /** * 检查教学班行是否匹配目标课程(支持课程号和课程名称) * @param {HTMLElement} row - 教学班行元素 * @param {string} targetCourseCodeOrName - 课程号或课程名称 * @returns {boolean} - 是否匹配 */ function isRowMatchingCourse(row, targetCourseCodeOrName) { const input = String(targetCourseCodeOrName).trim(); const isCode = isCourseCode(input); if (isCode) { // 按课程号匹配:检查 td.kch_id const kchIdCell = row.querySelector("td.kch_id"); if (kchIdCell && kchIdCell.textContent.trim() === input) { return true; } } else { // 按课程名称匹配:检查 td.jxbmc 中的教学班名称 const jxbmcCell = row.querySelector("td.jxbmc"); if (jxbmcCell) { const jxbmcText = jxbmcCell.textContent.trim(); const courseName = extractCourseNameFromJxbmc(jxbmcText); // 精确匹配或包含匹配 if ( courseName === input || courseName.includes(input) || input.includes(courseName) ) { return true; } } } return false; } // 查找目标课程的所有教学班(支持课程号和课程名称) function findAllTeachingClasses(targetCourseCodeOrName) { const teachingClasses = []; const input = String(targetCourseCodeOrName).trim(); const seenRows = new Set(); const pushTeachingClass = (row) => { if (!row || seenRows.has(row)) { return; } seenRows.add(row); const selectButton = row.querySelector('button, a, input[type="button"]'); if (!selectButton) { return; } const classInfo = extractTeachingClassInfo(row); if (classInfo && classInfo.id) { teachingClasses.push({ row: row, info: classInfo, button: selectButton, courseCode: targetCourseCodeOrName, }); } }; // 方法1:直接遍历所有教学班行,按课程号或课程名称匹配 const allRows = document.querySelectorAll("table tbody tr.body_tr"); for (let row of allRows) { if (isRowMatchingCourse(row, input)) { pushTeachingClass(row); } } // 方法2:新版页面的教学班行可能不再包含课程号,改从匹配的课程头所在区域收集行 if (teachingClasses.length === 0) { const heads = findMatchingCourseHeads(input); for (let head of heads) { const scopedRows = getTeachingRowsInCourseSection(head); for (let row of scopedRows) { pushTeachingClass(row); } } } // 方法3:如果方法1/2都没找到,再尝试通过课程区域查找 if (teachingClasses.length === 0) { const isCode = isCourseCode(input); // 查找包含目标课程的所有元素 const allElements = document.querySelectorAll("*"); let courseSection = null; // 首先找到课程主行 for (let element of allElements) { const text = element.textContent || ""; if (isCode) { // 课程号匹配 if (text.includes(`(${input})`) || text.includes(input)) { courseSection = element.closest("div, section, table"); break; } } else { // 课程名称匹配 if (text.includes(input)) { courseSection = element.closest("div, section, table"); break; } } } if (!courseSection) { // 如果没找到课程区域,尝试表格行方式 const courseRows = document.querySelectorAll("table tbody tr"); for (let row of courseRows) { if (isRowMatchingCourse(row, input)) { // 找到匹配的行,查找同一表格中的所有教学班 const table = row.closest("table"); if (table) { const tableRows = table.querySelectorAll("tbody tr"); for (let classRow of tableRows) { if (isRowMatchingCourse(classRow, input)) { pushTeachingClass(classRow); } } } break; } } } else { // 在课程区域内查找所有教学班行 const classRows = courseSection.querySelectorAll("tr"); for (let row of classRows) { pushTeachingClass(row); } } } log( `找到 ${teachingClasses.length} 个教学班`, "info", targetCourseCodeOrName, ); return teachingClasses; } // 查找所有目标课程的教学班 function findAllCoursesTeachingClasses() { const allClasses = new Map(); // courseCode -> teachingClasses[] for (let courseCode of activeCourses) { const classes = findAllTeachingClasses(courseCode); if (classes.length > 0) { allClasses.set(courseCode, classes); } } return allClasses; } const DROP_BUTTON_EXCLUDED_TEXTS = ["取消退选", "已退选", "不可退选"]; function isElementClickable(element) { if (!element) { return false; } const tagName = element.tagName; return ( tagName === "BUTTON" || tagName === "A" || (tagName === "INPUT" && element.getAttribute("type") === "button") || Boolean(element.onclick) || Boolean(element.getAttribute("onclick")) ); } function findClickableElementByText(root, targetText, excludedTexts = []) { if (!root) { return null; } const elements = root.querySelectorAll("*"); const candidates = []; for (let element of elements) { const elementText = (element.textContent || "").trim(); if (!elementText.includes(targetText)) { continue; } if (excludedTexts.some((text) => elementText.includes(text))) { continue; } if (isElementClickable(element)) { let depth = 0; let current = element.parentElement; while (current && current !== root) { depth++; current = current.parentElement; } candidates.push({ element, exact: elementText === targetText, depth, textLength: elementText.length, nativeControl: element.tagName === "BUTTON" || element.tagName === "A" || (element.tagName === "INPUT" && element.getAttribute("type") === "button"), }); } } candidates.sort((a, b) => { if (a.exact !== b.exact) return a.exact ? -1 : 1; if (a.depth !== b.depth) return b.depth - a.depth; if (a.nativeControl !== b.nativeControl) return a.nativeControl ? -1 : 1; return a.textLength - b.textLength; }); return candidates[0]?.element || null; } function findSelectedCourseRows(courseCodeOrName, root = document) { const rows = []; const seenRows = new Set(); const input = String(courseCodeOrName).trim(); const isCode = isCourseCode(input); const addRowsFromSection = (section) => { if (!section) { return; } const rowCandidates = section.querySelectorAll( "li.list-group-item, table tbody tr, tr", ); for (let row of rowCandidates) { if (seenRows.has(row)) { continue; } const dropButton = findClickableElementByText( row, "退选", DROP_BUTTON_EXCLUDED_TEXTS, ); const rowText = row.textContent || ""; if (!dropButton && !rowText.includes("退选")) { continue; } seenRows.add(row); rows.push({ row: row, button: dropButton, courseCode: courseCodeOrName, }); } }; const matchesSelectedCourseSection = (section) => { const sectionId = section.id || ""; const courseCodeInput = section.querySelector('input[name="right_kchid"]'); const sectionCourseCode = courseCodeInput ? String(courseCodeInput.value).trim() : ""; const headingText = section.querySelector("h6")?.textContent || ""; if (isCode) { return ( sectionCourseCode === input || sectionId === `right_${input}` || sectionId === `right_ul_${input}` || headingText.includes(`(${input})`) ); } return headingText.includes(input); }; const selectedSections = root.querySelectorAll( '.outer_xkxx_list, [id^="right_"]', ); for (let section of selectedSections) { if (matchesSelectedCourseSection(section)) { addRowsFromSection(section); } } const rightCourseInputs = root.querySelectorAll('input[name="right_kchid"]'); for (let courseInput of rightCourseInputs) { const sectionCourseCode = String(courseInput.value).trim(); if (isCode && sectionCourseCode !== input) { continue; } const section = courseInput.closest(".outer_xkxx_list") || courseInput.closest("ul") || courseInput.parentElement; const headingText = section?.querySelector("h6")?.textContent || ""; if (!isCode && !headingText.includes(input)) { continue; } addRowsFromSection(section); } return rows; } function isTeachingClassMatchingIdentifier(teachingClass, courseIdentifier) { if (!teachingClass || !teachingClass.row) { return false; } const input = String(courseIdentifier).trim(); if (!input) { return false; } if (isCourseCode(input)) { const kchIdCell = teachingClass.row.querySelector("td.kch_id"); if (kchIdCell) { return kchIdCell.textContent.trim() === input; } const classCourseCode = String(teachingClass.courseCode || "").trim(); return classCourseCode === input; } const className = teachingClass.info?.className || ""; const courseName = extractCourseNameFromJxbmc(className); if ( courseName && (courseName === input || courseName.includes(input) || input.includes(courseName)) ) { return true; } const rowText = teachingClass.row.textContent || ""; return rowText.includes(input); } // 提取教学班信息 function extractTeachingClassInfo(row) { try { let className = ""; let teacher = ""; let capacity = ""; let timeInfo = ""; // 记录原始文本用于调试 const fullText = row.textContent || row.innerText || ""; // 优先使用表格特定类名提取信息(更准确) // .jxbmc - 教学班名称 const jxbmcEl = row.querySelector(".jxbmc, td.jxbmc"); if (jxbmcEl) { className = jxbmcEl.textContent.trim(); } // .jsxmzc - 上课教师(格式:【教师名】职称) const jsxmzcEl = row.querySelector(".jsxmzc, td.jsxmzc"); if (jsxmzcEl) { teacher = jsxmzcEl.textContent.trim(); } // .sksj - 上课时间 const sksjEl = row.querySelector(".sksj, td.sksj"); if (sksjEl) { timeInfo = sksjEl.textContent.trim(); } // .rsxx - 已选/容量(格式:【36/50】) const rsxxEl = row.querySelector(".rsxx, td.rsxx"); if (rsxxEl) { capacity = rsxxEl.textContent.trim(); } // 如果通过类名没找到,回退到遍历所有单元格 if (!className || !teacher || !capacity || !timeInfo) { const cells = row.querySelectorAll("td"); for (let cell of cells) { const text = cell.textContent.trim(); // 提取教学班名称(如:工程化学-0001) if (!className && text.includes("-") && text.match(/\d{4}/)) { className = text; } // 提取教师信息 if (!teacher && text.includes("【") && text.includes("】")) { teacher = text; } // 提取容量信息 - 只选择数字/数字格式 if (!capacity && text.match(/\d+\/\d+/)) { capacity = text; } // 提取时间信息 if ( !timeInfo && (text.includes("星期") || text.includes("第") || text.includes("节")) ) { timeInfo = text; } } } // 如果仍未找到基本信息,尝试从整个行文本中提取 if (!className || !teacher || !capacity) { // 尝试提取教学班名称 if (!className) { const classMatch = fullText.match(/([^-\s]+[-]\d{4})/); if (classMatch) { className = classMatch[1]; } } // 尝试提取教师 if (!teacher) { const teacherMatch = fullText.match(/【([^】]+)】/); if (teacherMatch) { teacher = `【${teacherMatch[1]}】`; } } // 尝试提取容量 if (!capacity) { const capacityMatch = fullText.match(/(\d+\/\d+|已满)/); if (capacityMatch) { capacity = capacityMatch[1]; } } // 尝试提取时间 if (!timeInfo) { const timeMatch = fullText.match(/(星期[一二三四五六日][^星期]*)/g); if (timeMatch) { timeInfo = timeMatch.join(" "); } } } // 更宽松的信息检查 - 只要有按钮就认为是有效的教学班 const hasButton = row.querySelector('button, a, input[type="button"]') !== null; // 生成唯一ID(优先使用 jxb_id) const jxbIdEl = row.querySelector(".jxb_id, div.jxb_id"); const jxbId = jxbIdEl ? jxbIdEl.textContent.trim() : ""; const uniqueId = jxbId || className || teacher || capacity || fullText.substring(0, 20) || `row_${Date.now()}_${Math.random()}`; const result = { className: className || "未知教学班", teacher: teacher || "未知教师", capacity: capacity || "未知容量", timeInfo: timeInfo || "未知时间", id: `${uniqueId}_${teacher || "unknown"}`, jxbId: jxbId, // 保存教学班ID,可能用于后续操作 hasButton: hasButton, rawText: fullText.substring(0, 200), // 保留原始文本用于调试 }; return result; } catch (error) { // 返回默认信息而不是null return { className: "解析失败", teacher: "未知教师", capacity: "未知容量", timeInfo: "未知时间", id: `error_${Date.now()}_${Math.random()}`, jxbId: "", hasButton: false, rawText: (row.textContent || "").substring(0, 200), }; } } // 检查教学班是否可选课 // 逻辑:只判断 .full 元素("已满"标识)是否显示 function checkTeachingClassCapacity(teachingClass) { try { if (!teachingClass || !teachingClass.row) { return false; } // 检查 .full 元素是否可见 const fullElement = teachingClass.row.querySelector(".full, td.full"); if (fullElement) { const style = window.getComputedStyle(fullElement); const isVisible = style.display !== "none" && style.visibility !== "hidden"; // 如果"已满"可见,返回 false if (isVisible) { return false; } } // "已满"不可见,可以选课 return true; } catch (error) { return false; } } // 检查是否出现时间冲突警告 function checkTimeConflictWarning() { try { // 检查页面中是否出现时间冲突警告 const warningTexts = [ "所选教学班的上课时间与其他教学班有冲突", "上课时间与其他教学班有冲突", "时间冲突", "时间有冲突", ]; // 查找所有可能包含警告文本的元素 const allElements = document.querySelectorAll("*"); for (let element of allElements) { const text = element.textContent || element.innerText || ""; for (let warningText of warningTexts) { if (text.includes(warningText)) { return true; } } } // 检查弹窗或模态框中的警告 const modals = document.querySelectorAll( '.modal, .dialog, .alert, [role="dialog"], [role="alert"]', ); for (let modal of modals) { const modalText = modal.textContent || modal.innerText || ""; for (let warningText of warningTexts) { if (modalText.includes(warningText)) { return true; } } } return false; } catch (error) { return false; } } // 退选指定课程;courseCode 可为课程号或课程名,课程号匹配更安全 function dropCourse(courseCode) { return new Promise((resolve) => { try { log(`🔄 开始退选课程: ${courseCode}`, "info", courseCode); const selectedCourseRows = findSelectedCourseRows(courseCode); if (!isCourseCode(courseCode) && selectedCourseRows.length > 1) { log( `替换课程 ${courseCode} 匹配到多个已选课程,已放弃退选;请改用课程号避免误退`, "warning", courseCode, ); resolve(false); return; } let dropClass = selectedCourseRows.find((selectedRow) => selectedRow.button) || null; if (dropClass) { log(`已在已选课程列表中找到课程 ${courseCode}`, "info", courseCode); } else { if (selectedCourseRows.length > 0) { log( `已选课程列表中找到 ${courseCode},但未找到可点击的退选按钮,尝试回退查找教学班`, "warning", courseCode, ); } // 回退:部分页面会把已选课程同时保留在左侧课程卡片中 const teachingClasses = findAllTeachingClasses(courseCode); if (teachingClasses.length === 0) { log(`未找到课程 ${courseCode} 的教学班`, "warning", courseCode); resolve(false); return; } // 查找包含"退选"按钮的教学班 const dropCandidates = []; for (let tc of teachingClasses) { if (!isTeachingClassMatchingIdentifier(tc, courseCode)) { continue; } const dropButton = findClickableElementByText( tc.row, "退选", DROP_BUTTON_EXCLUDED_TEXTS, ); if (dropButton) { dropCandidates.push({ ...tc, button: dropButton, }); } } if (dropCandidates.length === 0) { log( `未匹配替换课程 ${courseCode} 的可退选教学班`, "warning", courseCode, ); resolve(false); return; } if (!isCourseCode(courseCode) && dropCandidates.length > 1) { log( `替换课程 ${courseCode} 匹配到多个可退选教学班,已放弃退选;请改用课程号避免误退`, "warning", courseCode, ); resolve(false); return; } dropClass = dropCandidates[0] || null; } if (!dropClass) { log(`课程 ${courseCode} 未找到可退选的教学班`, "warning", courseCode); resolve(false); return; } // 查找退选按钮 const row = dropClass.row; let dropButton = dropClass.button || findClickableElementByText(row, "退选", DROP_BUTTON_EXCLUDED_TEXTS); if (!dropButton) { log(`未找到课程 ${courseCode} 的退选按钮`, "warning", courseCode); resolve(false); return; } log(`找到退选按钮,正在点击...`, "info", courseCode); dropButton.click(); // 等待模态框出现并确认退选 setTimeout(() => { log(`等待退选确认模态框...`, "info", courseCode); // 多种方式查找模态框中的确定按钮 let confirmButton = null; // 方法1: 查找模态框内的确定按钮(优先) const modals = document.querySelectorAll( '.modal, .bootbox, [role="dialog"]', ); for (let modal of modals) { // 检查模态框是否包含退选相关文本 const modalText = modal.textContent || ""; if (modalText.includes("退选") || modalText.includes("你是否")) { // 在这个模态框内查找确定按钮 const buttons = modal.querySelectorAll( 'button, input[type="button"], a', ); for (let btn of buttons) { const btnText = btn.textContent.trim(); const btnId = btn.id || ""; const btnHandler = btn.getAttribute("data-bb-handler") || ""; // 匹配确定按钮的多种特征 if ( btnText.includes("确定") || btnText.includes("确认") || btnText.includes("OK") || btnId === "btn_ok" || btnHandler === "ok" || btnHandler === "confirm" ) { confirmButton = btn; log( `✅ 找到模态框确定按钮 (${btnText || btnId})`, "info", courseCode, ); break; } } if (confirmButton) break; } } // 方法2: 直接查找带有特定ID的确定按钮 if (!confirmButton) { confirmButton = document.querySelector( '#btn_ok, button[data-bb-handler="ok"], button[data-bb-handler="confirm"]', ); if (confirmButton) { log(`✅ 通过ID找到确定按钮`, "info", courseCode); } } // 方法3: 查找所有可见的确定按钮(最后备选) if (!confirmButton) { const allButtons = document.querySelectorAll( 'button, input[type="button"], a.btn', ); for (let btn of allButtons) { const text = btn.textContent.trim(); // 检查按钮是否可见 const style = window.getComputedStyle(btn); const isVisible = style.display !== "none" && style.visibility !== "hidden" && btn.offsetParent !== null; if ( isVisible && (text === "确定" || text === "确 定" || text.includes("确定")) ) { confirmButton = btn; log(`✅ 找到可见的确定按钮`, "info", courseCode); break; } } } if (confirmButton) { log(`正在点击确定按钮...`, "info", courseCode); confirmButton.click(); // 等待退选操作完成 setTimeout(() => { log(`✅ 已确认退选课程 ${courseCode}`, "success", courseCode); resolve(true); }, 1500); } else { log(`❌ 未找到退选确认按钮`, "error", courseCode); resolve(false); } }, 800); // 增加等待时间,确保模态框完全加载 } catch (error) { log(`退选课程失败: ${error.message}`, "error", courseCode); resolve(false); } }); } // 尝试选择教学班 function selectTeachingClass(teachingClass) { if (!teachingClass || !teachingClass.row) return false; const courseCode = teachingClass.courseCode; const classId = teachingClass.info.id; const state = getCourseState(courseCode); // 检查是否已经因时间冲突被跳过 if (state.conflicted.has(classId)) { log( `教学班 ${teachingClass.info.className} 已知时间冲突,跳过`, "warning", courseCode, ); return false; } try { log( `尝试选择教学班: ${teachingClass.info.className} (${teachingClass.info.teacher})`, "info", courseCode, ); log(`时间: ${teachingClass.info.timeInfo}`, "info", courseCode); log(`容量: ${teachingClass.info.capacity}`, "info", courseCode); // 标记正在选课 state.selecting = true; // 查找该行中真正的选课按钮或链接 const row = teachingClass.row; const rowText = row.textContent || ""; // 查找包含"选课"文本的元素 - 更精确的查找逻辑 let selectElement = null; const allElements = row.querySelectorAll("*"); // 优先级1: 查找明确的"选课"文本 for (let element of allElements) { const elementText = element.textContent.trim(); if (elementText === "选课") { // 确保不是退选按钮,且是可点击的 if ( !elementText.includes("退选") && (element.tagName === "BUTTON" || element.tagName === "A" || element.onclick || element.getAttribute("onclick")) ) { selectElement = element; break; } } } // 优先级2: 查找包含"选课"的可点击元素 if (!selectElement) { for (let element of allElements) { const elementText = element.textContent.trim(); if (elementText.includes("选课") && !elementText.includes("退选")) { if ( element.tagName === "BUTTON" || element.tagName === "A" || element.onclick || element.getAttribute("onclick") ) { selectElement = element; break; } } } } // 优先级3: 查找可点击的按钮或链接(但要排除明确的退选或其他功能) if (!selectElement) { const clickableElements = row.querySelectorAll( 'button, a, input[type="button"], [onclick]', ); for (let element of clickableElements) { const elementText = element.textContent.trim(); // 只有在排除了明确的其他功能按钮后才选择 if ( !elementText.includes("退选") && !elementText.includes("详情") && !elementText.includes("查看") && !elementText.includes("取消") && !elementText.includes("关闭") && elementText.length > 0 && (element.tagName === "BUTTON" || element.tagName === "A" || element.onclick || element.getAttribute("onclick")) ) { selectElement = element; break; } } } if (selectElement) { log("找到选课元素,正在点击...", "info", courseCode); // 记录已尝试的教学班 state.tried.add(classId); // 点击选课元素 selectElement.click(); // 等待并检查结果 setTimeout(() => { // 首先检查是否有时间冲突警告 if (checkTimeConflictWarning()) { log( `🛑 教学班 ${teachingClass.info.className} 时间冲突!`, "error", courseCode, ); // 记录冲突的教学班 state.conflicted.add(classId); // 尝试关闭警告弹窗 const cancelButtons = document.querySelectorAll( 'button, input[type="button"], a', ); for (let btn of cancelButtons) { const text = btn.textContent.trim(); if ( text.includes("取消") || text.includes("关闭") || text.includes("确定") ) { btn.click(); log("已关闭时间冲突警告弹窗", "info", courseCode); break; } } log(`继续尝试其他教学班...`, "info", courseCode); state.selecting = false; return; } // 如果没有时间冲突,查找并点击确认按钮 const confirmButtons = document.querySelectorAll( 'button, input[type="button"], a', ); for (let btn of confirmButtons) { const text = btn.textContent.trim(); if ( text.includes("确定") || text.includes("确认") || text.includes("提交") || text.includes("OK") ) { btn.click(); log("已点击确认按钮", "info", courseCode); // 点击确认后再次检查是否出现时间冲突警告 setTimeout(() => { if (checkTimeConflictWarning()) { log( `🛑 确认后检测到时间冲突: ${teachingClass.info.className}`, "error", courseCode, ); state.conflicted.add(classId); // 关闭冲突警告 const closeButtons = document.querySelectorAll( 'button, input[type="button"], a', ); for (let closeBtn of closeButtons) { const closeText = closeBtn.textContent.trim(); if ( closeText.includes("确定") || closeText.includes("取消") || closeText.includes("关闭") ) { closeBtn.click(); break; } } state.selecting = false; } else { // 等待更长时间再验证是否真正选课成功 setTimeout(() => { // 重新查找教学班,验证是否真的选上了 const updatedClasses = findAllTeachingClasses(courseCode); let reallySelected = false; for (let updatedClass of updatedClasses) { if ( updatedClass.info.className === teachingClass.info.className ) { const updatedRowText = updatedClass.row ? updatedClass.row.textContent : ""; if (updatedRowText.includes("退选")) { reallySelected = true; break; } } } if (reallySelected) { // 真正选课成功,重置失败计数器 state.failed = 0; state.success = true; selectedCourses.add(courseCode); activeCourses.delete(courseCode); // 从活跃列表中移除 log( `🎊 确认选课成功: ${teachingClass.info.className}!`, "success", courseCode, ); // 显示成功通知 try { if ( window.Notification && Notification.permission === "granted" ) { new Notification("抢课成功!", { body: `成功选择: ${courseCode} - ${teachingClass.info.className}`, icon: "/favicon.ico", }); } // 检查是否所有课程都已完成 if (activeCourses.size === 0) { alert( `🎉 所有课程抢课完成!\n成功课程: ${Array.from(selectedCourses).join(", ")}`, ); stopGrabbing(); } } catch (e) { // 忽略通知错误 } } else { // 实际上没有选课成功,增加重试计数 state.failed++; log( `⚠️ 选课请求已发送但未确认成功 (失败次数: ${state.failed}/${MAX_FAILED_ATTEMPTS})`, "warning", courseCode, ); if (state.failed >= MAX_FAILED_ATTEMPTS) { log( `❌ 课程 ${courseCode} 连续失败 ${MAX_FAILED_ATTEMPTS} 次,停止该课程抢课`, "error", courseCode, ); activeCourses.delete(courseCode); // 检查是否所有课程都已完成 if ( activeCourses.size === 0 && selectedCourses.size === 0 ) { alert( `抢课脚本已停止\n原因: 所有课程都无法选课成功\n建议: 检查网络连接或手动刷新页面后重试`, ); stopGrabbing(); } } } state.selecting = false; }, 3000); // 等待3秒再验证 } }, 1000); break; } } }, 500); return true; } else { log("未找到可点击的选课元素", "warning", courseCode); state.selecting = false; return false; } } catch (error) { log(`选择教学班失败: ${error.message}`, "error", courseCode); state.selecting = false; return false; } } // 刷新课程列表 function refreshCourseList() { try { // 全局节流:避免重复实例/重复触发导致的“刷新触发两次” const now = Date.now(); const last = __CG_GLOBAL__[__CG_REFRESH_LOCK_KEY__] || 0; if (now - last < 300) { return; } __CG_GLOBAL__[__CG_REFRESH_LOCK_KEY__] = now; const searchBtn = document.querySelector( 'button[onclick*="search"], input[value*="搜索"], input[value*="查询"]', ); if (searchBtn) { searchBtn.click(); log("已触发课程列表刷新"); } else { // 如果没有搜索按钮,尝试刷新页面数据 if (typeof jQuery !== "undefined" && jQuery("#searchBox").length) { jQuery("#searchBox").trigger("searchResult"); log("已触发jQuery搜索刷新"); } } // 🔥 刷新后强制恢复展开 if (click2expend_enabled && CLICK2EXPEND_ENABLED) setTimeout(forceExpandTargetCoursesAggressive, 600); } catch (e) { log(`刷新课程列表失败: ${e.message}`, "warning"); } } // 单个课程抢课逻辑 function attemptGrabSingleCourse(courseCode) { const state = getCourseState(courseCode); // 检查是否正在选课 if (state.selecting) { return; } // 检查是否已成功 if (state.success) { return; } // 检查是否达到最大失败次数 if (state.failed >= MAX_FAILED_ATTEMPTS) { return; } state.attempts++; // 查找所有教学班 const teachingClasses = findAllTeachingClasses(courseCode); if (teachingClasses.length === 0) { log("未找到教学班,尝试重新展开课程", "warning", courseCode); if (click2expend_enabled && CLICK2EXPEND_ENABLED) forceExpandTargetCoursesAggressive(); return; } // 设置标志:是否有时间不冲突但人数已满的教学班 let hasNonConflictedFullClass = false; // 逐个尝试所有教学班 for (let tc of teachingClasses) { // 确保教学班信息完整 if (!tc || !tc.info || !tc.info.id) { continue; } const classId = tc.info.id; // 检查是否已经因时间冲突被标记 if (state.conflicted.has(classId)) { continue; } // 检查是否已经尝试过 if (state.tried.has(classId)) { continue; } // 检查是否有选课按钮(排除退选按钮) const rowText = tc.row ? tc.row.textContent : ""; if (rowText.includes("退选")) { continue; } if (!rowText.includes("选课")) { continue; } // ========== 应用过滤器 ========== const filterResult = matchesFilters(tc, courseCode); // 调试日志:显示过滤器配置和匹配结果 const courseConfig = TARGET_COURSES.find((c) => c.code === courseCode); if ( courseConfig && (courseConfig.timeFilter || courseConfig.teacherFilter) ) { log(`🔍 过滤器检查 - 教学班: ${tc.info.className}`, "info", courseCode); if (courseConfig.timeFilter) { log( ` 时间过滤器: [${courseConfig.timeFilter.join(", ")}]`, "info", courseCode, ); log(` 教学班时间: ${tc.info.timeInfo}`, "info", courseCode); } if (courseConfig.teacherFilter) { log( ` 教师过滤器: [${courseConfig.teacherFilter.join(", ")}]`, "info", courseCode, ); log(` 教学班教师: ${tc.info.teacher}`, "info", courseCode); } log( ` 匹配结果: ${filterResult.match ? "✅通过" : "❌" + filterResult.reason}`, "info", courseCode, ); } if (!filterResult.match) { // 不满足过滤条件,跳过此教学班 log( `⏭️ 跳过教学班 ${tc.info.className}: ${filterResult.reason}`, "info", courseCode, ); log( ` 教师: ${tc.info.teacher}, 时间: ${tc.info.timeInfo}`, "info", courseCode, ); continue; } // 检查容量 const hasCapacity = checkTeachingClassCapacity(tc); if (hasCapacity) { // 有余量,检查是否需要先退选其他课程 log( `🎯 发现有余量的教学班: ${tc.info.className}`, "success", courseCode, ); log(`📚 教师: ${tc.info.teacher}`, "info", courseCode); log(`⏰ 时间: ${tc.info.timeInfo}`, "info", courseCode); log(`👥 容量: ${tc.info.capacity}`, "info", courseCode); // 获取课程配置 const courseConfig = TARGET_COURSES.find((c) => c.code === courseCode); // 如果配置了替换课程,先执行退选 if (courseConfig && courseConfig.replaceCode) { log( `🔄 检测到需要替换课程 ${courseConfig.replaceCode},立即执行退选...`, "warning", courseCode, ); addUILog && addUILog( "warning", `[${courseCode}] 🔄 发现空位!开始退选 ${courseConfig.replaceCode}`, ); // 异步执行退选,然后选课 dropCourse(courseConfig.replaceCode).then((dropSuccess) => { if (dropSuccess) { log( `✅ 退选成功,立即选择新课程 ${courseCode}`, "success", courseCode, ); addUILog && addUILog("success", `[${courseCode}] ✅ 退选成功,开始抢课...`); // 等待页面更新后立即选课 setTimeout(() => { selectTeachingClass(tc); }, 1500); } else { log(`❌ 退选失败,放弃本次选课`, "error", courseCode); addUILog && addUILog("error", `[${courseCode}] ❌ 退选失败,等待下次机会`); state.selecting = false; } }); return; // 等待异步退选完成 } else { // 没有配置替换课程,直接选课 const selectResult = selectTeachingClass(tc); if (selectResult) { return; // 尝试选课后等待结果 } } } else { // 没有余量,但时间不冲突,设置标志 hasNonConflictedFullClass = true; } } // 检查是否所有教学班都时间冲突 if ( !hasNonConflictedFullClass && state.conflicted.size > 0 && state.conflicted.size === teachingClasses.length ) { log("🛑 所有教学班都存在时间冲突,停止该课程抢课!", "error", courseCode); activeCourses.delete(courseCode); if (activeCourses.size === 0) { alert( `⚠️ 时间冲突警告\n\n课程 ${courseCode} 的所有教学班都与您已选课程时间冲突。\n冲突的教学班数量: ${state.conflicted.size}\n\n请手动检查课程表并解决时间冲突问题。`, ); stopGrabbing(); } return; } // 重置已尝试列表,继续轮询 if (state.attempts % 10 === 0 && state.tried.size > 0) { state.tried.clear(); log(`已重置尝试列表,继续监控`, "info", courseCode); } } // 主抢课逻辑(多课程并发版) function attemptGrabCourse() { attemptCount++; if (attemptCount > MAX_ATTEMPTS) { log(`已达到最大尝试次数 ${MAX_ATTEMPTS},停止抢课`, "warning"); stopGrabbing(); return; } if (activeCourses.size === 0) { log("所有课程已完成", "success"); stopGrabbing(); return; } log(`第 ${attemptCount} 次尝试抢课 (活跃课程: ${activeCourses.size})`); // 按优先级排序课程 const sortedCourses = Array.from(activeCourses).sort((a, b) => { const courseA = TARGET_COURSES.find((c) => c.code === a); const courseB = TARGET_COURSES.find((c) => c.code === b); const priorityA = courseA ? courseA.priority : 999; const priorityB = courseB ? courseB.priority : 999; return priorityA - priorityB; }); // 并发模式:同时尝试所有课程 if (CONCURRENT_ENABLED) { for (let courseCode of sortedCourses) { attemptGrabSingleCourse(courseCode); } } else { // 顺序模式:按优先级依次尝试 for (let courseCode of sortedCourses) { const state = getCourseState(courseCode); if (!state.selecting) { attemptGrabSingleCourse(courseCode); break; // 只尝试一个课程,等待结果 } } } } // 开始抢课 function startGrabbing(customCourses = null) { if (isRunning) { log("抢课脚本已在运行中!", "warning"); return; } // 使用自定义课程或默认课程 const coursesToGrab = customCourses || TARGET_COURSES; if (!coursesToGrab || coursesToGrab.length === 0) { log("❌ 未配置目标课程!请先配置 TARGET_COURSES 或传入课程列表", "error"); alert( '请先配置目标课程!\n\n在脚本中修改 TARGET_COURSES 数组,或使用:\ncourseGrabber.start([{code: "课程号", priority: 1}])', ); return; } // 请求通知权限 if (window.Notification && Notification.permission === "default") { Notification.requestPermission(); } isRunning = true; attemptCount = 0; refreshInProgress = false; if (refreshTimeoutId) { clearTimeout(refreshTimeoutId); refreshTimeoutId = null; } // 初始化课程状态 courseStates.clear(); selectedCourses.clear(); activeCourses.clear(); for (let course of coursesToGrab) { const courseCode = typeof course === "string" ? course : course.code; activeCourses.add(courseCode); initCourseState(courseCode); } // 自动判断是否需要展开功能(用于时间/教师过滤) // 如果没有任何过滤器配置,则无需展开课程详情 const needsFiltering = (() => { // 检查全局过滤器 if (GLOBAL_TIME_FILTER.length > 0 || GLOBAL_TEACHER_FILTER.length > 0) { return true; } // 检查每门课程的单独过滤器 for (let course of coursesToGrab) { if (typeof course === "object") { if ( (course.timeFilter && course.timeFilter.length > 0) || (course.teacherFilter && course.teacherFilter.length > 0) ) { return true; } } } return false; })(); // 根据是否需要过滤自动设置 click2expend_enabled if (!needsFiltering) { click2expend_enabled = false; log("📌 未检测到时间/教师过滤器配置,已自动禁用课程展开功能", "info"); } else { click2expend_enabled = true; log("📌 检测到过滤器配置,已自动启用课程展开功能", "info"); } log(`🚀 开始监控 ${activeCourses.size} 门课程`, "success"); log(`📋 课程列表: ${Array.from(activeCourses).join(", ")}`, "info"); log(`⏱️ 检查间隔: ${CHECK_INTERVAL / 1000} 秒`, "info"); log(`🎯 最大尝试次数: ${MAX_ATTEMPTS}`, "info"); log(`⚡ 并发模式: ${CONCURRENT_ENABLED ? "启用" : "禁用"}`, "info"); // 显示过滤器配置 if (GLOBAL_TIME_FILTER.length > 0) { log(`🔍 全局时间过滤: ${GLOBAL_TIME_FILTER.join(", ")}`, "info"); } if (GLOBAL_TEACHER_FILTER.length > 0) { log(`🔍 全局教师过滤: ${GLOBAL_TEACHER_FILTER.join(", ")}`, "info"); } // 显示每门课程的特定过滤器 for (let course of coursesToGrab) { if (typeof course === "object") { if (course.timeFilter && course.timeFilter.length > 0) { log( `🔍 [${course.code}] 时间过滤: ${course.timeFilter.join(", ")}`, "info", course.code, ); } if (course.teacherFilter && course.teacherFilter.length > 0) { log( `🔍 [${course.code}] 教师过滤: ${course.teacherFilter.join(", ")}`, "info", course.code, ); } } } // 立即执行一次 attemptGrabCourse(); // 设置定时器 intervalId = setInterval(() => { // 每8次尝试刷新一次课程列表 // 注意:attemptCount 在 attemptGrabCourse() 内部自增;如果这里先走“刷新分支”, // attemptGrabCourse() 会被延迟 1s,这段时间内 attemptCount 不变,会导致下一次 interval 再次满足 %8===0, // 从而出现“已触发jQuery搜索刷新”连续打印两次的现象。 if (attemptCount > 0 && attemptCount % 3 === 0 && !refreshInProgress) { refreshInProgress = true; refreshCourseList(); refreshTimeoutId = setTimeout(() => { refreshInProgress = false; refreshTimeoutId = null; attemptGrabCourse(); }, 1000); // 刷新后等待1秒再尝试 } else { attemptGrabCourse(); } }, CHECK_INTERVAL); } // 停止抢课 function stopGrabbing() { if (!isRunning) { log("抢课脚本未运行", "info"); return; } isRunning = false; refreshInProgress = false; if (intervalId) { clearInterval(intervalId); intervalId = null; } if (refreshTimeoutId) { clearTimeout(refreshTimeoutId); refreshTimeoutId = null; } log("⏹️ 抢课脚本已停止", "warning"); } // 获取状态 function getStatus() { const status = { isRunning: isRunning, attemptCount: attemptCount, activeCourses: Array.from(activeCourses), selectedCourses: Array.from(selectedCourses), checkInterval: CHECK_INTERVAL, maxAttempts: MAX_ATTEMPTS, concurrentMode: CONCURRENT_ENABLED, }; console.log( "%c========== 抢课状态 ==========", "color: #00ffff; font-weight: bold; font-size: 16px;", ); console.table(status); // 显示每门课程的详细状态 log("--- 课程详细状态 ---", "info"); for (let [courseCode, state] of courseStates) { log(`课程: ${courseCode}`, "info"); log(` 尝试次数: ${state.attempts}`, "info"); log(` 失败次数: ${state.failed}/${MAX_FAILED_ATTEMPTS}`, "info"); log(` 已尝试教学班: ${state.tried.size}个`, "info"); log(` 时间冲突教学班: ${state.conflicted.size}个`, "info"); log(` 正在选课: ${state.selecting ? "是" : "否"}`, "info"); log( ` 选课成功: ${state.success ? "是" : "否"}`, state.success ? "success" : "info", ); } return { status, courseStates: Array.from(courseStates.entries()).map(([code, state]) => ({ courseCode: code, attempts: state.attempts, failed: state.failed, triedCount: state.tried.size, conflictedCount: state.conflicted.size, selecting: state.selecting, success: state.success, })), }; } // 添加单门课程到监控列表 function addCourse(courseCode, priority = 999) { if (activeCourses.has(courseCode)) { log(`课程 ${courseCode} 已在监控列表中`, "warning"); return false; } activeCourses.add(courseCode); initCourseState(courseCode); TARGET_COURSES.push({ code: courseCode, priority: priority }); log(`✅ 已添加课程 ${courseCode} 到监控列表`, "success"); return true; } // 移除课程 function removeCourse(courseCode) { if (!activeCourses.has(courseCode)) { log(`课程 ${courseCode} 不在监控列表中`, "warning"); return false; } activeCourses.delete(courseCode); courseStates.delete(courseCode); const index = TARGET_COURSES.findIndex((c) => c.code === courseCode); if (index !== -1) { TARGET_COURSES.splice(index, 1); } log(`🗑️ 已从监控列表中移除课程 ${courseCode}`, "warning"); return true; } // 暴露全局控制接口 window.grab = { // 开始抢课 - 可以传入自定义课程列表 // 示例: grab.start([{code: 'CS101', priority: 1}, {code: 'CS102', priority: 2}]) start: startGrabbing, // 停止抢课 stop: stopGrabbing, // 查看状态 status: getStatus, // 添加课程 // 示例: grab.addCourse('CS103', 1) addCourse: addCourse, // 移除课程 // 示例: grab.removeCourse('CS103') removeCourse: removeCourse, // 调试信息 debug: (courseCode = null) => { log("=== 调试信息 ===", "info"); // 如果指定了课程,只调试该课程 const coursesToDebug = courseCode ? [courseCode] : Array.from(activeCourses); if (coursesToDebug.length === 0) { log("没有活跃的课程", "warning"); return null; } const debugInfo = []; for (let code of coursesToDebug) { log(`\n--- 课程: ${code} ---`, "info"); const classes = findAllTeachingClasses(code); log(`找到 ${classes.length} 个教学班`, "info"); const state = getCourseState(code); classes.forEach((tc, index) => { const rowText = tc.row ? tc.row.textContent : ""; // 查找所有数字/数字格式 const allCapacityMatches = rowText.match(/\d+\/\d+/g) || []; // 查找选课元素 let selectElement = null; let selectElementInfo = "无选课元素"; if (tc.row) { const allElements = tc.row.querySelectorAll("*"); for (let element of allElements) { const elementText = element.textContent.trim(); if (elementText === "选课" || elementText.includes("选课")) { if (!elementText.includes("退选")) { selectElement = element; selectElementInfo = `${element.tagName}(${elementText})`; break; } } } // 如果没找到选课元素,列出所有可点击元素 if (!selectElement) { const clickableElements = tc.row.querySelectorAll( 'button, a, input[type="button"], [onclick]', ); const clickableInfo = Array.from(clickableElements) .map((el) => `${el.tagName}(${el.textContent.trim()})`) .join(", "); selectElementInfo = `可点击元素: ${clickableInfo || "无"}`; } } // 检查选课/退选状态 const isSelectAvailable = rowText.includes("选课"); const isDropAvailable = rowText.includes("退选"); log(`教学班 ${index + 1}:`, "info", code); log(` 名称: ${tc.info.className}`, "info", code); log(` 教师: ${tc.info.teacher}`, "info", code); log(` 容量: ${tc.info.capacity}`, "info", code); log( ` 所有容量信息: [${allCapacityMatches.join(", ")}]`, "info", code, ); log(` 时间: ${tc.info.timeInfo}`, "info", code); log(` 选课元素: ${selectElementInfo}`, "info", code); log(` 行包含选课: ${isSelectAvailable}`, "info", code); log(` 行包含退选: ${isDropAvailable}`, "info", code); log(` ID: ${tc.info.id}`, "info", code); log(` 有余量: ${checkTeachingClassCapacity(tc)}`, "info", code); log(` 已尝试: ${state.tried.has(tc.info.id)}`, "info", code); log(` 时间冲突: ${state.conflicted.has(tc.info.id)}`, "info", code); // 检查过滤器匹配 const filterResult = matchesFilters(tc, code); log( ` 过滤器: ${filterResult.match ? "✅通过" : "❌" + filterResult.reason}`, "info", code, ); log( ` 可选择: ${isSelectAvailable && !isDropAvailable && checkTeachingClassCapacity(tc) && !state.conflicted.has(tc.info.id) && filterResult.match}`, "info", code, ); debugInfo.push({ courseCode: code, index: index + 1, className: tc.info.className, teacher: tc.info.teacher, capacity: tc.info.capacity, timeInfo: tc.info.timeInfo, hasCapacity: checkTeachingClassCapacity(tc), tried: state.tried.has(tc.info.id), conflicted: state.conflicted.has(tc.info.id), filterMatch: filterResult.match, filterReason: filterResult.reason, canSelect: isSelectAvailable && !isDropAvailable && checkTeachingClassCapacity(tc) && !state.conflicted.has(tc.info.id) && filterResult.match, }); }); } return debugInfo; }, // 定时开抢 schedule: (timeString) => { const targetTime = new Date(timeString); if (isNaN(targetTime.getTime())) { log('❌ 时间格式错误!请使用如: "2025-12-19 14:00:00"', "error"); return false; } setScheduledStart(targetTime); return true; }, cancelSchedule: () => { cancelScheduledStart(); }, // 配置管理 config: { getCourses: () => TARGET_COURSES, setCourses: (courses) => { TARGET_COURSES.length = 0; TARGET_COURSES.push(...courses); log("✅ 已更新课程配置", "success"); }, getInterval: () => CHECK_INTERVAL, getConcurrentMode: () => CONCURRENT_ENABLED, getGlobalTimeFilter: () => GLOBAL_TIME_FILTER, getGlobalTeacherFilter: () => GLOBAL_TEACHER_FILTER, // 显示过滤器信息 showFilters: () => { console.log( "%c=== 过滤器配置 ===", "color: #00ffff; font-weight: bold; font-size: 16px;", ); console.log("%c全局时间过滤:", "color: #ffaa00; font-weight: bold;"); if (GLOBAL_TIME_FILTER.length > 0) { console.log(" " + GLOBAL_TIME_FILTER.join(", ")); } else { console.log(" 未配置(不过滤)"); } console.log("%c全局教师过滤:", "color: #ffaa00; font-weight: bold;"); if (GLOBAL_TEACHER_FILTER.length > 0) { console.log(" " + GLOBAL_TEACHER_FILTER.join(", ")); } else { console.log(" 未配置(不过滤)"); } console.log("%c课程特定过滤:", "color: #ffaa00; font-weight: bold;"); TARGET_COURSES.forEach((course) => { console.log(` ${course.code}:`); if (course.timeFilter) { console.log(` 时间: ${course.timeFilter.join(", ")}`); } if (course.teacherFilter) { console.log(` 教师: ${course.teacherFilter.join(", ")}`); } if (!course.timeFilter && !course.teacherFilter) { console.log(` 无特定过滤`); } }); }, }, }; // 显示脚本信息 console.log( "%c🎓 自动抢课脚本已加载 - 多课程并发版", "color: #ff6b35; font-size: 18px; font-weight: bold;", ); console.log( "%c✨ 新特性: 支持多门课程同时抢课!", "color: #00ff00; font-size: 16px; font-weight: bold;", ); console.log( "%c📚 目标课程数: " + TARGET_COURSES.length, "color: #4ecdc4; font-size: 14px; font-weight: bold;", ); console.log( "%c⚡ 使用方法:", "color: #45b7d1; font-size: 14px; font-weight: bold;", ); console.log(" grab.start() - 🚀 开始抢课(使用配置的课程)"); console.log( ' grab.start([{code:"CS101", priority:1}]) - 🚀 使用自定义课程列表', ); console.log(" grab.stop() - ⏹️ 停止抢课"); console.log(" grab.status() - 📊 查看状态"); console.log(" grab.debug() - 🔍 调试所有课程"); console.log(' grab.debug("CS101") - 🔍 调试指定课程'); console.log(' grab.addCourse("CS101", 1) - ➕ 添加课程到监控列表'); console.log(' grab.removeCourse("CS101") - ➖ 移除课程'); console.log( "%c⚠️ 提醒: 确保您在正确的选课页面且已登录!", "color: #ffa500; font-weight: bold;", ); console.log("%c🛡️ 智能保护:", "color: #ff69b4; font-weight: bold;"); console.log(" • 多课程并发抢课(可配置)"); console.log(" • 优先级控制(数字越小优先级越高)"); console.log(" • 自动识别同一课程的多个教学班"); console.log(" • 时间冲突时自动尝试其他教学班"); console.log(" • 独立跟踪每门课程的状态"); console.log(" • 自动处理选课成功和失败"); console.log("%c📋 配置示例:", "color: #9370db; font-weight: bold;"); console.log(" 在脚本顶部修改 TARGET_COURSES:"); console.log(" const TARGET_COURSES = ["); console.log(' { code: "23286514", priority: 1 }, // 使用课程号'); console.log(' { code: "机器学习", priority: 1 }, // 使用课程名称'); console.log( ' { code: "CS102", priority: 2, timeFilter: ["星期一", "第1-2节"] }, // 只选星期一或1-2节的课', ); console.log( ' { code: "CS103", priority: 3, teacherFilter: ["张三", "讲师"] } // 只选张三或讲师的课', ); console.log(" ];"); console.log( "%c🆕 新功能: 支持课程号和课程名称两种输入方式!", "color: #00ff00; font-weight: bold;", ); console.log("%c🔍 过滤器功能:", "color: #ff1493; font-weight: bold;"); console.log(" • timeFilter - 时间过滤(支持星期、节次等关键词)"); console.log(" • teacherFilter - 教师过滤(支持教师姓名、职称等关键词)"); console.log(" • grab.config.showFilters() - 查看当前过滤器配置"); console.log( "%c💡 提示: 并发模式已" + (CONCURRENT_ENABLED ? "启用" : "禁用"), "color: #00ffff; font-weight: bold;", ); // 如果配置了过滤器,显示提示 if (GLOBAL_TIME_FILTER.length > 0 || GLOBAL_TEACHER_FILTER.length > 0) { console.log("%c⚠️ 已启用全局过滤器:", "color: #ffa500; font-weight: bold;"); if (GLOBAL_TIME_FILTER.length > 0) { console.log(" 时间: " + GLOBAL_TIME_FILTER.join(", ")); } if (GLOBAL_TEACHER_FILTER.length > 0) { console.log(" 教师: " + GLOBAL_TEACHER_FILTER.join(", ")); } } // ========== UI界面 ========== // 创建UI控制面板 function createUI() { // 检查是否已存在UI if (document.getElementById("courseGrabberUI")) { return; } // 创建样式 const style = document.createElement("style"); style.textContent = ` #courseGrabberUI { --cg-ink: #071a2d; --cg-surface: #0c243a; --cg-surface-raised: #112e47; --cg-surface-quiet: #0a2034; --cg-border: #2a4b66; --cg-text: #f5f2e9; --cg-muted: #a8b8c6; --cg-success: #42d6a4; --cg-warning: #f3b64d; --cg-danger: #e75d64; --cg-focus: #8ad9ff; position: fixed; top: 20px; right: 20px; width: min(440px, calc(100vw - 32px)); max-height: min(90vh, 760px); background: var(--cg-ink); border: 1px solid var(--cg-border); border-radius: 18px; box-shadow: 0 24px 70px rgba(4, 15, 27, 0.42), 0 2px 0 rgba(255, 255, 255, 0.05) inset; z-index: 999999; font-family: "Avenir Next", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; color: var(--cg-text); overflow: hidden; display: flex; flex-direction: column; } #courseGrabberUI * { box-sizing: border-box; } .cg-header { padding: 15px 16px; background: #081e32; border-bottom: 1px solid var(--cg-border); cursor: move; display: flex; justify-content: space-between; align-items: center; user-select: none; } .cg-title { font-size: 16px; font-weight: 700; letter-spacing: 0.04em; display: flex; align-items: center; gap: 8px; } .cg-controls { display: flex; gap: 6px; } .cg-close, .cg-minimize { background: #173750; border: 1px solid #31536c; color: var(--cg-text); width: 30px; height: 30px; border-radius: 8px; cursor: pointer; font-size: 17px; display: inline-flex; align-items: center; justify-content: center; transition: background-color 160ms ease, border-color 160ms ease, transform 160ms ease; } .cg-close:hover { background: #5a2632; border-color: var(--cg-danger); transform: rotate(90deg); } .cg-minimize:hover { background: #24506e; border-color: var(--cg-focus); } .cg-body { padding: 14px; overflow-y: auto; flex: 1; scrollbar-color: #496780 transparent; } .cg-section { background: var(--cg-surface-raised); border: 1px solid #244660; border-radius: 12px; padding: 13px; margin-bottom: 12px; box-shadow: 0 1px 0 rgba(255, 255, 255, 0.035) inset; } .cg-section:last-child { margin-bottom: 0; } .cg-section-title { color: var(--cg-text); font-size: 12px; font-weight: 700; letter-spacing: 0.07em; margin-bottom: 10px; display: flex; align-items: center; gap: 6px; } .cg-input { width: 100%; padding: 10px 11px; border: 1px solid #365873; background: var(--cg-surface-quiet); border-radius: 8px; color: var(--cg-text); font-size: 13px; margin-bottom: 8px; transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease; } .cg-input:hover { border-color: #58809d; } .cg-input:focus { outline: none; border-color: var(--cg-focus); background: #0d2940; box-shadow: 0 0 0 3px rgba(138, 217, 255, 0.16); } .cg-input::placeholder { color: #8398aa; } .cg-btn { min-height: 36px; padding: 9px 13px; border: 1px solid transparent; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 700; letter-spacing: 0.01em; transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease, transform 160ms ease, box-shadow 160ms ease; display: inline-flex; align-items: center; gap: 6px; justify-content: center; } .cg-btn:hover:not(:disabled) { transform: translateY(-1px); } .cg-btn:disabled { cursor: not-allowed; opacity: 0.48; } .cg-btn-primary { background: var(--cg-success); border-color: #68e4ba; color: #06261f; box-shadow: 0 7px 18px rgba(66, 214, 164, 0.16); } .cg-btn-primary:hover:not(:disabled) { background: #66e1b7; box-shadow: 0 9px 20px rgba(66, 214, 164, 0.25); } .cg-btn-danger { background: #9e3d49; border-color: #d55a63; color: #fff7f5; } .cg-btn-danger:hover:not(:disabled) { background: var(--cg-danger); } .cg-btn-secondary { background: #173a55; border-color: #315873; color: #dfeaf0; } .cg-btn-secondary:hover:not(:disabled) { background: #24516f; border-color: #56809d; } .cg-btn-small { min-height: 32px; padding: 6px 10px; font-size: 12px; } .cg-btn-group { display: flex; gap: 8px; margin-top: 10px; } .cg-course-list { max-height: 220px; overflow-y: auto; margin-top: 10px; scrollbar-color: #496780 transparent; } .cg-course-item { background: #0d2941; border: 1px solid #284c68; padding: 11px; border-radius: 9px; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; gap: 10px; transition: background-color 160ms ease, border-color 160ms ease; } .cg-course-item:last-child { margin-bottom: 0; } .cg-course-item:hover { background: #11334f; border-color: #4e7795; } .cg-course-info { flex: 1; min-width: 0; font-size: 13px; } .cg-course-code { color: var(--cg-text); font-weight: 700; margin-bottom: 5px; overflow-wrap: anywhere; } .cg-course-meta { color: var(--cg-muted); font-size: 11px; } .cg-status { padding: 10px 11px; background: #0c2b40; border: 1px solid #28536a; border-radius: 8px; font-size: 13px; display: flex; align-items: center; gap: 8px; } .cg-status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--cg-success); box-shadow: 0 0 0 4px rgba(66, 214, 164, 0.13); animation: cg-pulse 2s infinite; } @keyframes cg-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .cg-log-area { background: #061827; border: 1px solid #203f57; border-radius: 8px; padding: 11px; max-height: 168px; overflow-y: auto; color: #d5e1e8; font-size: 11px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; line-height: 1.65; scrollbar-color: #496780 transparent; } .cg-log-item { margin-bottom: 4px; } .cg-log-success { color: var(--cg-success); } .cg-log-error { color: #ff8c91; } .cg-log-warning { color: var(--cg-warning); } .cg-log-info { color: #86d5ff; } .cg-badge { display: inline-block; padding: 3px 7px; background: #1d4059; border: 1px solid #3b647e; border-radius: 999px; color: #d7e6ee; font-size: 11px; margin-left: 6px; } .cg-badge-success { background: rgba(66, 214, 164, 0.16); border-color: rgba(66, 214, 164, 0.45); color: #9bf0cf; } .cg-badge-running { background: rgba(66, 214, 164, 0.16); border: 1px solid rgba(66, 214, 164, 0.45); border-radius: 999px; color: #9bf0cf; padding: 3px 8px; } .cg-minimized { height: auto !important; width: 112px !important; } .cg-minimized .cg-body, .cg-minimized .cg-title { display: none !important; } .cg-filter-input { font-size: 12px; margin-bottom: 4px; } .cg-help-text { color: var(--cg-muted); font-size: 11px; line-height: 1.5; margin-top: 4px; } .cg-timer-display { background: #3d2e16; border: 1px solid #9e7132; color: #ffe0a4; padding: 14px; border-radius: 8px; text-align: center; font-size: 24px; font-weight: 700; letter-spacing: 0.08em; margin-top: 10px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; } .cg-timer-active { background: #4a3516; animation: cg-timer-pulse 2s infinite; } @keyframes cg-timer-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(243, 182, 77, 0.14); } 50% { box-shadow: 0 0 0 8px rgba(243, 182, 77, 0); } } .cg-time-input-group { display: flex; gap: 8px; align-items: center; } .cg-time-input-group input { flex: 1; } .cg-course-filters { background: #0a2135; border-left: 2px solid #3f6e8d; padding: 8px; border-radius: 0 6px 6px 0; margin-top: 7px; font-size: 11px; } .cg-course-filter-item { color: #c3d1da; margin-bottom: 4px; display: flex; align-items: center; gap: 4px; overflow-wrap: anywhere; } .cg-course-filter-item:last-child { margin-bottom: 0; } .cg-filter-label { color: #93acc0; min-width: 40px; } .cg-course-actions { display: flex; gap: 4px; flex-direction: column; } #courseGrabberUI button:focus-visible, #courseGrabberUI input:focus-visible { outline: 2px solid var(--cg-focus); outline-offset: 2px; } @media (max-width: 520px) { #courseGrabberUI { top: 12px; right: 12px; width: calc(100vw - 24px); max-height: calc(100vh - 24px); border-radius: 14px; } .cg-header { padding: 12px; } .cg-body { padding: 10px; } .cg-section { padding: 11px; margin-bottom: 10px; } .cg-course-item { align-items: flex-start; } .cg-course-actions { flex-direction: row; } } @media (prefers-reduced-motion: reduce) { #courseGrabberUI *, #courseGrabberUI *::before, #courseGrabberUI *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: 0.01ms !important; } } `; document.head.appendChild(style); // 创建UI容器 const ui = document.createElement("div"); ui.id = "courseGrabberUI"; ui.innerHTML = `