// ==UserScript== // @name 2026最新广东干部培训网络学院自动学习助手 // @namespace http://tampermonkey.net/ // @version 5.0.5 // @description 自动播放、播完自动切换下一课、卡死自动续播、动态水印。看课逻辑沿用成熟稳定的 v3.2,内置 20 分钟免费试用,到期后付款自动出码、粘贴即激活。 // @author LEEMO2023 // @match https://gbpx.gd.gov.cn/* // @match https://wcs1.shawcoder.xyz/* // @match https://cs1.gdgbpx.com/* // @match *://*gzqinghui.com.cn/* // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM_setClipboard // @grant window.close // @grant unsafeWindow // @connect * // @run-at document-idle // @noframes // @license MIT // @homepageURL https://gdpx-shop.pages.dev // @supportURL https://gdpx-shop.pages.dev/help.html // @antifeature payment 本脚本内置 20 分钟免费试用,到期后需付费购买激活码才能继续使用 // ==/UserScript== (function () { 'use strict'; /* ================== 配置区:改成你自己的服务地址 ================== */ var CONFIG = { // 授权服务端根地址(结尾必须带 /)。没有服务器请留空字符串 '',走纯卡密离线路线 // 当这里填了真实地址,脚本会自动切换成"扫码付款 → 自动出码 → 自动激活"模式 // ⚠️ 保持留空,直到 Cloudflare 云函数真正部署完成(需虎皮椒/微信支付密钥)。 // 一旦这里填了地址而后端没起来,脚本会以为有服务端,下单直接 404,购买流程会坏掉。 API_BASE: '', // 购买页地址(暂无在线支付通道时使用)。付款后由客服/网页把激活码发给你 SHOP_URL: 'https://gdpx-shop.pages.dev', // 免费试用时长(分钟),到点后弹出付款窗并暂停刷课 TRIAL_MINUTES: 20, // 单个视频最长播放时间(分钟)保险丝,超时强制关窗换下一课 MAX_VIDEO_MINUTES: 12, // 视频被认为看完的进度阈值(%) DONE_PERCENT: 98, // 课程列表页判定"已完成"的阈值(%) COURSE_DONE_PERCENT: 95, DEBUG: true }; /* 服务端公钥(SPKI PEM)。由 tools/genkey.js 生成,与服务端 keys/private.pem 配对 */ var PUBLIC_KEY_PEM = [ '-----BEGIN PUBLIC KEY-----', 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhMsflDqHj1qGdNcBJrhe', '0f8GJwsEo1acQFDFcUA6HJFjUzbGO6SbgXsbnuBvUYhL25KbOMFQ9/4udbEIY83y', 'pqOoqEAo9T7ayR2qrx2mR+htvYjTt1gid9D85UA4WAvt0a3GO5mMVhJ8KEzZU4z9', 'PJC4W1eY9AlAAWQeyeivEuiNywBjl8vJNRrRai5pThKWHbgut2dtspdJCQZV4TJV', 'XQd6oVsqfVO3cgJlfoa0a0Ex/vGoj8EKapuoEXAW3WbRAx+v4B3YvRugwmP1KduJ', 'LqWj34SBZYpVEUZfPfz91DWNKD+5yJml8FW7S5coN1YeLwRQGFbQq0i4KvwtHKHw', 'UwIDAQAB', '-----END PUBLIC KEY-----' ].join('\n'); var LOG_PREFIX = '[学习助手]'; function log() { if (!CONFIG.DEBUG) return; var a = Array.prototype.slice.call(arguments).map(function (x) { return typeof x === 'object' ? JSON.stringify(x) : String(x); }); console.log(LOG_PREFIX, a.join(' ')); } /* ============ 统一刷新闸门(修「一直在刷新」) ============ 旧版里 试用到期 / 找不到课程链接 / 视频窗反复关闭 三处都会直接 location.reload(), 叠在一起就会出现"网页停不下来地刷、视频永远等不到"。这里统一收口: ① 已停止(试用到期、未激活)绝不刷新; ② 处于登录页 index.aspx 绝不刷新(刷了也没用); ③ 3 秒内不重复刷新; ④ 同一标签页内同类刷新有次数上限(sessionStorage 计数,刷新后不清零)。 */ /* ============================================================ 5.0.2 修复(2026-10-26,按线上真实 DOM 实测) ① 视频页原本写死 5 分钟就关窗,而单节课长达 1880 秒 → 每 5 分钟被掐断并连发 3 次刷新消息,表现为"一直刷新、视频看不下去"。 改为跟踪 video.currentTime,播完(或 ≥ duration-2s)才关窗; 长时无进度自动续播,3 小时兜底。 ② 课程入口选择器全部作废(旧版 a[title^="已学习"]/a.courseware-list-reed/a[id^=gvList_ctl])。 新版真实结构:门户 index.aspx 的 .section_4 板块内 span.courseLearning 为「继续学习」, 课程详情页 Study/CourseDetail.aspx 的 #btnStudy(input[value=进入学习])为入口。 ③ StudyCenter.aspx 实际是「学时中心」,只有学时统计、没有课程列表, 旧逻辑在那里找不到课就 reload → 死循环。改为直接跳回门户。 ④ 刷新消息从 3 次降为 1 次。 ============================================================ */ /* 5.0.4:实测无用户手势时视频"带声"自动播放被浏览器拦截(paused:true, t:0), 先 v.muted = true 再 play() 即可正常播放并计入学时;同时移除旧的点按式播放逻辑 (tryClickPageCenter 会点中播放器中央反而把视频点停)。 */ /* 5.0.3:新版「继续学习」是 ,程序化 .click() 点外层 span 不会触发(站点 handler 需要可信手势),但直接读 href 在同标签页 跳转完全可行 —— 整条链路改为单标签页闭环,不再依赖弹窗/opener。 */ function reloadBudget(key, max) { try { var n = parseInt(sessionStorage.getItem('gdpx_rl_' + key) || '0', 10) || 0; if (n >= max) return false; sessionStorage.setItem('gdpx_rl_' + key, String(n + 1)); return true; } catch (e) { return true; } } function safeReload(reason, budgetKey, budgetMax) { try { if (STATE && STATE.stopped) { log('已停止看课,忽略刷新:' + (reason || '')); return; } if (/\/index\.aspx/i.test(location.href)) { log('当前在登录页,忽略刷新'); return; } var now = Date.now(); var last = parseInt(GM_getValue('gdpx_last_reload_at', '0'), 10) || 0; if (now - last < 3000) { log('刷新过于频繁,忽略:' + (reason || '')); return; } if (budgetKey && !reloadBudget(budgetKey, budgetMax || 3)) { log('刷新次数已达上限,停止自动刷新:' + (reason || '')); return; } GM_setValue('gdpx_last_reload_at', now); } catch (e) { } log('刷新页面:' + (reason || '')); location.reload(); } /* ================== 存储键 ================== */ var K = { deviceId: 'gdpx_v4_device_id', license: 'gdpx_v4_license', trialUsed: 'gdpx_v4_trial_used_ms', // 累计已试用毫秒(防刷新重置) trialTick: 'gdpx_v4_trial_tick_at', // 上次心跳时间戳 payOrder: 'gdpx_v4_pending_order' }; /* ================== 设备号 ================== */ function getDeviceId() { var id = GM_getValue(K.deviceId, ''); if (!id) { var t = Date.now().toString(36).toUpperCase(); var r = Math.random().toString(36).substring(2, 8).toUpperCase(); id = 'GD-' + t.substring(0, 4) + '-' + r; GM_setValue(K.deviceId, id); log('生成设备号', id); } return id; } /* ================== 激活码校验核心(可离线验签) ================== */ /* ---BEGIN LICENSE VERIFY CORE--- */ function b64uToBuf(str) { var s = String(str).replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; var bin = atob(s); var buf = new Uint8Array(bin.length); for (var i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); return buf; } function pemToBuf(pem) { var b64 = String(pem) .replace(/-----BEGIN PUBLIC KEY-----/, '') .replace(/-----END PUBLIC KEY-----/, '') .replace(/\s+/g, ''); return b64uToBuf(b64); } /** * 校验激活码 * @returns {Promise<{ok:boolean, reason?:string, expire?:number}>} */ async function verifyLicense(license, deviceId) { license = String(license || '').trim(); var parts = license.split('.'); if (parts.length !== 2) return { ok: false, reason: '激活码格式错误' }; var payload = parts[0]; var sig = parts[1]; // payload: GDPX1|deviceId|expireTs(秒,0=永久)|nonce // deviceId 为 * 时表示"通用卡密"(发卡平台批量预生成场景),任意机器可用 var f = payload.split('|'); if (f.length !== 4) return { ok: false, reason: '激活码格式错误' }; if (f[0] !== 'GDPX1') return { ok: false, reason: '激活码版本不匹配' }; if (f[1] !== deviceId && f[1] !== '*') return { ok: false, reason: '激活码与本机设备不匹配(一码一机)' }; var expire = parseInt(f[2], 10); if (isNaN(expire)) return { ok: false, reason: '激活码格式错误' }; if (expire !== 0 && Date.now() / 1000 > expire) return { ok: false, reason: '激活码已过期' }; // 浏览器 Web Crypto 验签(RSASSA-PKCS1-v1_5 + SHA-256) var floating = (f[1] === '*'); var subtle = (window.crypto && window.crypto.subtle) || (self.crypto && self.crypto.subtle); if (!subtle) { // 安全修复:离线卡密路线没有服务端可兜底,此时若放行, // 任何人只要构造 "GDPX1|*|0|任意.任意" 就能激活,等于授权形同虚设。 // 只有在确实配了在线服务端(激活码由服务端下发)时,才允许信任服务端结果。 if (!hasApi()) { return { ok: false, reason: '当前页面环境不支持本地验签(需 HTTPS),请换用 HTTPS 页面或联系客服' }; } return { ok: true, expire: expire, floating: floating, weak: true }; } try { var key = await subtle.importKey( 'spki', pemToBuf(PUBLIC_KEY_PEM), { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify'] ); var ok = await subtle.verify( { name: 'RSASSA-PKCS1-v1_5' }, key, b64uToBuf(sig), new TextEncoder().encode(payload) ); if (!ok) return { ok: false, reason: '激活码签名无效' }; return { ok: true, expire: expire, floating: floating }; } catch (e) { return { ok: false, reason: '验签失败:' + e.message }; } } /* ---END LICENSE VERIFY CORE--- */ /* ================== 服务端通信 ================== */ function api(action, params, method) { return new Promise(function (resolve, reject) { var qs = []; for (var k in params) if (params.hasOwnProperty(k)) { qs.push(encodeURIComponent(k) + '=' + encodeURIComponent(params[k])); } // 后端地址兼容两种: // ① Cloudflare Pages Functions(API_BASE 填站点根)→ /api/ // ② 自建 PHP(API_BASE 形如 .../api.php)→ api.php?action= var base = CONFIG.API_BASE; var url; if (/\.php$/i.test(base)) { url = base + '?action=' + action + (qs.length ? '&' + qs.join('&') : ''); } else { url = base.replace(/\/+$/, '') + '/api/' + action + (qs.length ? '?' + qs.join('&') : ''); } GM_xmlhttpRequest({ method: method || 'GET', url: url, timeout: 20000, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, onload: function (res) { try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(new Error('服务端返回异常:' + res.responseText.slice(0, 200))); } }, onerror: function (e) { reject(new Error('无法连接授权服务器')); }, ontimeout: function () { reject(new Error('授权服务器响应超时')); } }); }); } /* ==================== v3.2 看课逻辑兼容层 ==================== 看课函数全部原样保留 v3.2 的写法,门禁条件就是下面这两个变量。 它们由上面的 v4 授权模块实时同步,看课代码因此一行都不用改。 */ var ACTIVATION_CONFIG = { trialMinutes: CONFIG.TRIAL_MINUTES, // 试用分钟数 contactInfo: "付款后页面立即显示激活码,复制粘贴到脚本即完成激活" }; // v3.2 原有的状态变量(原样保留,值由授权模块同步) var bofangwan = false; var currentUrl = window.location.href; var isActivated = false; var trialTimeUsed = 0; // 已使用的试用时间(分钟) var deviceId = null; // 动态水印相关变量 var dynamicWatermark = null; var watermarkInterval = null; // ⚠️ 必须小写比较:实际网址路径大小写与旧代码写的不一致, // 用 indexOf 精确匹配会永远匹配不上,导致脚本认为"无需处理"而什么都不动 var lowUrl = currentUrl.toLowerCase(); // v3.2 的激活弹窗入口 → 指向 v4 的授权/付款窗 function showActivationPopup(reason) { showPayDialog(reason || '试用已结束'); } // 试用到期时停下当前播放(用 v3.2 的变量名,不动看课代码) function stopLearning() { removeDynamicWatermark(); if (window.videoTimer) { clearTimeout(window.videoTimer); window.videoTimer = null; } if (window.playInterval) { clearInterval(window.playInterval); window.playInterval = null; } try { var v = document.querySelector("video"); if (v) v.pause(); } catch (e) { } } /* ==================== 页面路由(原 v3.2 主逻辑,大小写无关匹配) ==================== */ function dispatchRoutes() { if (lowUrl.indexOf('play_pc') >= 0 || lowUrl.indexOf('playmp4') >= 0 || lowUrl.indexOf('courseware') >= 0) { // 视频播放页面(先判,避免被其他分支吃掉) setTimeout(function() { handleVideoPage(); }, 2000); } else if (lowUrl.indexOf('coursedetail.aspx') >= 0) { // 课程详情页(新版):点「进入学习」 setTimeout(function() { handleCourseDetailPage(); }, 2000); } else if (lowUrl.indexOf('studycenter.aspx') >= 0) { // 学时中心:只有学时统计,没有课程列表 setTimeout(function() { handleStudyCenterPage(); }, 1500); } else if (lowUrl.indexOf('learningcourse.aspx') >= 0) { // 旧版课程列表页面 setTimeout(function() { handleCourseListPage(); }, 2500); } else if (lowUrl.indexOf('index.aspx') >= 0 || /\/gdceportal\/?$/.test(currentUrl)) { // 门户首页(含「我的课程」) setTimeout(function() { handlePortalHome(); }, 2500); } else { // 其他页面 console.log("学习助手:等待处理"); } } /* ================== 状态 ================== */ var STATE = { deviceId: getDeviceId(), activated: false, license: '', expire: 0, trialUsedMs: 0, stopped: false // 试用到期后暂停学习 }; (async function boot() { registerMenus(); var saved = GM_getValue(K.license, ''); if (saved) { var v = await verifyLicense(saved, STATE.deviceId); if (v.ok) { STATE.activated = true; STATE.license = saved; STATE.expire = v.expire || 0; log('已激活', v.weak ? '(在线模式)' : '(离线验签通过)'); } else { log('本地激活码失效:' + v.reason); GM_deleteValue(K.license); } } STATE.trialUsedMs = parseInt(GM_getValue(K.trialUsed, '0'), 10) || 0; // 同步给 v3.2 看课门禁:看课代码一行未改,靠这两个变量放行/拦截 deviceId = STATE.deviceId; isActivated = STATE.activated; trialTimeUsed = Math.floor(STATE.trialUsedMs / 60000); if (STATE.activated) { dispatchRoutes(); } else { startTrialCountdown(); // 先按剩余试用时间跑,余额用尽时自动弹付款窗 if (trialLeftMs() <= 0) { STATE.stopped = true; showPayDialog('试用已结束'); } else { log('试用剩余', Math.ceil(trialLeftMs() / 60000), '分钟'); showFloatActivateBtn(); dispatchRoutes(); } } })(); function trialTotalMs() { return CONFIG.TRIAL_MINUTES * 60 * 1000; } function trialLeftMs() { return Math.max(0, trialTotalMs() - STATE.trialUsedMs); } /** 试用计时:每 30 秒累加一次实际在线时长,刷新/换页不会清零 */ function startTrialCountdown() { var last = parseInt(GM_getValue(K.trialTick, '0'), 10) || Date.now(); var now = Date.now(); if (now > last) { var delta = Math.min(now - last, 10 * 60 * 1000); // 单段最多计 10 分钟,防止挂机跨天暴涨 STATE.trialUsedMs += delta; GM_setValue(K.trialUsed, STATE.trialUsedMs); } GM_setValue(K.trialTick, now); setInterval(function () { if (STATE.activated || STATE.stopped) { GM_setValue(K.trialTick, Date.now()); return; } var t = Date.now(); var prev = parseInt(GM_getValue(K.trialTick, '0'), 10) || t; var d = Math.min(t - prev, 10 * 60 * 1000); if (d > 0) { STATE.trialUsedMs += d; GM_setValue(K.trialUsed, STATE.trialUsedMs); } GM_setValue(K.trialTick, t); // 同步给 v3.2 看课门禁 isActivated = STATE.activated; trialTimeUsed = Math.floor(STATE.trialUsedMs / 60000); if (trialLeftMs() <= 0) { STATE.stopped = true; stopLearning(); showPayDialog('试用时间已用完'); } }, 30000); } /* ================== 支付弹窗(全自动:扫码 → 轮询 → 激活 → 继续刷课) ================== */ function showPayDialog(reason) { if (document.getElementById('gdpx-pay-root')) return; log('显示支付窗口:' + reason); var root = document.createElement('div'); root.id = 'gdpx-pay-root'; root.innerHTML = '
' + '
' + '
' + '
学习助手 · 授权激活
' + '
扫码付款后自动激活,无需联系任何人
' + '×
' + '
' + '
' + reason + ',付款后立即恢复自动学习
' + '
' + '
正在生成收款二维码…
' + '
' + '
已有激活码?直接粘贴(离线可用)
' + '
' + '' + '
' + '
' + '
' + '设备号:' + STATE.deviceId + '' + '' + '
' + '
'; (document.body || document.documentElement).appendChild(root); document.getElementById('gdpx-pay-close').onclick = function () { root.remove(); }; // 复制设备号:买家把设备号发给客服,客服据此签发激活码 var copyBtn = document.getElementById('gdpx-copy-dev'); if (copyBtn) copyBtn.onclick = function () { copyText(STATE.deviceId); var t = document.getElementById('gdpx-copy-tip'); if (t) { t.textContent = '已复制'; setTimeout(function () { t.textContent = ''; }, 2000); } }; document.getElementById('gdpx-lic-btn').onclick = function () { var val = document.getElementById('gdpx-lic-input').value.trim(); redeemLicense(val).then(function (r) { var el = document.getElementById('gdpx-lic-msg'); if (r.ok) { el.innerHTML = '✓ 激活成功,正在恢复学习…'; setTimeout(function () { root.remove(); safeReload('激活成功'); }, 1200); } else { el.innerHTML = '✗ ' + r.reason + ''; } }); }; if (hasApi()) createOrderAndRender(root); else renderOfflineGuide(root); } /** 复制文本:优先用脚本管理器的剪贴板 API,失败则回退到临时 textarea */ function copyText(text) { try { if (typeof GM_setClipboard === 'function') { GM_setClipboard(text); return; } } catch (e) { /* 继续走降级 */ } try { var ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;top:-9999px;opacity:0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } catch (e2) { log('复制失败', e2.message); } } /** 是否已配置真正的授权服务端 */ function hasApi() { return !!CONFIG.API_BASE && CONFIG.API_BASE.indexOf('your-domain.com') < 0; } /** 无服务器路线:引导自助购买 → 粘贴卡密(卡密离线验签,永久有效) */ function renderOfflineGuide(root) { var stage = document.getElementById('gdpx-pay-stage'); var shop = CONFIG.SHOP_URL; var buyBtn = shop ? '
' + '① 点此自助购买激活码' : '
未配置购买链接(CONFIG.SHOP_URL)
'; stage.innerHTML = '
' + '激活三步:
' + '① 点下方按钮到购买页付款(请同时把设备号发给客服,下方「复制」按钮可一键复制)
' + '② 客服核对收款后,把激活码发给你(通常 30 分钟内)
' + '③ 把激活码粘贴到下方输入框,点「激活」即可使用' + '
' + '
' + buyBtn + '
' + '
激活码本地离线校验,激活后无需联网
'; } function createOrderAndRender(root) { var stage = document.getElementById('gdpx-pay-stage'); api('create_order', { device_id: STATE.deviceId }).then(function (d) { if (!d.ok) throw new Error(d.msg || '下单失败'); log('订单已创建', d.order_no, d.amount); GM_setValue(K.payOrder, d.order_no); stage.innerHTML = '
应付金额 ' + '¥' + Number(d.amount).toFixed(2) + ' · 永久授权
' + '
收款码
' + '
' + '' + '请用支付宝/微信扫码付款,付款后本页面自动激活
' + '
二维码无法显示?点此打开收银台 →
' + '
订单号 ' + d.order_no + '
'; pollOrder(root, d.order_no); }).catch(function (e) { log('下单失败', e.message); stage.innerHTML = '
下单失败:' + e.message + '
' + '
请检查网络,或稍后重试。也可使用下方激活码直接激活。
' + ''; var b = document.getElementById('gdpx-retry'); if (b) b.onclick = function () { createOrderAndRender(root); }; }); } function pollOrder(root, orderNo) { var tries = 0; var maxTries = 600; // 30 分钟 var timer = setInterval(function () { if (!document.getElementById('gdpx-pay-root')) { clearInterval(timer); return; } tries++; if (tries > maxTries) { clearInterval(timer); var tip = document.getElementById('gdpx-pay-tip'); if (tip) tip.innerHTML = '订单已超时,请关闭本窗口重新发起支付'; return; } api('query_order', { order_no: orderNo }).then(function (d) { if (!d.ok) return; if (d.status === 'paid' && d.license) { clearInterval(timer); log('支付成功,收到激活码'); redeemLicense(d.license).then(function (r) { if (r.ok) { var tip = document.getElementById('gdpx-pay-tip'); if (tip) tip.innerHTML = '✓ 支付成功,已激活,正在恢复学习…'; setTimeout(function () { root.remove(); safeReload('支付成功激活'); }, 1500); } else { var tip2 = document.getElementById('gdpx-pay-tip'); if (tip2) tip2.innerHTML = '激活码校验失败:' + r.reason + ''; } }); } }).catch(function () { /* 网络抖动,下一轮继续 */ }); }, 3000); // 收银台页面支付成功时也会主动通知(iframe / 新窗口场景) window.addEventListener('message', function (e) { if (e.data && e.data.type === 'gdpx_paid' && e.data.license) { clearInterval(timer); redeemLicense(e.data.license).then(function (r) { if (r.ok) { root.remove(); safeReload('支付回调激活'); } }); } }); } async function redeemLicense(license) { var v = await verifyLicense(license, STATE.deviceId); if (!v.ok) return { ok: false, reason: v.reason }; GM_setValue(K.license, license); STATE.activated = true; STATE.license = license; STATE.expire = v.expire || 0; STATE.stopped = false; removeFloatActivateBtn(); log('激活成功'); return { ok: true }; } /* ================== 脚本菜单(脚本猫/油猴 的扩展菜单里可点) ================== */ function registerMenus() { if (typeof GM_registerMenuCommand !== 'function') return; GM_registerMenuCommand('查看授权状态 / 激活', function () { showPayDialog('手动打开'); }); GM_registerMenuCommand('复制设备号(发给客服)', function () { copyText(STATE.deviceId); alert('设备号已复制:' + STATE.deviceId + '\n把它和付款截图一起发给客服即可。'); }); GM_registerMenuCommand('查看剩余试用时间', function () { var leftMs = Math.max(0, CONFIG.TRIAL_MINUTES * 60000 - STATE.trialUsedMs); alert(STATE.activated ? '已激活' + (STATE.expire ? ',有效期至 ' + new Date(STATE.expire * 1000).toLocaleString() : '(永久)') : '未激活,剩余试用约 ' + Math.ceil(leftMs / 60000) + ' 分钟'); }); } /* ================== 调试入口 ================== */ unsafeWindow.GDPX = { state: STATE, resetLicense: function () { GM_deleteValue(K.license); }, resetTrial: function () { GM_deleteValue(K.trialUsed); GM_deleteValue(K.trialTick); }, pay: function () { showPayDialog('手动触发'); }, deviceId: function () { return STATE.deviceId; } }; /* ================== 常驻激活按钮 ================== 未激活时在页面右下角挂一个小按钮,点一下就能弹出授权框。 解决"找不到激活入口"的问题:试用期内脚本不弹全屏窗, 用户想提前付费或试用中途想激活时没有入口。 */ function showFloatActivateBtn() { if (document.getElementById('gdpx-float-activate')) return; var b = document.createElement('div'); b.id = 'gdpx-float-activate'; b.textContent = '激活'; b.title = '学习助手:点击激活 / 查看授权'; b.style.cssText = 'position:fixed;right:14px;bottom:14px;z-index:2147482999;' + 'background:#2f6fed;color:#fff;font-size:13px;font-weight:600;' + 'padding:8px 16px;border-radius:999px;cursor:pointer;' + 'box-shadow:0 4px 14px rgba(47,111,237,.45);font-family:-apple-system,"Microsoft YaHei",sans-serif;' + 'user-select:none;transition:transform .15s'; b.onmouseenter = function () { b.style.transform = 'scale(1.06)'; }; b.onmouseleave = function () { b.style.transform = 'scale(1)'; }; b.onclick = function () { showPayDialog('手动激活'); }; document.body.appendChild(b); log('已挂上常驻激活按钮'); } function removeFloatActivateBtn() { var b = document.getElementById('gdpx-float-activate'); if (b) b.remove(); } /* ==================== 以下为 v3.2 看课逻辑(原样保留,仅 4 处必要适配) ==================== */ // ==================== 动态水印函数 ==================== function createDynamicWatermark() { // 移除旧的水印 removeDynamicWatermark(); // 创建水印元素 dynamicWatermark = document.createElement("div"); dynamicWatermark.id = "gbpx-dynamic-watermark"; dynamicWatermark.textContent = "视频自动播放中"; dynamicWatermark.style.cssText = ` position: fixed; z-index: 9999; pointer-events: none; background: rgba(0, 0, 0, 0.7); color: #4CAF50; padding: 12px 25px; border-radius: 30px; font-size: 16px; font-weight: bold; border: 3px solid #4CAF50; box-shadow: 0 0 25px rgba(76, 175, 80, 0.6); white-space: nowrap; user-select: none; backdrop-filter: blur(3px); animation: watermarkPulse 2s infinite; font-family: Arial, sans-serif; `; // 添加动画样式 var style = document.createElement('style'); style.textContent = ` @keyframes watermarkPulse { 0% { transform: scale(1); box-shadow: 0 0 20px rgba(76, 175, 80, 0.5); opacity: 0.9; } 50% { transform: scale(1.05); box-shadow: 0 0 35px rgba(76, 175, 80, 0.9); opacity: 1; } 100% { transform: scale(1); box-shadow: 0 0 20px rgba(76, 175, 80, 0.5); opacity: 0.9; } } `; document.head.appendChild(style); // 随机初始位置 var maxX = window.innerWidth - 220; var maxY = window.innerHeight - 60; var posX = Math.floor(Math.random() * maxX); var posY = Math.floor(Math.random() * maxY); // 初始速度 var velocityX = (Math.random() > 0.5 ? 1 : -1) * (1.5 + Math.random() * 1.5); var velocityY = (Math.random() > 0.5 ? 1 : -1) * (1 + Math.random() * 1); // 应用初始位置 dynamicWatermark.style.left = posX + "px"; dynamicWatermark.style.top = posY + "px"; document.body.appendChild(dynamicWatermark); console.log("学习助手:动态水印已创建"); // 启动水印移动动画 watermarkInterval = setInterval(function() { if (!dynamicWatermark) return; // 获取当前边界 var rect = dynamicWatermark.getBoundingClientRect(); var currentX = parseInt(dynamicWatermark.style.left) || 0; var currentY = parseInt(dynamicWatermark.style.top) || 0; // 更新位置 currentX += velocityX; currentY += velocityY; // 边界碰撞检测和反弹 var bounced = false; if (currentX <= 10) { currentX = 10; velocityX = Math.abs(velocityX); bounced = true; } else if (currentX >= maxX) { currentX = maxX; velocityX = -Math.abs(velocityX); bounced = true; } if (currentY <= 10) { currentY = 10; velocityY = Math.abs(velocityY); bounced = true; } else if (currentY >= maxY) { currentY = maxY; velocityY = -Math.abs(velocityY); bounced = true; } // 更新边界 maxX = window.innerWidth - rect.width - 10; maxY = window.innerHeight - rect.height - 10; // 确保在边界内 if (currentX > maxX) currentX = maxX; if (currentY > maxY) currentY = maxY; // 随机改变方向(只有未反弹时才改变) if (!bounced && Math.random() < 0.01) { velocityX += (Math.random() - 0.5) * 1.5; velocityY += (Math.random() - 0.5) * 1.5; // 限制最大速度 var maxSpeed = 4; velocityX = Math.max(-maxSpeed, Math.min(maxSpeed, velocityX)); velocityY = Math.max(-maxSpeed, Math.min(maxSpeed, velocityY)); } // 应用新位置 dynamicWatermark.style.left = currentX + "px"; dynamicWatermark.style.top = currentY + "px"; // 轻微旋转效果 var rotation = Math.sin(Date.now() / 800) * 2; dynamicWatermark.style.transform = `rotate(${rotation}deg) scale(1.05)`; }, 40); // 每秒更新25次 } function removeDynamicWatermark() { if (dynamicWatermark) { dynamicWatermark.remove(); dynamicWatermark = null; } if (watermarkInterval) { clearInterval(watermarkInterval); watermarkInterval = null; } } // ==================== 主逻辑函数 ==================== function handleCourseListPage() { // 旧版 LearningCourse.aspx 已下线,统一走门户首页逻辑 handlePortalHome(); } /* ============ 5.0.5:跳过「已学完 / 秒结束」的课程 ============ 线上实测(2026-10-26):门户只取第一张「继续学习」卡;若该课上次已学到结尾, 播放器 play 时会 currentTime(pos) 直接跳到末尾 → 立刻 ended → 本脚本关窗回门户 → 又挑同一门,约每 15 秒循环一次, 用户看到的就是"进不去视频、一直跳回课程页"。 对策:① 门户按进度过滤 + 跳过名单;② 播放页"一进来就在结尾"判定为已学完并拉黑。 */ var SKIP_KEY = 'gdpx_skip_courses'; var SKIP_TTL = 6 * 3600 * 1000; // 6 小时后自动重试,避免永久错过 function getSkipMap() { var raw = GM_getValue(SKIP_KEY, ''); var m = {}, out = {}; if (raw) { try { m = JSON.parse(raw) || {}; } catch (e) { m = {}; } } var now = Date.now(); for (var k in m) { if (m.hasOwnProperty(k) && m[k] && m[k] > now) out[k] = m[k]; } return out; } function addSkipCourse(id) { if (!id) return; id = String(id).toLowerCase(); var m = getSkipMap(); m[id] = Date.now() + SKIP_TTL; try { GM_setValue(SKIP_KEY, JSON.stringify(m)); } catch (e) { } log('已把课程加入跳过名单:' + id); } function courseIdFromUrl(u) { var m = String(u || '').match(/[?&]id=([0-9a-zA-Z\-]{8,})/); return m ? m[1].toLowerCase() : ''; } /* 播放页里拿当前课程 id:playdo 会把 courseId 写进 localStorage,门户侧则存在 GM 里 */ function courseIdFromContext() { try { var c = localStorage.getItem('courseId'); if (c) return String(c).toLowerCase(); } catch (e) { } return courseIdFromUrl(GM_getValue('gdpx_current_course', '')); } /* 从卡片文本里解析进度百分比 */ function cardProgressPct(txt) { var m = txt.match(/已学习\s*([\d.]+)\s*[%%]/) || txt.match(/学习进度\s*[::]?\s*([\d.]+)/) || txt.match(/([\d.]+)\s*[%%]/); if (m) { var v = parseFloat(m[1]); if (!isNaN(v)) return v; } if (txt.indexOf('已完成') >= 0) return 100; return 0; } /* 门户首页:从「我的课程」找未完成课程,点「继续学习」 */ function handlePortalHome() { if (!isActivated && trialTimeUsed >= ACTIVATION_CONFIG.trialMinutes) return; if (STATE.stopped) return; console.log("学习助手:处理门户首页(我的课程)"); setTimeout(function () { // 新版结构:.section_4 是「我的课程」板块,卡内按钮为 span.courseLearning var cards = document.querySelectorAll('.section_4 .box_6'); if (!cards.length) cards = document.querySelectorAll('.box_6'); if (!cards.length) { log('未找到课程卡片,等待页面加载'); return; } var skip = getSkipMap(); var picked = null; var seen = []; for (var i = 0; i < cards.length; i++) { var card = cards[i]; var txt = (card.innerText || '').replace(/\s+/g, ''); var a = card.querySelector('span.courseLearning a') || card.querySelector('a'); if (!a || !a.getAttribute('href')) continue; var cid = courseIdFromUrl(a.getAttribute('href')); var pct = cardProgressPct(txt); seen.push((cid ? cid.slice(0, 8) : '?') + '@' + Math.round(pct) + '%'); // 跳过已完成 if (txt.indexOf('未完成') < 0 && txt.indexOf('已完成') >= 0) continue; // 跳过已学满(含 100%) if (pct >= 99.5) continue; // 跳过本轮已被判定"一进去就在结尾"的课程 if (cid && skip[cid]) continue; if (!picked) picked = { url: a.href, name: txt.slice(0, 40), id: cid }; } log('课程候选:' + seen.join(' / ')); if (!picked) { log('没有可继续学习的课程(可能都已完成),停止自动跳转'); return; } log('准备学习:' + picked.name); // 记下本次挑选的课程,播放页判断"是否秒结束"时要用 try { GM_setValue('gdpx_current_course', picked.url); } catch (e) { } // 新版「继续学习」本身就是一个 , // 直接读取 href 在同标签页跳转,不依赖可信手势、也不依赖弹窗。 setTimeout(function () { location.href = picked.url; }, 600); }, 2500); } /* 课程详情页:点「进入学习」 */ function handleCourseDetailPage() { if (STATE.stopped) return; console.log("学习助手:处理课程详情页"); setTimeout(function () { var btn = document.getElementById('btnStudy'); if (btn) { log('点击「进入学习」'); try { btn.click(); } catch (e) { log('点击失败:' + e); } return; } log('页面上没有「进入学习」,返回门户'); location.href = 'https://gbpx.gd.gov.cn/gdceportal/index.aspx'; }, 2000); } function handleStudyCenterPage() { if (!isActivated && trialTimeUsed >= ACTIVATION_CONFIG.trialMinutes) return; if (STATE.stopped) return; // 学时中心只有学时统计,没有课程列表,直接回门户找课 log('学时中心无课程列表,返回门户'); setTimeout(function () { location.href = 'https://gbpx.gd.gov.cn/gdceportal/index.aspx'; }, 1200); } function handleVideoPage() { if (!isActivated && trialTimeUsed >= ACTIVATION_CONFIG.trialMinutes) { setTimeout(showActivationPopup, 3000); return; } console.log("学习助手:处理视频页面"); // 创建动态水印 createDynamicWatermark(); // 关键修复:视频长达 30 分钟以上,不能再固定 5 分钟关窗 // 改为跟踪真实播放进度,播完才关 startVideoProgressWatcher(); // 不再调用旧的点按式播放(tryClickPageCenter 会点中播放器中央导致暂停) // 播放完全交给 startVideoProgressWatcher 里的 ensureVideoPlaying } /* 递归查找 video(穿透同源 iframe) */ function findVideoDeep(doc, depth) { doc = doc || document; depth = depth || 0; if (depth > 4) return null; try { var vs = doc.querySelectorAll('video'); if (vs.length > 0) return vs[0]; } catch (e) { } try { var ifr = doc.querySelectorAll('iframe'); for (var i = 0; i < ifr.length; i++) { var d = null; try { d = ifr[i].contentDocument; } catch (e) { d = null; } if (d) { var r = findVideoDeep(d, depth + 1); if (r) return r; } } } catch (e) { } return null; } /* 保证视频在播:无用户手势时浏览器会拦截"带声"自动播放, 因此 play() 之前必须先静音;静音播放同样计入学时。 */ function ensureVideoPlaying(v) { if (!v || v.ended) return; try { if (v.paused) { v.muted = true; var pr = v.play(); if (pr && pr.catch) { pr.catch(function () { try { v.muted = true; v.play(); } catch (e) { } }); } } } catch (e) { } } /* 播放进度看护:播完才关窗,长时间无进度则重试 */ function startVideoProgressWatcher() { if (window.gdpxWatchTimer) { clearInterval(window.gdpxWatchTimer); window.gdpxWatchTimer = null; } var lastT = 0, stall = 0, startedAt = Date.now(), advanced = 0; function tick() { var v = findVideoDeep(); if (!v) { if (Date.now() - startedAt > 180000) { log('3 分钟内未找到视频,返回门户'); clearInterval(window.gdpxWatchTimer); window.gdpxWatchTimer = null; closeVideoAndCheckProgress(); } return; } ensureVideoPlaying(v); var t = v.currentTime || 0; var dur = v.duration || 0; if (t > lastT + 0.5) { advanced += (t - lastT); lastT = t; stall = 0; } else { stall++; } // 播完 / 已到结尾 if (v.ended || (dur > 0 && t >= dur - 2)) { var elapsed = Date.now() - startedAt; // 5.0.5:一进来就停在结尾 —— 这是"该课早已学完",不是真的看完 // (videoConfig.js 会在 play 时 currentTime(上次位置),学到尾部就等于一进来就 ended) if (advanced < 3 && elapsed < 90000) { var cid = courseIdFromContext(); log('视频一进入就在结尾(累计前进 ' + Math.round(advanced) + ' 秒,' + Math.round(t) + '/' + Math.round(dur) + '),判定该课已学完,加入跳过名单'); addSkipCourse(cid); clearInterval(window.gdpxWatchTimer); window.gdpxWatchTimer = null; closeVideoAndCheckProgress(); return; } // 刚进来不足 6 秒,先不下结论,等下一轮 if (elapsed < 6000) return; log('视频播放完毕 ' + Math.round(t) + '/' + Math.round(dur) + ' 秒,关闭窗口'); clearInterval(window.gdpxWatchTimer); window.gdpxWatchTimer = null; closeVideoAndCheckProgress(); return; } // 约 2 分钟没有进度,尝试续播 if (stall >= 24) { log('视频长时间无进度,尝试续播'); stall = 0; try { v.currentTime = t; var p2 = v.play(); if (p2 && p2.catch) p2.catch(function () { }); } catch (e) { } } // 兜底:超过 3 小时强制结束 if (Date.now() - startedAt > 3 * 3600 * 1000) { log('超过 3 小时,强制关闭窗口'); clearInterval(window.gdpxWatchTimer); window.gdpxWatchTimer = null; closeVideoAndCheckProgress(); } } tick(); window.gdpxWatchTimer = setInterval(tick, 4000); } function startVideoPlayback() { console.log("学习助手:开始尝试播放视频..."); // 增加播放尝试次数 var playAttempts = 0; var maxAttempts = 10; // 立即尝试第一次播放 tryToPlayVideo(); // 设置定期重试 var playInterval = setInterval(function() { playAttempts++; console.log("学习助手:播放尝试 " + playAttempts + "/" + maxAttempts); if (playAttempts >= maxAttempts) { clearInterval(playInterval); console.log("学习助手:达到最大尝试次数,停止尝试播放"); return; } // 检查视频是否已经在播放 if (isVideoPlaying()) { clearInterval(playInterval); console.log("学习助手:视频已在播放中,停止重试"); return; } // 再次尝试播放 tryToPlayVideo(); }, 3000); // 每3秒尝试一次 // 保存定时器以便清理 window.playInterval = playInterval; } // ==================== 工具函数 ==================== function isVideoPlaying() { try { var videos = document.querySelectorAll("video"); if (videos.length > 0) { var video = videos[0]; return !video.paused && !video.ended; } var progressElements = document.querySelectorAll(".vjs-play-progress"); if (progressElements.length > 0) { var progress = progressElements[0].style.width; if (progress && progress !== "0%" && progress !== "0px") { return true; } } } catch (e) { // 忽略错误 } return false; } function tryToPlayVideo() { console.log("学习助手:尝试播放视频..."); tryDirectVideoPlay(); setTimeout(function() { tryClickPlayButtons(); }, 500); setTimeout(function() { tryClickPageCenter(); }, 1000); } function tryDirectVideoPlay() { try { var videos = document.querySelectorAll("video"); if (videos.length > 0) { var video = videos[0]; video.volume = 0; video.muted = true; video.play().then(function() { console.log("学习助手:直接播放video元素成功"); }).catch(function(error) { console.log("学习助手:直接播放失败: " + error.message); }); } } catch (e) { // 忽略错误 } } function tryClickPlayButtons() { var playSelectors = [ '.vjs-big-play-button', '.vjs-play-control', '.vjs-play-button', '.video-js .vjs-big-play-button', 'button[title*="播放"]', 'button[title*="play"]', 'button[aria-label*="播放"]', 'button[aria-label*="play"]', '.play-btn', '.video-play', '.play-button', '.start-play', '.vjs-fullscreen-control', '.fullscreen-btn', '.start-button', '.begin-btn', 'video', '.video-js', '.vjs-poster', '.video-poster' ]; for (var i = 0; i < playSelectors.length; i++) { try { var elements = document.querySelectorAll(playSelectors[i]); if (elements.length > 0) { for (var j = 0; j < elements.length; j++) { var element = elements[j]; if (isElementVisible(element)) { console.log("学习助手:点击播放按钮: " + playSelectors[i]); element.click(); if (playSelectors[i] === 'video' || playSelectors[i] === '.video-js') { setTimeout(function() { tryDirectVideoPlay(); }, 100); } } } } } catch (e) { console.log("学习助手:点击" + playSelectors[i] + "失败"); } } } function tryClickPageCenter() { try { var x = window.innerWidth / 2; var y = window.innerHeight / 2; var element = document.elementFromPoint(x, y); if (element) { console.log("学习助手:点击页面中心元素: " + element.tagName); var clickEvent = new MouseEvent('click', { view: window, bubbles: true, cancelable: true, clientX: x, clientY: y }); element.dispatchEvent(clickEvent); } } catch (e) { console.log("学习助手:点击页面中心失败"); } } function isElementVisible(element) { if (!element) return false; var rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0 && rect.top >= 0 && rect.left >= 0 && rect.bottom <= window.innerHeight && rect.right <= window.innerWidth; } function closeVideoAndCheckProgress() { console.log("学习助手:视频播放完成"); // 清理水印 removeDynamicWatermark(); if (window.videoTimer) { clearTimeout(window.videoTimer); window.videoTimer = null; } if (window.playInterval) { clearInterval(window.playInterval); window.playInterval = null; } if (window.gdpxWatchTimer) { clearInterval(window.gdpxWatchTimer); window.gdpxWatchTimer = null; } try { if (window.opener && !window.opener.closed) { sendMultipleRefreshMessages(); } } catch (e) { console.log("学习助手:无法向主窗口发送消息"); } setTimeout(function() { // 单标签页闭环:直接回门户挑下一节 // (window.close() 对非脚本打开的标签页无效且不抛错,所以不再依赖它) log('返回门户,继续下一节'); window.location.href = "https://gbpx.gd.gov.cn/gdceportal/index.aspx"; }, 2000); } function sendMultipleRefreshMessages() { var refreshCount = 1; var currentCount = 0; console.log("学习助手:将发送" + refreshCount + "次刷新消息"); function sendNextRefresh() { if (currentCount < refreshCount) { currentCount++; console.log("学习助手:发送第" + currentCount + "次刷新消息"); window.opener.postMessage({ type: 'refreshPage', source: 'videoWindow', timestamp: Date.now(), refreshCount: currentCount, totalRefreshes: refreshCount }, '*'); if (currentCount < refreshCount) { setTimeout(sendNextRefresh, 5000); } else { console.log("学习助手:已发送所有" + refreshCount + "次刷新消息"); } } } setTimeout(sendNextRefresh, 1000); } function handleCompletionPage() { console.log("学习助手:播放完成页面"); setTimeout(function() { try { var closeButtons = document.querySelectorAll("button.instructions-close"); if (closeButtons.length > 0) { closeButtons[0].click(); console.log("学习助手:点击了关闭按钮"); } } catch (e) { console.log("学习助手:关闭按钮点击失败"); } setTimeout(function() { try { window.close(); } catch (e) { window.location.href = "https://gbpx.gd.gov.cn/gdceportal/index.aspx"; } }, 1000); }, 2000); } function getCurrentProgress() { var allDivs = document.querySelectorAll("div"); for (var i = 0; i < allDivs.length; i++) { var div = allDivs[i]; var text = div.textContent || div.innerText || ""; var match = text.match(/([\d.]+)%/); if (match) { var progress = parseFloat(match[1]); if (!isNaN(progress)) return progress; } } var styledDivs = document.querySelectorAll("div[style*='width: 50px']"); for (var j = 0; j < styledDivs.length; j++) { var styledDiv = styledDivs[j]; var text = styledDiv.textContent || styledDiv.innerText || ""; var match = text.trim().match(/([\d.]+)/); if (match) { var progress = parseFloat(match[1]); if (!isNaN(progress)) return progress; } } return 0; } function findAndClickCourse() { var alla = document.querySelectorAll('a[title^="已学习"]'); if (alla.length === 0) { alla = document.querySelectorAll("a.courseware-list-reed"); } if (alla.length === 0) { alla = document.querySelectorAll("a[id^='gvList_ctl']"); } if (alla.length > 0) { GM_deleteValue("gdpx_reload_cnt"); // 跳过已完成的课程,点第一个还没学完的(与课程列表页逻辑一致) var target = null; for (var i = 0; i < alla.length; i++) { var p = getCourseProgress(alla[i].title || alla[i].textContent || ""); if (p < 95) { target = alla[i]; break; } } if (target) { console.log("学习助手:点击课程继续学习"); target.click(); } else { console.log("学习助手:所有课程已完成"); } } else { console.log("学习助手:未找到课程,刷新本页重试"); // 原逻辑跳转 LearningCourse.aspx(该页已下线),改为刷新本页; // 最多刷 2 次,避免"一直刷新停不下来" var reloadCnt = parseInt(GM_getValue("gdpx_reload_cnt", "0"), 10) || 0; if (reloadCnt >= 2) { GM_deleteValue("gdpx_reload_cnt"); console.log("学习助手:连续 " + reloadCnt + " 次未找到课程链接,已停止刷新"); return; } GM_setValue("gdpx_reload_cnt", reloadCnt + 1); setTimeout(function() { safeReload('未找到课程链接重试', 'notfound', 3); }, 3000); } } function getCourseProgress(title) { var progress = 0; var match = title.match(/已学习\s*([\d.]+)[%%]/); if (match && match[1]) { progress = parseFloat(match[1]); } return progress; } // 添加主窗口消息监听器 // (课程列表页 LearningCourse.aspx 已下线,学习中心页现在就是课程列表页, // 两个页面都挂监听,视频窗关完后照常收到刷新消息) var listenUrl = window.location.href.toLowerCase(); if (listenUrl.indexOf("learningcourse.aspx") >= 0 || listenUrl.indexOf("studycenter.aspx") >= 0) { window.addEventListener('message', function(event) { if (event.data && event.data.type === 'refreshPage') { console.log("学习助手:收到刷新消息,刷新页面"); setTimeout(function() { safeReload('收到刷新消息', 'msg', 3); }, 2000); } if (event.data && event.data.type === 'multipleRefresh') { console.log("学习助手:收到多次刷新消息"); setTimeout(function() { safeReload('收到多次刷新消息', 'msg', 5); }, 2000); } }); setInterval(function() { if (window.videoWindow && window.videoWindow.closed) { console.log("学习助手:检测到视频窗口已关闭,刷新页面"); var refreshCount = 1; var currentCount = 0; function doRefresh() { if (currentCount < refreshCount) { currentCount++; console.log("学习助手:刷新第" + currentCount + "次,共" + refreshCount + "次"); if (currentCount < refreshCount) { safeReload('视频窗口已关闭', 'vw', 3); } else { console.log("学习助手:刷新完成,等待检测进度..."); setTimeout(function() { safeReload('视频窗口已关闭(收尾)', 'vw', 4); }, 3000); } } } setTimeout(doRefresh, 2000); } }, 30000); } })();