// ==UserScript== // @name 常州培训网自动学习助手 v2.9.1 // @namespace https://www.czpx.cn // @version 2.9.1 // @description 纯AI生成,只能自动观看视频,省去的章节和小结间视频需要人去手动点击的问题,方便挂机 // @author 菜鸟小韩 // @match *://www.czpx.cn/* // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_setClipboard // @grant GM_registerMenuCommand // @grant unsafeWindow // @run-at document-start // @icon https://www.czpx.cn/favicon.ico // @license MIT // ==/UserScript== (function () { 'use strict'; // 只在顶层页面运行整套逻辑;iframe 里的原生弹窗由 patchAllFrames 单独接管。 let IS_TOP = true; try { IS_TOP = (window.top === window.self); } catch (e) {} if (!IS_TOP) return; // 有 @grant 时脚本在沙箱里,页面真实的 window 要用 unsafeWindow。 const PAGE_WIN = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; // 同一页面同时装多份 v2.3 时,只让一份跑完整逻辑(另一份仍会接管弹窗)。 try { if (PAGE_WIN.__CZPX_AL23_ACTIVE__) return; PAGE_WIN.__CZPX_AL23_ACTIVE__ = 1; } catch (e) {} // ★ GM key 换前缀:日志里能看到 v2.1 / v2.2 同时启动过,旧版本共用 // al_v2_* 会互相写 running / 覆盖日志(v2.1 甚至把 v2.2 直接停了)。 // v2.3 用独立命名空间,即使旧版还在也不会再被它停机或刷屏。 const K = { RUN: 'al_v23_running', ACTIVE: 'al_v23_active', LOG: 'al_v23_log', CFG: 'al_config', BOOT: 'al_v23_boot', KICK: 'al_v23_kicked', // 站点「长时间未操作」踢出标记(跨页面) RECOVER: 'al_v23_recover', // 会话失效自愈次数(跨页面) LASTPLAY: 'al_v23_lastplay',// 最后一次成功播放页 URL(自愈用) PARAMREFRESH: 'al_v23_paramrefresh', // 参数错误刷新计数(10 分钟窗口) }; const STALE_MS = 6 * 60 * 60 * 1000; const END_HOLD_MS = 9000; const STUCK_MS = 60000; const MAX_FAIL_ROUNDS = 2; const KICK_FRESH_MS = 6 * 60 * 1000; // 踢出标记有效期(自愈可能连跳几页,放宽) const MAX_RECOVER = 3; // 空白失效页最多自动恢复几次 // ★ 不再定义任何硬编码 servlet URL。站点业务 URL 形如 // learnCourseChapterServlet.do?02B/XmUgyGpoa0vAjGGZow== / coursePlayServlet.do?+lsa+... // 参数是站点签发的一次性加密票据,脚本无法凭空构造;裸 URL 只会得到 // 「您的访问有误,请重试!」「系统未能检测到您的访问详情(参数)」+ 空白页。 // 重进只允许用:当前 URL 的 reload、state.lastPlay、history.back()、页面真实链接。 const KICK_RE = /长时间未操作|未操作|重新进入|长时间未移动|长时间没有操作|会话.*(超时|失效|过期)|请重新登录|登录已失效|超时.*重新/i; const PARAM_ERR_RE = /访问详情\(参数\)|访问有误|参数.{0,6}(错误|无效|缺失)|未能检测到|请刷新页面重试|系统未能检测/i; // 站点顶部/页脚导航词,绝不能当作「进入课程」的入口(v2.4 曾误点「学习中心」)。 const NAV_BLACKLIST = /^(首页|学习中心|在线学习|课程资源|联系我们|会员中心|学员中心|证书查询|学习要求|常见问题|收藏本页|退出|继续教育|\[学员中心\]|\[退出\]|联系电话.*|苏ICP备.*)$/; function readCfg() { try { const v = GM_getValue(K.CFG, null); return (v && typeof v === 'object') ? v : {}; } catch (e) { return {}; } } const CONFIG = Object.assign({ playbackRate: 1.0, autoNext: true, closeModals: true, keepAwakeInterval: 12000, // 防挂机心跳:原来 25s 且坐标固定,站点判「未移动」 autoDismissDialogs: true, // 接管原生 alert/confirm(核心) autoRecover: true, // 被「长时间未操作」踢出后自动重新进入课程 maxRecover: MAX_RECOVER, }, readCfg()); let running = false; try { const saved = GM_getValue(K.RUN, false); const active = Number(GM_getValue(K.ACTIVE, 0)) || 0; running = !!saved && (Date.now() - active < STALE_MS); } catch (e) { running = false; } const state = { get running() { return running; }, status: 'idle', pageType: 'unknown', currentVideo: null, chapterHandling: false, nearEndSince: 0, lastSrc: '', lastProgressAt: Date.now(), failRounds: 0, chapterPaused: false, endWatchdog: null, keepAliveTimer: null, stuckWatchdog: null, unknownRetries: 0, logLines: [], dialogCount: 0, // 已自动确认的原生弹窗数 lastDialog: null, // { kind, msg, at } guardedWindows: 0, // 成功接管的 window 数 dialogLogAt: Object.create(null), errSeen: Object.create(null), errIgnored: 0, keepAliveCount: 0, lastKickAt: 0, recoverTries: 0, recovering: false, lastRecoverAt: 0, lastPlay: '', paramErrAt: 0, }; try { const rec = GM_getValue(K.RECOVER, null); if (rec && typeof rec === 'object' && (Date.now() - (rec.at || 0) < 10 * 60 * 1000)) { state.recoverTries = Number(rec.n) || 0; } state.lastKickAt = Number(GM_getValue(K.KICK, 0)) || 0; state.lastPlay = String(GM_getValue(K.LASTPLAY, '') || ''); } catch (e) {} // ==================== 日志 ==================== function log(msg) { const line = '[' + new Date().toLocaleTimeString('zh-CN') + '] ' + msg; console.log('🎬 AutoLearn v2.8:', line); state.logLines.push(line); if (state.logLines.length > 400) state.logLines.shift(); try { GM_setValue(K.LOG, state.logLines.slice(-160).join('\n')); } catch (e) {} renderLog(); } function touch() { try { GM_setValue(K.ACTIVE, Date.now()); } catch (e) {} } // 同一条错误 30 秒内只记一次,其余累计;benign 错误直接忽略。 // 站点首页 info.do 自身的 bug(offsetWidth of null)会每秒刷上百条,直接静默。 const BENIGN_ERR = /play\(\) request was interrupted|AbortError.*play|NotAllowedError.*play|offsetWidth|appendChild/i; function logErr(kind, msg) { const s = String(msg || ''); if (BENIGN_ERR.test(s)) { state.errIgnored++; return; } const key = kind + '|' + s.slice(0, 140); const now = Date.now(); const rec = state.errSeen[key]; if (rec) { rec.n++; if (now - rec.at < 30000) return; log('❌ ' + kind + ': ' + s + '(30 秒内重复 ' + rec.n + ' 次,已限流)'); rec.at = now; rec.n = 0; } else { state.errSeen[key] = { at: now, n: 1 }; log('❌ ' + kind + ': ' + s + '(站点自身脚本报错,与助手功能无关)'); } } // ==================== 核心:接管原生弹窗 ==================== // Chrome 的 alert/confirm 是模态框,会同步冻结页面所有 JS(含定时器)。 // 站点在章节结束时 alert('本章已完成'),不点确定后面的跳转代码永远不执行。 // 覆盖后弹窗不再出现,页面 JS 立即继续,站点自己完成提交与跳章。 function onDialog(kind, msg) { state.dialogCount++; const s = String(msg == null ? '' : msg).slice(0, 120); state.lastDialog = { kind: kind, msg: s, at: Date.now() }; const key = kind + '|' + s; const now = Date.now(); if (state.dialogLogAt[key] && (now - state.dialogLogAt[key] < 10000)) return; state.dialogLogAt[key] = now; log('🔔 已自动确认原生 ' + kind + ':' + s); if (PARAM_ERR_RE.test(s)) { // 站点在 URL 缺少/失效的加密参数时报这个。这往往意味着当前页是失效页, // 或是脚本跳到了裸 servlet。记录时间戳,供自愈/页面处理判断。 state.paramErrAt = Date.now(); log('⚠️ 这是站点的「访问参数错误」提示(当前 URL 的加密票据缺失或已失效)'); log(' → 脚本不会再拼裸 URL;会按站点真实链接/刷新/历史返回重新进入'); } else if (KICK_RE.test(s)) { state.lastKickAt = Date.now(); try { GM_setValue(K.KICK, state.lastKickAt); } catch (e) {} log('⚠️ 这是站点的「防挂机 / 会话失效」提示(说明模拟操作没被站点认可)'); log(' → 页面随后可能变成空白失效页;脚本会自动重新进入课程继续播放'); } else if (/完成|结束|下一|继续|学时|章节|已学|恭喜/.test(s)) { log(' → 章节完成提示已放行,页面 JS 不再被阻塞,站点跳转逻辑可继续执行'); } } function alreadyPatched(w) { try { return !!(w.alert && w.alert.__alDlg && w.confirm && w.confirm.__alDlg && w.prompt && w.prompt.__alDlg); } catch (e) { return false; } } function guardOne(w) { if (!w) return false; let ok = false; // 站点可能在运行中把 alert 改回原生,需要能重新接管;因此每次都校验标记。 if (!alreadyPatched(w)) { const defs = [ ['alert', function (msg) { onDialog('alert', msg); }], ['confirm', function (msg) { onDialog('confirm', msg); return true; }], ['prompt', function (msg, def) { onDialog('prompt', msg); return (def === undefined) ? '' : def; }], ]; for (const pair of defs) { try { pair[1].__alDlg = true; // 标记,供 alreadyPatched 校验 try { Object.defineProperty(w, pair[0], { value: pair[1], writable: true, configurable: true }); } catch (e) { w[pair[0]] = pair[1]; } ok = true; } catch (e) {} } if (ok) { try { w.__alDlgGuard = (w.__alDlgGuard || 0) + 1; } catch (e) {} } } // onbeforeunload 也会被站点反复设置,每次心跳都清一次,避免"离开此页"二次阻塞 try { if (w.onbeforeunload) w.onbeforeunload = null; } catch (e) {} if (ok) state.guardedWindows++; return ok || alreadyPatched(w); } function patchAllFrames() { guardOne(PAGE_WIN); guardOne(window); try { document.querySelectorAll('iframe').forEach(function (f) { try { guardOne(f.contentWindow); } catch (e) {} }); } catch (e) {} } // document-start 立即接管,务必早于页面任何脚本调用 alert。 if (CONFIG.autoDismissDialogs) patchAllFrames(); // ==================== 全局错误钩子(立即注册、限流) ==================== function hookErrors() { const onErr = function (e) { logErr('页面脚本错误', (e && e.message ? e.message : '') + ' @' + (e && e.filename ? e.filename : '') + ':' + (e && e.lineno ? e.lineno : '')); }; const onRej = function (e) { const r = e && e.reason; logErr('未处理的 Promise', (r && r.message) ? r.message : String(r || '')); }; try { window.addEventListener('error', onErr); } catch (e) {} try { window.addEventListener('unhandledrejection', onRej); } catch (e) {} if (PAGE_WIN !== window) { try { PAGE_WIN.addEventListener('error', onErr); } catch (e) {} try { PAGE_WIN.addEventListener('unhandledrejection', onRej); } catch (e) {} } } hookErrors(); // ==================== CSS(DOM 就绪后再注入) ==================== const CSS = ` #__al_panel{position:fixed!important;top:120px!important;right:20px!important;width:310px!important;z-index:2147483647!important;background:#1a1a2e!important;border:2px solid #4361ee!important;border-radius:12px!important;box-shadow:0 10px 40px rgba(67,97,238,.4)!important;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif!important;font-size:13px!important;color:#e0e0e0!important;overflow:hidden!important} #__al_panel.al-collapsed .al-body{display:none!important} #__al_panel.al-collapsed{width:190px!important} .al-head{background:linear-gradient(135deg,#4361ee,#3a0ca3)!important;padding:10px 14px!important;cursor:move!important;display:flex!important;align-items:center!important;justify-content:space-between!important;user-select:none!important} .al-title{font-weight:700!important;font-size:14px!important;color:#fff!important} .al-mini{background:rgba(255,255,255,.2)!important;border:none!important;color:#fff!important;width:22px!important;height:22px!important;border-radius:50%!important;cursor:pointer!important;font-size:14px!important;line-height:1!important;padding:0!important} .al-body{padding:12px!important} .al-status{padding:8px 10px!important;background:rgba(67,97,238,.15)!important;border-radius:6px!important;margin-bottom:10px!important;border-left:3px solid #4361ee!important;word-break:break-all!important} .al-btns{display:flex!important;gap:6px!important;margin-bottom:8px!important;flex-wrap:wrap!important} .al-btn{flex:1 1 44%!important;padding:7px 0!important;border:none!important;border-radius:6px!important;font-weight:600!important;font-size:12px!important;cursor:pointer!important;color:#fff!important} .al-start{background:linear-gradient(135deg,#10b981,#059669)!important} .al-stop{background:linear-gradient(135deg,#ef4444,#dc2626)!important} .al-copy{background:linear-gradient(135deg,#6366f1,#4f46e5)!important} .al-reset{background:linear-gradient(135deg,#64748b,#475569)!important} .al-diag{background:linear-gradient(135deg,#f59e0b,#d97706)!important} .al-log{max-height:190px!important;overflow-y:auto!important;background:rgba(0,0,0,.3)!important;border-radius:6px!important;padding:8px!important;font-family:Consolas,Monaco,monospace!important;font-size:11px!important;line-height:1.6!important;color:#90caf9!important;white-space:pre-wrap!important;word-break:break-all!important} .al-log::-webkit-scrollbar{width:4px!important} .al-log::-webkit-scrollbar-thumb{background:#4361ee!important;border-radius:2px!important} .__al_hidden_overlay{display:none!important} `; function injectCSS() { try { const st = document.createElement('style'); st.textContent = CSS; (document.head || document.documentElement).appendChild(st); } catch (e) {} } // ==================== 页面识别 ==================== function hasPlayerDom() { return !!(document.querySelector('video') || document.querySelector('#mse, .xgplayer, [class*="xgplayer"], [class*="video-player"], [id*="player"]')); } function detectPage() { const url = location.href; if (/login|passport|signin/i.test(url)) return 'login'; if (hasPlayerDom()) return 'video'; if (/coursePlayServlet|coursePlay|videoPlay|learn.*play/i.test(url)) return 'video'; // 章节列表页(learnCourseChapterServlet.do?02B/...):站点踢出后常见的落地页, // 上面有真实的「继续学习/开始学习」入口,带加密参数。之前没有单独识别, // 被当成 courseList 后又误点了导航菜单「学习中心」。 if (/learnCourseChapterServlet|courseChapter|chapterList/i.test(url)) return 'chapterList'; if (/learnCourseListServlet|learnCourseList|courseList/i.test(url)) return 'courseList'; if (/learnCourseInfoServlet|learnCourseInfo|courseInfo/i.test(url)) return 'courseInfo'; const anchors = Array.from(document.querySelectorAll('a')); if (anchors.some(a => /coursePlay|开始学习|继续学习|进入学习|播放/.test(a.textContent || '') || /coursePlay|playServlet/i.test(a.getAttribute('href') || ''))) return 'courseInfo'; const body = document.body ? (document.body.innerText || '') : ''; if (/未完成课程|未完成|课程列表|选课/.test(body) && anchors.length > 5) return 'courseList'; return 'unknown'; } // ==================== 弹窗 / 遮罩 ==================== const CLOSE_SELS = [ '.modal .close', '.modal .btn-close', '.dialog .close', '.dialog .btn-close', '.layui-layer-close', '.layer-close', '.el-dialog__headerbtn', '.ant-modal-close', '.xgplayer-dialog-close', '[class*="modal"] .close', '[class*="dialog"] .close', '[class*="popup"] .close', ].join(','); const CONFIRM_TEXT = ['知道了', '我知道了', '好的', '确定', '确认', '关闭', 'OK']; const OVERLAY_SELS = [ '.modal-backdrop', '.modal-mask', '.modal-overlay', '.layui-layer-shade', '.el-overlay', '.ant-modal-mask', '.v-modal', '[class*="dialog-mask"]', '.xgplayer-dialog', '.xgplayer-full-loading', ].join(','); function visible(el) { if (!el || !el.isConnected) return false; const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) return false; const cs = getComputedStyle(el); return cs.visibility !== 'hidden' && cs.display !== 'none'; } function inModal(el) { return !!el.closest('[class*="modal"],[class*="dialog"],[class*="layer"],[class*="popup"],[class*="mask"]'); } function hasVisibleOverlay() { return Array.from(document.querySelectorAll(OVERLAY_SELS)).some(el => visible(el)); } function closeModals() { if (!CONFIG.closeModals) return 0; let n = 0; const loose = hasVisibleOverlay(); try { document.querySelectorAll(CLOSE_SELS).forEach(el => { if (visible(el) && !el.disabled) { try { el.click(); n++; } catch (e) {} } }); document.querySelectorAll('button, .btn, a, [role="button"]').forEach(el => { const t = (el.textContent || '').trim(); if (CONFIRM_TEXT.includes(t) && visible(el) && (loose || inModal(el))) { try { el.click(); n++; } catch (e) {} } }); document.querySelectorAll(OVERLAY_SELS).forEach(el => { if (el.querySelector('video') || el.closest('#mse,.xgplayer')) return; if (visible(el)) { el.classList.add('__al_hidden_overlay'); n++; } }); } catch (e) { log('⚠️ closeModals 异常: ' + e.message); } return n; } function restoreOverlays() { document.querySelectorAll('.__al_hidden_overlay').forEach(el => el.classList.remove('__al_hidden_overlay')); } // ==================== 播放器 ==================== const START_SELS = [ '.xgplayer-start', '.xgplayer-start-btn', '.xgplayer-start-icon', '.xgplayer-play', '.xgplayer-play-btn', '.xgplayer-play-icon', '[class*="xgplayer"][class*="start"]', ]; function clickStart() { for (const s of START_SELS) { const el = document.querySelector(s); if (visible(el)) { try { el.click(); return true; } catch (e) {} } } const box = document.querySelector('#mse, .xgplayer, [class*="xgplayer"]'); if (visible(box)) { try { box.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); const c = box.querySelector('.xgplayer-center'); if (c) c.click(); return true; } catch (e) {} } return false; } function waitForVideo(timeout) { timeout = timeout || 25000; return new Promise(resolve => { const found = () => document.querySelector('video'); const v0 = found(); if (v0) return resolve(v0); let tries = 0, done = false; let obs = { disconnect() {} }; const finish = v => { if (done) return; done = true; try { obs.disconnect(); } catch (e) {} clearInterval(timer); resolve(v); }; try { obs = new MutationObserver(() => { const v = found(); if (v) finish(v); }); obs.observe(document.documentElement, { childList: true, subtree: true }); } catch (e) {} let blankSince = 0; const timer = setInterval(() => { tries++; const v = found(); if (v) return finish(v); // 空白失效页不必等满 25s:判定为空白(助手面板已排除), // 或刚被踢出后仍无任何播放器 DOM,持续 5s 就提前收尾。 // v2.3 强依赖 readyState==='complete' + 被污染的空白判定,两个条件都不成立, // 所以每次都等满 25s 超时。 const justKicked = state.lastKickAt && (Date.now() - state.lastKickAt < KICK_FRESH_MS); if (pageLooksBlank() || (justKicked && noPlayerAtAll())) { if (!blankSince) blankSince = Date.now(); else if (Date.now() - blankSince > 5000) { log('❌ 页面持续空白,提前结束等待(会话失效)'); return finish(null); } } else blankSince = 0; if (tries % 8 === 0) { if (clickStart()) log(' video 尚未创建,重试点击开始按钮'); } if (tries * 300 > timeout) { log('❌ 等待 video 超时'); finish(null); } }, 300); }); } function playSafe(video) { try { const p = video.play(); if (p && p.catch) p.catch(() => {}); } catch (e) {} } async function ensurePlaying(video) { video.muted = true; try { video.playbackRate = CONFIG.playbackRate; } catch (e) {} try { await video.play(); log('▶️ 播放中(muted, ' + CONFIG.playbackRate + 'x)'); return; } catch (e) { log('⚠️ play() 被阻止: ' + (e && e.name) + ' ' + (e && e.message)); } const box = video.closest('#mse,.xgplayer,[class*="player"]') || video.parentElement || video; try { box.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); } catch (e) {} clickStart(); try { await video.play(); log('▶️ 重试后播放成功'); } catch (e2) { log('❌ 仍无法自动播放,请手动点一次播放按钮'); } } // ==================== 视频事件 ==================== function bindVideo(video) { if (state.currentVideo === video && video.dataset.alBound === '1') return; state.currentVideo = video; video.dataset.alBound = '1'; state.nearEndSince = 0; state.lastProgressAt = Date.now(); state.lastSrc = video.currentSrc || video.src || ''; video.addEventListener('loadedmetadata', () => { try { video.playbackRate = CONFIG.playbackRate; } catch (e) {} const s = video.currentSrc || video.src || ''; if (s && s !== state.lastSrc) { log('🔁 片源变化: ' + s.slice(-40) + '(播放器自行连播)'); state.lastSrc = s; state.nearEndSince = 0; state.chapterHandling = false; state.chapterPaused = false; state.failRounds = 0; } log('📐 时长 ' + fmt(video.duration)); }); video.addEventListener('ended', () => { log('🏁 视频 ended(交给结尾看门狗判定)'); }); video.addEventListener('error', () => { log('⚠️ video error code=' + (video.error ? video.error.code : '?') + '(不自动跳过)'); }); video.addEventListener('pause', () => { if (!state.running || state.chapterHandling || state.nearEndSince || video.ended) return; setTimeout(() => { if (state.running && !state.chapterHandling && !state.nearEndSince && video.paused && !video.ended && video.isConnected) { log('⏸️ 检测到暂停,恢复播放'); video.muted = true; playSafe(video); } }, 1500); }); video.addEventListener('timeupdate', () => { state.lastProgressAt = Date.now(); }); } // ==================== 结尾看门狗 ==================== function startEndWatchdog() { if (state.endWatchdog) clearInterval(state.endWatchdog); state.endWatchdog = setInterval(() => { if (!state.running || state.chapterHandling) return; const v = state.currentVideo; if (!v || !v.isConnected) return; const src = v.currentSrc || v.src || ''; if (src && state.lastSrc && src !== state.lastSrc) { log('🔁 检测到片源变化,判定为播放器自行连播,继续监听'); state.lastSrc = src; state.nearEndSince = 0; state.lastProgressAt = Date.now(); state.chapterHandling = false; state.failRounds = 0; state.chapterPaused = false; state.status = 'playing'; updateUI(); videoMutedPlay(v); return; } if (src && !state.lastSrc) state.lastSrc = src; if (state.chapterPaused) return; const d = v.duration; const nearEnd = Number.isFinite(d) && d > 0 && v.currentTime >= d - 1.0; if (v.ended || nearEnd) { if (!state.nearEndSince) { state.nearEndSince = Date.now(); log('🏁 到达结尾,等待 ' + (END_HOLD_MS / 1000) + 's 看播放器是否自动连播…'); } else if (Date.now() - state.nearEndSince >= END_HOLD_MS) { log('🏁 结尾停留超时 → 判定本章节已播完,开始章节跳转'); onChapterEnd(); } } else { if (state.nearEndSince) { state.nearEndSince = 0; log('▶️ 视频继续播放,取消结尾判定'); } state.lastProgressAt = Date.now(); } }, 2000); } function videoMutedPlay(v) { try { v.muted = true; playSafe(v); } catch (e) {} } function onChapterEnd() { if (state.chapterHandling || !state.running || !CONFIG.autoNext) return; state.chapterHandling = true; state.nearEndSince = 0; state.status = 'switching'; updateUI(); goNextOnce(); } // ==================== 章节指纹 ==================== function chapterFingerprint() { const el = document.querySelector( 'li.active, li.current, [class*="catalog"] .active, [class*="chapter"] .active, ' + '[class*="menu"] .active, .playing, [class*="now-playing"]' ); return el ? (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60) : ''; } // ==================== 章节跳转策略(兜底) ==================== const NEXT_TEXTS = [ '下一章', '下一节', '下一课', '下一讲', '下一集', '下一个', '下一节课程', '继续学习', '继续播放', '进入下一章', '学习下一章', '下一章节', ]; function findNextElement() { const els = Array.from(document.querySelectorAll('a, button, [role="button"], .btn, input[type="button"], li')); for (const el of els) { if (!visible(el)) continue; const t = (el.textContent || el.value || '').trim(); if (NEXT_TEXTS.some(x => t === x || (t.length <= 14 && t.includes(x)))) { if (el.tagName === 'LI') return el.querySelector('a, button, [onclick], [role="button"]') || el; return el; } } return null; } function findChapterNextItem() { const cur = document.querySelector( 'li.active, li.current, .playing, [class*="catalog"] .active, ' + '[class*="chapter"] .active, [class*="now-playing"], [class*="current"]' ); if (!cur) return null; let n = cur.nextElementSibling, guard = 0; while (n && guard++ < 8) { const a = n.querySelector('a, button, [onclick], [role="button"]') || n; if (visible(a) && (a.textContent || '').trim()) return a; n = n.nextElementSibling; } return null; } function findOnclickNext() { return Array.from(document.querySelectorAll('[onclick]')).find(el => visible(el) && /next|下一|goNext|playNext/i.test(el.getAttribute('onclick') || '')) || null; } function findListLink() { return Array.from(document.querySelectorAll('a')).find(a => visible(a) && /返回课程|课程列表|我的课程|返回列表/.test((a.textContent || '').trim())) || null; } const NEXT_FNS = [ 'goNextPage', 'nextPage', 'goNext', 'nextVideo', 'playNext', 'nextChapter', 'nextLesson', 'gotoNext', 'nextCatalog', 'nextCourse', ]; async function goNextOnce() { log('🔄 ===== 开始章节跳转(兜底策略)====='); const url0 = location.href; const fp0 = chapterFingerprint(); const v0 = state.currentVideo; const src0 = (v0 && (v0.currentSrc || v0.src)) || state.lastSrc || ''; // 只在"确实执行了某个跳转动作"之后才判定是否已切换。 // 片源变化要求新片源已开始播放,避免播放器重置/清空 src 造成误判。 function switched() { if (location.href !== url0) return true; const fp1 = chapterFingerprint(); if (fp0 && fp1 && fp1 !== fp0) return true; const v = state.currentVideo; if (v && v.isConnected) { const src1 = v.currentSrc || v.src || ''; if (src0 && src1 && src1 !== src0 && (v.currentTime > 0.5 || !v.paused)) return true; } return false; } const settled = async (ms, label) => { await sleep(ms); if (switched()) { log('✅ ' + label + ' 后检测到章节已切换,停止后续动作(避免重复提交)'); state.chapterHandling = false; state.failRounds = 0; return true; } return false; }; // ① 关确认弹窗:什么都没关掉时不做"已切换"判定 const closed = closeModals(); log(' ① 关闭确认弹窗: ' + closed + ' 个'); await sleep(1000); if (location.href !== url0) { log('✅ 页面已自行跳转'); state.chapterHandling = false; return; } // ② 平台全局函数 let fnName = null, fn = null; try { for (const n of NEXT_FNS) { if (typeof PAGE_WIN[n] === 'function') { fnName = n; fn = PAGE_WIN[n]; break; } } } catch (e) {} if (fn) { try { const noTip = document.querySelector('#noTip, [name="noTip"], .no-tip'); if (noTip && !noTip.checked) noTip.checked = true; log(' ② 调用页面函数 ' + fnName + '()'); fn.call(PAGE_WIN); if (await settled(2000, '②')) return; } catch (e) { log(' ⚠️ ' + fnName + '() 异常: ' + e.message); } } else { log(' ② 页面未暴露跳转函数,改用 DOM 按钮'); } // ③ DOM 文字按钮 const next = findNextElement(); if (next) { const label = (next.textContent || next.value || '').trim().slice(0, 20); const href = next.getAttribute && next.getAttribute('href'); log(' ③ 找到跳转入口: ' + label); // 裸播放页不带章节票据,跳过去可能从第一章重播 → 优先点按钮本身 if (isBarePlayUrl(href) || !href || href === '#' || /^javascript:/i.test(href)) { log(' 点击(携带章节参数): ' + label); try { next.click(); } catch (e) { log(' ⚠️ 点击异常: ' + e.message); } } else { log(' 跳转: ' + href.split('?')[0]); location.href = href; } if (await settled(2000, '③')) return; } else log(' ③ 未找到"下一章/下一节"文字按钮'); // ④ 章节目录下一项 const chapNext = findChapterNextItem(); if (chapNext) { log(' ④ 点击章节目录的下一项: ' + (chapNext.textContent || '').trim().slice(0, 20)); try { chapNext.click(); } catch (e) {} if (await settled(2000, '④')) return; } else log(' ④ 未识别到章节目录当前项'); // ⑤ onclick 含 next const oc = findOnclickNext(); if (oc) { log(' ⑤ 点击 onclick 含 next 的元素'); try { oc.click(); } catch (e) {} if (await settled(2000, '⑤')) return; } // ⑥ NEXT_PAGE_URL let nextUrl = null; try { nextUrl = PAGE_WIN.NEXT_PAGE_URL; } catch (e) {} if (typeof nextUrl === 'string' && nextUrl) { log(' ⑥ 跳转 NEXT_PAGE_URL: ' + nextUrl.split('?')[0]); location.href = nextUrl; return; } // ⑦ 返回课程列表 const list = findListLink(); if (list) { const href = list.getAttribute('href'); if (href && href !== '#' && !/^javascript:/i.test(href)) { log(' ⑦ 返回课程列表: ' + (list.textContent || '').trim()); location.href = href; return; } } state.failRounds++; log('❌ 未能自动切换章节(第 ' + state.failRounds + ' 次)。'); dumpDiagnostics(); if (state.failRounds >= MAX_FAIL_ROUNDS) { state.chapterPaused = true; state.chapterHandling = false; state.nearEndSince = 0; state.status = 'waiting'; log('⏸️ 已暂停自动章节跳转,避免重复提交/刷屏。'); log(' 请手动点一次「下一章」,脚本会在新页面继续自动播放。'); } else { state.chapterHandling = false; state.nearEndSince = Date.now() - END_HOLD_MS + 15000; } updateUI(); } // ==================== 页面诊断导出 ==================== function dumpDiagnostics() { log('🔍 ===== 页面诊断(把这一整段发我即可精确适配)====='); log(' URL: ' + location.href); log(' title: ' + document.title); log(' 原生弹窗接管: ' + (CONFIG.autoDismissDialogs ? '已开启' : '已关闭') + ' | 已接管 window 数: ' + state.guardedWindows + ' | 已自动确认弹窗: ' + state.dialogCount + ' 个'); if (state.lastDialog) log(' 最近原生弹窗: ' + state.lastDialog.kind + ' → ' + state.lastDialog.msg); log(' 已忽略站点自身 benign 错误: ' + state.errIgnored + ' 条'); const bi = pageBlankInfo(); log(' 防挂机心跳次数: ' + state.keepAliveCount + ' | 会话自愈次数: ' + state.recoverTries + ' | 空白页判定: ' + (pageLooksBlank() ? '是' : '否')); log(' 空白页明细(已排除助手面板): 正文字符=' + bi.text + ' 内容元素=' + bi.els + ' iframe=' + bi.iframes + ' 播放器DOM=' + (bi.player ? '有' : '无') + ' 标题=' + (bi.title.trim() || '(空)')); if (state.lastKickAt) log(' 最近一次「长时间未操作」踢出: ' + new Date(state.lastKickAt).toLocaleTimeString('zh-CN') + ' | 距今 ' + Math.round((Date.now() - state.lastKickAt) / 1000) + 's'); log(' 上次成功播放页: ' + (state.lastPlay ? state.lastPlay.split('?')[0] : '(无)')); const items = []; try { document.querySelectorAll('a, button, [role="button"], [onclick], .btn, li').forEach(el => { if (items.length >= 45 || !visible(el)) return; const t = (el.textContent || el.value || '').trim().replace(/\s+/g, ' ').slice(0, 26); if (!t) return; items.push(t + ' | onclick=' + (el.getAttribute('onclick') || '').slice(0, 55) + ' | href=' + (el.getAttribute('href') || '').slice(0, 55) + ' | class=' + (typeof el.className === 'string' ? el.className : '').slice(0, 38)); }); } catch (e) {} items.forEach((s, i) => log(' [' + i + '] ' + s)); // 带加密参数的真实链接是自愈唯一合法的跳转目标,单独完整列出 const plinks = findParamLinks(); log(' 带参数链接(' + plinks.length + '): ' + (plinks.length ? plinks.slice(0, 12).map((l, i) => '[' + i + ']' + (l.name || '?') + '→' + l.href.slice(0, 80)).join(' | ') : '(无)')); const ce = findCourseEntry(); log(' 识别到的学习入口: ' + (ce ? (ce.name || '') + ' | ' + (ce.href || '(点击)') + ' | score=' + ce.score : '(无)')); const rs = findResumeChapterEntry(); log(' 蓝色图标定位的当前章: ' + (rs ? (rs.name || '') + ' | 蓝色证据=' + rs.blueBy + ' | img=' + ((rs.imgSrc || '').split('/').pop() || '(无)') + ' | 带票据=' + rs.hasParam + ' | href=' + (rs.href || '(点击)').slice(0, 60) + ' | onclick=' + (rs.onclick || '').slice(0, 50) : '(无)')); try { const d = PAGE_WIN.__alChapterBlueDiag; if (d && d.length) log(' 蓝色逐行判定: ' + d.join(' | ')); } catch (e) {} // 章节表格逐行导出:img 文件名是识别蓝色按钮的决定性证据(站点真实 DOM: // begin_learn_ok.png = 蓝色开始学习;learnUnable() = 不可学章) const crows = []; try { document.querySelectorAll('table.altrowtable tbody tr, tr').forEach(tr => { if (crows.length >= 14) return; const cells = Array.from(tr.querySelectorAll('td, th')) .map(td => (td.innerText || '').replace(/\s+/g, ' ').trim()).filter(Boolean); const txt = cells.join(' | '); if (!txt || !/第[一二三四五六七八九十百零〇\d]+[章节讲课]|本章完成|开始学习|继续学习/.test(txt)) return; const b = tr.querySelector('a, button, input[type="button"], [onclick]'); const img = (b && b.querySelector && b.querySelector('img')) || tr.querySelector('img'); const isrc = img ? (img.getAttribute('src') || '') : ''; crows.push(txt.slice(0, 52) + ' || btn=' + (b ? ((b.textContent || b.value || '').trim().slice(0, 10)) : '(无)') + ' img=' + (isrc.split('/').pop() || '(无)') + ' 蓝标=' + (BLUE_IMG_RE.test(isrc) ? '是' : '否') + ' cls=' + (b ? (typeof b.className === 'string' ? b.className : '').slice(0, 26) : '') + ' href=' + (b ? (b.getAttribute('href') || '').slice(0, 46) : '') + ' oc=' + (b ? (b.getAttribute('onclick') || '').slice(0, 30) : '')); }); } catch (e) {} crows.forEach((s, i) => log(' [章节' + i + '] ' + s)); log(' history.length: ' + history.length + ' | 页面类型: ' + state.pageType); const fns = []; try { for (const k of Object.keys(PAGE_WIN)) { if (fns.length >= 40) break; let f = null; try { f = PAGE_WIN[k]; } catch (e) { continue; } if (typeof f === 'function' && /next|go|play|chapter|lesson|video|study|learn|catalog|finish|submit|heart|keep|alive|active|timer|timeout|online|check/i.test(k)) fns.push(k); } } catch (e) {} log(' 页面全局函数候选: ' + (fns.join(', ') || '(无)')); log(' iframe 数: ' + document.querySelectorAll('iframe').length); log(' 章节指纹: ' + (chapterFingerprint() || '(未找到高亮项)')); const v = state.currentVideo; log(' video: ' + (v ? ('duration=' + fmt(v.duration) + ' currentTime=' + v.currentTime.toFixed(1) + ' ended=' + v.ended + ' paused=' + v.paused + ' src=' + (v.currentSrc || v.src || '').slice(-45)) : '(无)')); log('🔍 ===== 诊断结束 ====='); } // ==================== 心跳 / 停滞看门狗 ==================== // 站点「长时间未操作」判定通常监听 mousemove/keydown 并同时记录坐标; // 旧版只在 document 上派发固定 (100,100) 的 mousemove:到不了绑在 body 上的 // 监听器,坐标也不变,于是被判「一直没移动」。这里改为多目标、多事件、 // 坐标随时间变化,并尝试覆盖 isTrusted,同源 iframe 内也派发一份。 function dispatchActivity() { const now = Date.now(); // 一轮内派发 3 个不同随机坐标,形成「移动轨迹」;固定坐标/单帧会被 // 站点判定为「没有真正移动」。坐标随机而非取模,避免出现可识别的规律。 const points = []; for (let i = 0; i < 3; i++) { points.push([60 + Math.floor(Math.random() * 620), 60 + Math.floor(Math.random() * 400)]); } let x = points[0][0], y = points[0][1]; const mk = (Evt, type, extra) => { let ev = null; try { ev = new Evt(type, Object.assign({ bubbles: true, cancelable: true, clientX: x, clientY: y, screenX: x, screenY: y, view: PAGE_WIN, }, extra || {})); } catch (e) { return null; } // 有些站点校验 isTrusted / pageX / movementX;合成事件默认是 false / 0, // 在实例上定义自有属性遮蔽原型 getter。逐个 try,避免单个属性不可配置 // 导致整组 defineProperties 全部失败。 const props = { isTrusted: true, pageX: x, pageY: y, movementX: 1 + (now % 3), movementY: 1 + (now % 2), }; for (const k of Object.keys(props)) { try { Object.defineProperty(ev, k, { value: props[k], configurable: true }); } catch (e) {} } return ev; }; const ME = PAGE_WIN.MouseEvent || MouseEvent; const KE = PAGE_WIN.KeyboardEvent || KeyboardEvent; const targets = []; try { const b = document.body || document.documentElement; if (b) targets.push(b); const deep = document.querySelector('#mse, .xgplayer, video, .content, #content, .main') || (b && b.firstElementChild); if (deep && targets.indexOf(deep) < 0) targets.push(deep); targets.push(document, window); } catch (e) {} let n = 0; for (const t of targets) { for (const pt of points) { x = pt[0]; y = pt[1]; for (const type of ['mousemove', 'pointermove', 'mouseover']) { const ev = mk(ME, type, type === 'pointermove' ? { pointerId: 1, pointerType: 'mouse', isPrimary: true } : {}); if (ev) { try { t.dispatchEvent(ev); n++; } catch (e) {} } } } } x = points[2][0]; y = points[2][1]; // 无害按键 Shift:不用空格/方向键,避免触发播放器快捷键 for (const t of [document.body || document.documentElement, document, window]) { if (!t) continue; for (const type of ['keydown', 'keyup']) { const ev = mk(KE, type, { key: 'Shift', code: 'ShiftLeft', keyCode: 16, which: 16, shiftKey: type === 'keydown' }); if (ev) { try { t.dispatchEvent(ev); n++; } catch (e) {} } } } // 有些站点只认 mousedown/mouseup 作为「操作」;派发到 body(不命中具体控件, // 不用 click,避免误触导航/提交学时)。 for (const t of [document.body || document.documentElement]) { if (!t) continue; for (const type of ['mousedown', 'mouseup']) { const ev = mk(ME, type, { button: 0, buttons: type === 'mousedown' ? 1 : 0 }); if (ev) { try { t.dispatchEvent(ev); n++; } catch (e) {} } } } // 轻微滚轮/滚动,覆盖以 scroll 判定操作的站点 for (const t of [document.body || document.documentElement, document]) { if (!t) continue; for (const type of ['wheel', 'scroll']) { const ev = mk(ME, type, { deltaY: (now % 2 ? 1 : -1), deltaMode: 0 }); if (ev) { try { t.dispatchEvent(ev); } catch (e) {} } } } try { document.querySelectorAll('iframe').forEach(function (f) { const d = f.contentDocument, tt = d && (d.body || d.documentElement); if (!tt) return; const W = f.contentWindow; for (const pt of points) { x = pt[0]; y = pt[1]; const mv = mk((W && W.MouseEvent) || ME, 'mousemove', {}); if (mv) { try { tt.dispatchEvent(mv); n++; } catch (e) {} } } const kd = mk((W && W.KeyboardEvent) || KE, 'keydown', { key: 'Shift', keyCode: 16, which: 16 }); if (kd) { try { tt.dispatchEvent(kd); } catch (e) {} } }); } catch (e) {} // 不少站点把 click 也算「操作」;派发到 document/body,target 不是具体控件, // 不会误点链接或按钮,但能触发绑在这些容器上的监听器。 for (const t of [document.body || document.documentElement, document]) { if (!t) continue; const ev = mk(ME, 'click', {}); if (ev) { try { t.dispatchEvent(ev); n++; } catch (e) {} } } // 只派发 focus(不派发 blur),避免被站点误判为「切走窗口」 try { window.dispatchEvent(new Event('focus')); } catch (e) {} state.keepAliveCount++; return n; } function startKeepAlive() { stopKeepAlive(); try { dispatchActivity(); } catch (e) {} // 启动立即打一次 state.keepAliveTimer = setInterval(() => { if (!state.running) return; let n = 0; try { n = dispatchActivity(); } catch (e) {} touch(); patchAllFrames(); // 新出现的 iframe 也补上弹窗接管 const v = state.currentVideo; if (v && v.isConnected && v.paused && !v.ended && !state.chapterHandling && !state.nearEndSince) { v.muted = true; playSafe(v); } if (state.keepAliveCount % 40 === 0) { log('💓 防挂机心跳 ' + state.keepAliveCount + ' 次(本轮派发 ' + n + ' 个事件)'); } }, CONFIG.keepAwakeInterval); } function stopKeepAlive() { if (state.keepAliveTimer) { clearInterval(state.keepAliveTimer); state.keepAliveTimer = null; } } function startStuckWatchdog() { if (state.stuckWatchdog) clearInterval(state.stuckWatchdog); state.stuckWatchdog = setInterval(() => { const v = state.currentVideo; if (!state.running || !v || !v.isConnected || state.chapterHandling || state.nearEndSince) return; if (Date.now() - state.lastProgressAt > STUCK_MS) { log('⏰ 进度超过 60 秒未推进,尝试恢复播放'); state.lastProgressAt = Date.now(); v.muted = true; playSafe(v); clickStart(); } }, 10000); } // ==================== 会话失效 / 空白页自愈 ==================== // 站点弹「您已经长时间未操作,请重新进入!」后会跳到一个带新 token 的 // coursePlayServlet.do?+lsa+... 页面,但该页面什么都不渲染(title 空、 // 无 iframe、无 video、页面上只有助手面板自己)。 // // ★ v2.3 的致命 bug:pageLooksBlank() 直接统计 document.body.innerText 和 // 全页 span/button,把助手面板自己的日志文字、状态 span 都算成了「页面内容」, // 于是空白失效页永远被判为「有内容」,走 else 分支直接 return,自愈一次都没跑 // (日志实锤:空白页判定: 否 / 会话自愈次数: 0)。这里改成克隆 body 并移除 // 助手面板后再统计。 function pageBlankInfo() { const i = { text: 0, els: 0, iframes: 0, video: false, player: false, title: '' }; try { i.title = document.title || ''; i.video = !!document.querySelector('video'); i.iframes = document.querySelectorAll('iframe').length; i.player = !!document.querySelector('object, embed, #mse, [class*="xgplayer"], [id*="player"], [class*="player"]'); const clone = document.body ? document.body.cloneNode(true) : null; if (clone) { const p = clone.querySelector('#__al_panel'); if (p && p.parentNode) p.parentNode.removeChild(p); clone.querySelectorAll('script, style, noscript').forEach(n => { if (n.parentNode) n.parentNode.removeChild(n); }); // 脱离文档的节点 innerText 可能为空,用 textContent 兜底 i.text = (clone.innerText || clone.textContent || '').replace(/\s+/g, '').length; i.els = clone.querySelectorAll( 'a, button, input, img, table, li, p, span, h1, h2, h3, form, select, textarea, object, embed, canvas' ).length; } } catch (e) {} return i; } function pageLooksBlank() { const i = pageBlankInfo(); if (i.video || i.player || i.iframes > 0) return false; // 站点内容极少即判定为失效空白页。标题通常为空;即使有标题, // 只要正文和元素几乎为零也算(部分失效页会保留一个空壳标题)。 if (i.text >= 120 || i.els > 10) return false; return !i.title.trim() || (i.text < 20 && i.els <= 3); } function noPlayerAtAll() { const i = pageBlankInfo(); return !i.video && !i.player && i.iframes === 0; } function setRecover(n) { state.recoverTries = n; try { GM_setValue(K.RECOVER, { n: n, at: Date.now() }); } catch (e) {} } function recoverSession(reason, kicked) { if (!CONFIG.autoRecover || !state.running) return; // 同一页面可能被 boot 的 setTimeout 和 URL 变化监听多次调用,用时间戳防重入, // 避免 recoverTries 几秒内累加到上限直接放弃。跳转后页面会重新加载,锁自然释放。 const now = Date.now(); if (state.recovering || (state.lastRecoverAt && now - state.lastRecoverAt < 8000)) return; state.recovering = true; state.lastRecoverAt = now; const n = state.recoverTries + 1; setRecover(n); const max = Number(CONFIG.maxRecover) || MAX_RECOVER; log('♻️ 会话失效自愈 ' + n + '/' + max + ':' + reason + (kicked ? '(刚被站点踢出)' : '')); if (n > max) { // 不再 setRunning(false):保持运行,用户手动点一次「继续学习」后脚本会自动接着播 log('⏸️ 自动重进已连续 ' + max + ' 次未成功,暂停自动跳转(脚本仍保持运行)。'); log(' 请手动从课程列表点一次「继续学习」进入播放页,脚本会自动继续自动播放。'); log(' (若课程确已全部学完,这是正常的,可忽略。)'); setRecover(0); state.recovering = false; state.status = 'waiting'; updateUI(); return; } // ★ 绝不再拼任何 servlet URL。站点业务 URL 都带站点签发的一次性加密票据, // 裸 URL 会得到「您的访问有误」+ 空白页(v2.4 的 COURSE_LIST_URL 就是教训)。 // 合法的四种来源:① 刷新当前页 ② 上次成功播放页 ③ 浏览器历史返回 // ④ 当前页面 DOM 里真实存在的带参数链接。 const hasParam = /\?/.test(location.href); // 站点防挂机踢出后跳到 coursePlayServlet.do?+lsa+<失效票据>,这一页刷新 // 永远拿不回内容(v2.5 日志里连续刷新 2 次都无效,第 3 次 history.back 才成功)。 // 识别出来后第一步就直接历史返回,省掉 2 轮无效刷新。 const deadTicket = /coursePlayServlet\.do\?\+lsa|[?&]\+lsa\+|\?\+lsa/i.test(location.href); const paramLinks = findParamLinks(); const lastPlay = state.lastPlay; let action = null, label = ''; if (n === 1 && deadTicket) { action = () => { if (history.length > 1) history.back(); else location.reload(); }; label = '① 检测到失效票据页(+lsa),刷新无效 → 直接历史返回到上一个有效页'; } else if (n === 1 && hasParam) { // 站点弹「请刷新页面重试」时,当前 URL 的参数还在,刷新即可重新拿票据 action = () => location.reload(); label = '① 刷新当前页(沿用 URL 上已有的加密参数)'; } else if (n === 2 && lastPlay && lastPlay !== location.href) { action = () => { location.href = lastPlay; }; label = '② 回到上次成功播放的页面(完整 URL,含票据): ' + lastPlay.split('?')[0]; } else if (n === 3) { action = () => { if (history.length > 1) history.back(); else location.reload(); }; label = '③ 浏览器历史返回上一页(历史里的 URL 都带合法参数)'; } else { // 兜底:用页面上真实存在的、带参数的课程相关链接 const good = paramLinks.find(l => /course|learn|study|play|center|index/i.test(l.href)); if (good) { action = () => { location.href = good.href; }; label = '↩ 点击页面真实入口「' + (good.name || '') + '」: ' + good.href.split('?')[0]; } else if (hasParam) { action = () => location.reload(); label = '↩ 刷新当前页'; } else { action = () => { if (history.length > 1) history.back(); else location.reload(); }; label = '↩ 浏览器历史返回'; } } log(' → ' + label); if (paramLinks.length) { log(' 本页可用带参链接 ' + paramLinks.length + ' 个: ' + paramLinks.slice(0, 4).map(l => (l.name || '?') + '→' + l.href.split('?')[0]).join(' | ')); } setTimeout(() => { try { action(); } catch (e) {} }, 1200); // 跳转后页面会卸载、状态自然重置;但若跳转被站点拦下而没卸载, // 10 秒后必须解锁,否则本页再也不会重试自愈。 setTimeout(() => { state.recovering = false; }, 10000); } // ==================== 各页面处理 ==================== async function handleVideoPage() { state.status = 'playing'; updateUI(); log('🎞️ 视频播放页'); restoreOverlays(); closeModals(); clickStart(); const video = await waitForVideo(); if (!video) { log('❌ 未找到 video 元素'); const kicked = (Date.now() - state.lastKickAt) < KICK_FRESH_MS; const bi = pageBlankInfo(); log(' 空白诊断: 正文字符=' + bi.text + ' 内容元素=' + bi.els + ' iframe=' + bi.iframes + ' 播放器DOM=' + (bi.player ? '有' : '无') + ' 标题=' + (bi.title.trim() || '(空)') + ' → ' + (pageLooksBlank() ? '空白页' : '非空白') + ' | 距踢出=' + (kicked ? Math.round((Date.now() - state.lastKickAt) / 1000) + 's' : '无记录')); // ★ 只要「刚被站点踢出」或「页面空白」任一成立就自愈。 // v2.3 只认 pageLooksBlank(),而该方法被助手面板自身文字污染恒为 false, // 导致这里走 else 直接 return,脚本彻底静止(日志实锤:会话自愈次数 0)。 if (pageLooksBlank() || kicked) { log(' → 判定为会话失效 / 播放器未加载,进入自动重进流程'); dumpDiagnostics(); recoverSession(kicked ? '站点提示「长时间未操作」后跳入失效页' : '播放页空白,播放器未加载', kicked); } else { log(' 页面有内容但没找到播放器,请确认是否已进入播放页'); } return; } log('✅ 找到视频元素'); // 恢复正常播放 → 清零自愈计数和踢出标记,避免历史自愈一直累加到上限。 if (state.recoverTries || state.lastKickAt) { log('✅ 已恢复正常播放,清空会话自愈计数'); setRecover(0); state.lastKickAt = 0; try { GM_deleteValue(K.KICK); } catch (e) {} } try { state.lastPlay = location.href; GM_setValue(K.LASTPLAY, location.href); } catch (e) {} state.chapterHandling = false; state.failRounds = 0; state.chapterPaused = false; bindVideo(video); await ensurePlaying(video); startEndWatchdog(); startKeepAlive(); startStuckWatchdog(); touch(); } // 站点业务链接形如 /xxxServlet.do?<一次性加密票据>。票据只存在于页面真实 DOM 里, // 是自愈唯一合法的跳转目标。收集它们(排除退出、javascript:、#、裸无参 servlet)。 function findParamLinks() { const out = []; try { document.querySelectorAll('a[href]').forEach(a => { const href = a.getAttribute('href') || ''; if (!/\?/.test(href)) return; // 必须有参数 if (/logout|退出|javascript:/i.test(href)) return; if (!/\.do\?|\.jsp\?/i.test(href)) return; // 业务 servlet const t = (a.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 30); out.push({ href: href, name: t }); }); } catch (e) {} return out; } // ★★ 按真实 DOM 的「蓝色图标」定位当前章(用户明确要求:不看时长)★★ // 用户提供 DevTools 截图,站点真实结构: //
| 第七章 | 24分0秒 | 40分46秒 | //
// ![]() |