// ==UserScript== // @name 双卫网刷课小助手*自动连播答题🔥 // @namespace https://www.sww.com.cn/ // @version 1.0 // @description 🐎播放结束后自动点击“进入考试”并切换下一集;解决视频中断验证geng'zhi“我在线”;直接自动试错作答课后题目并记忆正确答案 // @license MIT // @match http://www.sww.com.cn/* // @match https://www.sww.com.cn/* // @match http://*.sww.com.cn/* // @match https://*.sww.com.cn/* // @match http://sww.com.cn/* // @match https://sww.com.cn/* // @match https://chatshare.ren/* // @match https://fcai.me/* // @include /^https?:\/\/([a-z0-9-]+\.)*sww\.com\.cn\// // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @icon https://sww.com.cn/favicon.ico // @grant GM_registerMenuCommand // ==/UserScript== (function () { 'use strict'; // 只让脚本在顶层页面运行一次,避免 iframe 里重复执行 if (window.top !== window.self) return; // ============ 默认配置(可在浮动面板 / 菜单里改) ============ const DEFAULTS = { autoNext: true, // 是否自动播放下一个 autoPlay: true, // 是否自动开始播放视频 nextSelector: '', // 自定义“下一集”按钮选择器,留空则自动探测 videoSelector: '', // 自定义 video 选择器,留空则自动探测 onlineClickEnabled: true, // 网站弹出“是否在线”时,是否自动点击“我在线” onlineClickSelector: '', // 自定义“我在线”按钮选择器,留空则自动探测 quizEnabled: true, // 是否启用自动答题 quizAutoSubmit: true, // 题目全部答完后是否自动提交 quizLearn: true, // 是否自动学习并记忆正确答案 quizBruteForce: true, // 默认开启“试错求解”,遇到陌生题目直接自动试错,无需先手动答题 }; const CFG = loadConfig(); function loadConfig() { const c = Object.assign({}, DEFAULTS); try { for (const k of Object.keys(DEFAULTS)) { const v = GM_getValue(k, null); if (v !== null && v !== undefined) c[k] = v; } } catch (e) {} // 迁移:旧版本“试错求解”默认关闭,本次升级改为默认直接自动答题。 // 只在尚未迁移过时执行一次,之后尊重用户自己的开关选择。 try { if (GM_getValue('quizBruteForceMigrated_v130', null) !== true) { c.quizBruteForce = true; GM_setValue('quizBruteForce', true); GM_setValue('quizBruteForceMigrated_v130', true); } } catch (e) {} return c; } function saveConfig() { try { for (const k of Object.keys(CFG)) GM_setValue(k, CFG[k]); } catch (e) {} } // 播放状态:用于判断“进入考试”按钮是否在视频结束后才可点击 const state = { videoPlaying: false, videoEnded: false, autoPlayTries: 0, autoPlayLogged: false, }; // ============ 工具函数 ============ function log(...args) { console.log('[双卫网自动连播]', ...args); } function visibleArea(el) { if (!el) return 0; return (el.offsetWidth || 0) * (el.offsetHeight || 0); } // 收集顶层 + 同源 iframe 里的 video function collectVideos() { const vids = []; document.querySelectorAll('video').forEach((v) => vids.push(v)); document.querySelectorAll('iframe').forEach((f) => { try { const d = f.contentDocument; if (d) d.querySelectorAll('video').forEach((v) => vids.push(v)); } catch (e) { /* 跨域 iframe 无法访问 */ } }); return vids; } function findMainVideo() { const sel = CFG.videoSelector; if (sel) { const el = document.querySelector(sel); if (el) return el; } // 本站主播放器优先 const main = document.querySelector('#kejian-video video'); if (main && visibleArea(main) > 10000) return main; // 排除隐藏的人脸认证摄像头视频 let vids = collectVideos().filter((v) => !v.closest('#dialog-face, #living-face, .dialog-face')); // 优先选择可见且足够大的 const visible = vids.filter((v) => visibleArea(v) > 10000); if (visible.length) vids = visible; vids.sort((a, b) => visibleArea(b) - visibleArea(a)); return vids[0] || null; } function isCrossOriginIframeLikely() { let hasCross = false; document.querySelectorAll('iframe').forEach((f) => { try { if (!f.contentDocument) hasCross = true; } catch (e) { hasCross = true; } }); return hasCross; } // 给某个 document 挂一次 ended 捕获监听(ended 不冒泡,用捕获阶段) function attachEnded(doc) { if (!doc || doc.__swwEnded) return; doc.__swwEnded = true; doc.addEventListener('ended', onVideoEnded, true); doc.addEventListener('play', onVideoPlay, true); } // 探测“下一集”按钮 function scoreNextCandidate(el) { const tag = el.tagName.toLowerCase(); const txt = (el.innerText || el.textContent || '').trim(); const cls = (el.className || '') + ' ' + (el.id || ''); let score = 0; if (tag === 'a' || tag === 'button') score += 100; if (el.getAttribute('onclick')) score += 40; if (/next/i.test(cls)) score += 30; if (/^(下一集|下一节|下一课|下一讲|下一个|下集|下一章|下一段|继续学习|继续|进入考试|开始考试|开始答题|进入答题|去考试|立即考试|开始测试|进入测试|开始测验)$/.test(txt)) score += 60; else if (/^(下一|下集|next|继续|进入考试|开始考试|开始答题|进入答题)/i.test(txt)) score += 30; // 文本太长通常是容器,降权 if (txt.length > 20) score -= 20; return score; } function findNextButton() { const sel = CFG.nextSelector; if (sel) { const el = document.querySelector(sel); return el || null; } let best = null; let bestScore = 0; document.querySelectorAll('a, button, [onclick], li, [class*="next" i], [id*="next" i]').forEach((el) => { const txt = (el.innerText || el.textContent || '').trim(); if (!txt) return; const score = scoreNextCandidate(el); if (score > bestScore) { // 尽量取文本更短的“叶子”元素,避免点到容器 if (best && bestScore === score && txt.length >= best.textContent.length) return; best = el; bestScore = score; } }); // 策略二:找到“当前/正在播放”的列表项,点它的下一个兄弟里的链接 if (!best) { const active = document.querySelector( '[class*="active" i], [class*="current" i], [class*="playing" i], [class*="on" i]' ); if (active) { let n = active.nextElementSibling; while (n && !(n.tagName === 'A' || n.querySelector('a'))) n = n.nextElementSibling; if (n) { best = n.tagName === 'A' ? n : n.querySelector('a') || n; } } } return best || null; } // ============ 核心动作 ============ function clickNext() { const btn = findNextButton(); if (!btn) { log('未找到“下一集”按钮。可点浮动面板里“查看说明”定位选择器。'); updatePanel(); return false; } log('点击下一集:', btn.innerText || btn.textContent || btn.tagName); try { btn.click(); return true; } catch (e) { log('点击失败', e); return false; } } function onVideoEnded(e) { const v = e.target; if (!(v && v.tagName && v.tagName.toLowerCase() === 'video')) return; // 忽略明显过小的视频 if (visibleArea(v) < 10000 && collectVideos().some((x) => visibleArea(x) >= 10000)) return; state.videoPlaying = false; state.videoEnded = true; if (!CFG.autoNext) return; log('视频结束,准备切换下一集'); setTimeout(() => { clickNext(); // 有些站点点完“下一集”不会自动播放,尝试辅助播放 setTimeout(assistPlay, 1200); }, 400); } function onVideoPlay(e) { const v = e.target; if (!(v && v.tagName && v.tagName.toLowerCase() === 'video')) return; // 忽略隐藏的“人脸认证”小视频,只认主播放器 if (visibleArea(v) < 10000) return; state.videoPlaying = true; state.videoEnded = false; } function assistPlay() { const v = findMainVideo(); if (v && v.paused && !v.ended) { v.play().catch(() => { log('自动播放被浏览器拦截,请手动点击一次播放即可。'); }); } } function clickPlayButton() { const btn = document.querySelector('.prism-big-play-btn, .prism-play-btn, [class*="play-btn" i], [class*="big-play" i]'); if (btn) { try { btn.click(); return true; } catch (e) {} } return false; } function autoPlayVideo() { if (!CFG.autoPlay) return; const v = findMainVideo(); if (!v || v.ended) return; if (!v.paused) return; state.autoPlayTries++; try { // 直接静音后播放,绕过浏览器“未交互禁止自动播放”的限制 v.muted = true; const p = v.play(); if (p && typeof p.then === 'function') { p.then(() => { if (!state.autoPlayLogged) { state.autoPlayLogged = true; log('视频已自动开始播放(静音)'); } }).catch(() => { clickPlayButton(); }); } } catch (e) { clickPlayButton(); } } // ============ 考试入口(自动点“进入考试/开始答题”等按钮) ============ const proceedState = { lastSig: '', lastClickAt: 0 }; function findExamEntryButton() { const patterns = [ '进入考试', '开始考试', '开始答题', '进入答题', '去考试', '立即考试', '开始测试', '进入测试', '开始测验', ]; const els = Array.from( document.querySelectorAll('button, a, [role="button"], [onclick], input[type="button"], input[type="submit"], [class*="btn" i]') ); for (const el of els) { const txt = (el.innerText || el.textContent || el.value || '').trim(); if (!txt) continue; if (patterns.some((p) => txt === p || txt.indexOf(p) >= 0)) return el; } return null; } function isActiveButton(el) { if (!el) return false; if (el.disabled === true) return false; if (el.getAttribute('aria-disabled') === 'true') return false; const cls = (typeof el.className === 'string' ? el.className : '') + ' ' + (el.id || ''); if (/(disabled|disable|forbid|gray|grey|unactive|inactive|ban|lock)/i.test(cls)) return false; const st = cssOf(el); if (st) { if (st.pointerEvents === 'none') return false; const opacity = parseFloat(st.opacity); if (!isNaN(opacity) && opacity < 0.35) return false; // “变成蓝色才可点”是最可靠的启用信号 if (colorLooksLike(st.color, 'blue') || colorLooksLike(st.backgroundColor, 'blue')) return true; } // 没有明确蓝色、也没有明确禁用时,交给外层“视频已结束”判断 return false; } function handleProceedClick() { if (!CFG.autoNext) return; const btn = findExamEntryButton(); if (!btn) return; const v = findMainVideo(); if (v && !v.ended) state.videoEnded = false; const playing = v && !v.paused && !v.ended; const videoEnded = v ? v.ended : state.videoEnded; const enabled = isActiveButton(btn); // 视频还在播放时,绝不点“进入考试”(否则会反复弹出“看完视频才能答题”) if (playing) return; // 视频没结束、按钮也没有变蓝/启用,就不点 if (!enabled && !videoEnded) return; const txt = (btn.innerText || btn.textContent || btn.value || '').trim(); const sig = txt + '|' + (btn.id || '') + '|' + (typeof btn.className === 'string' ? btn.className : ''); const now = Date.now(); if (sig === proceedState.lastSig && now - proceedState.lastClickAt < 8000) return; proceedState.lastSig = sig; proceedState.lastClickAt = now; log('点击考试入口:', txt, enabled ? '(蓝色/已启用)' : '(视频已结束)'); try { btn.click(); } catch (e) { log('点击考试入口失败', e); } } // ============ 网站在线提示(自动点“我在线”) ============ const onlineState = { lastSig: '', lastClickAt: 0 }; function isVisible(el) { if (!el) return false; const st = cssOf(el); if (st && (st.display === 'none' || st.visibility === 'hidden')) return false; return el.getClientRects().length > 0; } function findOnlineDialog() { const sel = CFG.onlineClickSelector; if (sel) { const el = document.querySelector(sel); if (!el) return null; const btn = el.querySelector('#btn-answer, button, input[type="button"], input[type="submit"]'); return btn ? { dialog: el, btn } : null; } const dialogs = Array.from( document.querySelectorAll('.dati-dialog, [class*="dialog" i], [class*="modal" i], [class*="popup" i], [class*="layer" i]') ); for (const d of dialogs) { if (!isVisible(d)) continue; const txt = (d.innerText || d.textContent || '').trim(); if (!/是否在线|您还在|还在学习|在线学习/.test(txt)) continue; const btn = d.querySelector('#btn-answer, button, input[type="button"], input[type="submit"]'); if (btn) return { dialog: d, btn }; } return null; } function findOnlineButton() { const sel = CFG.onlineClickSelector; if (sel) { const el = document.querySelector(sel); return el || null; } const hasBtn = (el) => !!el.querySelector('button, a, [role="button"], [onclick], input[type="button"], input[type="submit"]'); // 先找“是否在线”提示容器,再取其中的按钮,避免误点普通“下一集/继续学习” const containers = Array.from( document.querySelectorAll( 'div, section, form, [class*="dialog" i], [class*="modal" i], [class*="popup" i], [class*="mask" i], [class*="layer" i]' ) ); for (const c of containers) { if (!hasBtn(c)) continue; const txt = (c.innerText || c.textContent || '').trim(); if (!txt || txt.length > 300) continue; if (/是否在线|您还在|还在学习|在线学习/.test(txt)) { const btns = Array.from( c.querySelectorAll('button, a, [role="button"], [onclick], input[type="button"], input[type="submit"]') ); const norm = (b) => (b.innerText || b.textContent || b.value || '').trim(); const exact = btns.find((b) => /^(我在线|我还在线)$/.test(norm(b))); if (exact) return exact; const near = btns.find((b) => /我在线|继续学习|继续观看/.test(norm(b))); if (near) return near; } } // 兜底:跨文档精确查找“我在线” const roots = [document]; document.querySelectorAll('iframe').forEach((f) => { try { if (f.contentDocument) roots.push(f.contentDocument); } catch (e) {} }); for (const root of roots) { const el = Array.from( root.querySelectorAll('button, a, [role="button"], [onclick], input[type="button"], input[type="submit"], [class*="btn" i]') ).find((b) => /^(我在线|我还在线)$/.test((b.innerText || b.textContent || b.value || '').trim())); if (el) return el; } return null; } function handleOnlineClick() { if (!CFG.onlineClickEnabled) return; const dlg = findOnlineDialog(); if (!dlg) return; // 勾选“A、是”这类选项(可能是 checkbox,也可能是 radio) dlg.dialog.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((input) => { setRadioChecked(input); }); const btn = dlg.btn; const txt = (btn.innerText || btn.textContent || btn.value || '').trim(); const sig = (dlg.dialog.innerText || dlg.dialog.textContent || '').trim().slice(0, 200) + '|' + (btn.id || '') + '|' + (typeof btn.className === 'string' ? btn.className : ''); const now = Date.now(); // 同一弹窗 6 秒内只提交一次,避免重复点击 if (sig === onlineState.lastSig && now - onlineState.lastClickAt < 6000) return; onlineState.lastSig = sig; onlineState.lastClickAt = now; log('检测到在线确认弹窗,勾选并提交:', txt); try { btn.click(); } catch (e) { log('提交在线确认失败', e); } } // ============ 答题功能 ============ const ANSWER_BANK = loadAnswerBank(); function loadAnswerBank() { try { const v = GM_getValue('answerBank_v2', null); if (v && typeof v === 'object' && !Array.isArray(v)) return v; } catch (e) {} return {}; } function saveAnswerBank() { try { GM_setValue('answerBank_v2', ANSWER_BANK); } catch (e) {} } const quizState = { lastKeys: '', lastSubmitSig: '', lastSubmitAt: 0, lastRetryAt: 0, lastPick: {}, pendingRetry: false, pendingRetryAt: 0, submitted: false, lastReturnSig: '', lastReturnAt: 0, attempts: 0, maxAttempts: 30, wasActive: false, candidates: {}, }; function normText(s) { return String(s || '') .replace(/\s+/g, ' ') .replace(/[,。!?、,.!?;;::'"“”‘’()()【】\[\]<>《》]/g, '') .trim() .toLowerCase(); } function cssOf(el) { try { const w = el.ownerDocument && el.ownerDocument.defaultView; return w ? w.getComputedStyle(el) : null; } catch (e) { return null; } } // 取某个 radio 对应的选项文本 function getChoiceText(radio) { if (radio.id) { try { const lab = radio.ownerDocument.querySelector('label[for="' + CSS.escape(radio.id) + '"]'); if (lab) { const t = (lab.innerText || lab.textContent || '').trim(); if (t) return t; } } catch (e) {} } const lab = radio.closest('label'); if (lab) { const t = (lab.innerText || lab.textContent || '').trim(); if (t) return t; } // 常见结构:
选项文本
const p = radio.parentElement; if (p) { const t = (p.innerText || p.textContent || '').trim(); if (t && t.length <= 80) return t; } return radio.value || ''; } function commonAncestor(a, b) { let n = a; while (n && !n.contains(b)) n = n.parentElement; return n; } function groupContainer(radios) { if (!radios.length) return null; let node = radios[0]; for (let i = 1; i < radios.length; i++) node = commonAncestor(node, radios[i]); const parent = node && node.parentElement; if (parent && radios.every((r) => parent.contains(r))) return parent; return node; } function questionTextFor(container, radios) { if (!container) return ''; let txt = container.innerText || container.textContent || ''; const opts = radios.map(getChoiceText).filter((t) => t && t.length >= 2); for (const o of opts) txt = txt.split(o).join(' '); return normText(txt); } function collectQuestionGroups(doc) { doc = doc || document; const groups = []; const radios = Array.from(doc.querySelectorAll('input[type="radio"]')); const byName = new Map(); radios.forEach((r) => { const name = r.name || ('__radio_' + (r.id || Math.random().toString(36))); if (!byName.has(name)) byName.set(name, []); byName.get(name).push(r); }); byName.forEach((list) => { if (list.length < 2) return; // 单个 name 下选项过多时,按容器拆开,避免把多道题当成一道 const slices = list.length > 12 ? splitRadioList(list) : [list]; slices.forEach((slice) => { const container = groupContainer(slice); const key = questionTextFor(container, slice) || normText(slice[0].name || ''); if (!key) return; groups.push({ radios: slice, container, key }); }); }); return groups; } function splitRadioList(list) { const buckets = new Map(); list.forEach((r) => { const p = r.closest('li, .question, .item, fieldset, tr') || r.parentElement; if (!buckets.has(p)) buckets.set(p, []); buckets.get(p).push(r); }); const out = []; buckets.forEach((arr) => out.push(arr)); return out.length ? out : [list]; } function collectAllGroups() { let groups = collectQuestionGroups(document); document.querySelectorAll('iframe').forEach((f) => { try { if (f.contentDocument) groups = groups.concat(collectQuestionGroups(f.contentDocument)); } catch (e) {} }); return groups; } // 判断某个选项是否被站点标记为“正确/错误” function radioMarker(r) { const els = [r]; const lab = r.closest('label'); if (lab) els.push(lab); if (r.parentElement) els.push(r.parentElement); let cls = ''; let wrapText = ''; let color = ''; for (const el of els) { if (!el) continue; cls += ' ' + ((typeof el.className === 'string' ? el.className : '') + ' ' + (el.id || '')); wrapText += ' ' + ((el.innerText || el.textContent || '').trim()); const st = cssOf(el); if (st && st.color) color += ' ' + st.color; } const isCorrect = /(^|[\s_-])(correct|right|true|success|ok|dui|pass)([\s_-]|$)/i.test(cls) || /正确答案|答对|正确|√|✓|✔/.test(cls + wrapText) || colorLooksLike(color, 'green'); const isWrong = /(^|[\s_-])(wrong|error|false|fail|cuo|incorrect)([\s_-]|$)/i.test(cls) || /答错|错误|×|✗|✘/.test(cls + wrapText) || colorLooksLike(color, 'red'); if (isCorrect) return 'correct'; if (isWrong) return 'wrong'; return null; } function colorLooksLike(colorText, which) { let m; while ((m = colorText.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i))) { const r = +m[1]; const g = +m[2]; const b = +m[3]; if (which === 'green' && g > 120 && g > r * 1.25 && g > b * 1.25) return true; if (which === 'red' && r > 120 && r > g * 1.25 && r > b * 1.25) return true; if (which === 'blue' && b > 120 && b > r * 1.2 && b > g * 1.05) return true; colorText = colorText.slice(m.index + m[0].length); } return false; } function learnFromGroups(groups) { if (!CFG.quizLearn) return; let learned = 0; for (const g of groups) { for (const r of g.radios) { if (radioMarker(r) !== 'correct') continue; const ans = getChoiceText(r); if (!ans) continue; if (normText(ANSWER_BANK[g.key] || '') !== normText(ans)) { ANSWER_BANK[g.key] = ans; learned++; } } } if (learned) { saveAnswerBank(); log('已记忆', learned, '道题的正确答案'); } } function setRadioChecked(r) { if (r.checked) return; try { r.click(); // 原生 click 能触发站点自己的选项高亮/状态切换逻辑 } catch (e) {} if (r.checked) return; r.checked = true; ['click', 'change', 'input'].forEach((t) => { try { r.dispatchEvent(new Event(t, { bubbles: true })); } catch (e) {} }); } function fillKnown(groups) { let known = 0; for (const g of groups) { const ans = ANSWER_BANK[g.key]; if (!ans) continue; const target = normText(ans); for (const r of g.radios) { if (normText(getChoiceText(r)) === target) { setRadioChecked(r); known++; break; } } } return known; } // 直接读取页面里已经写好的正确答案(li 上的 is_right="1"),这是本站最可靠的方式 function fillCorrect(groups) { let filled = 0; for (const g of groups) { for (const r of g.radios) { const li = r.closest('li'); if (li && String(li.getAttribute('is_right')) === '1') { setRadioChecked(r); ANSWER_BANK[g.key] = getChoiceText(r); filled++; break; } } } if (filled) saveAnswerBank(); return filled; } function initCandidates(groups) { groups.forEach((g) => { if (!quizState.candidates[g.key]) { quizState.candidates[g.key] = g.radios .map(getChoiceText) .map(normText) .filter(Boolean); } }); } // 试错求解:把被标记为错误的选项从候选里移除,逐步逼近正确答案 function bruteFill(groups) { initCandidates(groups); let filled = 0; for (const g of groups) { if (ANSWER_BANK[g.key]) { filled++; continue; } const cands = quizState.candidates[g.key] || []; g.radios.forEach((r) => { const m = radioMarker(r); if (m === 'wrong') { const t = normText(getChoiceText(r)); const i = cands.indexOf(t); if (i >= 0) cands.splice(i, 1); } }); if (cands.length === 1) { const ans = g.radios.find((r) => normText(getChoiceText(r)) === cands[0]); if (ans) { setRadioChecked(ans); ANSWER_BANK[g.key] = getChoiceText(ans); saveAnswerBank(); filled++; } continue; } if (!cands.length) continue; const cur = g.radios.find((r) => r.checked); const curText = cur ? normText(getChoiceText(cur)) : null; const curWrong = cur && radioMarker(cur) === 'wrong'; const curEliminated = !!curText && cands.indexOf(curText) < 0; if (!cur || curWrong || curEliminated) { // 选第一个剩余候选;若当前选中项已被判错/已被排除,则换到下一个候选 const pick = g.radios.find((r) => normText(getChoiceText(r)) === cands[0]); if (pick) { setRadioChecked(pick); quizState.lastPick[g.key] = normText(getChoiceText(pick)); } } } return filled; } function answersSignature(groups) { return groups.map((g) => g.radios.findIndex((r) => r.checked)).join(','); } function allAnswered(groups) { return groups.every((g) => g.radios.some((r) => r.checked)); } function findQuizSubmit() { const byId = document.querySelector('#btn-submit'); if (byId) return byId; const els = Array.from( document.querySelectorAll('button, a, input[type="button"], input[type="submit"], [role="button"], [onclick]') ); let best = null; let bestScore = 0; els.forEach((el) => { const txt = (el.innerText || el.textContent || el.value || '').trim(); if (!txt) return; let s = 0; if (/^交卷$/.test(txt)) s = 120; else if (/^(提交|提交答案|确定|完成|下一题|继续)$/.test(txt)) s = 100; else if (/交卷/.test(txt)) s = 90; else if (/提交|确定|完成|继续/.test(txt)) s = 60; if (txt.length > 8) s -= 20; if (s > bestScore) { best = el; bestScore = s; } }); return best; } function findQuizRetry() { const els = Array.from(document.querySelectorAll('button, a, [role="button"], [onclick]')); return els.find((el) => /重新答题|重做|再答一次|重新作答|重考|重试/.test(el.innerText || el.textContent || el.value || '') ); } function cnToNumber(s) { s = String(s || '').trim(); if (!s) return NaN; s = s.replace(/[0-9]/g, (d) => String(d.charCodeAt(0) - 0xff10)); if (/^[0-9]+$/.test(s)) return parseInt(s, 10); const map = { 零: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 }; if (map[s] !== undefined) return map[s]; if (s === '十') return 10; if (/^十[一二三四五六七八九]$/.test(s)) return 10 + map[s[1]]; if (/^[一二三四五六七八九]十$/.test(s)) return map[s[0]] * 10; if (/^[一二三四五六七八九]十[一二三四五六七八九]$/.test(s)) return map[s[0]] * 10 + map[s[2]]; return NaN; } function parseWrongNumbers(text, total) { const nums = new Set(); const stripped = String(text || '') .replace(/共\s*[0-90-9一二三四五六七八九十]+\s*题/g, ' ') .replace(/总共\s*[0-90-9一二三四五六七八九十]+\s*题/g, ' ') .replace(/合计\s*[0-90-9一二三四五六七八九十]+\s*题/g, ' ') .replace(/答对\s*[0-90-9一二三四五六七八九十]+\s*题/g, ' ') .replace(/答错\s*[0-90-9一二三四五六七八九十]+\s*题/g, ' ') .replace(/满分\s*[0-90-9一二三四五六七八九十]+\s*题/g, ' '); const re = /第\s*([0-90-9一二三四五六七八九十]+(?:\s*[、,,]\s*[0-90-9一二三四五六七八九十]+)*)\s*题/g; let m; while ((m = re.exec(stripped))) { m[1].split(/[、,,\s]+/).forEach((p) => { const n = cnToNumber(p); if (Number.isFinite(n) && n >= 1 && n <= total) nums.add(n); }); } if (!nums.size) { const re2 = /(?:错题|错误题目|答错题目|答错的题目|错误的题|错误的是)[::是为]?\s*([0-90-9一二三四五六七八九十、,,\s]+)/g; while ((m = re2.exec(stripped))) { m[1].split(/[、,,\s]+/).forEach((p) => { const n = cnToNumber(p); if (Number.isFinite(n) && n >= 1 && n <= total) nums.add(n); }); } } if (!nums.size) { const re3 = /([0-90-9一二三四五六七八九十]+(?:\s*[、,,]\s*[0-90-9一二三四五六七八九十]+)*)\s*题/g; while ((m = re3.exec(stripped))) { m[1].split(/[、,,\s]+/).forEach((p) => { const n = cnToNumber(p); if (Number.isFinite(n) && n >= 1 && n <= total) nums.add(n); }); } } return Array.from(nums).sort((a, b) => a - b); } function learnWrongFromText(text) { if (!CFG.quizBruteForce) return; const groups = collectAllGroups(); if (!groups.length) return; const nums = parseWrongNumbers(text, groups.length); if (!nums.length) { log('未能从弹窗解析出错题号,弹窗原文:', text); return; } let eliminated = 0; nums.forEach((n) => { const g = groups[n - 1]; if (!g) return; if (ANSWER_BANK[g.key]) return; if (!quizState.candidates[g.key]) { quizState.candidates[g.key] = g.radios.map(getChoiceText).map(normText).filter(Boolean); } const arr = quizState.candidates[g.key]; const checked = g.radios.find((r) => r.checked); const t = quizState.lastPick[g.key] || (checked ? normText(getChoiceText(checked)) : null); if (!t) return; const i = arr.indexOf(t); if (i >= 0) { arr.splice(i, 1); eliminated++; } }); log('根据弹窗排除错题号:', nums.join('、'), ',共排除', eliminated, '个错误选项'); } function findResultPopup() { const containers = Array.from( document.querySelectorAll( 'div, section, form, [class*="dialog" i], [class*="modal" i], [class*="popup" i], [class*="layer" i], [class*="mask" i]' ) ); for (const c of containers) { const txt = (c.innerText || c.textContent || '').trim(); if (!txt || txt.length > 800) continue; const strong = /回答错误|答错|错题|未通过|不及格/.test(txt); const loose = /错误/.test(txt) && /[0-90-9一二三四五六七八九十]+\s*题/.test(txt); if (!strong && !loose) continue; const ok = Array.from( c.querySelectorAll('button, a, [role="button"], [onclick], input[type="button"], input[type="submit"]') ).find((b) => /^(确定|知道了|关闭|OK|好的|继续)$/i.test((b.innerText || b.textContent || b.value || '').trim())); if (ok) return { container: c, ok, text: txt }; } return null; } const popupState = { lastSig: '', lastClickAt: 0 }; function handleResultPopup() { if (!CFG.quizEnabled) return; const popup = findResultPopup(); if (!popup) return; log('检测到答题结果弹窗:', popup.text); learnWrongFromText(popup.text); const ok = popup.ok; const sig = popup.text + '|' + (ok.id || '') + '|' + (typeof ok.className === 'string' ? ok.className : ''); const now = Date.now(); if (sig === popupState.lastSig && now - popupState.lastClickAt < 4000) return; popupState.lastSig = sig; popupState.lastClickAt = now; quizState.pendingRetry = true; quizState.pendingRetryAt = now; log('点击结果弹窗“确定”'); try { ok.click(); } catch (e) { log('点击“确定”失败', e); } } function findPassPopup() { const containers = Array.from( document.querySelectorAll( 'div, section, form, [class*="dialog" i], [class*="modal" i], [class*="popup" i], [class*="layer" i], [class*="mask" i]' ) ); for (const c of containers) { const txt = (c.innerText || c.textContent || '').trim(); if (!txt || txt.length > 300) continue; if (!/考试通过|已通过|通过|合格|是否进入|进入下一个|进入下一节|进入下一课/.test(txt)) continue; const btns = Array.from( c.querySelectorAll('button, a, [role="button"], [onclick], input[type="button"], input[type="submit"]') ); const norm = (b) => (b.innerText || b.textContent || b.value || '').trim(); const btn = btns.find((b) => /^(进入下一个学习|进入下一节|进入下一课|继续学习|确定|继续|是|确认|好的)$/.test(norm(b)) ); if (btn) return { container: c, btn, text: txt }; } return null; } const passPopupState = { lastSig: '', lastClickAt: 0 }; function handlePassPopup() { if (!CFG.quizEnabled) return; const p = findPassPopup(); if (!p) return; const btnTxt = (p.btn.innerText || p.btn.textContent || p.btn.value || '').trim(); const sig = p.text + '|' + (p.btn.id || '') + '|' + (typeof p.btn.className === 'string' ? p.btn.className : ''); const now = Date.now(); if (sig === passPopupState.lastSig && now - passPopupState.lastClickAt < 5000) return; passPopupState.lastSig = sig; passPopupState.lastClickAt = now; log('检测到考试通过弹窗,点击:', btnTxt); try { p.btn.click(); } catch (e) { log('点击考试通过弹窗按钮失败', e); } } function handleRetry() { if (!CFG.quizEnabled || !CFG.quizBruteForce) return; if (!quizState.pendingRetry) return; const btn = findQuizRetry(); const now = Date.now(); if (btn) { if (now - quizState.lastRetryAt < 3000) return; quizState.lastRetryAt = now; quizState.pendingRetry = false; log('检测到错题,点击“重新答题”'); try { btn.click(); } catch (e) { log('点击“重新答题”失败', e); } } else if (now - quizState.pendingRetryAt > 3000) { // 没有“重新答题”按钮,说明页面可直接再次作答 quizState.pendingRetry = false; } } function findReturnButton() { const els = Array.from(document.querySelectorAll('a, button, [role="button"], [onclick]')); return els.find((el) => /返回学习|返回课程|继续学习|下一节|下一集/.test((el.innerText || el.textContent || el.value || '').trim()) ); } function handleReturnAfterSubmit() { if (!CFG.quizEnabled || !CFG.quizAutoSubmit) return; if (!quizState.submitted) return; if (findQuizRetry()) return; if (findResultPopup()) return; if (findPassPopup()) return; const now = Date.now(); if (now - quizState.lastSubmitAt < 3000) return; const btn = findReturnButton(); if (!btn) return; const txt = (btn.innerText || btn.textContent || btn.value || '').trim(); const sig = txt + '|' + (btn.id || '') + '|' + (typeof btn.className === 'string' ? btn.className : ''); if (sig === quizState.lastReturnSig && now - quizState.lastReturnAt < 8000) return; quizState.lastReturnSig = sig; quizState.lastReturnAt = now; log('考试已完成,点击返回:', txt); try { btn.click(); } catch (e) { log('返回点击失败', e); } } function submitQuiz() { const btn = findQuizSubmit(); if (!btn) { log('已答完所有题,但未找到“提交”按钮。'); return; } quizState.attempts++; quizState.submitted = true; log('自动提交答案(第 ' + quizState.attempts + ' 次)'); try { btn.click(); } catch (e) { log('提交失败', e); } } function handleQuiz() { if (!CFG.quizEnabled) return; const groups = collectAllGroups(); if (!groups.length) { if (quizState.wasActive) { quizState.wasActive = false; log('答题已结束,尝试继续下一集'); setTimeout(() => { clickNext(); setTimeout(assistPlay, 1200); }, 800); } quizState.lastKeys = ''; quizState.lastSubmitSig = ''; quizState.attempts = 0; quizState.candidates = {}; quizState.lastPick = {}; quizState.pendingRetry = false; quizState.submitted = false; return; } quizState.wasActive = true; const keysSig = groups.map((g) => g.key).join('|'); if (keysSig !== quizState.lastKeys) { quizState.lastKeys = keysSig; quizState.attempts = 0; quizState.lastSubmitSig = ''; quizState.candidates = {}; quizState.lastPick = {}; quizState.pendingRetry = false; quizState.submitted = false; } learnFromGroups(groups); // 优先直接读取 li 上的 is_right 正确答案;读不到再退回答案库/试错 const hasRight = groups.some((g) => g.radios.some((r) => { const li = r.closest('li'); return li && String(li.getAttribute('is_right')) === '1'; }) ); let known; if (hasRight) { known = fillCorrect(groups); } else { known = fillKnown(groups); if (CFG.quizBruteForce) { known = Math.max(known, bruteFill(groups)); } } const total = groups.length; if (known < total && !(CFG.quizBruteForce && allAnswered(groups))) { log('检测到答题(共 ' + total + ' 题),答案库已覆盖 ' + known + ' 题。'); } if (!CFG.quizAutoSubmit) return; if (quizState.attempts >= quizState.maxAttempts) { log('已达到自动提交次数上限,停止自动提交。'); return; } if (quizState.pendingRetry) return; // 若已判错且存在“重新答题”,先交给 handleRetry 重置,避免边提交边重试 if (CFG.quizBruteForce) { const anyWrong = groups.some((g) => g.radios.some((r) => radioMarker(r) === 'wrong')); if (anyWrong && findQuizRetry()) return; } const canSubmit = known === total || (CFG.quizBruteForce && allAnswered(groups)); if (!canSubmit) return; const sig = answersSignature(groups); const now = Date.now(); if (sig === quizState.lastSubmitSig) return; if (now - quizState.lastSubmitAt < 3500) return; quizState.lastSubmitSig = sig; quizState.lastSubmitAt = now; submitQuiz(); } // ============ 浮动控制面板 ============ let panel = null; function buildPanel() { if (panel) return; panel = document.createElement('div'); panel.id = 'sww-panel'; panel.innerHTML = `
自动连播×
视频检测中…
下一集按钮检测中…
自动连播-
自动播放-
自动点“我在线”-
自动答题检测中…
`; (document.body || document.documentElement).appendChild(panel); panel.querySelector('#sww-panel-close').addEventListener('click', () => panel.remove()); panel.querySelector('#sww-st-auto').addEventListener('click', () => { CFG.autoNext = !CFG.autoNext; saveConfig(); updatePanel(); }); panel.querySelector('#sww-st-play').addEventListener('click', () => { CFG.autoPlay = !CFG.autoPlay; saveConfig(); updatePanel(); }); panel.querySelector('#sww-st-online').addEventListener('click', () => { CFG.onlineClickEnabled = !CFG.onlineClickEnabled; saveConfig(); updatePanel(); }); panel.querySelector('#sww-st-quiz').addEventListener('click', () => { CFG.quizEnabled = !CFG.quizEnabled; saveConfig(); updatePanel(); }); } function updatePanel() { if (!panel || !(document.body && document.body.contains(panel))) return; const v = findMainVideo(); const n = findNextButton(); const groups = collectAllGroups(); const set = (id, txt, cls) => { const el = panel.querySelector('#' + id); if (el) { el.textContent = txt; el.className = 'val ' + (cls || ''); } }; set('sww-st-video', v ? '已发现 ✓' : '未发现 ✗', v ? 'ok' : 'no'); set('sww-st-next', n ? '已发现 ✓' : '未发现 ✗', n ? 'ok' : 'no'); set('sww-st-auto', CFG.autoNext ? '开' : '关'); set('sww-st-play', CFG.autoPlay ? '开' : '关', CFG.autoPlay ? 'ok' : ''); set('sww-st-online', CFG.onlineClickEnabled ? '开' : '关', CFG.onlineClickEnabled ? 'ok' : ''); let quizTxt = '关'; let quizCls = ''; if (!CFG.quizEnabled) { quizTxt = '关'; } else if (!groups.length) { quizTxt = '未发现'; } else { const known = groups.filter((g) => ANSWER_BANK[g.key]).length; quizTxt = known + '/' + groups.length + ' 题有答案'; quizCls = known >= groups.length ? 'ok' : ''; } set('sww-st-quiz', quizTxt, quizCls); const hint = panel.querySelector('#sww-hint'); let h = ''; if (!v) { h = '未找到视频元素。'; if (isCrossOriginIframeLikely()) h += '视频可能在跨域 iframe 中,脚本无法监听(详见说明)。'; else h += '开始播放后会自动检测,或在下方面板自定义选择器。'; } else if (!n) { h = '未找到“下一集”按钮。请在播放页右键“下一集”→检查,把它的 class 填进脚本顶部 nextSelector。'; } if (groups.length && CFG.quizEnabled) { const known = groups.filter((g) => ANSWER_BANK[g.key]).length; if (known < groups.length) { h = (h ? h + ' ' : '') + '答题:正在自动试错,暂有 ' + known + '/' + groups.length + ' 题答案。'; } } hint.textContent = h; } // ============ 菜单命令 ============ function registerMenus() { try { GM_registerMenuCommand('自动连播:' + (CFG.autoNext ? '开' : '关'), () => { CFG.autoNext = !CFG.autoNext; saveConfig(); updatePanel(); }); GM_registerMenuCommand('自动播放:' + (CFG.autoPlay ? '开' : '关'), () => { CFG.autoPlay = !CFG.autoPlay; saveConfig(); updatePanel(); }); GM_registerMenuCommand('自动点“我在线”:' + (CFG.onlineClickEnabled ? '开' : '关'), () => { CFG.onlineClickEnabled = !CFG.onlineClickEnabled; saveConfig(); updatePanel(); }); GM_registerMenuCommand('自动答题:' + (CFG.quizEnabled ? '开' : '关'), () => { CFG.quizEnabled = !CFG.quizEnabled; saveConfig(); updatePanel(); }); GM_registerMenuCommand('试错求解:' + (CFG.quizBruteForce ? '开' : '关'), () => { CFG.quizBruteForce = !CFG.quizBruteForce; saveConfig(); updatePanel(); }); GM_registerMenuCommand('清空答题答案库', () => { for (const k of Object.keys(ANSWER_BANK)) delete ANSWER_BANK[k]; saveAnswerBank(); quizState.candidates = {}; log('答案库已清空'); updatePanel(); }); GM_registerMenuCommand('重新扫描页面', () => { scan(); updatePanel(); }); } catch (e) {} } // ============ 扫描与初始化 ============ function safe(fn, label) { try { fn(); } catch (e) { log(label || '操作', '出错:', e); } } function scan() { attachEnded(document); collectVideos().forEach(() => {}); document.querySelectorAll('iframe').forEach((f) => { try { if (f.contentDocument) attachEnded(f.contentDocument); } catch (e) {} }); if (CFG.autoPlay && state.autoPlayTries < 20) { safe(autoPlayVideo, '自动播放'); } safe(handleOnlineClick, '在线提示点击'); safe(handleProceedClick, '考试入口点击'); safe(handleResultPopup, '结果弹窗处理'); safe(handlePassPopup, '考试通过弹窗处理'); safe(handleQuiz, '自动答题'); safe(handleRetry, '重新答题'); safe(handleReturnAfterSubmit, '考试完成后返回'); } function init() { buildPanel(); attachEnded(document); registerMenus(); // 动态内容:定期重新扫描(视频/iframe/按钮/题目/在线提示可能是后加载的) setInterval(() => { scan(); updatePanel(); }, 2500); // SPA 路由变化时也刷新 const obs = new MutationObserver(() => { scan(); }); obs.observe(document.body || document.documentElement, { childList: true, subtree: true }); scan(); updatePanel(); log('已启动。配置:', CFG); } function safeInit() { try { init(); } catch (e) { log('初始化失败', e); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', safeInit, { once: true }); } else { safeInit(); } })();