// ==UserScript== // @name 整活型GPA计算工具(适用于WHPU正方教务系统) // @namespace https://github.com/SomeBottle/fastfood // @version 1.0.3 // @description 在正方教务成绩页面一键计算平均学分绩点(GPA) // @author SomeBottle // @match *://*.edu.cn/* // @icon https://ae01.alicdn.com/kf/Hf7b4a77c0dde45c2b69eb762ddc690236.jpg // @grant none // @downloadURL none // ==/UserScript== (function () { 'use strict'; var GPAs = false; const congratuVidURL = 'https://ae01.alicdn.com/kf/Hc2aa6292d6d4434c9204067e12fa4c2df.jpg', popperVidURL = 'https://ae01.alicdn.com/kf/H6de89a79ee6644fcbc83efaaa6edf0ba6.jpg', popperAudURL = 'https://ae01.alicdn.com/kf/H90b31944004c48fb9bd38384404ced05N.jpg', congratuAudURL = 'https://music.163.com/song/media/outer/url?id=396696', countingAudURL = 'https://ae01.alicdn.com/kf/Hbb784b22951a40708d4f1721f68cafb2b.jpg', confirmAudURL = 'https://ae01.alicdn.com/kf/H9e546dbd00f84498a5dbd6b9c55cfb42O.jpg', objectURLs = {}, applyStyle = (elemArr, styleObj) => { // 批量应用样式 elemArr = Array.isArray(elemArr) ? elemArr : [elemArr]; // 支持单一元素 elemArr.forEach(elem => { if (elem instanceof Element) { for (let key in styleObj) elem.style[key] = styleObj[key]; // 应用样式 } }); }, S = (elemID) => document.querySelector(`#${elemID}`); // 按ID拾取元素 /*写正方教务的家伙真是个人才,把数组原型链上的filter方法给改了,你自己加个方法也好啊,非得覆盖,这里用MDN给出的polyfill加回来*/ if (!Array.prototype.myFilter) { Array.prototype.myFilter = function (func, thisArg) { 'use strict'; if (!((typeof func === 'Function' || typeof func === 'function') && this)) throw new TypeError(); var len = this.length >>> 0, res = new Array(len), // preallocate array t = this, c = 0, i = -1; if (thisArg === undefined) { while (++i !== len) { // checks to see if the key was set if (i in this) { if (func(t[i], i, t)) { res[c++] = t[i]; } } } } else { while (++i !== len) { // checks to see if the key was set if (i in this) { if (func.call(thisArg, t[i], i, t)) { res[c++] = t[i]; } } } } res.length = c; // shrink down array to proper size return res; }; } // Polyfill End function extractMedia(url, type = 'video/mp4') { // 从图片中提取出媒体 let key = btoa(url), saved = objectURLs[key]; if (!saved) { return fetch(url).then(res => res.blob(), rej => Promise.reject(rej)) .then(blob => { let mediaBlob = blob.slice(70070, blob.size, type), // blob流前70070字节是图片 objURL = URL.createObjectURL(mediaBlob); objectURLs[key] = objURL; return Promise.resolve(objURL); // 返回objectURL }) } else { return Promise.resolve(saved); } } async function congratulate() { let mainVideo = S('mainVideo'), popperVideo = S('popperVideo'), popperAudio = S('popperAudio'), pointSpan = S('finalGPA'), mainAudio = S('mainAudio'), afterAnimation = () => { pointSpan.removeEventListener('animationend', afterAnimation, false); applyStyle(pointSpan, { 'animation': 'bouncy 2s ease-in-out infinite', 'color': '#fbff00' }); }; pointSpan.style.animation = 'popUp 2s 1 forwards'; pointSpan.addEventListener('animationend', afterAnimation, false); applyStyle([mainVideo, popperVideo], { 'display': 'block' }); mainVideo.src = await extractMedia(congratuVidURL); popperVideo.src = await extractMedia(popperVidURL, 'video/webm'); popperAudio.src = await extractMedia(popperAudURL, 'audio/wav'); mainVideo.play(); popperVideo.play(); mainAudio.play(); popperAudio.play(); } // Polyfill End function collectMyGPA() { let tdElems = document.getElementsByTagName('td'), // 先找到所有的td元素 tBodyElem, GPACalc = (x) => { let totalCreditPoint = x.reduce((prev, current) => prev + current.creditPoint, 0), // 求出学分绩点总和 totalCredit = x.reduce((prev, current) => prev + current.credit, 0), // 求出总学分 GPA = totalCreditPoint / totalCredit; // 求出GPA //console.log(totalCredit, totalCreditPoint); return GPA.toFixed(3); // 保留三位小数 }; for (let td of tdElems) { if (td.getAttribute('aria-describedby') == 'tabGrid_cj') { // 找到包含成绩项的列表分量 tBodyElem = td.parentNode.parentNode; // 向上两层找到tbody元素 break; } } if (tBodyElem && tBodyElem.tagName.toLowerCase() == 'tbody') { // 确认上层是tbody元素 let trElems = tBodyElem.getElementsByTagName('tr'), // 找到所有的tr元素 trArr = [], rows = []; for (let i of trElems) { if (i.getAttribute('class') !== 'jqgfirstrow') { trArr.push(i); } } trArr.forEach(tr => { let tdElems = tr.getElementsByTagName('td'), currentObj = {}; for (let td of tdElems) { switch (td.getAttribute('aria-describedby')) { case 'tabGrid_kcmc': // 课程名称 currentObj['courseName'] = td.innerText; break; case 'tabGrid_kch': // 课程号 currentObj['courseCode'] = td.innerText; break; case 'tabGrid_kcxzmc': // 课程性质 currentObj['courseChr'] = td.innerText; break; case 'tabGrid_xf': // 学分 currentObj['credit'] = parseFloat(td.innerText); break; case 'tabGrid_cj': // 成绩 currentObj['score'] = parseFloat(td.innerText); break; case 'tabGrid_jd': // 绩点 currentObj['gradePoint'] = parseFloat(td.innerText); break; case 'tabGrid_xfjd': // 学分绩点 currentObj['creditPoint'] = parseFloat(td.innerText); break; case 'tabGrid_kcbj': // 课程标记 currentObj['courseMark'] = td.innerText; break; } } rows.push(currentObj); }); let rowsCompulsory = rows.myFilter((row) => row['courseChr'].includes('必修')), rowsElective = rows.myFilter(row => row['courseChr'].includes('选修')), GPAResults = { 'all': GPACalc(rows), // 注意GPACalc返回值是字符串 'compulsory': GPACalc(rowsCompulsory), 'elective': GPACalc(rowsElective) }; GPAs = GPAResults; } else { GPAs = false; GPANotice('找不到任何成绩信息诶...') } } function insertDot(str) { // 插入小数点 return str.slice(0, 1) + '.' + str.slice(1); } function promiseDuration(audio) { // 等待音频duration属性 return new Promise(res => { let timer = setInterval(() => { if (!isNaN(audio.duration)) { res(audio.duration); clearInterval(timer); } }, 50); }); } async function countingAnimation(pointStr) { // 动画效果 console.log('Start counting.'); let drumAudio = document.createElement('audio'), // 小军鼓音频 confirmAudio = document.createElement('audio'), // 确定数字时的音频 zeroFiller = (times, str = '') => { if (times > 0) { times -= 1; str += '0'; return zeroFiller(times, str) } else { return str; } }, mainAudio = S('mainAudio'), pointSpan = S('finalGPA'), closeBtn = S('closeBtn'), counterTimer, finalTp = pointStr.replaceAll(/\./g, ''), // 最终去除小数点的GPA字符串 currentTp = zeroFiller(finalTp.length), // 当前去除小数点的GPA字符串 pointer = currentTp.length - 1, // 下标指针 playEnded = () => { drumAudio.removeEventListener('ended', playEnded, false); drumAudio.currentTime = 0; clearInterval(counterTimer); pointSpan.innerHTML = insertDot(finalTp); // 显示最终绩点 congratulate(); closeBtn.style.display = 'block'; // 显示关闭按钮 setTimeout(() => { closeBtn.style.opacity = '1'; }, 10); }, startPlaying = async () => { let slices = currentTp.length, // 分成几个阶段 duration = await promiseDuration(drumAudio), // 获得音频时长 interval = duration / slices, // 每阶段持续时长 stages = [duration]; // 存放每个阶段的时间 for (let i = 0; i < (slices - 1); i++) { let last = stages[stages.length - 1]; stages.push(last - interval); } mainAudio.src = congratuAudURL; // 预加载主音乐 drumAudio.removeEventListener('play', startPlaying, false); counterTimer = setInterval(() => { counting(stages); // 传入存放time阶段数组 }, 10); }, counting = (stages) => { let randomNum = Math.floor(Math.random() * 10).toString(), // 获得一个随机数字 beforeParts = currentTp.slice(0, pointer), // 指针前的部分 afterParts = currentTp.slice(pointer + 1), // 指针后的部分 currentTime = drumAudio.currentTime, // 获得音频播放进度 currentStage = stages[pointer]; // 获得当前阶段上限时间 pointSpan.innerHTML = insertDot(beforeParts + randomNum + afterParts); if (currentTime >= currentStage && pointer > 0) { // 超过当前阶段时间了,指针前移 currentTp = currentTp.slice(0, pointer) + finalTp.slice(pointer, pointer + 1) + currentTp.slice(pointer + 1); confirmAudio.currentTime = 0; confirmAudio.play(); // 确认一个数字的时候就播放音频 pointer -= 1; // 指针前移 } }; drumAudio.src = await extractMedia(countingAudURL, 'audio/mpeg'); confirmAudio.src = await extractMedia(confirmAudURL, 'audio/mpeg'); closeBtn.style.opacity = 0; // 开始动画后暂时隐藏关闭按钮 setTimeout(() => { closeBtn.style.display = 'none'; }, 500); drumAudio.addEventListener('ended', playEnded, false); // 监听音频播放结束(结束后展示最终GPA结果) drumAudio.addEventListener('play', startPlaying, false); // 监听音频播放开始 drumAudio.play(); // 播放小军鼓 } window.showMyGPA = function (option = false) { if (!option) { // 有没有选择选项 collectMyGPA(); // 先把各项GPA计算好 if (GPAs) { // 如果能算出GPA let floatPage = S('GPAFloat'), optionElem = S('GPAOptions'); applyStyle([floatPage, optionElem], { 'display': 'block' }) setTimeout(() => { floatPage.style.opacity = 1; }, 10); } } else { // 点击了选项 let optionElem = S('GPAOptions'), GPADisplay = S('GPADisplay'), optionDict = { // 选项映射的GPA类型 1: 'compulsory', 2: 'elective', 3: 'all' }; optionElem.style.transform = "translate(-50%,-500%)"; setTimeout(() => { applyStyle([optionElem], { 'display': 'none', 'transform': 'translate(-50%,-50%)' });// 动画完成后暗中还原 }, 1000); GPADisplay.style.display = 'block'; setTimeout(() => { GPADisplay.style.opacity = 1; // 展示GPA的几个数字 }, 10); countingAnimation(GPAs[optionDict[option]]); // 开始动画 } } window.closeFloat = function () { // 关闭浮页 let floatPage = S('GPAFloat'), GPADisplay = S('GPADisplay'), pointSpan = S('finalGPA'), mainVideo = S('mainVideo'), popperVideo = S('popperVideo'), popperAudio = S('popperAudio'), mainAudio = S('mainAudio'); applyStyle([floatPage, GPADisplay], { 'opacity': 0 }); setTimeout(() => { pointSpan.innerHTML = '0.000'; pointSpan.style.animation = 'none'; pointSpan.style.color = 'black'; applyStyle(pointSpan, { 'animation': 'none', 'color': 'black' }); applyStyle([floatPage, GPADisplay, mainVideo, popperVideo], { 'display': 'none' }); popperAudio.pause(); mainAudio.pause(); mainVideo.pause(); popperVideo.pause(); popperAudio.currentTime = 0; mainAudio.currentTime = 0; popperVideo.currentTime = 0; }, 500); } window.GPANotice = function (txt) { // 弹出提示窗口 let popElem = S('GPANotice'); popElem.innerHTML = txt; popElem.style.transform = 'none'; clearInterval(window.GPATimer); window.GPATimer = setTimeout(() => { popElem.style.transform = 'translateY(-100%)'; }, 1500); } /*渲染HTML元素*/ let GPADiv = document.createElement('div'); GPADiv.id = 'GPADiv'; GPADiv.innerHTML = ` 算算GPA