// ==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();
}
})();