// ==UserScript== // @name 高校教务系统智能抢课工具 v3.1 // @namespace https://github.com/school-course-grabber // @version 3.1.0 // @description 【多课程版】支持一次抢多门课,按顺序依次监控。API嗅探+直连+预连接+自适应间隔。 // @author Auto-Grabber // @license MIT // @match *://*/student/courseSelect/* // @match *://*/student/course/* // @match *://*/student/selectCourse/* // @match *://*/xsxk/* // @match *://*/xk/* // @match *://*/*courseSelect* // @match *://*/*selectCourse* // @match *://*/*elective* // @include http://111.43.36.164/* // @include http://172.20.139.153:7700/* // @include https://*-*-*-*-*.webvpn.qqhru.edu.cn/* // @grant none // @run-at document-start // ==/UserScript== (function () { 'use strict'; // ==================== 配置 ==================== const CONFIG = { cardSelectors: [ '.course-card','.course-item','.courseItem','.kccg_item', 'div[class*="course" i]','div[class*="kccg" i]', 'tr[class*="course" i]','li[class*="course" i]', '.card','div[class*="card" i]', ], selectButtonSelectors: [ 'button:contains("选择")','a:contains("选择")', 'button[class*="select" i]','a[class*="select" i]', '.select-btn','.btn-select','button:contains("选课")','a:contains("选课")', ], confirmButtonSelectors: [ '.modal button:contains("确定")','.modal button:contains("提交")', '.modal button:contains("确认")','.el-dialog button:contains("确定")', '.layui-layer button:contains("确定")','.ant-modal button:contains("确定")', 'button.confirm','button.submit', ], domPollMin:500, domPollMax:800, apiSafetyFloor:80, apiMargin:3, playSound:true, showDesktopNotification:true, stopOnSuccess:false, // 多课程模式下不自动停止,抢完所有才停 debug:false, }; // ==================== 状态 ==================== const STATE = { targetList: [], // [{code, name, status:'pending'|'grabbed'|'failed'}] currentIdx: 0, // 当前正在监控的课程在targetList中的索引 maxGrab: 1, // 最多抢几门 isRunning: false, timerId: null, attemptCount: 0, grabbedCount: 0, logLines: [], apiMode: false, apiTemplate: null, capturedAPIs: [], rttHistory: [], cardSelector: null, matchedCardFound: false, }; // ==================== 预连接 ==================== function injectPreconnect() { const host = location.hostname; if (!host || host === 'localhost' || host === '127.0.0.1') return; [{rel:'dns-prefetch',href:'//'+host},{rel:'preconnect',href:'//'+host},{rel:'preconnect',href:'//'+host,crossorigin:'use-credentials'}].forEach(a=>{ const l=document.createElement('link');l.rel=a.rel;l.href=a.href; if(a.crossorigin)l.setAttribute('crossorigin',a.crossorigin); document.head.appendChild(l); }); log('INFO','已注入预连接: '+host); } // ==================== API嗅探器 ==================== function setupAPISniffer() { const KW=['select','course','elective','choose','enroll','pick','选课','选择','选','xk','xsxk','add','submit','save','commit']; const isAPI=(u,b)=>KW.some(k=>(u+' '+(b||'')).toLowerCase().includes(k)); function norm(u,m,b){ try{new URL(u,location.origin)}catch(e){return null} const p={};new URL(u,location.origin).searchParams.forEach((v,k)=>{p[k]=/^\d{3,}$/.test(v)?'{{INPUT}}':v}); const nu=new URL(u,location.origin);nu.search='';Object.entries(p).forEach(([k,v])=>nu.searchParams.set(k,v)); let bp=null; if(b&&typeof b==='string'){try{const j=JSON.parse(b);bp=JSON.stringify(j).replace(/:\s*"\d{3,}"|:\s*\d{3,}/g,': "{{INPUT}}"')}catch(e){bp=b.replace(/\b\d{3,}\b/g,'{{INPUT}}')}} return{url:nu.href,method:m.toUpperCase(),headers:{'Content-Type':'application/x-www-form-urlencoded'},bodyPattern:bp,rawBody:b}; } const OX=window.XMLHttpRequest; window.XMLHttpRequest=function(){const x=new OX();let m='',u='',b=null; const oo=x.open;x.open=function(me,ur){m=me;u=ur;return oo.apply(this,arguments)}; const os=x.send;x.send=function(bd){b=bd;if(isAPI(u,b)){const t=norm(u,m,b);if(t&&!STATE.capturedAPIs.find(a=>a.url===t.url)){STATE.capturedAPIs.push(t);log('INFO','[嗅探] '+m+' '+u?.substring(0,80));updateAPIPanel()}}return os.apply(this,arguments)}; return x}; window.XMLHttpRequest.prototype=OX.prototype; const of=window.fetch;window.fetch=function(i,ni){const u=typeof i==='string'?i:(i.url||'');const m=((ni||{}).method||'GET').toUpperCase();const b=(ni||{}).body||''; if(isAPI(u,b)){const t=norm(u,m,typeof b==='string'?b:'');if(t&&!STATE.capturedAPIs.find(a=>a.url===t.url)){STATE.capturedAPIs.push(t);log('INFO','[嗅探] '+m+' '+u?.substring(0,80));updateAPIPanel()}}return of.apply(this,arguments)}; } // ==================== 工具函数 ==================== function log(level,msg){ const ts=new Date().toLocaleTimeString(),line=`[${ts}] [${level}] ${msg}`; console.log('[抢课] '+line);STATE.logLines.push(line);if(STATE.logLines.length>300)STATE.logLines.shift(); const box=document.getElementById('gb-log'); if(box){const d=document.createElement('div');d.textContent=line;d.style.cssText=`font-size:11px;line-height:1.5;font-family:'Courier New',monospace;color:${level==='SUCCESS'?'#0f0':level==='ERROR'?'#f44':level==='WARN'?'#fa0':level==='ALERT'?'#f44':level==='API'?'#0af':level==='PROGRESS'?'#ff0':'#0c6'};${level==='ALERT'?'font-weight:bold;':''}`;box.appendChild(d);box.scrollTop=box.scrollHeight} const st=document.getElementById('gb-status-text'),sd=document.getElementById('gb-status-dot'); if(st){if(level==='STATUS'){st.textContent=msg;if(sd)sd.style.background='#fa0'}else if(level==='SUCCESS'){st.textContent='✅ '+msg;if(sd)sd.style.background='#0f0'}else if(level==='ALERT'){st.textContent='🚨 '+msg;if(sd)sd.style.background='#f44'}} } function playSound(){if(!CONFIG.playSound)return;try{const c=new(window.AudioContext||window.webkitAudioContext)();[800,1000,1200].forEach((f,i)=>{const o=c.createOscillator(),g=c.createGain();o.type='square';o.frequency.value=f;g.gain.setValueAtTime(.3,c.currentTime+i*.15);g.gain.exponentialRampToValueAtTime(.001,c.currentTime+i*.15+.12);o.connect(g);g.connect(c.destination);o.start(c.currentTime+i*.15);o.stop(c.currentTime+i*.15+.12)})}catch(e){}} function notify(title,body){if(!CONFIG.showDesktopNotification)return;if('Notification' in window){if(Notification.permission==='granted')new Notification(title,{body});else if(Notification.permission==='default')Notification.requestPermission().then(p=>{if(p==='granted')new Notification(title,{body})})}} function showPageAlert(title,msg,ok){const old=document.getElementById('gb-alert');if(old)old.remove();const ov=document.createElement('div');ov.id='gb-alert';ov.style.cssText='position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:99999;display:flex;align-items:center;justify-content:center;';const cl=ok?'#00ff88':'#ff4444';ov.innerHTML=`
${ok?'🎉':'⚠️'}

${title}

${msg}

`;document.body.appendChild(ov);ov.addEventListener('click',e=>{if(e.target===ov)ov.remove()})} // ==================== 进度辅助 ==================== function getCurrentTarget(){ if(STATE.currentIdx>=STATE.targetList.length)return null; // 跳过非pending while(STATE.currentIdx=STATE.targetList.length)return null; return STATE.targetList[STATE.currentIdx]; } function advanceToNext(){ STATE.currentIdx++; STATE.matchedCardFound=false; while(STATE.currentIdx=STATE.targetList.length)return false; const t=STATE.targetList[STATE.currentIdx]; log('PROGRESS',`━━━ 切换到下一门: [${t.code||t.name}] (进度 ${STATE.grabbedCount}/${STATE.maxGrab}) ━━━`); return true; } function countPending(){return STATE.targetList.filter(t=>t.status==='pending').length} function allGrabbed(){return STATE.grabbedCount>=STATE.maxGrab||countPending()===0} // ==================== API直连抢课 ==================== async function apiGrab(){ STATE.attemptCount++; const target=getCurrentTarget(); if(!target){log('SUCCESS','✅ 所有课程抢课完成!');stopGrabber();return;} if(!STATE.apiTemplate){log('ERROR','API模板为空,回退DOM模式');STATE.apiMode=false;return} const tpl=STATE.apiTemplate; const code=target.code||target.name; let url=tpl.url.replace(/\{\{INPUT\}\}/g,code); let body=(tpl.bodyPattern||'').replace(/\{\{INPUT\}\}/g,code); try{ const init={method:tpl.method,credentials:'include',headers:Object.assign({},tpl.headers||{})}; if(tpl.method!=='GET'&&body)init.body=body; const st=performance.now(); const resp=await fetch(url,init); const elapsed=(performance.now()-st).toFixed(0); const text=await resp.text(); STATE.rttHistory.push(+elapsed); if(STATE.rttHistory.length>5)STATE.rttHistory.shift(); log('API',`[API直连] ${tpl.method} 响应 ${resp.status} (${elapsed}ms)`); if(checkSuccess(resp,text)){ log('SUCCESS',`✅ 抢课成功: [${target.code||target.name}]`); onGrabSuccess(target,'API直连'); }else if(/满|已满|full|no.*slot|无.*名额/i.test(text)){ log('DATA',`[${target.code||target.name}] 名额已满,继续监控...`); }else if(/冲突|已选|已经|already|exist/i.test(text)){ log('SUCCESS',`可能已选过: [${target.code||target.name}]`); onGrabSuccess(target,'API直连(可能已选)'); }else{log('WARN',`API返回未知状态: ${text.substring(0,150)}`)} }catch(e){ log('ERROR',`API请求失败: ${e.message}`); if(STATE.attemptCount>3){log('WARN','API连续失败,切换到DOM模式');STATE.apiMode=false} } } function checkSuccess(resp,text){if(resp.ok){const fk=['失败','错误','异常','error','fail','exception','满'];if(!fk.some(k=>text.toLowerCase().includes(k)))return true}return false} function onGrabSuccess(target,mode){ target.status='grabbed'; STATE.grabbedCount++; const label=`[${target.code||target.name}]`; playSound();setTimeout(()=>playSound(),400); notify('🚨 抢课成功!',`课程 ${label} 已通过${mode}完成选课!(${STATE.grabbedCount}/${STATE.maxGrab})`); showPageAlert('抢课成功!',`课程:${label}\n模式:${mode}\n进度:${STATE.grabbedCount}/${STATE.maxGrab}`,true); if(allGrabbed()){ log('SUCCESS',`🎉 全部完成!共抢到 ${STATE.grabbedCount} 门课`); stopGrabber(); }else if(!advanceToNext()){ log('SUCCESS','所有课程已处理完毕'); stopGrabber(); } } // ==================== DOM模式抢课 ==================== function findCards(){ if(STATE.cardSelector){const c=document.querySelectorAll(STATE.cardSelector);if(c.length>0)return Array.from(c)} for(const s of CONFIG.cardSelectors){try{const c=document.querySelectorAll(s);if(c.length>0){STATE.cardSelector=s;return Array.from(c)}}catch(e){}} const all=document.querySelectorAll('div,tr,li'),candidates=[]; for(const e of all){const t=e.textContent||'';if(/\b\d{2,4}\b/.test(t)&&/\d+\s*\/\s*\d+/.test(t)&&e.children.length>=2){let anc=false,p=e.parentElement;while(p&&p!==document.body){if(candidates.includes(p)){anc=true;break}p=p.parentElement}if(!anc)candidates.push(e)}} return candidates } function extractCode(card){const t=(card.textContent||'').replace(/\s+/g,' ').trim();return(t.match(/\[(\d{2,4})\s*[-–—]/)||t.match(/\b(\d{2,4})\s*[-–—]/)||[''])[1]||''} function extractName(card){const t=(card.textContent||'').replace(/\s+/g,' ').trim();const m=t.match(/\[?\d{2,4}\s*[-–—]\s*([^\s\]\[]+)/);return m?m[1]:''} function parseCapacity(card){const t=(card.textContent||'').replace(/\s+/g,' ').trim();let m=t.match(/已选[\/\s]*容量[::]\s*(\d+)\s*\/\s*(\d+)/);if(m)return{sel:+m[1],cap:+m[2]};m=t.match(/已选[::]\s*(\d+)[\s\S]*?容量[::]\s*(\d+)/);if(m)return{sel:+m[1],cap:+m[2]};for(const mm of t.matchAll(/(\d+)\s*\/\s*(\d+)/g)){const s=+mm[1],c=+mm[2];if(c>=10&&c<=500&&s<=c)return{sel:s,cap:c}}return null} function findSelectBtn(card){for(const s of CONFIG.selectButtonSelectors){try{if(s.includes(':contains(')){const kw=s.match(/:contains\("(.+?)"\)/)?.[1];if(!kw)continue;const b=s.replace(/:contains\(".+?"\)/,'');const l=b?card.querySelectorAll(b):card.querySelectorAll('button,a,[role="button"]');for(const e of l){if((e.textContent||'').includes(kw))return e}}else{const btn=card.querySelector(s);if(btn)return btn}}catch(e){}}for(const e of card.querySelectorAll('button,a,[onclick],[role="button"]')){const t=(e.textContent||'');if(t.includes('选择')||t.includes('选课'))return e}return null} function visible(el){if(!el)return false;const r=el.getBoundingClientRect();if(r.width===0||r.height===0)return false;const s=getComputedStyle(el);return s.display!=='none'&&s.visibility!=='hidden'&&s.opacity!=='0'} function clickConfirm(){for(const s of CONFIG.confirmButtonSelectors){try{if(s.includes(':contains(')){const kw=s.match(/:contains\("(.+?)"\)/)?.[1];if(!kw)continue;const b=s.replace(/:contains\(".+?"\)/,'');const l=b?document.querySelectorAll(b):document.querySelectorAll('button,a');for(const e of l){if((e.textContent||'').trim().includes(kw)&&visible(e)){e.click();return true}}}else{const btn=document.querySelector(s);if(btn&&visible(btn)){btn.click();return true}}}catch(e){}}for(const m of document.querySelectorAll('.modal,.dialog,.layer,[class*="modal" i],[class*="dialog" i],[role="dialog"]')){if(!visible(m))continue;for(const b of m.querySelectorAll('button,a.btn,.btn')){if(!visible(b))continue;const t=(b.textContent||'').trim();if(['确定','提交','确认','是'].some(k=>t===k||t.includes(k))){b.click();return true}}}return false} let _oc=null;function hijackConfirm(){if(_oc===null){_oc=window.confirm;window.confirm=()=>true}}function restoreConfirm(){if(_oc!==null){window.confirm=_oc;_oc=null}} function domScan(){ STATE.attemptCount++; const target=getCurrentTarget(); if(!target){log('SUCCESS','✅ 所有课程抢课完成!');stopGrabber();return} const cards=findCards(); if(!cards.length){if(STATE.attemptCount%5===0)log('WARN','未找到课程卡片');return} let tc=null; for(const c of cards){const cd=extractCode(c),nm=extractName(c);if((target.code&&cd===target.code)||(target.name&&nm&&nm.includes(target.name))){tc=c;if(!STATE.matchedCardFound){STATE.matchedCardFound=true;log('SUCCESS',`找到: [${cd||nm}] (进度 ${STATE.grabbedCount+1}/${STATE.maxGrab})`)}break}} if(!tc){STATE.matchedCardFound=false;clickConfirm();return} STATE.matchedCardFound=true; const cap=parseCapacity(tc);if(!cap)return; log('DATA',`[${target.code||target.name}] ${cap.sel}/${cap.cap} (剩余:${cap.cap-cap.sel})`); if(cap.seltc.classList.remove('gb-highlight'),2000); const btn=findSelectBtn(tc);if(!btn){log('ERROR','未找到选择按钮');return} btn.click();log('INFO','已点击选择按钮'); const st=Date.now();const iv=setInterval(()=>{if(clickConfirm())clearInterval(iv);if(Date.now()-st>3000)clearInterval(iv)},150); onGrabSuccess(target,'DOM点击'); } clickConfirm(); } // ==================== 主循环 ==================== function scanLoop(){if(!STATE.isRunning)return;if(STATE.apiMode){apiGrab().then(()=>scheduleNext())}else{domScan();scheduleNext()}} function scheduleNext(){ if(!STATE.isRunning)return;let ms; if(STATE.apiMode){if(STATE.rttHistory.length>0){const a=STATE.rttHistory.reduce((x,y)=>x+y,0)/STATE.rttHistory.length;ms=Math.max(CONFIG.apiSafetyFloor,a+CONFIG.apiMargin);ms+=(Math.random()-.5)*4}else{ms=200}} else{ms=CONFIG.domPollMin+Math.random()*(CONFIG.domPollMax-CONFIG.domPollMin)} STATE.timerId=setTimeout(scanLoop,ms); } function startGrabber(){ if(STATE.isRunning)return; // 读取所有课程输入 const countEl=document.getElementById('gb-count'); const count=countEl?parseInt(countEl.value):1; STATE.maxGrab=count; STATE.targetList=[]; for(let i=0;i0){STATE.apiTemplate=STATE.capturedAPIs[0];STATE.apiMode=true} STATE.isRunning=true;STATE.attemptCount=0;STATE.grabbedCount=0;STATE.currentIdx=0; STATE.matchedCardFound=false;STATE.logLines=[]; const box=document.getElementById('gb-log');if(box)box.innerHTML=''; const names=STATE.targetList.map(t=>`[${t.code||t.name}]`).join(', '); const mode=STATE.apiMode?'⚡API直连':'🐢DOM模式'; log('STATUS',`抢课计划: ${STATE.maxGrab}门 → ${names}`); log('PROGRESS',`━━━ 开始监控第 1 门: [${STATE.targetList[0].code||STATE.targetList[0].name}] ━━━`); log('INFO',`模式: ${mode} | 自适应间隔 = 平均RTT+3ms,最低${CONFIG.apiSafetyFloor}ms`); hijackConfirm(); if('Notification' in window&&Notification.permission==='default')Notification.requestPermission(); scanLoop();updateBtns(); } function stopGrabber(){ STATE.isRunning=false; if(STATE.timerId){clearTimeout(STATE.timerId);STATE.timerId=null} restoreConfirm(); const done=STATE.targetList.filter(t=>t.status==='grabbed').length; log('STATUS',`监控已停止。抢到 ${done}/${STATE.targetList.length} 门`); updateBtns(); } function updateBtns(){ const s=document.getElementById('gb-start'),t=document.getElementById('gb-stop'),c=document.getElementById('gb-count'); if(s)s.disabled=STATE.isRunning; if(t)t.disabled=!STATE.isRunning; if(c)c.disabled=STATE.isRunning; for(let i=0;i<6;i++){const inp=document.getElementById('gb-code-'+i);if(inp)inp.disabled=STATE.isRunning} } // ==================== API面板 ==================== function updateAPIPanel(){ const p=document.getElementById('gb-api-list'),s=document.getElementById('gb-api-status'); if(!p||!s)return; if(STATE.capturedAPIs.length===0){p.innerHTML='
等待捕获接口...
请手动操作一次选课流程
';s.textContent='🔍 监听中...';s.style.color='#fa0'} else{p.innerHTML=STATE.capturedAPIs.map((a,i)=>{const ac=STATE.apiTemplate&&STATE.apiTemplate.url===a.url;return`
${a.method} ${a.url.substring(0,60)}...
`}).join('');s.textContent=`✅ 已捕获 ${STATE.capturedAPIs.length} 个接口`;s.style.color='#0f0';p.querySelectorAll('.gb-api-item').forEach(e=>e.addEventListener('click',()=>{const i=+e.dataset.idx;STATE.apiTemplate=STATE.capturedAPIs[i];STATE.apiMode=true;updateAPIPanel();updateModeIndicator();log('API','已选择API: '+STATE.apiTemplate.method+' '+STATE.apiTemplate.url.substring(0,60))}))} } function updateModeIndicator(){const e=document.getElementById('gb-mode-indicator');if(e){e.textContent=STATE.apiMode?'⚡ API直连模式':'🐢 DOM模式';e.style.color=STATE.apiMode?'#0af':'#888'}} // ==================== 输入框生成 ==================== function generateInputs(count){ const container=document.getElementById('gb-inputs'); if(!container)return; let html=''; for(let i=0;i`} container.innerHTML=html; // 给每个输入框绑定回车键不触发起始 for(let i=0;i{if(e.key==='Enter'){e.preventDefault();const next=document.getElementById('gb-code-'+(i+1));if(next)next.focus();else startGrabber()}})} } // ==================== UI ==================== const US=document.createElement('style'); US.textContent=`@keyframes gbFadeIn{from{opacity:0;transform:scale(.8)}to{opacity:1;transform:scale(1)}}@keyframes gbPulse{0%,100%{box-shadow:0 0 5px #00ff88}50%{box-shadow:0 0 20px #00ff88,0 0 40px #00ff88}}.gb-highlight{animation:gbPulse .6s ease-in-out 3!important;outline:3px solid #00ff88!important;border-radius:8px!important}#gb-ui *{box-sizing:border-box}`; document.head.appendChild(US); function createUI(){ const ui=document.createElement('div');ui.id='gb-ui'; ui.style.cssText='position:fixed;top:20px;right:20px;width:370px;max-height:85vh;z-index:99998;background:linear-gradient(135deg,#0a0a1a,#1a1a2e);border:1px solid #00ff88;border-radius:12px;font-family:Courier New,Microsoft YaHei,monospace;color:#00ff88;display:flex;flex-direction:column;user-select:none;box-shadow:0 0 20px rgba(0,255,136,.15),0 8px 32px rgba(0,0,0,.5);'; ui.innerHTML=`
🎓 抢课工具 v3.1 ⚡ API直连
按顺序依次抢
📡 API嗅探器🔍 监听中...
等待捕获接口...
请手动操作一次选课流程
💡 手动点一次选课,脚本自动学习API
就绪 - 设置抢课数量并填入代码
⚡ 仅限个人学习自用 | 请勿用于违规操作
`; document.body.appendChild(ui); document.getElementById('gb-start').addEventListener('click',startGrabber); document.getElementById('gb-stop').addEventListener('click',stopGrabber); document.getElementById('gb-count').addEventListener('change',function(){generateInputs(parseInt(this.value))}); let min=false; document.getElementById('gb-min').addEventListener('click',()=>{min=!min;document.getElementById('gb-body').style.display=min?'none':'';document.getElementById('gb-min').textContent=min?'+':'−'}); // 拖拽 const dr=document.getElementById('gb-drag');let ox=0,oy=0,dg=false; dr.addEventListener('mousedown',e=>{if(e.target.id==='gb-min')return;dg=true;const r=ui.getBoundingClientRect();ox=e.clientX-r.left;oy=e.clientY-r.top;dr.style.cursor='grabbing';document.body.style.userSelect='none'}); document.addEventListener('mousemove',e=>{if(!dg)return;let x=e.clientX-ox,y=e.clientY-oy;const mx=innerWidth-ui.offsetWidth,my=innerHeight-ui.offsetHeight;if(x<0)x=0;if(y<0)y=0;if(x>mx)x=mx;if(y>my)y=my;ui.style.left=x+'px';ui.style.top=y+'px';ui.style.right='auto'}); document.addEventListener('mouseup',()=>{dg=false;dr.style.cursor='grab';document.body.style.userSelect=''}); generateInputs(3); // 默认3门 updateModeIndicator(); } // ==================== 初始化 ==================== function init(){ (function dp(){if(document.head)injectPreconnect();else setTimeout(dp,10)})(); setupAPISniffer(); if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',()=>setTimeout(createUI,300))}else{setTimeout(createUI,300)} if('Notification' in window&&Notification.permission==='default')Notification.requestPermission(); console.log('[抢课工具 v3.1] 已加载 - 多课程顺序抢课模式'); } init(); })();