// ==UserScript== // @name 可可英语自动答题 // @namespace https://bbs.tampermonkey.net.cn/ // @version 0.2.4 // @description 可可英语自动答题,带悬浮控制面板、暂停、连关 // @author 江禾 // @license MIT // @match https://bdc.kekenet.com/* // @include https://bdc.kekenet.com/* // @include http://bdc.kekenet.com/* // @run-at document-idle // @grant unsafeWindow // ==/UserScript== (function () { 'use strict'; const KEEP_MARKED_COUNT = 10; const LOG = (...args) => console.log('[可可]', ...args); const WARN = (...args) => console.warn('[可可]', ...args); // ── 状态 ── let running = false; let paused = false; let isLastQuestion = false; let knownWords = []; let currentProgress = ''; let lastTotal = 0; let lastAnswered = 0; let examObserver = null; let wordObserver = null; let fillTimeoutId = null; // ── 配置 ── let cfgFontSize = 13; // ═══════════════════════════════════════ 悬浮面板 ═══════════════════════════════════════ (function initStyles() { if (document.getElementById('ke-auto-style')) return; const s = document.createElement('style'); s.id = 'ke-auto-style'; s.textContent = ` #ke-panel,#ke-panel *{box-sizing:border-box;margin:0;padding:0} @media(max-width:480px){#ke-panel{width:92vw!important;right:4vw!important;top:12px!important}} @media(min-width:481px) and (max-width:768px){#ke-panel{width:300px!important}} `; document.head.appendChild(s); })(); const GUI = (function build() { const el = document.createElement('div'); el.id = 'ke-panel'; el.style.fontSize = cfgFontSize + 'px'; Object.assign(el.style, { position: 'fixed', top: '80px', right: '16px', zIndex: '99999', width: '272px', maxWidth: '100vw', background: '#fff', borderRadius: '14px', color: '#1a1a2e', userSelect: 'none', overflow: 'hidden', border: '1px solid #e2e6ec', boxShadow: '0 6px 28px rgba(0,0,0,.10),0 2px 6px rgba(0,0,0,.05)', fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif' }); const hd = ce('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', cursor: 'move', borderBottom: '1px solid #f0f2f5', background: '#fafbfc' }); const title = ce('span', { fontWeight: '600', color: '#2c3e50' }, '🎧 可可答题'); const status = ce('span', { fontSize: '11px', padding: '2px 8px', borderRadius: '10px', background: '#f0f2f5', color: '#888' }, '待启动'); hd.append(title, status); el.appendChild(hd); const info = ce('div', { padding: '12px 14px 6px' }); const progress = ce('div', { color: '#888', marginBottom: '6px', minHeight: '18px', lineHeight: '18px', wordBreak: 'break-all' }, '📋 等待开始…'); const question = ce('div', { color: '#555', marginBottom: '6px', minHeight: '18px', lineHeight: '18px', wordBreak: 'break-all' }); const match = ce('div', { color: '#16a34a', marginBottom: '6px', minHeight: '16px', lineHeight: '16px' }); info.append(progress, question, match); el.appendChild(info); const btns = ce('div', { padding: '6px 14px', display: 'flex', gap: '8px' }); const styleBtn = c => `flex:1;padding:8px 0;border:none;border-radius:8px;font-weight:500;cursor:pointer;transition:.15s;background:${c.bg};color:${c.cl}`; const btnStart = ce('button', styleBtn({ bg: '#2563eb', cl: '#fff' }), '开始答题'); const btnPause = ce('button', styleBtn({ bg: '#f59e0b', cl: '#fff' }) + ';opacity:.5', '暂停'); const btnStop = ce('button', styleBtn({ bg: '#f0f2f5', cl: '#888' }), '停止'); btnPause.disabled = true; btnStop.disabled = true; btns.append(btnStart, btnPause, btnStop); el.appendChild(btns); const cfg = ce('div', { padding: '6px 14px 14px', borderTop: '1px solid #f0f2f5', marginTop: '4px' }); const row = ce('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }); row.appendChild(ce('span', { color: '#555' }, '🔤 字体大小')); const sizeBox = ce('div', { display: 'flex', gap: '4px' }); [11, 13, 15].forEach(size => { const sel = size === 13; const b = ce('button', `padding:2px 8px;border-radius:6px;cursor:pointer;font-size:12px;transition:.15s;border:1px solid ${sel ? '#2563eb' : '#d1d5db'};background:${sel ? '#dbeafe' : '#fff'};color:${sel ? '#2563eb' : '#555'}`, String(size)); b.addEventListener('click', () => { cfgFontSize = size; el.style.fontSize = size + 'px'; LOG('字体大小切换为', size); sizeBox.querySelectorAll('button').forEach((x, i) => { const v = [11, 13, 15][i]; const s = v === size; x.style.background = s ? '#dbeafe' : '#fff'; x.style.color = s ? '#2563eb' : '#555'; x.style.borderColor = s ? '#2563eb' : '#d1d5db'; }); }); sizeBox.appendChild(b); }); row.appendChild(sizeBox); cfg.appendChild(row); el.appendChild(cfg); makeDraggable(el, hd); btnStart.addEventListener('click', () => { if (!running) start(); }); btnPause.addEventListener('click', () => { if (running) togglePause(); }); btnStop.addEventListener('click', () => { if (running || paused) stop(); }); el._statusEl = status; el._progressEl = progress; el._questionEl = question; el._matchEl = match; el._btnStart = btnStart; el._btnPause = btnPause; el._btnStop = btnStop; return el; })(); document.body.appendChild(GUI); function ce(tag, style, text) { const e = document.createElement(tag); if (typeof style === 'string') e.style.cssText = style; else if (style) Object.assign(e.style, style); if (text) e.textContent = text; return e; } function makeDraggable(el, handle) { let sx, sy, ix, iy; function down(e) { if (e.target.tagName === 'BUTTON') return; e.preventDefault(); const p = e.touches ? e.touches[0] : e; sx = p.clientX; sy = p.clientY; ix = el.offsetLeft; iy = el.offsetTop; document.addEventListener(e.touches ? 'touchmove' : 'mousemove', move); document.addEventListener(e.touches ? 'touchend' : 'mouseup', up); } function move(e) { const p = e.touches ? e.touches[0] : e; el.style.right = ''; el.style.left = (ix + p.clientX - sx) + 'px'; el.style.top = (iy + p.clientY - sy) + 'px'; } function up() { document.removeEventListener('touchmove', move); document.removeEventListener('mousemove', move); document.removeEventListener('touchend', up); document.removeEventListener('mouseup', up); } handle.addEventListener('mousedown', down); handle.addEventListener('touchstart', down, { passive: false }); } // ── GUI 快捷更新 ── function guiStatus(t, c = '#888', b = '#f0f2f5') { const s = GUI._statusEl; s.textContent = t; s.style.background = b; s.style.color = c; } function guiProgress(t) { GUI._progressEl.textContent = t; } function guiQuestion(t) { GUI._questionEl.textContent = t; } function guiMatch(t) { GUI._matchEl.textContent = t; } function guiSetButtons(state) { const bS = GUI._btnStart, bP = GUI._btnPause, bSt = GUI._btnStop; const act = (b, en, bg, cl) => { b.disabled = !en; b.style.opacity = en ? '1' : '.5'; b.style.background = bg; b.style.color = cl; }; if (state === 'idle') { act(bS, 1, '#2563eb', '#fff'); act(bP, 0, '#f59e0b', '#fff'); act(bSt, 0, '#f0f2f5', '#888'); } else if (state === 'running') { act(bS, 0, '#2563eb', '#fff'); act(bP, 1, '#f59e0b', '#fff'); bP.textContent = '暂停'; act(bSt, 1, '#ef4444', '#fff'); } else { act(bS, 0, '#2563eb', '#fff'); act(bP, 1, '#10b981', '#fff'); bP.textContent = '继续'; act(bSt, 1, '#ef4444', '#fff'); } } // ═══════════════════════════════════════ 初始化:标熟 + 缓存 ═══════════════════════════════════════ async function initMarks() { const items = document.querySelectorAll('.words-box .word-item'); const wordBtn = document.querySelector('.word-bottom-btn.word-btn-do'); if (items.length === 0) return false; LOG(`初始化标熟 — 共 ${items.length} 个单词,前 ${KEEP_MARKED_COUNT} 个标熟`); guiProgress(`📋 正在初始化标熟… (${items.length} 个单词)`); for (let i = 0; i < items.length; i++) { const btn = items[i].querySelector('.word-item-right'); if (!btn) continue; const text = btn.textContent.trim(); const want = i < KEEP_MARKED_COUNT; if (want && text === '+标熟') { btn.click(); LOG(` [${i}] 点击标熟`); } else if (!want && text !== '+标熟') { btn.click(); LOG(` [${i}] 取消标熟`); } await new Promise(r => setTimeout(r, 150)); } knownWords = []; for (let i = KEEP_MARKED_COUNT; i < items.length; i++) { const left = items[i].querySelector('.word-item-left'); if (!left) continue; const b = left.querySelector('b'), span = left.querySelector('span'); const w = b ? b.textContent.trim() : '', m = span ? span.textContent.trim() : ''; knownWords.push({ word: w, meaning: m }); LOG(` 缓存 [${i}]: "${w}" — ${m}`); } LOG(`标熟完成 — 缓存 ${knownWords.length} 个未标熟单词`); guiProgress(`📋 已缓存 ${knownWords.length} 个未标熟单词`); guiMatch(`单词: ${knownWords.map(k => k.word).join('; ') || '无'}`); if (wordBtn) { wordBtn.click(); LOG('点击「开始答题」按钮'); } return true; } // ═══════════════════════════════════════ 答题 ═══════════════════════════════════════ function dispatch() { if (!running || paused) return; (document.querySelector('.exam-keyboard')) ? fillInAnswer() : selectOptionAnswer(); } // ── 选择题 ── /** 「听发音选解释」模式:字符集相似度匹配 */ function matchByMeaning(optText, sortedWords) { const nt = optText.replace(/\s+/g, ''); let best = null, bestScore = 0; for (const k of sortedWords) { if (!k.meaning) continue; const nm = k.meaning.replace(/\s+/g, ''); // 交集字符数 / 并集字符数 const setA = new Set(nt.split('')); const setB = new Set(nm.split('')); const intersection = [...setA].filter(c => setB.has(c)).length; const union = new Set([...setA, ...setB]).size; const score = intersection / union; if (score > bestScore) { bestScore = score; best = k; } } if (bestScore > 0.35) return best; return null; } function selectOptionAnswer() { const topLeft = document.querySelector('.exam-top .exam-top-left'); if (!topLeft) return; const progressText = topLeft.textContent.trim(); const options = document.querySelectorAll('.exam-option-box .exam-option'); if (options.length === 0) { LOG('选项未就绪,500ms 后重试'); setTimeout(dispatch, 500); return; } parseProgress(progressText); guiProgress(progressText); guiQuestion(''); const isMeaningMode = /解释/.test(progressText); const sortedWords = [...knownWords].sort((a, b) => b.word.length - a.word.length); LOG(`选择 ${progressText},${options.length} 个选项:\n ${[...options].map(o => o.textContent.trim()).join('\n ')}`); for (const opt of options) { const t = opt.textContent.trim(); // 优先按单词头匹配(始终尝试,因为"看单词选解释"的选项仍含英文单词) let f = sortedWords.find(k => t.startsWith(k.word)); // 未命中且是释义类题型 → 回退到释义相似度匹配 if (!f && isMeaningMode) { f = matchByMeaning(t, sortedWords); } if (f) { guiMatch(`✅ 匹配: "${f.word}" → "${t}"`); LOG(` ✅ 匹配缓存 "${f.word}" → 点击 "${t}"`); opt.click(); setTimeout(afterAnswer, 300); return; } } WARN(` ❌ 未匹配 — 缓存: [${knownWords.map(k => k.word).join('; ')}]`); guiMatch('❌ 未匹配,已停止'); guiStatus('未匹配', '#b91c1c', '#fee2e2'); stop(); } // ── 填空题 ── function fillInAnswer() { const topLeft = document.querySelector('.exam-top .exam-top-left'); const progressText = topLeft ? topLeft.textContent.trim() : ''; const chineseEl = document.querySelector('.exam-info-top .exam-row.exam-question'); const chinese = chineseEl ? chineseEl.textContent.trim() : ''; const englishEl = document.querySelector('.exam-info-bottom .exam-row.exam-question'); if (!englishEl) { WARN('未找到填空题英文题干'); return; } const { template } = parseTemplate(englishEl); const pattern = template.map(t => t.type === 'fill' ? '_' : t.value).join(''); parseProgress(progressText); guiProgress(progressText); guiQuestion(chinese); LOG(`填空 ${progressText} 中文: "${chinese}" 模板: ${pattern}`); let found = null; const nc = chinese.replace(/\s+/g, ''); found = knownWords.find(k => { if (!k.meaning) return false; const nm = k.meaning.replace(/\s+/g, ''); return nc.includes(nm) || nm.includes(nc); }); if (!found) { LOG(' 中文未匹配,尝试英文模板匹配...'); found = knownWords.find(k => templateMatchWord(template, k.word)); } if (!found) { WARN(` ❌ 填空未匹配 — 中文: "${chinese}" 模板: ${pattern}`); guiMatch('❌ 未匹配,已停止'); guiStatus('未匹配', '#b91c1c', '#fee2e2'); stop(); return; } const missing = getMissing(template, found.word); guiMatch(`✅ "${found.word}" 待填: [${missing.join('; ')}]`); LOG(` ✅ 匹配 "${found.word}" (${found.meaning}) → 待填: [${missing.join('; ')}]`); if (missing.length === 0) { LOG(' 无需填写,跳过'); return; } const keyboard = document.querySelector('.exam-keyboard'); typeLetters(keyboard, missing, 0, () => { confirmBtn(); afterAnswer(); }); } function templateMatchWord(template, word) { let wi = 0; for (const t of template) { if (wi >= word.length) return false; if (t.type === 'char' && t.value.toLowerCase() !== word[wi].toLowerCase()) return false; wi++; } return wi === word.length; } function confirmBtn() { document.querySelectorAll('.exam-key').forEach(k => { if (k.textContent.trim() === '确定') { k.click(); LOG(' 点击「确定」按钮'); } }); } function parseTemplate(el) { const t = [], fs = []; el.childNodes.forEach(c => { if (c.nodeType === 1 && c.classList.contains('fill-space')) { t.push({ type: 'fill' }); fs.push(c); } else if (c.nodeType === 3) { for (const ch of c.textContent) t.push({ type: 'char', value: ch }); } }); return { template: t, fillSpaces: fs }; } function getMissing(template, word) { const l = []; let wi = 0; for (const t of template) { if (t.type === 'fill') l.push(wi < word.length ? word[wi] : '?'); wi++; } return l; } function typeLetters(keyboard, letters, idx, onDone) { if (!running) { fillTimeoutId = null; return; } if (paused) { fillTimeoutId = setTimeout(() => typeLetters(keyboard, letters, idx, onDone), 300); return; } if (idx >= letters.length) { fillTimeoutId = null; LOG(' 所有字母填写完毕'); setTimeout(onDone, 200); return; } const target = letters[idx]; keyboard.querySelectorAll('.exam-key').forEach(k => { if (k.textContent.trim().toLowerCase() === target.toLowerCase()) { k.click(); LOG(` 点击键盘: "${target}"`); } }); fillTimeoutId = setTimeout(() => typeLetters(keyboard, letters, idx + 1, onDone), 200); } // ═══════════════════════════════════════ 进度 & 连关 ═══════════════════════════════════════ function parseProgress(text) { const m = text.match(/(\d+)\/(\d+)/); if (m) { lastAnswered = +m[1]; lastTotal = +m[2]; } } function afterAnswer() { if (lastTotal && lastAnswered >= lastTotal) { isLastQuestion = true; LOG(`🏁 最后一题已答 (${lastAnswered}/${lastTotal})`); } } function onExamPageGone() { if (!running || paused || !isLastQuestion) return; stopExamWatcher(); LOG('考试页面已消失,准备连关...'); guiProgress('📋 等待下一关...'); guiStatus('连关中', '#7c3aed', '#ede9fe'); pollNextBtn(0); } function pollNextBtn(tries) { if (!running || paused) return; if (tries > 40) { WARN('连关超时 — 未找到下一关按钮'); guiProgress('⚠ 连关超时,请手动操作'); return; } const btn = document.querySelector('.res-btn-next'); if (!btn) { setTimeout(() => pollNextBtn(tries + 1), 500); return; } if (tries < 3) { if (tries === 0) LOG('检测到「下一关」按钮,等待 Vue 水合...'); setTimeout(() => pollNextBtn(tries + 1), 300); return; } LOG('🔄 点击「下一关」按钮'); btn.click(); btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: unsafeWindow })); knownWords = []; lastTotal = 0; lastAnswered = 0; currentProgress = ''; isLastQuestion = false; setTimeout(() => { if (running && !paused) { LOG('开始监听新关卡单词列表'); watchForWordsBox(); } }, 1000); } // ═══════════════════════════════════════ 监听 ═══════════════════════════════════════ function startExamWatcher() { currentProgress = ''; if (examObserver) examObserver.disconnect(); LOG('启动题目监听...'); examObserver = new MutationObserver(() => { if (!running || paused) return; const tl = document.querySelector('.exam-top .exam-top-left'); if (!tl) { if (isLastQuestion) onExamPageGone(); return; } const t = tl.textContent.trim(); if (t === currentProgress) return; currentProgress = t; LOG(`题目进度变更: ${t}`); setTimeout(dispatch, 300); }); examObserver.observe(document.documentElement, { childList: true, subtree: true, characterData: true }); if (document.querySelector('.exam-top .exam-top-left')) { currentProgress = ''; dispatch(); } } function stopExamWatcher() { if (examObserver) { examObserver.disconnect(); examObserver = null; LOG('题目监听已断开'); } } function stopWordWatcher() { if (wordObserver) { wordObserver.disconnect(); wordObserver = null; } } function watchForWordsBox() { stopWordWatcher(); LOG('监听 .words-box 出现...'); wordObserver = new MutationObserver(() => { const box = document.querySelector('.words-box'); if (box && box.children.length > 0) { stopWordWatcher(); LOG('.words-box 已出现'); bootExam(); } }); wordObserver.observe(document.documentElement, { childList: true, subtree: true }); if (document.querySelector('.words-box')?.children.length > 0) { stopWordWatcher(); LOG('.words-box 已存在'); bootExam(); } } async function bootExam() { const ok = await initMarks(); if (ok && running && !paused) { startExamWatcher(); guiProgress('📋 题目监听中…'); guiStatus('运行中', '#2563eb', '#dbeafe'); } } // ═══════════════════════════════════════ 控制 ═══════════════════════════════════════ function start() { LOG('▶ 开始答题'); running = true; paused = false; guiSetButtons('running'); guiStatus('运行中', '#2563eb', '#dbeafe'); watchForWordsBox(); } function togglePause() { paused = !paused; if (paused) { LOG('⏸ 暂停答题'); guiSetButtons('paused'); guiStatus('已暂停', '#d97706', '#fef3c7'); guiProgress('⏸ 已暂停 — 点击「继续」恢复'); } else { LOG('▶ 继续答题'); guiSetButtons('running'); guiStatus('运行中', '#2563eb', '#dbeafe'); guiProgress('📋 题目监听中…'); if (fillTimeoutId) { clearTimeout(fillTimeoutId); fillTimeoutId = null; } dispatch(); } } function stop() { LOG('⏹ 停止答题'); running = false; paused = false; stopExamWatcher(); stopWordWatcher(); if (fillTimeoutId) { clearTimeout(fillTimeoutId); fillTimeoutId = null; } knownWords = []; lastTotal = 0; lastAnswered = 0; isLastQuestion = false; guiSetButtons('idle'); guiStatus('已停止', '#888', '#f0f2f5'); guiProgress('⏸ 已停止'); guiMatch(''); } LOG('脚本已加载,悬浮面板就绪'); guiStatus('待启动'); guiProgress('📋 点击「开始答题」启动'); })();