// ==UserScript==
// @name HQU 华侨大学选课脚本
// @namespace Violentmonkey Scripts
// @version 2025-09-14
// @description 华侨大学选课助手:自动展开课程详情、识别课程+教学班、优先级抢课、通知提示(鲁棒版)
// @author qimubanben
// @match *://xk.hqu.edu.cn/xsxk/elective/grablessons*
// @match *://xk.hqu.edu.cn/xsxk/profile/index.html
// @match *://xk.hqu.edu.cn/xsxk/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=hqu.edu.cn
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/* ---------------- 状态 ---------------- */
let allClasses = []; // 全局教学班数组(扁平)
let monitorInterval = null;
let monitoringMap = {}; // id -> classObj
let uiReady = false;
let isSidebarMinimized = false;
/* ------------- UI 初始化 ------------- */
function initUI() {
if (document.getElementById('hqu-course-sidebar')) return;
const sidebar = document.createElement('div');
sidebar.id = 'hqu-course-sidebar';
sidebar.innerHTML = `
<div id="hqu-sidebar-header" style="display:flex;justify-content:space-between;align-items:center;cursor:move;">
<h2 style="margin:0;">抢课助手</h2>
<div>
<button id="hqu-minimize-btn" style="background:none;border:none;font-size:16px;cursor:pointer;">−</button>
<button id="hqu-close-btn" style="background:none;border:none;font-size:16px;cursor:pointer;">×</button>
</div>
</div>
<div id="hqu-sidebar-content">
<div style="display:flex;gap:6px;margin-top:10px;">
<button id="hqu-detect-btn">检测课程</button>
<button id="hqu-start-btn">开始监控</button>
<button id="hqu-stop-btn">停止监控</button>
</div>
<div style="margin-top:8px;font-size:12px;color:#555">注意:脚本会尝试自动展开"课程详情"以检测教学班</div>
<hr>
<div id="hqu-course-list" style="max-height:360px;overflow:auto;"></div>
<hr>
<div id="hqu-log" style="max-height:160px;overflow:auto;font-size:12px;"></div>
<div style="margin-top:6px">
<label><input id="hqu-sound-toggle" type="checkbox" checked> 声音提示</label>
</div>
</div>
`;
document.body.appendChild(sidebar);
// 创建最小化时的浮动按钮
const floatBtn = document.createElement('div');
floatBtn.id = 'hqu-float-btn';
floatBtn.innerHTML = '抢课';
floatBtn.style.display = 'none';
document.body.appendChild(floatBtn);
const style = document.createElement('style');
style.textContent = `
#hqu-course-sidebar {
position: fixed;
top: 40px;
right: 10px;
width: 380px;
height: 84vh;
background: #fff;
border: 1px solid #ccc;
z-index: 2147483647 !important;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
padding: 10px;
font-family: Arial, sans-serif;
font-size: 13px;
resize: both;
overflow: auto;
}
#hqu-sidebar-header {
background: #f0f0f0;
padding: 5px;
margin: -10px -10px 10px -10px;
border-bottom: 1px solid #ccc;
}
#hqu-float-btn {
position: fixed;
top: 50%;
right: 0;
transform: translateY(-50%);
background: #e60012;
color: white;
padding: 10px 5px;
border-radius: 5px 0 0 5px;
cursor: pointer;
z-index: 2147483646;
box-shadow: -2px 0 5px rgba(0,0,0,0.2);
font-weight: bold;
writing-mode: vertical-rl;
text-orientation: mixed;
}
#hqu-float-btn:hover {
background: #ff3333;
}
#hqu-course-sidebar h2 { margin: 0; font-size: 16px; }
#hqu-course-sidebar button { padding:6px 8px; font-size:13px; cursor:pointer; }
.hqu-class-entry { padding:6px; border-radius:6px; margin:6px 0; background:#fafafa; border:1px solid #eee; }
.hqu-class-entry.selected { background:#eafaf0; border-color:#cdebd4; }
.hqu-log-line { margin:4px 0; }
.hqu-success { color:#198754; }
.hqu-warn { color:#f59e0b; }
.hqu-error { color:#dc3545; }
`;
document.head.appendChild(style);
// 绑定按钮事件
document.getElementById('hqu-detect-btn').addEventListener('click', () => {
detectAndRender(true);
});
document.getElementById('hqu-start-btn').addEventListener('click', startMonitoring);
document.getElementById('hqu-stop-btn').addEventListener('click', stopMonitoring);
// 最小化和关闭按钮
document.getElementById('hqu-minimize-btn').addEventListener('click', toggleSidebar);
document.getElementById('hqu-close-btn').addEventListener('click', () => {
document.body.removeChild(sidebar);
document.body.removeChild(floatBtn);
});
// 浮动按钮点击事件
floatBtn.addEventListener('click', toggleSidebar);
// 实现拖拽功能
makeDraggable(sidebar, document.getElementById('hqu-sidebar-header'));
uiReady = true;
log('侧边栏已加载', 'info');
// 自动首次检测(给页面一点渲染时间)
setTimeout(() => detectAndRender(true), 1200);
}
// 侧边栏最小化/恢复功能
function toggleSidebar() {
const sidebar = document.getElementById('hqu-course-sidebar');
const floatBtn = document.getElementById('hqu-float-btn');
if (isSidebarMinimized) {
// 恢复侧边栏
sidebar.style.display = 'block';
floatBtn.style.display = 'none';
isSidebarMinimized = false;
} else {
// 最小化侧边栏
sidebar.style.display = 'none';
floatBtn.style.display = 'block';
isSidebarMinimized = true;
}
}
// 实现元素可拖动
function makeDraggable(element, dragHandle) {
let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
dragHandle.onmousedown = dragMouseDown;
function dragMouseDown(e) {
e = e || window.event;
e.preventDefault();
// 获取鼠标初始位置
pos3 = e.clientX;
pos4 = e.clientY;
document.onmouseup = closeDragElement;
// 鼠标移动时调用elementDrag函数
document.onmousemove = elementDrag;
}
function elementDrag(e) {
e = e || window.event;
e.preventDefault();
// 计算新位置
pos1 = pos3 - e.clientX;
pos2 = pos4 - e.clientY;
pos3 = e.clientX;
pos4 = e.clientY;
// 设置元素的新位置
element.style.top = (element.offsetTop - pos2) + "px";
element.style.left = (element.offsetLeft - pos1) + "px";
}
function closeDragElement() {
// 停止移动
document.onmouseup = null;
document.onmousemove = null;
}
}
/* ------------- 日志 & 提示 ------------- */
function log(msg, level = 'info') {
const logDiv = document.getElementById('hqu-log');
if (!logDiv) return console.log(msg);
const line = document.createElement('div');
line.className = 'hqu-log-line';
if (level === 'success') line.classList.add('hqu-success');
if (level === 'warn') line.classList.add('hqu-warn');
if (level === 'error') line.classList.add('hqu-error');
line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
logDiv.prepend(line);
// 保持日志区域不会无限增长
if (logDiv.children.length > 50) {
logDiv.removeChild(logDiv.lastChild);
}
}
function notifyUser(title, body) {
// 浏览器通知(非必须)
try {
if (window.Notification && Notification.permission === 'granted') {
new Notification(title, { body });
} else if (window.Notification && Notification.permission !== 'denied') {
Notification.requestPermission().then(p => {
if (p === 'granted') new Notification(title, { body });
});
}
} catch (e) {
console.warn('通知失败', e);
}
// 声音(可控)
try {
if (document.getElementById('hqu-sound-toggle')?.checked) {
const a = new Audio('https://actions.google.com/sounds/v1/alarms/alarm_clock.ogg');
a.volume = 0.3;
a.play().catch(()=>{});
}
} catch (e) {}
}
/* ------------- 辅助:尝试展开课程详情 ------------- */
function clickCourseDetailsLinks() {
// 页面里通常有 "课程详情" 的链接,优先点击它们以展开课程、加载教学班
const links = Array.from(document.querySelectorAll('a,button'));
let cnt = 0;
links.forEach(el => {
const txt = (el.textContent || '').trim();
if (txt.includes('课程详情') || txt.includes('教学班详情')) {
try {
el.click();
cnt++;
} catch (e) {}
}
});
// 有些页面用折叠面板 header 展开
const headers = document.querySelectorAll('.el-collapse-item__header');
headers.forEach(h => {
try {
// 只点击未展开的
if (!h.classList.contains('is-active')) {
h.click();
cnt++;
}
} catch(e){}
});
log(`尝试自动展开课程详情:点击 ${cnt} 个可能的展开项`, 'info');
return cnt;
}
/* ------------- 解析课程/教学班 ------------- */
function getTextTrim(el) {
return el ? (el.textContent || '').trim() : '';
}
// 从一个可能的 courseBody DOM 节点解析课程信息
function parseCourseInfoFromBody(courseBody) {
if (!courseBody) return null;
const labels = courseBody.querySelectorAll('.card-item .label.cv-pull-left');
function getValue(label) {
for (const lab of labels) {
if ((lab.textContent || '').includes(label)) {
const next = lab.parentElement.querySelector('.value');
if (next) return getTextTrim(next);
}
}
return '';
}
return {
id: getValue('课程号'),
name: getValue('课程名称'),
classCount: getValue('教学班个数'),
category: getValue('课程类别'),
credit: getValue('学分'),
rawElement: courseBody
};
}
// 在祖先链或前面同级查找最近的课程体(用于将教学班匹配到课程)
function findNearestCourseBody(node) {
let cur = node;
for (let i = 0; i < 12 && cur; i++) {
// 向上查找包含课程号/课程名标识的节点
if (cur.querySelector && cur.querySelector('.value.cv-pull-left.has-choosed-course')) {
return cur;
}
// 向前找同级元素
if (cur.previousElementSibling) {
cur = cur.previousElementSibling;
if (cur.querySelector && cur.querySelector('.value.cv-pull-left.has-choosed-course')) return cur;
} else {
cur = cur.parentElement;
}
}
return null;
}
// 抓取页面所有教学班(全局扁平)
function extractAllClassCards() {
const selectors = [
'.card-list.course-jxb .el-card.jxb-card .el-card__body',
'.card-list.course-jxb .el-card__body',
'.el-card.jxb-card .el-card__body',
'.card-item.head' // fallback:直接以 head 节点为基础处理
];
const nodes = new Set();
selectors.forEach(sel => {
document.querySelectorAll(sel).forEach(n => nodes.add(n));
});
const classes = [];
let idx = 0;
nodes.forEach(node => {
// 如果 selector 匹到 .card-item.head,本身可能不是 el-card__body
let parentCard = node;
if (node.classList && node.classList.contains('card-item') && node.classList.contains('head')) {
// 找到包含它的 el-card__body
const p = node.closest('.el-card__body');
if (p) parentCard = p;
}
// 教师/标题通常在 .card-item.head .one-row span 或 head.textContent
const headEl = parentCard.querySelector('.card-item.head');
const title = headEl ? getTextTrim(headEl) : getTextTrim(parentCard.querySelector('.one-row')) || getTextTrim(parentCard.querySelector('div[title]')) || '';
// 时间/地点通常在第二个 card-item
const timePlace = parentCard.querySelector('.card-item:nth-child(2)') ? getTextTrim(parentCard.querySelector('.card-item:nth-child(2)')) : '';
// 容量
const capacityEl = parentCard.querySelector('.card-item .value') || parentCard.querySelector('.cv-pull-left .value');
const capacity = capacityEl ? getTextTrim(capacityEl) : '';
// 已选人数
const selectedSpan = parentCard.querySelector('.card-item span');
const selectedCount = selectedSpan ? getTextTrim(selectedSpan) : '';
// 找按钮(选择 / 退选)
let selectBtn = null;
let isSelected = false;
parentCard.querySelectorAll('button').forEach(btn => {
const t = (btn.textContent || '').trim();
if (!selectBtn && (t.includes('选择') || t.includes('选课'))) selectBtn = btn;
if (t.includes('退选') || t.includes('已选')) { selectBtn = btn; isSelected = true; }
});
// 试着找到最近的课程 body 用于关联课程名
const nearestCourseBody = findNearestCourseBody(parentCard);
const courseInfo = nearestCourseBody ? parseCourseInfoFromBody(nearestCourseBody) : null;
const courseName = courseInfo ? courseInfo.name : '';
idx++;
classes.push({
_idx: idx,
id: `${courseInfo?.id || 'unknown'}-${idx}`,
courseId: courseInfo?.id || '',
courseName: courseName || '(未绑定课程)',
title: title || ('教学班' + idx),
details: `${timePlace} ${capacity ? '| ' + capacity : ''} ${selectedCount ? '| ' + selectedCount : ''}`.trim(),
isSelected,
button: selectBtn,
el: parentCard,
priority: 999
});
});
return classes;
}
/* ------------- 检测并渲染 ------------- */
async function detectAndRender(forceExpand = false) {
if (!uiReady) initUI();
log('开始检测课程/教学班...', 'info');
// 首先尝试点击页面上的"课程详情"等展开项(若需要)
if (forceExpand) {
clickCourseDetailsLinks();
// 等待一段时间,给页面加载展开内容
await new Promise(r => setTimeout(r, 700));
}
// 使用 MutationObserver 监听短时间内的新增节点(若页面还在异步渲染)
const found = await new Promise((resolve) => {
let timeoutId = null;
const observer = new MutationObserver((mutList) => {
// 只要检测到可能的 class-list 就认为可用
const possible = document.querySelectorAll('.card-list.course-jxb .el-card__body, .el-card.jxb-card .el-card__body, .card-item.head');
if (possible.length > 0) {
clearTimeout(timeoutId);
observer.disconnect();
resolve(true);
}
});
observer.observe(document.body, { childList: true, subtree: true });
// 超时后直接继续(不阻塞太久)
timeoutId = setTimeout(() => { observer.disconnect(); resolve(false); }, 1100);
});
if (!found) {
// 再次尝试点击 "课程详情" 更彻底一点
clickCourseDetailsLinks();
await new Promise(r => setTimeout(r, 800));
}
// 最后直接抓取所有教学班
allClasses = extractAllClassCards();
// 渲染侧边栏
renderClassList();
log(`检测完成:找到 ${allClasses.length} 个教学班`, 'success');
return allClasses.length;
}
/* ------------- 渲染侧边栏列表 ------------- */
function renderClassList() {
const listDiv = document.getElementById('hqu-course-list');
listDiv.innerHTML = '';
if (!allClasses || allClasses.length === 0) {
listDiv.innerHTML = '<div style="color:#666">未找到教学班(可尝试先点击页面的某门课程以展开其教学班,或点击上方"检测课程")</div>';
return;
}
// 将同一课程的教学班归组展示
const grouped = {};
allClasses.forEach(cls => {
const key = cls.courseName || '(未绑定课程)';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(cls);
});
Object.keys(grouped).forEach(courseName => {
const header = document.createElement('div');
header.style.fontWeight = '600';
header.style.marginTop = '6px';
header.textContent = courseName;
listDiv.appendChild(header);
grouped[courseName].forEach(cls => {
const div = document.createElement('div');
div.className = 'hqu-class-entry';
div.id = `hqu-class-${cls._idx}`;
div.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;">
<div style="flex:1">
<div style="font-weight:600">${cls.title}</div>
<div style="font-size:12px;color:#666">${cls.details}</div>
</div>
<div style="text-align:right">
<div style="margin-bottom:6px">
<label><input type="checkbox" class="hqu-class-checkbox" data-id="${cls._idx}"> 监控</label>
</div>
<div>
优先级 <input class="hqu-priority-input" data-id="${cls._idx}" type="number" value="${cls.priority}" min="1" style="width:52px;">
</div>
</div>
</div>
`;
listDiv.appendChild(div);
});
});
}
/* ------------- 监控/抢课 ------------- */
function startMonitoring() {
// 读用户勾选
monitoringMap = {};
const cbs = Array.from(document.querySelectorAll('.hqu-class-checkbox:checked'));
if (cbs.length === 0) {
log('未选择任何教学班进行监控', 'warn');
return;
}
cbs.forEach(cb => {
const id = Number(cb.dataset.id);
const cls = allClasses.find(x => x._idx === id);
if (!cls) return;
const prInput = document.querySelector(`.hqu-priority-input[data-id="${id}"]`);
if (prInput) cls.priority = parseInt(prInput.value) || 999;
monitoringMap[cls.id] = cls;
});
const total = Object.keys(monitoringMap).length;
if (total === 0) {
log('未找到可监控的教学班', 'warn');
return;
}
// 启动定时器
if (monitorInterval) clearInterval(monitorInterval);
monitorInterval = setInterval(monitorOnce, 2000);
log(`开始监控 ${total} 个教学班(每 2s 尝试)`, 'success');
}
function stopMonitoring() {
if (monitorInterval) {
clearInterval(monitorInterval);
monitorInterval = null;
log('已停止监控', 'info');
} else {
log('当前没有在监控', 'info');
}
}
function monitorOnce() {
// 保证最新的按钮引用(页面可能在变)
// 重新刷新 allClasses 中的 button 引用(尝试从 DOM 重新获取)
allClasses.forEach(cls => {
try {
// 如果已保存 el,重查该 el 下的按钮
if (cls.el) {
let btn = null;
cls.el.querySelectorAll('button').forEach(b => {
const t = (b.textContent || '').trim();
if (!btn && (t.includes('选择') || t.includes('选课'))) btn = b;
if (t.includes('退选') || t.includes('已选')) { btn = b; cls.isSelected = true; }
});
if (btn) cls.button = btn;
}
} catch (e) {}
});
// 按优先级排序后尝试一次点击
const candidates = Object.values(monitoringMap).sort((a, b) => a.priority - b.priority);
for (const cls of candidates) {
try {
if (!cls.button) {
// 若没有按钮,尝试展开并重新检测
continue;
}
const text = (cls.button.textContent || '').trim();
if (text.includes('选择') || text.includes('选课')) {
cls.button.click();
log(`尝试选择(优先级 ${cls.priority}): ${cls.courseName} - ${cls.title}`, 'success');
notifyUser('尝试抢课', `${cls.courseName} - ${cls.title}`);
// 点击一次后本轮停止(避免点太快),下一轮继续
break;
} else if (cls.isSelected || text.includes('退选') || text.includes('已选')) {
log(`检测到已选:${cls.courseName} - ${cls.title}`, 'success');
notifyUser('选课成功', `${cls.courseName} - ${cls.title}`);
// 从监控列表移除
delete monitoringMap[cls.id];
} else {
// 未找到明确按钮文字,跳过
}
} catch (e) {
log(`监控出错:${cls.courseName} - ${cls.title} (${e.message})`, 'error');
}
}
// 如果监控项已空,停止定时器
if (Object.keys(monitoringMap).length === 0) {
log('所有监控项已完成或被移除,停止监控', 'info');
stopMonitoring();
}
}
/* ------------- 自动挂载 & 监控 DOM ------------- */
// 尝试多入口插入 UI(应对 SPA / 早期注入)
function tryInitUISoon() {
if (document.getElementById('hqu-course-sidebar')) return;
if (document.readyState === 'complete' || document.readyState === 'interactive') {
initUI();
} else {
document.addEventListener('DOMContentLoaded', () => setTimeout(initUI, 400));
}
// 额外保险:window.load 也再尝试一次
window.addEventListener('load', () => setTimeout(initUI, 800));
}
// 监控 DOM,如果检测到新的课程卡片就自动触发一次检测
function watchForCourseArea() {
const observer = new MutationObserver((muts) => {
const found = document.querySelectorAll('.card-list.course-jxb .el-card__body, .el-card.jxb-card .el-card__body, .card-item.head');
if (found.length > 0 && uiReady) {
// 自动刷新一次检测(节流:只每 3s 最多一次)
const last = window.__hqu_last_auto_detect || 0;
if (Date.now() - last > 3000) {
window.__hqu_last_auto_detect = Date.now();
detectAndRender(false);
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
/* ------------- 启动脚本 ------------- */
tryInitUISoon();
watchForCourseArea();
// 暴露到 console 方便手动调试
window.HQU_GRAB = {
detect: () => detectAndRender(true),
start: startMonitoring,
stop: stopMonitoring,
getClasses: () => allClasses
};
})();