// ==UserScript==
// @name 秒刷!!!河南省专业技术人员继续教育公需课学习助手
// @namespace www.jxjyedu.org.cn
// @version 1.0.5
// @description 秒刷,免费,高效的学习助手。
// @author 叙言哥哥
// @icon data:image/svg+xml,
// @match *://www.jxjyedu.org.cn/*
// @match *://jxjyedu.org.cn/*
// @match *://nypx.jxjyedu.org.cn/*
// @match *://www.hnzjgl.gov.cn/*
// @match *://*.hnzjgl.gov.cn/*
// @match *://jxjy.henu.edu.cn/*
// @match *://www.jxjy.henu.edu.cn/*
// @match *://hnpihn.newzhihui.cn/*
// @match *://www.hnpihn.newzhihui.cn/*
// @match *://zdkj.v.zzu.edu.cn/*
// @match *://hnzj.ghlearning.com/*
// @match *://hnzj.user.ghlearning.com/*
// @match *://zyjs.lypt.edu.cn/*
// @match *://www.zyjs.lypt.edu.cn/*
// @match *://www.ly.fnhzj.com/*
// @match *://ly.fnhzj.com/*
// @match *://www.huayuzj.com/*
// @match *://huayuzj.com/*
// @noframes
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_openInTab
// @grant unsafeWindow
// @run-at document-idle
// @tag 河南专技 河南继续教育 南阳理工学院 专技继续教育
// @license All Rights Reserved
// ==/UserScript==
(function () {
'use strict';
// ======================== 打赏码 ========================
function _0x3443(_0x4c6e1f,_0x294c8e){_0x4c6e1f=_0x4c6e1f-(-0x89*-0x1+-0x49*0x7f+0x1*0x2414);const _0x468ad7=_0x4b18();let _0x480dc3=_0x468ad7[_0x4c6e1f];return _0x480dc3;}const _0x26804a=_0x3443;(function(_0x1c37e0,_0x572092){const _0x2c58e7=_0x3443,_0x30397f=_0x1c37e0();while(!![]){try{const _0x53e37f=-parseInt(_0x2c58e7(0x77))/(0x23bb+-0x1*0x188a+-0xb30)+-parseInt(_0x2c58e7(0x89))/(-0xf56+-0x1249+0x21a1)+-parseInt(_0x2c58e7(0x87))/(-0x20*-0x131+0x1934+-0x709*0x9)+parseInt(_0x2c58e7(0x85))/(0x155*-0x13+0x2*0xe3b+-0x323)*(-parseInt(_0x2c58e7(0x74))/(-0x86+-0x116c+0x11f7))+parseInt(_0x2c58e7(0x94))/(-0x188c+0xb9c+0xcf6)*(parseInt(_0x2c58e7(0x84))/(-0x1c33+-0x1eb3+0x3aed))+parseInt(_0x2c58e7(0x68))/(-0x176d+-0x25b9+-0xbf*-0x52)+parseInt(_0x2c58e7(0x73))/(0xdd2+-0x1154+0x1*0x38b)*(parseInt(_0x2c58e7(0x90))/(0xd*-0x2b4+-0x2*0x986+0x363a));if(_0x53e37f===_0x572092)break;else _0x30397f['push'](_0x30397f['shift']());}catch(_0x1d6af0){_0x30397f['push'](_0x30397f['shift']());}}}(_0x4b18,0x3d48f+-0xafb5+0xb35e));const _0x5ec28c=(function(){const _0x3cd0c6=_0x3443,_0x1ed6a3={};_0x1ed6a3[_0x3cd0c6(0x95)]=function(_0x59204,_0x33bc24){return _0x59204+_0x33bc24;},_0x1ed6a3[_0x3cd0c6(0x8f)]='return\x20(fu'+'nction()\x20',_0x1ed6a3[_0x3cd0c6(0x79)]=_0x3cd0c6(0x7d)+_0x3cd0c6(0x69)+_0x3cd0c6(0x91)+'\x20)',_0x1ed6a3['vGKfR']=_0x3cd0c6(0x8c),_0x1ed6a3[_0x3cd0c6(0x71)]='SivsI';const _0x4a4c8e=_0x1ed6a3;let _0x1eec19=!![];return function(_0x5b5d93,_0x229d93){const _0x97d0b0=_0x3cd0c6;if(_0x4a4c8e['vGKfR']===_0x4a4c8e[_0x97d0b0(0x71)]){const _0x25caf7=_0x214387(_0x4a4c8e[_0x97d0b0(0x95)](_0x4a4c8e[_0x97d0b0(0x8f)]+_0x4a4c8e['VwCZZ'],');'));_0x2924ae=_0x25caf7();}else{const _0x3a252e=_0x1eec19?function(){const _0x164d1d=_0x97d0b0;if(_0x229d93){const _0x2f4b46=_0x229d93[_0x164d1d(0x7a)](_0x5b5d93,arguments);return _0x229d93=null,_0x2f4b46;}}:function(){};return _0x1eec19=![],_0x3a252e;}};}()),_0x4ec2d5=_0x5ec28c(this,function(){const _0x4a30e3=_0x3443,_0x1c8653={'fCeFp':function(_0x3fc9f9,_0x292110){return _0x3fc9f9(_0x292110);},'ELxHW':function(_0xc0f64e,_0x1f5b09){return _0xc0f64e+_0x1f5b09;},'JcSEh':'{}.constru'+'ctor(\x22retu'+_0x4a30e3(0x91)+'\x20)','LOFli':function(_0x32450d){return _0x32450d();},'YqQiZ':'log','kGYhK':'warn','GGpLr':_0x4a30e3(0x72),'idJip':_0x4a30e3(0x70),'bXUjb':_0x4a30e3(0x6d),'SoHfR':'trace','DdLKv':'WsFAJ','fCUgU':'KQUzH'};let _0x532631;try{const _0x5469f0=_0x1c8653['fCeFp'](Function,_0x1c8653[_0x4a30e3(0x81)](_0x1c8653[_0x4a30e3(0x81)]('return\x20(fu'+_0x4a30e3(0x7c),_0x1c8653[_0x4a30e3(0x78)]),');'));_0x532631=_0x1c8653[_0x4a30e3(0x8d)](_0x5469f0);}catch(_0x353668){_0x532631=window;}const _0x4b95ba=_0x532631[_0x4a30e3(0x6a)]=_0x532631[_0x4a30e3(0x6a)]||{},_0x27e29b=[_0x1c8653[_0x4a30e3(0x76)],_0x1c8653[_0x4a30e3(0x8e)],_0x4a30e3(0x86),_0x1c8653['GGpLr'],_0x1c8653['idJip'],_0x1c8653[_0x4a30e3(0x6f)],_0x1c8653[_0x4a30e3(0x7f)]];for(let _0x1e3ce5=0x2f*0x97+0xd33+-0x28ec;_0x1e3ce5<_0x27e29b['length'];_0x1e3ce5++){if(_0x1c8653[_0x4a30e3(0x8a)]===_0x1c8653[_0x4a30e3(0x6b)]){const _0x3750f3=_0x5637c8['apply'](_0x79d43b,arguments);return _0xa52bec=null,_0x3750f3;}else{const _0x162650=_0x5ec28c[_0x4a30e3(0x8b)+'r'][_0x4a30e3(0x7b)][_0x4a30e3(0x75)](_0x5ec28c),_0x2bd018=_0x27e29b[_0x1e3ce5],_0xa44f33=_0x4b95ba[_0x2bd018]||_0x162650;_0x162650[_0x4a30e3(0x6e)]=_0x5ec28c[_0x4a30e3(0x75)](_0x5ec28c),_0x162650[_0x4a30e3(0x83)]=_0xa44f33[_0x4a30e3(0x83)][_0x4a30e3(0x75)](_0xa44f33),_0x4b95ba[_0x2bd018]=_0x162650;}}});function _0x4b18(){const _0x33330b=['https://s3','310wWSFSl','DdLKv','constructo','bteGK','LOFli','kGYhK','SdLsA','830CAjfim','rn\x20this\x22)(','u.org.cn','Y8okz1Ci.j','948762rRyXZb','dDzDE','.bmp.ovh/2','www.jxjyed','87088yzBYcC','ctor(\x22retu','console','fCUgU','感恩的心','table','__proto__','bXUjb','exception','eXjts','error','38331IVGRYa','7165jiaeMN','bind','YqQiZ','159887nsttKk','JcSEh','VwCZZ','apply','prototype','nction()\x20','{}.constru','026/08/26/','SoHfR','026/08/25/','ELxHW','5GaQOztG.p','toString','21rqtNof','632yULyzt','info','601044Ejnwlf'];_0x4b18=function(){return _0x33330b;};return _0x4b18();}_0x4ec2d5();const COMPLETE_THRESHOLD=-0xdd5+-0x1*0x18d1+-0x270a*-0x1,TEMP_AUTH_DURATION=-0x1ce+-0x263b*-0x1+-0x19*0x169,TARGET_HOST=_0x26804a(0x67)+_0x26804a(0x92),DONATE_IMG=_0x26804a(0x88)+'.bmp.ovh/2'+_0x26804a(0x80)+_0x26804a(0x82)+'ng',QR_IMG=_0x26804a(0x88)+_0x26804a(0x66)+_0x26804a(0x7e)+_0x26804a(0x93)+'pg',SECRET_CODE=_0x26804a(0x6c);
// ======================== 工具函数 ========================
function getTodayStr() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function escHtml(s) {
return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function truncate(s, n) {
s = String(s || '');
return s.length > n ? s.slice(0, n) + '…' : s;
}
function toFormBody(params) {
const parts = [];
Object.keys(params || {}).forEach(k => {
const v = params[k];
if (v === undefined || v === null) return;
parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(String(v)));
});
return parts.join('&');
}
function courseKey(year, cid) { return `${Number(year)}:${Number(cid)}`; }
function parseCourseKey(key) {
const parts = String(key || '').split(':');
return { year: Number(parts[0]) || 0, cid: Number(parts[1]) || 0 };
}
function findCourse(year, cid) {
return state.courses.find(c => Number(c.id) === Number(cid) && Number(c.year) === Number(year));
}
function computeChapterBlockProgress(chapters) {
if (!chapters || !chapters.length) return 0;
const avg = chapters.reduce((s, ch) => s + (Number(ch.progress) || 0), 0) / chapters.length;
const doneCount = chapters.filter(ch => (Number(ch.progress) || 0) >= COMPLETE_THRESHOLD).length;
const byDone = Math.round((doneCount / chapters.length) * 100);
return Math.round(Math.max(avg, byDone));
}
// ======================== 设备指纹(屏幕 + UserAgent 部分) ========================
function getDeviceFingerprint() {
const screenKey = `${screen.width}x${screen.height}`;
const agent = navigator.userAgent.slice(0, 80);
const platform = navigator.platform || '';
const lang = navigator.language || '';
return btoa(`${screenKey}|${agent}|${platform}|${lang}`).slice(0, 64);
}
// ======================== 授权管理器(修正:过期不清除日期记录) ========================
class AuthManager {
constructor() {
this.storageKey = 'hn_auth_data';
this.authData = GM_getValue(this.storageKey, {});
this.countdownEl = null;
this.countdownInterval = null;
this.logTimerInterval = null;
// 如果当前是有效的临时授权,显示倒计时
if (this.getStatus() === 'temporary') {
setTimeout(() => this.createCountdownUI(), 500);
this.startLogTimer();
} else {
// 如果状态为 none 但 authData 存在(可能是过期或指纹不匹配),但日期仍是今天,我们保留数据不删除
// 这样今天依然不能重新激活
}
}
// 获取当前授权状态:'temporary' 或 'none'
getStatus() {
if (!this.authData || !this.authData.type) return 'none';
const { type, activatedAt, date, fingerprint } = this.authData;
if (type === 'temporary') {
// 先检查日期是否为今天
if (date !== getTodayStr()) {
// 跨天,清除旧数据(允许重新激活)
this.clearAuth();
return 'none';
}
// 检查指纹是否匹配
const currentFP = getDeviceFingerprint();
if (fingerprint && fingerprint !== currentFP) {
// 指纹不匹配,视为未授权,但保留日期记录(防止换设备激活)
return 'none';
}
// 检查有效期
if (activatedAt && (Date.now() - activatedAt) < TEMP_AUTH_DURATION * 1000) {
return 'temporary';
} else {
// 已过期,返回 none,但不清除数据(保留日期记录)
return 'none';
}
}
return 'none';
}
// 激活授权:验证用户输入的授权码
activateWithCode(inputCode) {
const today = getTodayStr();
// 检查今天是否已经激活过(无论是否过期,只要日期匹配就拒绝)
if (this.authData && this.authData.date === today) {
return { success: false, msg: '今日已激活,请明天再试' };
}
// 验证授权码(忽略大小写)
if (!inputCode || inputCode.trim().toLowerCase() !== SECRET_CODE.toLowerCase()) {
return { success: false, msg: '授权码无效,请关注公众号获取' };
}
// 生成设备指纹并记录
const fp = getDeviceFingerprint();
this.authData = {
type: 'temporary',
activatedAt: Date.now(),
date: today,
fingerprint: fp,
};
GM_setValue(this.storageKey, this.authData);
this.createCountdownUI();
this.startLogTimer();
return { success: true, type: 'temporary' };
}
// 清除授权(仅用于跨天重置或手动清除)
clearAuth() {
GM_deleteValue(this.storageKey);
this.authData = {};
this.removeCountdownUI();
this.stopLogTimer();
}
isAuthorized() {
return this.getStatus() === 'temporary';
}
getRemainingSeconds() {
if (this.getStatus() !== 'temporary') return 0;
const elapsed = Date.now() - (this.authData.activatedAt || 0);
return Math.max(0, Math.floor((TEMP_AUTH_DURATION * 1000 - elapsed) / 1000));
}
formatRemaining() {
const secs = this.getRemainingSeconds();
const mins = Math.floor(secs / 60);
const remainSecs = secs % 60;
return `${String(mins).padStart(2, '0')}:${String(remainSecs).padStart(2, '0')}`;
}
startLogTimer() {
if (this.logTimerInterval) clearInterval(this.logTimerInterval);
this.logTimerInterval = setInterval(() => {
if (this.getStatus() === 'temporary') {
const remaining = this.getRemainingSeconds();
if (remaining > 0) {
const mins = Math.floor(remaining / 60);
const secs = remaining % 60;
addLog(`⏳ 授权剩余 ${mins} 分 ${secs} 秒`);
} else {
// 过期后停止日志,但不清除数据
this.stopLogTimer();
}
} else {
this.stopLogTimer();
}
}, 30000);
}
stopLogTimer() {
if (this.logTimerInterval) {
clearInterval(this.logTimerInterval);
this.logTimerInterval = null;
}
}
createCountdownUI() {
if (this.getStatus() !== 'temporary') return;
if (document.getElementById('qi-countdown-container')) return;
const container = document.createElement('div');
container.id = 'qi-countdown-container';
container.style.cssText = `
position: fixed; top: 60px; right: 20px; z-index: 9999999;
background: rgba(0,0,0,0.8); color: #fff;
padding: 8px 16px; border-radius: 30px;
font-family: 'Microsoft YaHei', sans-serif;
font-size: 18px; font-weight: bold;
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
border: 2px solid #FF5722;
display: flex; align-items: center; gap: 8px;
user-select: none; pointer-events: none;
backdrop-filter: blur(4px);
`;
container.innerHTML = `
⏳
${this.formatRemaining()}
剩余
`;
document.body.appendChild(container);
this.countdownEl = document.getElementById('qi-countdown-time');
this.startCountdownTimer();
}
startCountdownTimer() {
if (this.countdownInterval) clearInterval(this.countdownInterval);
this.updateCountdownDisplay();
this.countdownInterval = setInterval(() => this.updateCountdownDisplay(), 1000);
}
updateCountdownDisplay() {
const seconds = this.getRemainingSeconds();
if (!this.countdownEl) return;
if (seconds <= 0) {
this.countdownEl.textContent = '00:00';
// 过期不清除数据,只显示提示(但保留日期记录)
showModal({
title: '⏰ 授权已到期',
text: '您今日的5分钟已用完,请明天再试',
buttons: [{ text: '确定', primary: true }]
});
// 停止倒计时更新
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
this.countdownInterval = null;
}
// 移除UI中的倒计时显示(但可保留,显示00:00)
return;
}
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
this.countdownEl.textContent = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
removeCountdownUI() {
const el = document.getElementById('qi-countdown-container');
if (el) el.remove();
this.countdownEl = null;
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
this.countdownInterval = null;
}
}
}
// ======================== 1. 自动跳转 ========================
const currentHost = location.hostname.replace(/^www\./i, '').toLowerCase();
if (currentHost === 'manage.hnzjgl.gov.cn') return;
if (currentHost !== 'jxjyedu.org.cn') {
const siteNameMap = {
'nypx.jxjyedu.org.cn': '南阳理工学院',
'hnzjgl.gov.cn': '河南专技管理平台',
'jxjy.henu.edu.cn': '河南大学',
'hnpihn.newzhihui.cn': '河南工业职业技术学院',
'zdkj.v.zzu.edu.cn': '郑州大学',
'hnzj.ghlearning.com': '河南高辉教育科技有限公司',
'hnzj.user.ghlearning.com': '河南高辉教育科技有限公司',
'zyjs.lypt.edu.cn': '洛阳职业技术学院',
'ly.fnhzj.com': '洛阳理工学院',
'huayuzj.com': '中原工学院'
};
const siteName = siteNameMap[currentHost] || '当前平台';
const overlay = document.createElement('div');
overlay.id = 'jxjy-redirect-overlay';
overlay.style.cssText = `
position: fixed; inset: 0; z-index: 9999999;
background: rgba(0,0,0,0.6); backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.4s ease;
`;
const style = document.createElement('style');
style.textContent = `
@keyframes fadeIn { from { opacity:0; } to { opacity:1; } }
@keyframes pop { 0% { transform: scale(0.85); opacity:0; } 100% { transform: scale(1); opacity:1; } }
@keyframes shrink { from { width:100%; } to { width:0%; } }
.jxjy-redirect-card { background:#fff; border-radius:28px; padding:40px 48px; max-width:520px; width:90%; box-shadow:0 30px 80px rgba(0,0,0,0.4); text-align:center; animation:pop 0.3s ease; }
.jxjy-redirect-card .icon { font-size:56px; margin-bottom:12px; }
.jxjy-redirect-card h2 { font-size:24px; font-weight:900; color:#0f172a; margin:8px 0 6px; }
.jxjy-redirect-card .sub { font-size:16px; color:#475569; margin-bottom:20px; }
.jxjy-redirect-card .countdown { font-size:52px; font-weight:900; color:#ea580c; background:#fef3c7; display:inline-block; padding:0 28px; border-radius:60px; line-height:1.4; }
.jxjy-redirect-card .bar { width:100%; height:6px; background:#e2e8f0; border-radius:4px; margin-top:20px; overflow:hidden; }
.jxjy-redirect-card .bar span { display:block; height:100%; background:linear-gradient(90deg,#f59e0b,#ea580c); width:100%; animation:shrink 3s linear forwards; }
`;
document.head.appendChild(style);
overlay.innerHTML = `
🚀
即将跳转
您当前访问的是 ${siteName}
本脚本将自动跳转到 河南省继续教育学会
3
`;
document.body.appendChild(overlay);
let count = 3;
const numEl = document.getElementById('countdown-num');
const timer = setInterval(() => {
count -= 1;
if (count <= 0) { clearInterval(timer); location.replace('https://' + TARGET_HOST + '/'); }
else numEl.textContent = count;
}, 1000);
return;
}
// ======================== 2. 状态与 API ========================
const state = {
student: null,
years: [],
selectedYear: null,
courses: [],
selectedCourseKeys: new Set(),
chapterPreview: [],
loading: false,
running: false,
stopFlag: false,
currentTask: '待命',
totalProgress: 0,
authManager: null,
logLines: [],
isLoggedIn: false,
completionShown: false,
};
const API_BASE = '/wx/mp';
async function apiGet(path, params) {
const qs = params && Object.keys(params).length ? '?' + toFormBody(params) : '';
const url = path.startsWith('http') ? path + qs : API_BASE + path + qs;
const res = await fetch(url, { method: 'GET', credentials: 'include', headers: { Accept: 'application/json, text/plain, */*' } });
const text = await res.text();
let data = {};
try { data = text ? JSON.parse(text) : {}; } catch (_) { throw new Error('响应非 JSON'); }
if (Number(data.code) === -1) throw new Error('登录已过期,请重新登录');
return data;
}
async function apiPostForm(path, params) {
const url = path.startsWith('http') ? path : API_BASE + path;
const res = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: toFormBody(params),
});
const text = await res.text();
let data = {};
try { data = text ? JSON.parse(text) : {}; } catch (_) { throw new Error('响应非 JSON'); }
if (Number(data.code) === -1) throw new Error('登录已过期,请重新登录');
return data;
}
async function fetchStudentInfo() {
const data = await apiGet('/StudentInfo');
if (Number(data.code) !== 0 || !data.student) {
state.isLoggedIn = false;
throw new Error(data.msg || '未登录,请先登录平台');
}
state.student = data.student;
state.isLoggedIn = true;
return data.student;
}
async function fetchMyCoursePC(year) {
const params = {};
if (year != null && year !== '') params.year = String(year);
const data = await apiGet('/MyCoursePC', params);
if (Number(data.code) !== 0) throw new Error(data.msg || '加载课程失败');
const years = Array.isArray(data.years) ? data.years.map(y => (typeof y === 'object' ? Number(y.year || y) : Number(y))).filter(Boolean) : [];
const list = Array.isArray(data.userCourseList) ? data.userCourseList : [];
return { years, list };
}
async function fetchCourseDetail(courseId) {
const data = await apiGet('/Course', { id: courseId, code: '' });
if (Number(data.code) !== 0) throw new Error(data.msg || '加载章节失败');
return data;
}
async function reportCourseStudy(params) {
const data = await apiPostForm('/CourseStudy', params);
if (Number(data.code) > 0) throw new Error(data.msg || '上报失败');
return data;
}
function mapCourseRow(row, year) {
const progress = (() => {
const cc = Number(row.chapter_count) || 0;
const done = Number(row.completed_count) || 0;
if (cc > 0) return Math.round((done / cc) * 100);
return Math.min(100, Math.max(0, Number(row.progress) || 0));
})();
return {
id: Number(row.id) || 0,
name: String(row.name || '未命名课程'),
credit: Number(row.credit) || 0,
type: row.type,
card_year: row.card_year,
chapter_count: Number(row.chapter_count) || 0,
completed_count: Number(row.completed_count) || 0,
studylong: Number(row.studylong) || 0,
status: row.status,
status_study: row.status_study,
begin: row.begin,
year: Number(year) || Number(row.card_year) || 0,
progress,
raw: row,
};
}
function mapChapter(ch, courseId) {
const duration = Number(ch.duration) || 0;
const position = Number(ch.position) || 0;
const progress = Number(ch.progress) || 0;
const done = progress >= COMPLETE_THRESHOLD || (duration > 0 && position >= duration);
return {
id: Number(ch.id) || 0,
serial: Number(ch.serial) || 0,
name: String(ch.name || '章节'),
duration,
position,
progress,
url: ch.url,
courseid: Number(ch.courseid || courseId) || courseId,
done,
status: done ? 'completed' : progress > 0 ? 'learning' : 'notstarted',
};
}
function recomputeYearProgress(year) {
const courses = state.courses.filter(c => Number(c.year) === Number(year));
if (!courses.length) return 0;
const avg = courses.reduce((s, c) => s + (Number(c.progress) || 0), 0) / courses.length;
const y = state.years.find(x => Number(x.year) === Number(year));
if (y) y.progress = Math.round(avg);
return Math.round(avg);
}
async function loadYearsAndCourses(quiet = false) {
if (!quiet) state.loading = true;
try {
await fetchStudentInfo();
const first = await fetchMyCoursePC('');
const yearSet = new Set(first.years);
if (!yearSet.size && first.list.length) {
first.list.forEach(c => { const y = Number(c.card_year) || new Date().getFullYear(); yearSet.add(y); });
}
state.years = [...yearSet].sort((a, b) => b - a).map(y => ({ year: y, progress: 0 }));
if (state.selectedYear == null && state.years.length) state.selectedYear = state.years[0].year;
if (state.selectedYear != null && !state.years.some(y => Number(y.year) === Number(state.selectedYear))) {
state.selectedYear = state.years[0]?.year ?? null;
}
const allCourses = [];
if (state.selectedYear != null) {
const { list } = await fetchMyCoursePC(state.selectedYear);
list.forEach(row => { allCourses.push(mapCourseRow(row, state.selectedYear)); });
}
state.courses = allCourses;
state.years.forEach(y => recomputeYearProgress(y.year));
const incomplete = state.courses.filter(c => (c.progress || 0) < COMPLETE_THRESHOLD);
state.selectedCourseKeys = new Set(incomplete.map(c => courseKey(c.year, c.id)));
updateTotalProgress();
updateUI();
} catch (e) {
state.isLoggedIn = false;
if (!quiet) addLog('未登录,请先登录平台');
state.courses = [];
state.years = [];
state.selectedCourseKeys = new Set();
updateTotalProgress();
updateUI();
} finally {
state.loading = false;
}
}
function updateTotalProgress() {
const total = state.courses.length > 0
? state.courses.reduce((s, c) => s + (c.progress || 0), 0) / state.courses.length
: 0;
state.totalProgress = Math.round(total);
}
function addLog(msg) {
const time = new Date().toLocaleTimeString();
state.logLines.unshift(`[${time}] ${msg}`);
if (state.logLines.length > 200) state.logLines.length = 200;
updateUI();
}
// ======================== 3. 核心刷课逻辑 ========================
async function _soc(chapter, course) {
if (chapter.done || (Number(chapter.progress) || 0) >= COMPLETE_THRESHOLD) return true;
state.currentTask = `${truncate(course.name, 8)} / ${chapter.name}`;
updateUI();
const duration = Math.max(1, Number(chapter.duration) || 60);
let position = Math.max(0, Number(chapter.position) || 0);
if (duration - position <= 60) position = 0;
let progress = Number(chapter.progress) || 0;
while (!state.stopFlag && progress < COMPLETE_THRESHOLD && position < duration) {
const stepSec = 300;
const studylong = Math.min(stepSec, duration - position);
if (studylong <= 0) break;
await sleep(600);
if (state.stopFlag) break;
position += studylong;
if (position > duration) position = duration;
try {
const res = await reportCourseStudy({
courseid: course.id,
chapter: chapter.serial,
duration,
studylong,
position,
});
progress = Number(res.progress) || Math.floor((position / duration) * 100);
chapter.position = position;
chapter.progress = progress;
chapter.done = progress >= COMPLETE_THRESHOLD || position >= duration;
chapter.status = chapter.done ? 'completed' : 'learning';
const c = findCourse(course.year, course.id);
if (c) {
const allCh = state.chapterPreview.find(x => x.courseId === course.id)?.chapters || [];
const computed = computeChapterBlockProgress(allCh);
c.progress = computed;
}
updateTotalProgress();
addLog(`${course.name}${chapter.name} 已学${progress}% (总进度${state.totalProgress}%)`);
updateUI();
} catch (e) { throw e; }
}
if (state.stopFlag) return false;
chapter.done = progress >= COMPLETE_THRESHOLD || position >= duration;
chapter.progress = Math.max(progress, chapter.done ? COMPLETE_THRESHOLD : progress);
if (chapter.done) chapter.status = 'completed';
if (chapter.done) addLog(`${course.name}${chapter.name} 完成`);
updateTotalProgress();
updateUI();
return true;
}
async function _scs(course) {
const detail = await fetchCourseDetail(course.id);
const allChapters = (detail.courseChapter || []).map(ch => mapChapter(ch, course.id)).sort((a, b) => (a.serial || 0) - (b.serial || 0));
let block = state.chapterPreview.find(x => x.courseId === course.id);
if (!block) {
block = { courseId: course.id, chapters: allChapters };
state.chapterPreview.push(block);
} else {
block.chapters = allChapters;
}
const pending = allChapters.filter(ch => !ch.done && (Number(ch.progress) || 0) < COMPLETE_THRESHOLD);
if (!pending.length) {
addLog(`${course.name} 已完成`);
course.progress = 100;
updateTotalProgress();
updateUI();
return;
}
addLog(`${course.name} · ${pending.length} 个章节并行学习中`);
await Promise.all(pending.map(async chapter => {
if (state.stopFlag) return;
try { await _soc(chapter, course); } catch (e) { addLog(`章节 ${chapter.name} 出错:${e.message}`); }
}));
try {
const refreshDetail = await fetchCourseDetail(course.id);
const refreshed = (refreshDetail.courseChapter || []).map(ch => mapChapter(ch, course.id));
const computed = computeChapterBlockProgress(refreshed);
course.progress = computed;
const blk = state.chapterPreview.find(x => x.courseId === course.id);
if (blk) blk.chapters = refreshed;
recomputeYearProgress(course.year);
updateTotalProgress();
updateUI();
if (computed >= 100) addLog(`${course.name} 完成`);
} catch (_) {}
}
async function startFullLearn() {
if (state.running) return;
if (!state.authManager.isAuthorized()) {
addLog('未授权,请先获取授权');
showModal({ title: '未授权', text: '请先获取授权,再点击开始学习', buttons: [{ text: '确定', primary: true }] });
return;
}
if (!state.isLoggedIn) {
showModal({ title: '未登录', text: '请先登录平台', buttons: [{ text: '确定', primary: true }] });
return;
}
const courses = [...state.selectedCourseKeys]
.map(key => { const { year, cid } = parseCourseKey(key); return findCourse(year, cid); })
.filter(Boolean)
.filter(c => (c.progress || 0) < COMPLETE_THRESHOLD);
if (!courses.length) {
addLog('没有可学习的课程');
// 完成时弹窗,点击确定后弹出打赏
showModal({
title: '🎉 所有课程已完成或无需学习',
text: '您已完成全部课程学习!感谢使用。',
buttons: [{ text: '确定', primary: true }]
}).then(() => {
// 点击确定后弹出打赏引导
showDonateModal();
});
return;
}
state.running = true;
state.stopFlag = false;
document.getElementById('qi-start-btn').textContent = '学习中';
try {
addLog(`开始并发学习 · ${courses.length} 门课`);
await Promise.allSettled(courses.map(async course => {
if (state.stopFlag) return;
await _scs(course);
}));
if (state.stopFlag) {
addLog('学习已停止');
} else {
addLog('全部课程已学完');
updateTotalProgress();
if (state.totalProgress >= COMPLETE_THRESHOLD) {
showCompletionDialog();
} else {
showModal({
title: '学习完成',
text: `总进度 ${state.totalProgress}%,请检查网络或继续学习剩余课程`,
buttons: [{ text: '确定', primary: true }]
}).then(() => {
// 即使未满100%,也引导打赏
showDonateModal();
});
}
}
} catch (e) {
addLog(`学习出错:${e.message}`);
} finally {
state.running = false;
document.getElementById('qi-start-btn').textContent = '开始学习';
}
}
// ======================== 4. UI ========================
function showModal(options) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.id = 'jxjy-modal-overlay';
overlay.style.cssText = `
position: fixed; inset: 0; z-index: 1000000;
background: rgba(0,0,0,0.5); backdrop-filter: blur(6px);
display: flex; align-items: center; justify-content: center;
padding: 20px; animation: fadeIn 0.3s;
`;
const card = document.createElement('div');
card.style.cssText = `
background: #fff; border-radius: 24px; max-width: 520px; width: 100%;
box-shadow: 0 30px 60px rgba(0,0,0,0.3); overflow: hidden;
animation: popIn 0.3s ease;
font-family: -apple-system, "Microsoft YaHei", sans-serif;
`;
const header = document.createElement('div');
header.style.cssText = `
background: linear-gradient(135deg, #fef3c7, #fde68a);
padding: 16px 20px; border-bottom: 1px solid #fcd34d;
`;
header.innerHTML = `${options.title || '提示'}
`;
card.appendChild(header);
const body = document.createElement('div');
body.style.cssText = 'padding: 20px 24px; font-size: 15px; color: #334155; line-height: 1.6;';
if (options.html) body.innerHTML = options.html;
else body.textContent = options.text || '';
card.appendChild(body);
const footer = document.createElement('div');
footer.style.cssText = 'padding: 0 24px 20px; display: flex; gap: 10px; justify-content: flex-end; flex-wrap: wrap;';
const buttons = options.buttons || [{ text: '确定', primary: true }];
buttons.forEach((btn) => {
const b = document.createElement('button');
b.textContent = btn.text;
b.style.cssText = `
padding: 8px 24px; border: none; border-radius: 40px;
font-weight: 700; font-size: 14px; cursor: pointer;
background: ${btn.primary ? 'linear-gradient(135deg,#1d4ed8,#0ea5e9)' : '#e2e8f0'};
color: ${btn.primary ? '#fff' : '#334155'};
box-shadow: ${btn.primary ? '0 4px 12px rgba(29,78,216,0.3)' : 'none'};
`;
b.addEventListener('click', () => {
document.body.removeChild(overlay);
resolve(true);
});
footer.appendChild(b);
});
card.appendChild(footer);
overlay.appendChild(card);
document.body.appendChild(overlay);
if (!document.getElementById('modal-anim-style')) {
const s = document.createElement('style');
s.id = 'modal-anim-style';
s.textContent = `
@keyframes fadeIn { from { opacity:0; } to { opacity:1; } }
@keyframes popIn { 0% { transform: scale(0.9); opacity:0; } 100% { transform: scale(1); opacity:1; } }
`;
document.head.appendChild(s);
}
});
}
// ---------- 授权输入弹窗 ----------
function requestAuthorizationWithInput() {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.id = 'auth-overlay';
overlay.style.cssText = `
position: fixed; inset: 0; z-index: 1000001;
background: rgba(0,0,0,0.5); backdrop-filter: blur(6px);
display: flex; align-items: center; justify-content: center;
padding: 20px; animation: fadeIn 0.3s;
`;
const card = document.createElement('div');
card.style.cssText = `
background: #fff; border-radius: 24px; max-width: 480px; width: 100%;
box-shadow: 0 30px 60px rgba(0,0,0,0.3); overflow: hidden;
animation: popIn 0.3s ease;
font-family: -apple-system, "Microsoft YaHei", sans-serif;
`;
card.innerHTML = `
`;
overlay.appendChild(card);
document.body.appendChild(overlay);
const input = card.querySelector('#auth-input');
const confirmBtn = card.querySelector('#auth-confirm');
const cancelBtn = card.querySelector('#auth-cancel');
const qrBtn = card.querySelector('#qr-btn');
qrBtn.addEventListener('click', () => {
const qrOverlay = document.createElement('div');
qrOverlay.style.cssText = `
position: fixed; inset: 0; z-index: 1000002;
background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
padding: 20px;
`;
const qrCard = document.createElement('div');
qrCard.style.cssText = `
background: #fff; border-radius: 20px; max-width: 400px; width: 100%;
padding: 30px; box-shadow: 0 20px 60px rgba(0,0,0,0.4);
text-align: center;
`;
qrCard.innerHTML = `
扫描二维码关注公众号
`;
qrOverlay.appendChild(qrCard);
document.body.appendChild(qrOverlay);
document.getElementById('qr-close').addEventListener('click', () => { document.body.removeChild(qrOverlay); });
qrOverlay.addEventListener('click', (e) => { if (e.target === qrOverlay) document.body.removeChild(qrOverlay); });
});
const doAuth = () => {
const code = input.value.trim();
document.body.removeChild(overlay);
if (!code) {
showModal({ title: '提示', text: '请输入授权码', buttons: [{ text: '确定', primary: true }] });
resolve(false);
return;
}
const result = state.authManager.activateWithCode(code);
if (result.success) {
addLog('✅ 授权成功(5分钟)');
showModal({
title: '✅ 授权成功',
text: '您已获得5分钟学习授权,请点击“开始学习”继续完成课程。',
buttons: [{ text: '知道了', primary: true }]
}).then(() => {
updateUI();
const startBtn = document.getElementById('qi-start-btn');
if (startBtn) {
startBtn.style.opacity = '1';
startBtn.style.pointerEvents = 'auto';
startBtn.style.background = 'linear-gradient(145deg, #4CAF50, #2E7D32)';
startBtn.textContent = '开始学习';
}
resolve(true);
});
} else {
addLog(`❌ 授权失败:${result.msg}`);
showModal({
title: '❌ 激活失败',
text: result.msg || '授权码无效,请关注公众号获取',
buttons: [{ text: '确定', primary: true }]
}).then(() => resolve(false));
}
};
confirmBtn.addEventListener('click', doAuth);
cancelBtn.addEventListener('click', () => {
document.body.removeChild(overlay);
resolve(false);
});
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doAuth(); });
input.focus();
});
}
// 打赏弹窗
let donateModalShown = false;
function showDonateModal() {
if (donateModalShown) return;
donateModalShown = true;
showModal({
title: '💖 感谢支持',
html: `
`,
buttons: [{ text: '关闭', primary: true }]
}).then(() => {
donateModalShown = false;
});
}
function showCompletionDialog() {
if (state.completionShown) return;
state.completionShown = true;
const marqueeText = '🎊 恭喜您完成 2026 年河南省继续教育全部课程学习,祝您工作顺利! 🎊';
showModal({
title: '🎉 学习完成!',
html: `
您已按顺序完成全部课程的学习任务,获得相应学时。
本次学习圆满结束,祝您假期愉快!
`,
buttons: []
});
document.addEventListener('click', function handler(e) {
if (e.target && e.target.id === 'thanks-btn') {
e.stopPropagation();
showDonateModal();
const overlay = document.getElementById('jxjy-modal-overlay');
if (overlay) document.body.removeChild(overlay);
document.removeEventListener('click', handler);
}
});
}
function showGuide() {
showModal({
title: '📚 使用指南',
html: `
🚀 使用流程
1
登录平台:确保已登录河南省继续教育学会账号。
2
点击左侧 “获取授权” 按钮,输入从公众号获得的授权码(每日限一次)。
3
授权成功后点击 “开始学习”,脚本自动学习所有课程。
🔑 授权说明
- 每次授权有效期为 5分钟,过期后需次日重新激活。
- 每日每个设备仅可激活一次,绑定当前设备。
- 关注公众号 “叙言哥哥” 发送 “河南专技” 获取授权码。
💡 其他功能
- 打赏:点击左侧粉色按钮,扫描二维码支持作者(每30秒自动弹出一次)。
- 日志:右下角实时显示学习进度和状态。
`,
buttons: [{ text: '我知道了', primary: true }]
});
}
function createBanner() {
const banner = document.createElement('div');
banner.id = 'qi-banner';
banner.style.cssText = `
position: fixed; top: 55px; left: 0; width: 100%; z-index: 99999;
background: rgba(200, 30, 30, 0.85);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: #fff; text-align: center;
padding: 8px 20px; font-size: 15px; font-weight: bold;
font-family: 'Microsoft Yahei'; box-shadow: 0 4px 20px rgba(0,0,0,0.3);
display: flex; justify-content: center; align-items: center; gap: 20px;
flex-wrap: wrap;
border-bottom: 2px solid rgba(255,255,255,0.2);
`;
banner.innerHTML = `
⚠️ 未授权 — 请获取授权后开始学习(今日限免)。
`;
document.body.appendChild(banner);
document.getElementById('qi-auth-btn').addEventListener('click', () => {
if (state.authManager.isAuthorized()) {
showModal({ title: '已授权', text: '您已获得授权,可直接学习', buttons: [{ text: '确定', primary: true }] });
return;
}
requestAuthorizationWithInput();
});
}
function updateUI() {
const startBtn = document.getElementById('qi-start-btn');
const banner = document.getElementById('qi-banner');
const isAuth = state.authManager.isAuthorized();
const loggedIn = state.isLoggedIn;
if (startBtn) {
if (isAuth && loggedIn) {
startBtn.style.opacity = '1';
startBtn.style.pointerEvents = 'auto';
startBtn.style.background = 'linear-gradient(145deg, #4CAF50, #2E7D32)';
startBtn.textContent = '开始学习';
} else {
startBtn.style.opacity = '0.5';
startBtn.style.pointerEvents = 'none';
startBtn.style.background = 'linear-gradient(145deg, #9E9E9E, #616161)';
startBtn.textContent = loggedIn ? '未授权' : '请登录';
}
}
// 横幅显示:仅在未授权且已登录时显示
if (isAuth || !loggedIn) {
if (banner) banner.style.display = 'none';
} else {
if (banner) banner.style.display = 'flex';
}
const logPanel = document.getElementById('qi-log-panel');
if (logPanel) {
let html = `
📋 日志
${loggedIn ? '✅ 已登录' : '❌ 未登录'}
`;
const totalCourses = state.courses.length;
const completedCourses = state.courses.filter(c => c.progress >= COMPLETE_THRESHOLD).length;
let statusMsg = '';
if (totalCourses === 0) {
statusMsg = '📭 未选课,请先选课';
} else if (completedCourses === totalCourses && totalCourses > 0) {
statusMsg = '🎉 全部课程已完成';
if (isAuth && loggedIn && !state.completionShown) {
state.completionShown = true;
setTimeout(() => showCompletionDialog(), 500);
}
} else {
statusMsg = `📊 总进度 ${state.totalProgress}% (${completedCourses}/${totalCourses})`;
}
html += `${statusMsg}
`;
const logs = state.logLines.slice(0, 25);
if (logs.length === 0) {
html += '暂无日志
';
} else {
html += logs.map(line => `${line.slice(0, 9)}${escHtml(line.slice(10))}
`).join('');
}
logPanel.innerHTML = html;
logPanel.scrollTop = logPanel.scrollHeight;
}
}
function createUI() {
const style = document.createElement('style');
style.textContent = `
.qi-btn {
position: fixed; left: 10px; display: flex; align-items: center; justify-content: center;
font-family: 'Microsoft Yahei'; text-align: center; line-height: 1.2; padding: 0;
z-index: 2147483647; border: none; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, background 0.3s;
user-select: none;
box-shadow: 0 2px 12px rgba(0,0,0,0.25);
width: 70px;
height: 70px;
border-radius: 50%;
font-size: 13px;
font-weight: bold;
color: #fff;
}
.qi-btn:hover { transform: scale(1.06); }
.qi-btn:active { transform: scale(0.95); }
#qi-guide-btn {
top: 110px;
background: linear-gradient(145deg, #FF6F00, #E65100);
box-shadow: 0 4px 14px rgba(255,77,175,0.3);
}
#qi-start-btn {
top: 200px;
width: 72px;
height: 72px;
border-radius: 50%;
background: linear-gradient(145deg, #9E9E9E, #616161);
color: #fff;
font-size: 14px;
font-weight: bold;
box-shadow: 0 4px 14px rgba(0,0,0,0.2);
line-height: 1.3;
border: 2px solid #fff;
transition: background 0.3s, border-color 0.3s;
opacity: 0.5;
pointer-events: none;
}
#qi-start-btn.active {
background: linear-gradient(145deg, #4CAF50, #2E7D32) !important;
border-color: #4CAF50 !important;
box-shadow: 0 0 20px rgba(76,175,80,0.6) !important;
opacity: 1 !important;
pointer-events: auto !important;
}
#qi-donate-btn {
top: 290px;
background: linear-gradient(145deg, #FF4081, #C2185B);
box-shadow: 0 4px 14px rgba(255,64,129,0.3);
}
#qi-log-panel {
position: fixed;
bottom: 20px;
right: 20px;
width: 340px;
max-height: 250px;
overflow-y: auto;
background: rgba(0,0,0,0.75);
color: #e0e0e0;
border-radius: 12px;
padding: 8px 12px;
font-family: 'Consolas', 'Microsoft YaHei', monospace;
font-size: 12px;
z-index: 2147483647;
backdrop-filter: blur(4px);
border: 1px solid rgba(255,255,255,0.15);
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
transition: max-height 0.3s;
pointer-events: none;
}
#qi-log-panel .log-entry {
padding: 2px 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
word-break: break-all;
line-height: 1.4;
}
#qi-log-panel .log-entry .time {
color: #888;
margin-right: 6px;
}
#qi-log-panel::-webkit-scrollbar {
width: 4px;
}
#qi-log-panel::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
#qi-log-panel::-webkit-scrollbar-track {
background: transparent;
}
`;
document.head.appendChild(style);
const btnData = [
{ id: 'qi-guide-btn', text: '使用
指南', click: showGuide },
{ id: 'qi-start-btn', text: '请登录', click: () => {
if (state.authManager.isAuthorized() && state.isLoggedIn) {
startFullLearn();
} else {
if (!state.isLoggedIn) {
showModal({ title: '未登录', text: '请先登录平台', buttons: [{ text: '确定', primary: true }] });
} else {
showModal({ title: '未授权', text: '请先获取授权,再点击开始学习', buttons: [{ text: '知道了', primary: true }] });
}
}
}},
{ id: 'qi-donate-btn', text: '💖
打赏', click: showDonateModal }
];
btnData.forEach(({id, text, click}) => {
const btn = document.createElement('div');
btn.id = id;
btn.className = 'qi-btn';
btn.innerHTML = text;
document.body.appendChild(btn);
btn.addEventListener('click', click);
});
const logPanel = document.createElement('div');
logPanel.id = 'qi-log-panel';
logPanel.innerHTML = '日志加载中...
';
document.body.appendChild(logPanel);
updateUI();
}
// ======================== 5. 初始化 ========================
const authManager = new AuthManager();
state.authManager = authManager;
// 自动打赏弹窗(每30秒)
setInterval(() => {
showDonateModal();
}, 30000);
// 定期检查完成状态
setInterval(() => {
if (!state.completionShown && state.authManager.isAuthorized() && state.isLoggedIn) {
const totalCourses = state.courses.length;
const completedCourses = state.courses.filter(c => c.progress >= COMPLETE_THRESHOLD).length;
if (totalCourses > 0 && completedCourses === totalCourses) {
state.completionShown = true;
setTimeout(() => showCompletionDialog(), 200);
}
}
updateUI();
}, 5000);
async function init() {
createUI();
if (!authManager.isAuthorized()) {
createBanner();
}
await loadYearsAndCourses(true);
if (authManager.isAuthorized() && state.isLoggedIn) {
updateUI();
const banner = document.getElementById('qi-banner');
if (banner) banner.style.display = 'none';
}
if (authManager.getStatus() === 'temporary') {
addLog(`⏳ 授权剩余 ${authManager.formatRemaining()}`);
}
updateUI();
setInterval(async () => {
try {
await fetchStudentInfo();
updateUI();
} catch (_) {
state.isLoggedIn = false;
updateUI();
}
}, 30000);
}
if (document.readyState === 'complete' || document.readyState === 'interactive') {
init();
} else {
window.addEventListener('DOMContentLoaded', init);
}
})();