// ==UserScript== // @name AI答题助手 // @namespace https://github.com/QfangY/AI-answer // @homepageURL https://github.com/QfangY/AI-answer // @icon https://1828333021.v.123pan.cn/1828333021/41600992 // @version 2.2.2 // @description 通用AI答题助手,支持超星学习通、智慧树、雨课堂等主流平台,自动识别题目并AI答题。 // @author QfangY // @license MIT // @match *://*/* // @grant GM_setClipboard // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @connect api.deepseek.com // @connect api.openai.com // @connect api.xiaomimimo.com // @connect dashscope.aliyuncs.com // @connect open.bigmodel.cn // @connect api-inference.modelscope.cn // @connect localhost // @connect 127.0.0.1 // @run-at document-end // ==/UserScript== (function() { 'use strict'; // ============================================================ // 常量与默认配置 // ============================================================ const STORAGE_PREFIX = 'chaoxing-ai-v2'; const STORAGE_KEYS = { aiConfig: `${STORAGE_PREFIX}.ai-config`, ballPos: `${STORAGE_PREFIX}.ball-position`, settings: `${STORAGE_PREFIX}.ui-settings`, wizardSeen: `${STORAGE_PREFIX}.wizard-seen` }; const AI_PROVIDERS = [ { id: 'custom', name: '自定义', url: '', model: '', desc: '手动填写API地址、模型和密钥' }, { id: 'deepseek', name: 'DeepSeek', url: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-v4-flash', desc: 'V4-Flash快速模型,国内首选' }, { id: 'deepseek-p', name: 'DeepSeek(Pro)', url: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-v4-pro', desc: 'V4-Pro旗舰模型' }, { id: 'mimo', name: 'MiMo', url: 'https://api.xiaomimimo.com/v1/chat/completions', model: 'mimo-v2.5-pro', desc: '小米MiMo,按量付费' }, { id: 'mimo-flash', name: 'MiMo-Flash', url: 'https://api.xiaomimimo.com/v1/chat/completions', model: 'mimo-v2-flash', desc: '小米MiMo-Flash,极速推理' }, { id: 'qwen', name: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', model: 'qwen-plus', desc: '阿里云百炼,千问Plus' }, { id: 'qwen-turbo', name: '千问Turbo', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', model: 'qwen-turbo', desc: '千问Turbo,速度快价格低' }, { id: 'zhipu', name: '智谱AI', url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', model: 'glm-4-flash', desc: 'GLM-4-Flash,免费模型' }, { id: 'zhipu-5', name: '智谱GLM-5', url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', model: 'glm-5-turbo', desc: 'GLM-5-Turbo,旗舰模型' }, { id: 'openai', name: 'OpenAI', url: 'https://api.openai.com/v1/chat/completions', model: 'gpt-4o-mini', desc: 'GPT-4o-mini,需要海外Key' }, { id: 'ollama', name: 'Ollama(本地)', url: 'http://localhost:11434/v1/chat/completions', model: 'deepseek-r1:8b', desc: '本地部署,无需API Key' } ]; const DEFAULT_AI_CONFIG = { provider: 'deepseek', apiUrl: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-v4-flash', apiKey: '', temperature: 0.1 }; const DEFAULT_SETTINGS = { ballSize: 64, floatDuration: 4.8, panelWidth: 380, panelOpacity: 97, rememberPosition: true, reducedMotion: false, autoOpenPage: 'basic', autoAnswerDelay: 1500, autoNextQuestion: true }; const DEFAULT_MARGIN = 20; // ============================================================ // 工具函数 // ============================================================ const clamp = (v, min, max) => Math.min(Math.max(v, min), max); const safeParse = (t) => { try { return JSON.parse(t); } catch { return null; } }; const loadJSON = (key, fallback) => { const raw = GM_getValue(key, null); if (raw === null) return { ...fallback }; const parsed = typeof raw === 'string' ? safeParse(raw) : raw; return parsed ? { ...fallback, ...parsed } : { ...fallback }; }; const saveJSON = (key, obj) => GM_setValue(key, JSON.stringify(obj)); // ============================================================ // 状态管理 // ============================================================ let aiConfig = loadJSON(STORAGE_KEYS.aiConfig, DEFAULT_AI_CONFIG); let uiSettings = loadJSON(STORAGE_KEYS.settings, DEFAULT_SETTINGS); let autoAnswerRunning = false; let autoAnswerTimer = null; const saveAiConfig = () => saveJSON(STORAGE_KEYS.aiConfig, aiConfig); const saveUiSettings = () => saveJSON(STORAGE_KEYS.settings, uiSettings); // ============================================================ // 平台检测 // ============================================================ const PLATFORM_RULES = [ { id: 'chaoxing', name: '超星学习通', match: /chaoxing\.com|chaoxing\.cn/, selectors: { container: '.questionLi', title: 'h3', options: '.answerBg, .answer_item, .options li', type: 'typename' } }, { id: 'zhihuishu', name: '智慧树', match: /zhihuishu\.com/, selectors: { container: '.questionItem, .exam-item, .topic-item', title: '.questionTitle, .topic-title, h3, h4', options: '.optionItem, .topic-option li, .answer-option', type: 'data-type' } }, { id: 'yuketang', name: '雨课堂', match: /yuketang\.com|ykt\.io/, selectors: { container: '.question, .problem, .quiz-item', title: '.question-title, .problem-title, h3', options: '.option, .choice, .answer-item', type: 'data-type' } }, { id: 'icourse163', name: '中国大学MOOC', match: /icourse163\.org/, selectors: { container: '.u-questionItem, .question-item', title: '.j-title, .question-title, h3', options: '.u-optionItem, .option-item', type: 'data-type' } }, { id: 'mooc', name: '学堂在线', match: /xuetangx\.com/, selectors: { container: '.question, .problem-block', title: '.question-title, h3', options: '.option, .choice-item', type: 'data-type' } } ]; const GENERIC_SELECTORS = { containers: [ '[class*="question"]', '[class*="Question"]', '[class*="problem"]', '[class*="Problem"]', '[class*="quiz"]', '[class*="Quiz"]', '[class*="exam"]', '[class*="Exam"]', '[class*="topic"]', '[class*="Topic"]', '[class*="exercise"]', '[class*="Exercise"]', '[id*="question"]', '[id*="problem"]', 'fieldset', '.question', '.problem' ], titles: [ 'h3', 'h4', 'h5', '[class*="title"]', '[class*="Title"]', '[class*="stem"]', '[class*="Stem"]', '[class*="题干"]', '[class*="题目"]' ], options: [ '[class*="option"]', '[class*="Option"]', '[class*="choice"]', '[class*="Choice"]', '[class*="answer"]', '[class*="Answer"]', 'input[type="radio"]', 'input[type="checkbox"]', '.option', '.choice', '.answer' ], inputs: [ 'input[type="text"]', 'input:not([type])', 'textarea', '[contenteditable="true"]' ] }; let currentPlatform = null; function detectPlatform() { const url = window.location.href; for (const rule of PLATFORM_RULES) { if (rule.match.test(url)) { currentPlatform = rule; return rule; } } currentPlatform = null; return null; } function hasAnswerArea() { if (detectPlatform()) { const containers = document.querySelectorAll(currentPlatform.selectors.container); if (containers.length > 0) return true; } for (const selector of GENERIC_SELECTORS.containers) { try { const elements = document.querySelectorAll(selector); if (elements.length > 0) { const hasOptions = elements[0].querySelectorAll(GENERIC_SELECTORS.options.join(', ')).length > 0; const hasInputs = elements[0].querySelectorAll(GENERIC_SELECTORS.inputs.join(', ')).length > 0; if (hasOptions || hasInputs) return true; } } catch (e) {} } return false; } // ============================================================ // AI 接口请求 // ============================================================ function callAiApi(prompt) { return new Promise((resolve, reject) => { if (!aiConfig.apiUrl) return reject(new Error('请先配置AI接口地址')); if (!aiConfig.apiKey && !aiConfig.apiUrl.includes('localhost') && !aiConfig.apiUrl.includes('127.0.0.1')) { return reject(new Error('请先配置API Key')); } const headers = { 'Content-Type': 'application/json' }; if (aiConfig.apiKey) { headers['Authorization'] = `Bearer ${aiConfig.apiKey}`; } const body = JSON.stringify({ model: aiConfig.model, messages: [ { role: 'system', content: '你是一个精准的答题助手。用户会给你题目和选项,你需要直接给出答案。只返回JSON数组,不要包含任何其他文字、markdown标记或代码块。格式:[{"questionId":"ID","answer":"答案内容","analysis":"简短解析"}]。选择题答案用选项字母(如A、B、AB),填空题直接给答案文本,判断题用"对"或"错"。' }, { role: 'user', content: prompt } ], temperature: aiConfig.temperature, stream: false }); const bodySize = new Blob([body]).size; log(`🤖 正在请求AI (${aiConfig.model})...`, '#74b9ff'); log(`📡 请求地址: ${aiConfig.apiUrl}`, '#636e72'); log(`📦 请求体大小: ${(bodySize / 1024).toFixed(1)} KB`, '#636e72'); const startTime = Date.now(); GM_xmlhttpRequest({ method: 'POST', url: aiConfig.apiUrl, headers, data: body, timeout: 120000, // 增加到120秒 onload(resp) { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); log(`📥 收到响应,耗时 ${elapsed}s,状态码: ${resp.status}`, '#636e72'); if (resp.status >= 200 && resp.status < 300) { try { const data = JSON.parse(resp.responseText); const content = data.choices?.[0]?.message?.content || ''; const usage = data.usage; if (usage) { log(`📊 Token用量: 输入${usage.prompt_tokens} + 输出${usage.completion_tokens} = ${usage.total_tokens}`, '#636e72'); } log(`✅ AI响应成功,响应长度: ${content.length} 字符`, '#55efc4'); resolve(content); } catch (e) { log(`❌ JSON解析失败: ${resp.responseText.substring(0, 200)}`, '#ff7675'); reject(new Error('AI返回格式解析失败')); } } else { let errMsg = `AI请求失败 (${resp.status})`; try { const errData = JSON.parse(resp.responseText); errMsg = errData.error?.message || errData.message || errMsg; log(`❌ 错误详情: ${JSON.stringify(errData).substring(0, 300)}`, '#ff7675'); } catch { log(`❌ 响应内容: ${resp.responseText.substring(0, 300)}`, '#ff7675'); } reject(new Error(errMsg)); } }, onerror(err) { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); log(`❌ 网络请求失败,耗时 ${elapsed}s`, '#ff7675'); log(`❌ 错误信息: ${err.error || '未知错误'}`, '#ff7675'); reject(new Error('网络请求失败: ' + (err.error || '未知错误'))); }, ontimeout() { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); log(`⏰ 请求超时,耗时 ${elapsed}s`, '#ff7675'); reject(new Error('AI请求超时(120s),可能是网络问题或题目过多')); } }); }); } // ============================================================ // 题目识别(通用版) // ============================================================ let questionCache = null; let lastDetectTime = 0; function getQuestionType(q, title) { const typeAttr = q.getAttribute('typename') || q.getAttribute('data-type') || q.getAttribute('type') || ''; const titleLower = (title + typeAttr).toLowerCase(); if (typeAttr.includes('多选') || titleLower.includes('多选') || titleLower.includes('multiple')) return '多选题'; if (typeAttr.includes('判断') || titleLower.includes('判断') || titleLower.includes('true/false') || titleLower.includes('对错')) return '判断题'; if (typeAttr.includes('填空') || titleLower.includes('填空') || titleLower.includes('blank') || titleLower.includes('____')) return '填空题'; if (typeAttr.includes('简答') || typeAttr.includes('论述') || titleLower.includes('简答') || titleLower.includes('short answer')) return '简答题'; return '单选题'; } function isAnswered(q) { // 1. 检查已选中的radio/checkbox const checkedInputs = q.querySelectorAll('input[type="radio"]:checked, input[type="checkbox"]:checked'); if (checkedInputs.length > 0) return true; // 2. 超星专用:检查选项背景样式 const chaoxingSelected = q.querySelectorAll('.answerBg[style]'); for (const opt of chaoxingSelected) { const style = opt.getAttribute('style') || ''; if (style.includes('background') && !style.includes('background-color: transparent') && !style.includes('background: none')) { return true; } } // 3. 检查选项元素的选中状态(仅针对选项,不检查整个题目容器) const optionSelectors = '.answerBg, .answer_item, .options li, [class*="option"], [class*="Option"], [class*="choice"], [class*="Choice"]'; const options = q.querySelectorAll(optionSelectors); for (const opt of options) { const cls = (opt.className || '').toLowerCase(); const style = opt.getAttribute('style') || ''; // 检查明确的选中类名 if (/\b(selected|checked|chosen|picked)\b/i.test(cls)) return true; // 检查aria属性 if (opt.getAttribute('aria-selected') === 'true' || opt.getAttribute('data-selected') === 'true') return true; // 检查内联样式的背景色(排除透明) if (style.includes('background') && !style.includes('transparent') && !style.includes('none')) return true; } // 4. 检查文本输入框 const inputs = q.querySelectorAll('input[type="text"], input:not([type]):not([radio]):not([checkbox]), textarea'); for (const input of inputs) { if (input.value && input.value.trim().length > 0) return true; } // 5. 检查富文本编辑器 const editors = q.querySelectorAll('.edui-body-container, [contenteditable="true"]'); for (const editor of editors) { const text = editor.innerText?.trim(); if (text && text.length > 0 && !['请在此处输入答案', '请输入答案', '请输入'].includes(text)) return true; } // 6. 检查iframe内容 const iframes = q.querySelectorAll('iframe'); for (const iframe of iframes) { try { const body = iframe.contentDocument?.body; if (body && body.innerText?.trim()?.length > 0) return true; } catch (e) {} } return false; } function getOptions(q) { const selectors = currentPlatform ? currentPlatform.selectors.options : GENERIC_SELECTORS.options.join(', '); return Array.from(q.querySelectorAll(selectors)).map(o => o.innerText.trim()).filter(Boolean); } function detectQuestions(forceRefresh = false) { const now = Date.now(); if (!forceRefresh && questionCache && (now - lastDetectTime) < 1000) { return questionCache; } detectPlatform(); let items = []; if (currentPlatform) { items = document.querySelectorAll(currentPlatform.selectors.container); } if (!items.length) { for (const selector of GENERIC_SELECTORS.containers) { try { const found = document.querySelectorAll(selector); if (found.length > 0) { const hasOptions = found[0].querySelectorAll(GENERIC_SELECTORS.options.join(', ')).length > 0; const hasInputs = found[0].querySelectorAll(GENERIC_SELECTORS.inputs.join(', ')).length > 0; if (hasOptions || hasInputs) { items = found; break; } } } catch (e) {} } } if (!items.length) { questionCache = []; return questionCache; } questionCache = Array.from(items).map((q, i) => { const id = q.getAttribute('data') || q.id?.replace('question', '') || q.getAttribute('data-id') || String(i + 1); const titleSelectors = currentPlatform ? currentPlatform.selectors.title : GENERIC_SELECTORS.titles.join(', '); const title = q.querySelector(titleSelectors)?.innerText?.replace(/\s+/g, ' ').trim() || ''; const type = getQuestionType(q, title); const answered = isAnswered(q); const opts = getOptions(q); return { id, title, type, options: opts, answered, element: q }; }); lastDetectTime = now; return questionCache; } function invalidateQuestionCache() { questionCache = null; } // ============================================================ // 题目网格更新(全局) // ============================================================ let startFromIndex = -1; let lastGridUpdate = 0; let gridUpdateTimer = null; const questionAnswerMap = {}; // 存储已获取的答案 { questionId: { answer, type } } function updateQuestionGrid(force = false) { const now = Date.now(); if (!force && (now - lastGridUpdate) < 2000) { return; } const grid = document.getElementById('yan-question-grid'); if (!grid) return; const questions = detectQuestions(force); if (!questions.length) { grid.innerHTML = '
未检测到题目
'; lastGridUpdate = now; return; } const answeredCount = questions.filter(q => q.answered).length; const fragment = document.createDocumentFragment(); questions.forEach((q, i) => { const cell = document.createElement('div'); cell.className = `yan-question-cell ${q.answered ? 'answered' : 'unanswered'}`; cell.dataset.index = i; cell.title = `题目 ${i+1} (ID: ${q.id}) - ${q.answered ? '已答' : '未答'}`; cell.textContent = i + 1; // 选择题/判断题显示答案角标 const ansData = questionAnswerMap[q.id]; if (ansData && (ansData.type.includes('选') || ansData.type.includes('判断'))) { const badge = document.createElement('span'); badge.className = 'yan-answer-badge'; badge.textContent = String(ansData.answer).toUpperCase().trim(); badge.title = `答案: ${ansData.answer}`; cell.appendChild(badge); } fragment.appendChild(cell); }); grid.innerHTML = ''; grid.appendChild(fragment); // 添加统计信息 const stats = document.createElement('div'); stats.style.cssText = 'grid-column:1/-1;text-align:center;font-size:11px;color:#718197;padding:4px 0;'; stats.textContent = `共 ${questions.length} 题,已答 ${answeredCount} 题,未答 ${questions.length - answeredCount} 题`; grid.appendChild(stats); // 绑定点击事件 grid.querySelectorAll('.yan-question-cell').forEach(cell => { cell.addEventListener('click', () => { const index = parseInt(cell.dataset.index); startFromIndex = index; if (typeof startAutoAnswerFromIndex === 'function') { startAutoAnswerFromIndex(index); } }); }); lastGridUpdate = now; } async function startAutoAnswerFromIndex(startIndex) { if (autoAnswerRunning) { stopAutoAnswerLoop(); await new Promise(r => setTimeout(r, 500)); } autoAnswerRunning = true; updateAutoAnswerUI(); log(`🚀 从第 ${startIndex + 1} 题开始自动答题`, '#00b894'); const questions = detectQuestions(true); const unansweredFromStart = questions.slice(startIndex).filter(q => !q.answered); if (!unansweredFromStart.length) { log('📋 从该题开始没有未答题目', '#dfe6e9'); stopAutoAnswerLoop(); return; } log(`📊 从第 ${startIndex + 1} 题开始,有 ${unansweredFromStart.length} 道未答题`, '#74b9ff'); let totalFilled = 0; let consecutiveErrors = 0; for (let i = 0; i < unansweredFromStart.length; i++) { if (!autoAnswerRunning) { log('⏹️ 自动答题已停止', '#fdcb6e'); break; } const q = unansweredFromStart[i]; const originalIndex = questions.indexOf(q); log(`📦 处理第 ${originalIndex + 1} 题 (ID: ${q.id})`, '#74b9ff'); let prompt; try { prompt = buildPrompt([q]); log(`📝 Prompt生成成功,长度: ${prompt.length}`, '#636e72'); } catch (e) { log(`❌ Prompt生成失败: ${e.message}`, '#ff7675'); continue; } let response = null; let success = false; for (let retry = 0; retry < MAX_RETRIES; retry++) { if (!autoAnswerRunning) break; try { if (retry > 0) { log(`🔄 第${retry + 1}次重试...`, '#fdcb6e'); await new Promise(r => setTimeout(r, RETRY_DELAY)); } else if (consecutiveErrors >= 3) { log(`⚠️ 连续失败,等待${RATE_LIMIT_DELAY/1000}秒...`, '#fdcb6e'); await new Promise(r => setTimeout(r, RATE_LIMIT_DELAY)); } log(`📡 开始请求AI...`, '#636e72'); response = await callAiApi(prompt); log(`📥 收到AI响应,长度: ${response?.length || 0}`, '#636e72'); success = true; consecutiveErrors = 0; break; } catch (e) { log(`❌ 请求失败: ${e.message}`, '#ff7675'); consecutiveErrors++; if (consecutiveErrors >= 6) { log(`🚫 连续${consecutiveErrors}次失败,自动停止`, '#ff7675'); autoAnswerRunning = false; updateAutoAnswerUI(); invalidateQuestionCache(); updateQuestionGrid(true); return; } } } if (!success || !response) { log(`⏭️ 跳过第${originalIndex + 1}题`, '#fdcb6e'); await new Promise(r => setTimeout(r, REQUEST_DELAY)); continue; } const answers = parseAiResponse(response); if (!answers || !Array.isArray(answers)) { log(`❌ AI返回格式无法解析`, '#ff7675'); continue; } for (const item of answers) { const qDom = document.querySelector(`.questionLi[data="${item.questionId}"], #question${item.questionId}`); if (!qDom) { log(`⚠️ 未找到题目 ${item.questionId}`, '#fdcb6e'); continue; } log(`📋 题目${item.questionId}答案: ${item.answer}`, '#00cec9'); // 存储答案到map,用于网格显示 questionAnswerMap[item.questionId] = { answer: item.answer, type: q.type || '' }; const type = qDom.getAttribute('typename') || ''; const ok = await fillAnswer(qDom, item, type); if (ok) { totalFilled++; log(`✅ [${totalFilled}/${unansweredFromStart.length}] 题目${item.questionId}已填写`, '#55efc4'); invalidateQuestionCache(); updateQuestionGrid(true); } else { log(`⚠️ 题目${item.questionId}填写失败`, '#fdcb6e'); } } if (i < unansweredFromStart.length - 1 && autoAnswerRunning) { log(`⏳ 等待${REQUEST_DELAY/1000}秒...`, '#636e72'); await new Promise(r => setTimeout(r, REQUEST_DELAY)); } } log(`🎯 总计已填写 ${totalFilled}/${unansweredFromStart.length} 道题`, '#00b894'); autoAnswerRunning = false; updateAutoAnswerUI(); invalidateQuestionCache(); updateQuestionGrid(true); } function buildPrompt(questions) { const lines = questions.map((q, i) => { let line = `[${i + 1}] ID:${q.id}`; if (q.type) line += ` 题型:${q.type}`; line += ` 题目:${q.title}`; if (q.options && q.options.length) line += ` 选项:${q.options.join('|')}`; return line; }); return `分析以下题目并严格返回JSON数组。必须包含"analysis"字段给出简短理由。\n格式:[{"questionId":"ID","answer":"内容","analysis":"解析"}]\n\n题目:\n${lines.join('\n\n')}`; } function parseAiResponse(text) { if (!text) return null; let cleaned = text.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); const firstBracket = cleaned.indexOf('['); const lastBracket = cleaned.lastIndexOf(']'); if (firstBracket === -1 || lastBracket === -1) return null; try { return JSON.parse(cleaned.substring(firstBracket, lastBracket + 1)); } catch { return null; } } // ============================================================ // 答案回填 // ============================================================ async function fillAnswer(qDom, item, type) { if (!qDom || !item) return false; if (item.analysis) { log(`💡 题${item.questionId}解析: ${item.analysis}`, '#eccc68'); } if (type.includes('填空')) { const answers = String(item.answer).split(/[;;\n]/); for (let idx = 0; idx < answers.length; idx++) { const val = answers[idx].trim(); if (!val) continue; const editorId = `answerEditor${item.questionId}${idx + 1}`; const s = document.createElement('script'); s.textContent = `(function(){ if(window.UE && UE.getEditor("${editorId}")){ UE.getEditor("${editorId}").ready(function(){ this.setContent("${val.replace(/"/g, '\\"')}"); }); } })();`; document.body.appendChild(s); s.remove(); await new Promise(r => setTimeout(r, 300)); } return true; } if (type.includes('简答') || type.includes('论述') || type.includes('名词解释') || type.includes('问答')) { log(`📝 答案: ${item.answer}`, '#74b9ff'); const answerText = String(item.answer); const simulateTyping = (doc, body) => { body.focus(); doc.execCommand('selectAll', false, null); doc.execCommand('delete', false, null); doc.execCommand('insertText', false, answerText); return body.innerText?.trim()?.length > 0; }; const iframe = qDom.querySelector('iframe.edui-editor-iframeholder iframe, .edui-editor-iframeholder iframe, iframe'); if (iframe) { try { const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; const iframeBody = iframeDoc.body; log(`🔍 找到iframe编辑器,模拟输入中...`, '#636e72'); const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); iframeBody.dispatchEvent(clickEvent); await new Promise(r => setTimeout(r, 200)); const success = simulateTyping(iframeDoc, iframeBody); if (success) { iframeBody.dispatchEvent(new Event('input', { bubbles: true })); iframeBody.dispatchEvent(new Event('change', { bubbles: true })); iframeBody.dispatchEvent(new Event('blur', { bubbles: true })); log(`✅ 已模拟输入到iframe编辑器`, '#55efc4'); return true; } } catch (e) { log(`⚠️ iframe输入失败: ${e.message}`, '#fdcb6e'); } } const contentEditable = qDom.querySelector('[contenteditable="true"]'); if (contentEditable) { log(`🔍 找到contenteditable编辑器,模拟输入中...`, '#636e72'); contentEditable.click(); contentEditable.focus(); await new Promise(r => setTimeout(r, 200)); document.execCommand('selectAll', false, null); document.execCommand('delete', false, null); document.execCommand('insertText', false, answerText); if (contentEditable.innerText?.trim()?.length > 0) { contentEditable.dispatchEvent(new Event('input', { bubbles: true })); contentEditable.dispatchEvent(new Event('change', { bubbles: true })); contentEditable.dispatchEvent(new Event('blur', { bubbles: true })); log(`✅ 已模拟输入到contenteditable编辑器`, '#55efc4'); return true; } } const textarea = qDom.querySelector('textarea'); if (textarea) { log(`🔍 找到textarea,模拟输入中...`, '#636e72'); textarea.click(); textarea.focus(); await new Promise(r => setTimeout(r, 200)); const nativeSetter = Object.getOwnPropertyDescriptor( window.HTMLTextAreaElement.prototype, 'value' )?.set; if (nativeSetter) { nativeSetter.call(textarea, answerText); } else { textarea.value = answerText; } textarea.dispatchEvent(new Event('input', { bubbles: true })); textarea.dispatchEvent(new Event('change', { bubbles: true })); textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true })); textarea.dispatchEvent(new Event('blur', { bubbles: true })); if (textarea.value.length > 0) { log(`✅ 已模拟输入到textarea`, '#55efc4'); return true; } } try { GM_setClipboard(answerText); log(`📋 答案已复制到剪贴板,请手动粘贴`, '#fdcb6e'); log(`💡 提示:点击输入框后使用 Ctrl+V 粘贴`, '#74b9ff'); return false; } catch (e) { log(`⚠️ 无法复制到剪贴板`, '#fdcb6e'); } log(`⚠️ 未找到编辑器,答案已在日志中显示`, '#fdcb6e'); return false; } const ansStr = String(item.answer).toUpperCase().trim(); const opts = qDom.querySelectorAll('.answerBg, .answer_item, .options li'); let filled = false; if (type.includes('判断')) { for (const opt of opts) { const label = opt.innerText?.trim(); const isCorrect = (ansStr.includes('对') && (label.includes('对') || label.includes('√') || label.includes('正确') || label.includes('A'))) || (ansStr.includes('错') && (label.includes('错') || label.includes('×') || label.includes('不正确') || label.includes('B'))); if (isCorrect) { opt.click(); filled = true; break; } } } else { for (let i = 0; i < opts.length; i++) { const opt = opts[i]; const label = opt.querySelector('.num_option, .num_option_dx, b')?.innerText?.trim().replace('.', '') || opt.getAttribute('data') || ''; if (label && ansStr.includes(label.toUpperCase())) { if (type.includes('多选') && typeof addMultipleChoice === 'function') { addMultipleChoice(opt); } else if (typeof addChoice === 'function') { addChoice(opt); } else { opt.click(); } filled = true; await new Promise(r => setTimeout(r, 200)); } } } return filled; } // ============================================================ // 自动答题核心(逐题处理,带重试和限流检测) // ============================================================ const BATCH_SIZE = 1; const REQUEST_DELAY = 5000; // 增加到5秒 const MAX_RETRIES = 3; const RETRY_DELAY = 15000; // 重试间隔15秒 const RATE_LIMIT_DELAY = 30000; // 限流后等待30秒 async function doAutoAnswer() { const questions = detectQuestions(); const unanswered = questions.filter(q => !q.answered); if (!unanswered.length) { log('📋 未发现未答题目,所有题目已作答', '#dfe6e9'); return 0; } let totalFilled = 0; let consecutiveErrors = 0; for (let i = 0; i < unanswered.length; i++) { if (!autoAnswerRunning) { log('⏹️ 自动答题已停止', '#fdcb6e'); break; } const q = unanswered[i]; log(`📦 处理第 ${i + 1}/${unanswered.length} 题 (ID: ${q.id})`, '#74b9ff'); const prompt = buildPrompt([q]); let response = null; let success = false; for (let retry = 0; retry < MAX_RETRIES; retry++) { if (!autoAnswerRunning) break; try { if (retry > 0) { log(`🔄 第${retry + 1}次重试...`, '#fdcb6e'); await new Promise(r => setTimeout(r, RETRY_DELAY)); } else if (consecutiveErrors >= 3) { log(`⚠️ 检测到连续失败,等待${RATE_LIMIT_DELAY/1000}秒避免限流...`, '#fdcb6e'); await new Promise(r => setTimeout(r, RATE_LIMIT_DELAY)); } response = await callAiApi(prompt); success = true; consecutiveErrors = 0; break; } catch (e) { log(`❌ 请求失败: ${e.message}`, '#ff7675'); consecutiveErrors++; // 检测限流错误 const isRateLimit = e.message.includes('429') || e.message.includes('rate') || e.message.includes('limit') || e.message.includes('too many'); if (isRateLimit) { log(`🚫 检测到API限流,等待${RATE_LIMIT_DELAY/1000}秒后重试...`, '#fdcb6e'); await new Promise(r => setTimeout(r, RATE_LIMIT_DELAY)); } if (consecutiveErrors >= 6) { log(`🚫 连续${consecutiveErrors}次失败,自动停止`, '#ff7675'); log(`💡 建议:等待5-10分钟后再尝试,或更换API Key`, '#fdcb6e'); autoAnswerRunning = false; updateAutoAnswerUI(); return -1; } } } if (!success || !response) { log(`⏭️ 跳过第${i + 1}题,继续下一题`, '#fdcb6e'); // 跳过后也等待一段时间 await new Promise(r => setTimeout(r, REQUEST_DELAY)); continue; } const answers = parseAiResponse(response); if (!answers || !Array.isArray(answers)) { log(`❌ AI返回格式无法解析`, '#ff7675'); log(`原始响应: ${response.substring(0, 300)}`, '#636e72'); continue; } for (const item of answers) { const qDom = document.querySelector(`.questionLi[data="${item.questionId}"], #question${item.questionId}, [data-qid="${item.questionId}"]`); if (!qDom) { log(`⚠️ 未找到题目 ${item.questionId}`, '#fdcb6e'); continue; } // 显示答案内容 log(`📋 题目${item.questionId}答案: ${item.answer}`, '#00cec9'); // 存储答案到map,用于网格显示 questionAnswerMap[item.questionId] = { answer: item.answer, type: q.type || '' }; updateQuestionGrid(true); // 立即刷新网格显示角标 const type = qDom.getAttribute('typename') || ''; const ok = await fillAnswer(qDom, item, type); if (ok) { totalFilled++; log(`✅ [${totalFilled}/${unanswered.length}] 题目${item.questionId}已填写`, '#55efc4'); } else { log(`⚠️ 题目${item.questionId}填写失败`, '#fdcb6e'); } } if (i < unanswered.length - 1 && autoAnswerRunning) { log(`⏳ 等待${REQUEST_DELAY/1000}秒...`, '#636e72'); await new Promise(r => setTimeout(r, REQUEST_DELAY)); } } log(`🎯 总计已填写 ${totalFilled}/${unanswered.length} 道题`, '#00b894'); if (autoAnswerRunning) { log(`✅ 自动答题完成`, '#00b894'); } return totalFilled; } async function startAutoAnswerLoop() { if (autoAnswerRunning) return; autoAnswerRunning = true; updateAutoAnswerUI(); const questions = detectQuestions(); const unanswered = questions.filter(q => !q.answered); const answeredCount = questions.length - unanswered.length; log(`📊 共 ${questions.length} 道题,已答 ${answeredCount} 道,未答 ${unanswered.length} 道`, '#74b9ff'); log(`🔍 将逐题处理未答题目`, '#74b9ff'); log(`⚙️ 配置: 请求间隔${REQUEST_DELAY/1000}秒, 失败重试${MAX_RETRIES}次, 重试间隔${RETRY_DELAY/1000}秒`, '#636e72'); log('🚀 自动答题已启动', '#00b894'); const loop = async () => { if (!autoAnswerRunning) return; const result = await doAutoAnswer(); if (result === -1) { log('⚠️ 发生错误,自动答题已停止', '#ff7675'); stopAutoAnswerLoop(); return; } if (autoAnswerRunning && uiSettings.autoNextQuestion) { const nextBtn = document.querySelector('.nextDiv a, .next-btn, .btn_next, a[class*="next"]'); if (nextBtn) { log('➡️ 自动切换下一题...', '#74b9ff'); nextBtn.click(); autoAnswerTimer = setTimeout(loop, uiSettings.autoAnswerDelay + 2000); } else { log('✅ 所有题目已完成,自动答题停止', '#00b894'); stopAutoAnswerLoop(); } } else { stopAutoAnswerLoop(); } }; autoAnswerTimer = setTimeout(loop, 500); } function stopAutoAnswerLoop() { autoAnswerRunning = false; if (autoAnswerTimer) { clearTimeout(autoAnswerTimer); autoAnswerTimer = null; } updateAutoAnswerUI(); log('⏹️ 自动答题已停止', '#dfe6e9'); } function updateAutoAnswerUI() { const btn = document.getElementById('btn-auto-answer'); if (!btn) return; if (autoAnswerRunning) { btn.textContent = '⏹️ 停止自动答题'; btn.style.background = 'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)'; } else { btn.textContent = '🤖 开始自动答题'; btn.style.background = 'linear-gradient(135deg, #00b894 0%, #00a085 100%)'; } } // ============================================================ // 日志系统 // ============================================================ const log = (msg, color = '#00ff00') => { const box = document.getElementById('yan-log'); if (box) { const div = document.createElement('div'); div.style.color = color; const time = new Date().toLocaleTimeString('zh-CN', { hour12: false }); div.innerText = `[${time}] ${msg}`; box.appendChild(div); box.scrollTop = box.scrollHeight; if (box.children.length > 500) { box.removeChild(box.firstChild); } } }; // ============================================================ // 解锁粘贴限制 // ============================================================ const hookUEditor = () => { if (window.UE && window.UE.Editor) { const originalFire = window.UE.Editor.prototype.fireEvent; window.UE.Editor.prototype.fireEvent = function(type) { if (type === 'beforepaste') return; return originalFire.apply(this, arguments); }; log("🔓 UEditor粘贴限制已解除"); } else { setTimeout(hookUEditor, 1500); } }; const hookPasteEvents = () => { document.addEventListener('paste', function(e) { e.stopPropagation(); }, true); document.addEventListener('copy', function(e) { e.stopPropagation(); }, true); document.addEventListener('cut', function(e) { e.stopPropagation(); }, true); document.addEventListener('keydown', function(e) { if (e.ctrlKey && (e.key === 'v' || e.key === 'V' || e.key === 'c' || e.key === 'C')) { e.stopPropagation(); } }, true); log("🔓 全局复制粘贴限制已解除"); }; const forceSetValue = (element, value) => { if (!element) return false; element.focus(); if (element.tagName === 'TEXTAREA' || element.tagName === 'INPUT') { const nativeInputValueSetter = Object.getOwnPropertyDescriptor( window.HTMLTextAreaElement.prototype, 'value' )?.set || Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' )?.set; if (nativeInputValueSetter) { nativeInputValueSetter.call(element, value); } else { element.value = value; } element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true })); return true; } if (element.contentEditable === 'true') { element.innerHTML = value.replace(/\n/g, '
'); element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); element.dispatchEvent(new Event('blur', { bubbles: true })); return true; } if (element.tagName === 'IFRAME') { try { const body = element.contentDocument?.body; if (body) { body.innerHTML = value.replace(/\n/g, '
'); body.dispatchEvent(new Event('input', { bubbles: true })); return true; } } catch (e) { log(`⚠️ iframe访问受限`, '#fdcb6e'); } } return false; }; // ============================================================ // UI 样式 // ============================================================ GM_addStyle(` :root { --yan-panel-width: ${uiSettings.panelWidth}px; --yan-panel-opacity: ${uiSettings.panelOpacity / 100}; --yan-float-duration: ${uiSettings.floatDuration}s; --yan-ball-size: ${uiSettings.ballSize}px; } #yan-ball { position: fixed; left: 20px; top: 20px; width: var(--yan-ball-size); height: var(--yan-ball-size); background: radial-gradient(circle at 28% 24%, rgba(255,255,255,0.98) 0 8%, rgba(255,255,255,0.38) 10%, rgba(255,255,255,0) 30%), radial-gradient(circle at 68% 72%, rgba(31,145,255,0.24) 0 14%, rgba(31,145,255,0) 54%), linear-gradient(145deg, #58b9ff 0%, #2d93ff 40%, #0f68ef 100%); border-radius: 46% 54% 52% 48% / 44% 42% 58% 56%; box-shadow: 0 18px 34px rgba(8,72,170,0.28), inset 0 1px 3px rgba(255,255,255,0.45), inset 0 -10px 18px rgba(0,0,0,0.14); cursor: grab; z-index: 9999999; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 13px; font-weight: 700; letter-spacing: 0.04em; user-select: none; -webkit-user-select: none; touch-action: none; overflow: hidden; transition: box-shadow 0.22s ease, filter 0.22s ease; animation: yan-morph var(--yan-float-duration) ease-in-out infinite, yan-breathe 3.1s ease-in-out infinite, yan-drift 7.8s ease-in-out infinite; will-change: transform, left, top, border-radius; } #yan-ball::before { content: ""; position: absolute; inset: -12%; border-radius: 48% 52% 54% 46% / 50% 46% 54% 50%; background: radial-gradient(circle at 28% 28%, rgba(255,255,255,0.56) 0 10%, rgba(255,255,255,0.12) 28%, rgba(255,255,255,0) 56%), radial-gradient(circle at 68% 72%, rgba(34,141,255,0.32) 0 12%, rgba(34,141,255,0) 60%); filter: blur(6px); opacity: 0.9; transform: translate3d(0,0,0); animation: yan-swim calc(var(--yan-float-duration)*1.15) ease-in-out infinite; pointer-events: none; } #yan-ball::after { content: ""; position: absolute; inset: 12% 16% 50% 16%; border-radius: 50%; background: linear-gradient(to bottom, rgba(255,255,255,0.72), rgba(255,255,255,0)); filter: blur(1px); opacity: 0.95; animation: yan-sheen 2.7s ease-in-out infinite; pointer-events: none; } #yan-ball span { position: relative; z-index: 1; text-shadow: 0 1px 2px rgba(0,0,0,0.16); } #yan-ball:hover { filter: saturate(1.05) brightness(1.03); box-shadow: 0 20px 38px rgba(8,72,170,0.32), inset 0 1px 3px rgba(255,255,255,0.5), inset 0 -10px 18px rgba(0,0,0,0.12); } #yan-ball.dragging { cursor: grabbing; animation: none; transform: scale(1.08); } #yan-ball.was-dragged { animation: yan-settle 0.22s ease-out; } #yan-ball.releasing { animation: yan-release 0.36s cubic-bezier(.2,.8,.2,1); } #yan-panel { position: fixed; width: var(--yan-panel-width); background: rgba(255,255,255,var(--yan-panel-opacity)); border-radius: 22px; box-shadow: 0 24px 60px rgba(0,0,0,0.18); border: 1px solid rgba(255,255,255,0.72); backdrop-filter: blur(14px); z-index: 9999998; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; opacity: 0; transform: translateY(14px) scale(0.96); transition: opacity 220ms ease, transform 260ms cubic-bezier(.2,.9,.2,1); pointer-events: none; } #yan-panel.is-open { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; } .yan-panel-shell { display: flex; flex-direction: column; max-height: min(85vh, 800px); } .yan-header { padding: 14px 16px 12px; background: linear-gradient(180deg, #fafcff 0%, #eff5ff 100%); border-bottom: 1px solid #e8eef9; } .yan-header-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } .yan-title-wrap { display: flex; flex-direction: column; gap: 6px; } .yan-title { font-weight: 800; font-size: 16px; color: #20324a; display: block; letter-spacing: 0.01em; } .yan-subtitle { font-size: 12px; color: #62738b; line-height: 1.4; } .yan-close { border: none; background: rgba(27,52,89,0.08); color: #20324a; width: 30px; height: 30px; border-radius: 50%; cursor: pointer; flex: 0 0 auto; font-size: 16px; } .yan-body { padding: 14px; overflow: auto; } .yan-tabs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; padding: 10px 14px 0; } .yan-tab { border: 1px solid #e6ecf6; background: #f8fbff; color: #42526b; border-radius: 999px; padding: 8px 6px; font-size: 11px; font-weight: 800; cursor: pointer; text-align: center; transition: transform 160ms ease, background 160ms ease, color 160ms ease, border-color 160ms ease; } .yan-tab:hover { transform: translateY(-1px); background: #eef4ff; border-color: #cfdcf8; } .yan-tab.is-active { background: linear-gradient(135deg, #3e76ff 0%, #1a57e8 100%); border-color: transparent; color: #fff; box-shadow: 0 10px 22px rgba(34,89,226,0.18); } .yan-page { display: none; animation: yan-page-in 180ms ease-out; } .yan-page.is-active { display: block; } .yan-section { border: 1px solid #edf1f7; background: #fff; border-radius: 18px; margin-bottom: 12px; overflow: hidden; } .yan-section-title { padding: 10px 14px 8px; font-size: 13px; font-weight: 800; color: #21314a; background: linear-gradient(180deg, #fbfcfe 0%, #f7f9fd 100%); border-bottom: 1px solid #edf1f7; letter-spacing: 0.02em; } .yan-section-desc { padding: 10px 14px 0; font-size: 12px; line-height: 1.55; color: #718197; } .yan-action-grid { display: grid; grid-template-columns: 1fr; gap: 10px; padding: 12px 14px 14px; } .yan-action-grid-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 12px 14px 14px; } .yan-btn { width: 100%; padding: 13px 16px; border: none; cursor: pointer; font-weight: 700; font-size: 14px; color: #eaf1ff; text-align: left; border-radius: 14px; box-shadow: 0 8px 18px rgba(0,0,0,0.1); transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease; } .yan-btn:hover { transform: translateY(-1px); filter: brightness(1.03); box-shadow: 0 12px 24px rgba(0,0,0,0.14); } .yan-btn:active { transform: translateY(0); } .yan-btn-sm { padding: 10px 12px; font-size: 12px; } #btn-auto-answer { background: linear-gradient(135deg, #00b894 0%, #00a085 100%); } .yan-question-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; padding: 12px 14px 14px; } .yan-question-cell { aspect-ratio: 1; display: flex; align-items: center; justify-content: center; border-radius: 8px; font-size: 12px; font-weight: 700; cursor: pointer; transition: all 160ms ease; border: 1px solid #e6ecf6; } .yan-question-cell.answered { background: linear-gradient(135deg, #00b894 0%, #00a085 100%); color: #fff; border-color: transparent; box-shadow: 0 4px 12px rgba(0, 184, 148, 0.3); } .yan-question-cell.unanswered { background: #f0f2f5; color: #636e72; } .yan-question-cell:hover { transform: scale(1.05); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); } .yan-question-cell.active { background: linear-gradient(135deg, #0984e3 0%, #6c5ce7 100%); color: #fff; border-color: transparent; box-shadow: 0 4px 12px rgba(9, 132, 227, 0.4); } .yan-answer-badge { position: absolute; top: -4px; right: -4px; min-width: 14px; height: 14px; padding: 0 3px; font-size: 9px; font-weight: 800; line-height: 14px; text-align: center; color: #fff; background: linear-gradient(135deg, #fd79a8 0%, #e84393 100%); border-radius: 7px; box-shadow: 0 2px 6px rgba(232, 67, 147, 0.4); pointer-events: none; z-index: 1; } .yan-question-cell { position: relative; } #btn-export { background: linear-gradient(135deg, #364a63 0%, #243449 100%); } #btn-import { background: linear-gradient(135deg, #25b46b 0%, #18a35d 100%); } #btn-reset { background: linear-gradient(135deg, #f59b23 0%, #e77b12 100%); } #btn-wizard { background: linear-gradient(135deg, #f8fbff 0%, #eef4ff 100%); color: #243449; border: 1px solid #dbe5fb; box-shadow: 0 8px 18px rgba(25,53,96,0.06); } #btn-wizard:hover { color: #1f3357; background: linear-gradient(135deg, #eff4ff 0%, #e4ecff 100%); } .yan-settings { display: grid; gap: 12px; padding: 12px 14px 14px; } .yan-field { display: grid; gap: 8px; } .yan-field-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 12px; color: #42526b; } .yan-field-label { font-weight: 700; color: #22324a; } .yan-field-value { font-variant-numeric: tabular-nums; color: #61738b; } .yan-range { width: 100%; margin: 0; accent-color: #3f79ff; } .yan-switch { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #edf1f7; border-radius: 14px; background: #fbfcfe; } .yan-switch small { display: block; color: #7a8797; margin-top: 4px; line-height: 1.4; } .yan-switch strong { color: #21314a; } .yan-switch input { width: 18px; height: 18px; } .yan-input { width: 100%; padding: 10px 12px; border: 1px solid #dfe6f1; border-radius: 12px; background: #fff; color: #22324a; font-size: 13px; outline: none; font-family: monospace; box-sizing: border-box; } .yan-input:focus { border-color: #3f79ff; box-shadow: 0 0 0 3px rgba(63,121,255,0.12); } .yan-input::placeholder { color: #b0bec5; } .yan-select { width: 100%; padding: 11px 12px; border: 1px solid #dfe6f1; border-radius: 14px; background: #fff; color: #22324a; font-weight: 700; outline: none; box-sizing: border-box; } .yan-provider-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; padding: 12px 14px; } .yan-provider-btn { border: 2px solid #e6ecf6; background: #f8fbff; color: #42526b; border-radius: 12px; padding: 8px 4px; font-size: 11px; font-weight: 700; cursor: pointer; text-align: center; transition: all 160ms ease; } .yan-provider-btn:hover { background: #eef4ff; border-color: #cfdcf8; transform: translateY(-1px); } .yan-provider-btn.is-active { background: linear-gradient(135deg, #3e76ff 0%, #1a57e8 100%); border-color: transparent; color: #fff; box-shadow: 0 6px 14px rgba(34,89,226,0.18); } #yan-log { height: 180px; background: #20242a; color: #89ff9c; overflow-y: auto; padding: 12px; font-size: 12px; font-family: 'Cascadia Code', 'Fira Code', monospace; border-radius: 0 0 18px 18px; line-height: 1.6; } .yan-log-frame { border: 1px solid #edf1f7; border-radius: 18px; overflow: hidden; background: #20242a; } .yan-status-bar { padding: 8px 14px; background: #f0f4ff; border-radius: 12px; font-size: 12px; color: #42526b; display: flex; align-items: center; gap: 8px; margin: 8px 14px; border: 1px solid #e6ecf6; } #yan-search-input { font-family: inherit; line-height: 1.5; } #yan-search-result { font-family: inherit; line-height: 1.6; overflow-y: auto; max-height: 200px; } .yan-status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .yan-status-dot.ok { background: #00b894; box-shadow: 0 0 6px rgba(0,184,148,0.4); } .yan-status-dot.warn { background: #fdcb6e; box-shadow: 0 0 6px rgba(253,203,110,0.4); } .yan-status-dot.error { background: #ff7675; box-shadow: 0 0 6px rgba(255,118,117,0.4); } .yan-status-dot.idle { background: #b2bec3; } @keyframes yan-morph { 0%,100%{border-radius:46% 54% 52% 48%/44% 42% 58% 56%;transform:translate3d(0,0,0) rotate(-2deg)} 25%{border-radius:52% 48% 41% 59%/50% 58% 42% 50%;transform:translate3d(0,-4px,0) rotate(1deg)} 50%{border-radius:42% 58% 56% 44%/58% 46% 54% 42%;transform:translate3d(0,2px,0) rotate(2deg)} 75%{border-radius:58% 42% 48% 52%/42% 56% 44% 58%;transform:translate3d(0,-2px,0) rotate(-1deg)} } @keyframes yan-swim { 0%,100%{transform:translate3d(-6px,-2px,0) scale(1)} 33%{transform:translate3d(5px,3px,0) scale(1.03)} 66%{transform:translate3d(-2px,5px,0) scale(1.01)} } @keyframes yan-sheen { 0%,100%{transform:translate3d(0,0,0) scale(1);opacity:.95} 50%{transform:translate3d(2px,4px,0) scale(.98);opacity:.8} } @keyframes yan-breathe { 0%,100%{filter:saturate(1) brightness(1)} 50%{filter:saturate(1.08) brightness(1.03)} } @keyframes yan-settle { 0%{transform:scale(1.12)} 70%{transform:scale(.98)} 100%{transform:scale(1)} } @keyframes yan-release { 0%{transform:scale(1.05)} 40%{transform:scale(.96)} 100%{transform:scale(1)} } @keyframes yan-drift { 0%,100%{filter:hue-rotate(0deg) saturate(1)} 50%{filter:hue-rotate(10deg) saturate(1.08)} } @keyframes yan-page-in { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} } @media (prefers-reduced-motion: reduce) { #yan-ball,#yan-ball::before,#yan-ball::after,#yan-panel{animation:none!important;transition-duration:.01ms!important} } `); // ============================================================ // 主界面初始化 // ============================================================ function createRangeField(id, label, min, max, step, unit, value) { return `
${label}${value}${unit}
`; } function getProviderStatus() { if (!aiConfig.apiUrl) return { cls: 'warn', text: '未配置AI接口' }; if (!aiConfig.apiKey && !aiConfig.apiUrl.includes('localhost') && !aiConfig.apiUrl.includes('127.0.0.1')) return { cls: 'warn', text: '未配置API Key' }; return { cls: 'ok', text: `${AI_PROVIDERS.find(p => p.id === aiConfig.provider)?.name || '自定义'} · ${aiConfig.model}` }; } function init() { if (document.getElementById('yan-ball')) return; const ball = document.createElement('div'); ball.id = 'yan-ball'; ball.innerHTML = 'AI 答题'; document.body.appendChild(ball); const status = getProviderStatus(); const panel = document.createElement('div'); panel.id = 'yan-panel'; panel.innerHTML = `
AI答题 拖动球体换位置 · 点击打开面板 · 自动识别题目并AI答题
${status.text}
🤖 智能答题
自动识别页面题目,调用AI生成答案并回填。
📊 题目进度
点击格子从该题开始答题,绿色=已答,灰色=未答
等待检测题目...
📋 手动操作
🔍 手动搜题
输入题目内容,点击回答获取答案
题目内容
AI回答
🔧 AI 服务商预设
${AI_PROVIDERS.map(p => ``).join('')}
⚙️ 接口参数
API 地址
模型名称
API Key
${createRangeField('yan-ai-temp', '温度 (越低越精确)', 0, 1, 0.05, '', aiConfig.temperature)}
📋 运行日志
显示AI请求、答题结果和错误信息。
就绪... 请先配置AI接口
⚙️ 通用设置
${createRangeField('yan-setting-ball-size', '浮球大小', 52, 92, 1, ' px', uiSettings.ballSize)} ${createRangeField('yan-setting-float-duration', '浮动速度', 2.8, 8, 0.1, ' s', uiSettings.floatDuration.toFixed(1))} ${createRangeField('yan-setting-panel-width', '面板宽度', 320, 520, 10, ' px', uiSettings.panelWidth)} ${createRangeField('yan-setting-panel-opacity', '面板透明度', 85, 100, 1, ' %', uiSettings.panelOpacity)} ${createRangeField('yan-setting-delay', '答题间隔', 500, 5000, 100, ' ms', uiSettings.autoAnswerDelay)}
`; document.body.appendChild(panel); // ---- 向导面板 ---- const wizardMask = document.createElement('div'); wizardMask.id = 'yan-wizard-mask'; wizardMask.style.cssText = 'position:fixed;inset:0;background:rgba(13,22,39,0.42);backdrop-filter:blur(8px);z-index:9999997;opacity:0;pointer-events:none;transition:opacity 220ms ease;'; wizardMask.innerHTML = `

配置向导

首次使用请按步骤配置AI接口,之后即可自动答题。

1 配置AI接口

切换到「AI配置」页面,选择一个AI服务商(推荐DeepSeek),填入API Key后保存。

2 开始答题

回到「答题」页面,点击「开始自动答题」即可自动识别题目并AI作答。也可使用「单次答题」手动触发一次。

3 查看日志

在「日志」页面可以查看AI请求状态、答题结果和错误信息。

`; document.body.appendChild(wizardMask); // ============================================================ // 事件绑定 // ============================================================ const pageMap = Array.from(panel.querySelectorAll('.yan-page')); const tabButtons = Array.from(panel.querySelectorAll('.yan-tab')); const setPage = (page) => { tabButtons.forEach(b => b.classList.toggle('is-active', b.dataset.page === page)); pageMap.forEach(n => n.classList.toggle('is-active', n.dataset.pagePanel === page)); if (panel.classList.contains('is-open')) positionPanel(ball, panel); }; const openPanelTo = (page) => { setPage(page); panel.style.display = 'block'; panel.getBoundingClientRect(); positionPanel(ball, panel); requestAnimationFrame(() => panel.classList.add('is-open')); }; const hidePanel = () => { panel.classList.remove('is-open'); setTimeout(() => { if (!panel.classList.contains('is-open')) panel.style.display = 'none'; }, 260); }; const togglePanel = () => { if (panel.classList.contains('is-open')) hidePanel(); else openPanelTo(uiSettings.autoOpenPage); }; tabButtons.forEach(btn => { btn.addEventListener('click', () => { setPage(btn.dataset.page); uiSettings.autoOpenPage = btn.dataset.page; saveUiSettings(); }); }); document.getElementById('yan-close').addEventListener('click', hidePanel); // ---- 向导 ---- const showWizard = () => { wizardMask.style.opacity = '1'; wizardMask.style.pointerEvents = 'auto'; const wp = document.getElementById('yan-wizard-panel'); wp.style.opacity = '1'; wp.style.transform = 'translate(-50%,-50%) scale(1)'; wp.style.pointerEvents = 'auto'; GM_setValue(STORAGE_KEYS.wizardSeen, true); }; const hideWizard = () => { wizardMask.style.opacity = '0'; wizardMask.style.pointerEvents = 'none'; const wp = document.getElementById('yan-wizard-panel'); wp.style.opacity = '0'; wp.style.transform = 'translate(-50%,-46%) scale(0.96)'; wp.style.pointerEvents = 'none'; }; document.getElementById('yan-wizard-close').addEventListener('click', hideWizard); document.getElementById('yan-wizard-start').addEventListener('click', hideWizard); document.getElementById('yan-wizard-never').addEventListener('click', hideWizard); wizardMask.addEventListener('click', e => { if (e.target === wizardMask) hideWizard(); }); document.getElementById('btn-wizard')?.addEventListener('click', showWizard); // ---- AI配置 ---- document.querySelectorAll('.yan-provider-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.yan-provider-btn').forEach(b => b.classList.remove('is-active')); btn.classList.add('is-active'); const provider = AI_PROVIDERS.find(p => p.id === btn.dataset.provider); if (provider) { aiConfig.provider = provider.id; if (provider.url) document.getElementById('yan-ai-url').value = provider.url; if (provider.model) document.getElementById('yan-ai-model').value = provider.model; aiConfig.apiUrl = provider.url || aiConfig.apiUrl; aiConfig.model = provider.model || aiConfig.model; } }); }); document.getElementById('yan-ai-url').addEventListener('input', e => { aiConfig.apiUrl = e.target.value.trim(); }); document.getElementById('yan-ai-model').addEventListener('input', e => { aiConfig.model = e.target.value.trim(); }); document.getElementById('yan-ai-key').addEventListener('input', e => { aiConfig.apiKey = e.target.value.trim(); }); document.getElementById('yan-ai-temp').addEventListener('input', e => { aiConfig.temperature = parseFloat(e.target.value); document.getElementById('yan-ai-temp-value').textContent = aiConfig.temperature; }); document.getElementById('btn-save-ai').addEventListener('click', () => { saveAiConfig(); const s = getProviderStatus(); const statusEl = panel.querySelector('.yan-status-bar'); if (statusEl) statusEl.innerHTML = `${s.text}`; log('💾 AI配置已保存', '#55efc4'); }); // ---- 答题按钮 ---- document.getElementById('btn-auto-answer').addEventListener('click', () => { if (autoAnswerRunning) { stopAutoAnswerLoop(); } else { startFromIndex = 0; startAutoAnswerFromIndex(0); } }); // ---- 导出/回填 ---- document.getElementById('btn-export').addEventListener('click', () => { const questions = detectQuestions(); if (!questions.length) { log('❌ 未检测到题目', '#ff7675'); return; } const prompt = buildPrompt(questions); GM_setClipboard(prompt); log(`✅ 已导出 ${questions.length} 题到剪贴板`, '#55efc4'); }); document.getElementById('btn-import').addEventListener('click', async () => { try { const text = await navigator.clipboard.readText(); const answers = parseAiResponse(text); if (!answers) { log('❌ 剪贴板内容无法解析为JSON', '#ff7675'); return; } for (const item of answers) { const qDom = document.querySelector(`.questionLi[data="${item.questionId}"], #question${item.questionId}`); if (!qDom) continue; const type = qDom.getAttribute('typename') || ''; await fillAnswer(qDom, item, type); await new Promise(r => setTimeout(r, 300)); } log(`🎯 回填完成`, '#00b894'); } catch (e) { log('❌ 剪贴板读取失败: ' + e.message, '#ff7675'); } }); // ---- 手动搜题 ---- document.getElementById('btn-search-answer').addEventListener('click', async () => { const input = document.getElementById('yan-search-input'); const result = document.getElementById('yan-search-result'); const question = input.value.trim(); if (!question) { result.textContent = '❌ 请输入题目内容'; result.style.color = '#ff7675'; return; } result.textContent = '🤖 正在请求AI...'; result.style.color = '#74b9ff'; const prompt = `请回答以下题目,直接给出答案和简短解析。\n\n题目:${question}`; try { const response = await callAiApi(prompt); result.textContent = response; result.style.color = '#333'; log('✅ 手动搜题完成', '#55efc4'); } catch (e) { result.textContent = `❌ 请求失败: ${e.message}`; result.style.color = '#ff7675'; } }); document.getElementById('btn-copy-answer').addEventListener('click', () => { const result = document.getElementById('yan-search-result'); const text = result.textContent; if (!text || text.includes('请输入题目') || text.includes('正在请求')) { return; } GM_setClipboard(text); const btn = document.getElementById('btn-copy-answer'); const originalText = btn.textContent; btn.textContent = '✅ 已复制'; setTimeout(() => { btn.textContent = originalText; }, 1500); log('📋 答案已复制到剪贴板', '#55efc4'); }); // ---- 设置控件 ---- const bindRange = (id, key, transform) => { const el = document.getElementById(id); if (!el) return; el.addEventListener('input', e => { uiSettings[key] = transform ? transform(e.target.value) : parseFloat(e.target.value); const valEl = document.getElementById(`${id}-value`); if (valEl) valEl.textContent = typeof uiSettings[key] === 'number' && uiSettings[key] % 1 !== 0 ? uiSettings[key].toFixed(1) : uiSettings[key]; saveUiSettings(); applyUiToElements(ball, panel); }); }; bindRange('yan-setting-ball-size', 'ballSize', v => clamp(Number(v), 52, 92)); bindRange('yan-setting-float-duration', 'floatDuration', v => clamp(Number(v), 2.8, 8)); bindRange('yan-setting-panel-width', 'panelWidth', v => clamp(Number(v), 320, 520)); bindRange('yan-setting-panel-opacity', 'panelOpacity', v => clamp(Number(v), 85, 100)); bindRange('yan-setting-delay', 'autoAnswerDelay', v => clamp(Number(v), 500, 5000)); document.getElementById('yan-setting-next')?.addEventListener('change', e => { uiSettings.autoNextQuestion = e.target.checked; saveUiSettings(); }); document.getElementById('yan-setting-remember')?.addEventListener('change', e => { uiSettings.rememberPosition = e.target.checked; saveUiSettings(); }); document.getElementById('yan-setting-motion')?.addEventListener('change', e => { uiSettings.reducedMotion = e.target.checked; saveUiSettings(); applyUiToElements(ball, panel); }); document.getElementById('btn-reset').addEventListener('click', () => { uiSettings = { ...DEFAULT_SETTINGS }; saveUiSettings(); saveJSON(STORAGE_KEYS.ballPos, {}); applyUiToElements(ball, panel); const pos = getDefaultBallPos(); applyBallPos(ball, pos.left, pos.top); log('↺ 已恢复默认参数', '#fdcb6e'); }); // ---- 拖拽 ---- const drag = { active: false, moved: false, startX: 0, startY: 0, originLeft: 0, originTop: 0 }; ball.addEventListener('pointerdown', e => { if (e.button !== 0) return; e.preventDefault(); drag.active = true; drag.moved = false; drag.startX = e.clientX; drag.startY = e.clientY; const r = ball.getBoundingClientRect(); drag.originLeft = r.left; drag.originTop = r.top; ball.classList.remove('was-dragged'); ball.setPointerCapture(e.pointerId); }); ball.addEventListener('pointermove', e => { if (!drag.active) return; const dx = e.clientX - drag.startX, dy = e.clientY - drag.startY; if (!drag.moved && Math.hypot(dx, dy) > 6) { drag.moved = true; ball.classList.add('dragging'); } if (!drag.moved) return; applyBallPos(ball, drag.originLeft + dx, drag.originTop + dy); if (panel.classList.contains('is-open')) positionPanel(ball, panel); }); ball.addEventListener('pointerup', e => { if (!drag.active) return; drag.active = false; try { ball.releasePointerCapture(e.pointerId); } catch {} if (drag.moved) { saveBallPos(ball); ball.classList.remove('dragging'); ball.classList.add('was-dragged', 'releasing'); if (panel.classList.contains('is-open')) positionPanel(ball, panel); setTimeout(() => ball.classList.remove('was-dragged', 'releasing'), 260); return; } togglePanel(); }); ball.addEventListener('pointercancel', () => { drag.active = false; ball.classList.remove('dragging'); }); window.addEventListener('resize', () => { const r = ball.getBoundingClientRect(); applyBallPos(ball, r.left, r.top); if (panel.classList.contains('is-open')) positionPanel(ball, panel); }); // ---- 初始化位置和样式 ---- applyUiToElements(ball, panel); const pos = loadBallPos() || getDefaultBallPos(); applyBallPos(ball, pos.left, pos.top); if (!GM_getValue(STORAGE_KEYS.wizardSeen, false)) { setTimeout(showWizard, 500); } log('🚀 学习通AI答题助手已加载 v1.0.0', '#55efc4'); } // ============================================================ // UI 辅助 // ============================================================ function getDefaultBallPos() { return { left: DEFAULT_MARGIN, top: Math.max(DEFAULT_MARGIN, window.innerHeight - uiSettings.ballSize - 80) }; } function loadBallPos() { if (!uiSettings.rememberPosition) return null; const p = safeParse(GM_getValue(STORAGE_KEYS.ballPos, '')); return p && Number.isFinite(p.left) && Number.isFinite(p.top) ? p : null; } function saveBallPos(ball) { if (!uiSettings.rememberPosition) return; const r = ball.getBoundingClientRect(); saveJSON(STORAGE_KEYS.ballPos, { left: Math.round(r.left), top: Math.round(r.top) }); } function applyBallPos(ball, left, top) { const s = uiSettings.ballSize; const maxL = Math.max(DEFAULT_MARGIN, window.innerWidth - s - DEFAULT_MARGIN); const maxT = Math.max(DEFAULT_MARGIN, window.innerHeight - s - DEFAULT_MARGIN); ball.style.left = `${clamp(left, DEFAULT_MARGIN, maxL)}px`; ball.style.top = `${clamp(top, DEFAULT_MARGIN, maxT)}px`; ball.style.right = 'auto'; ball.style.bottom = 'auto'; } function positionPanel(ball, panel) { const bR = ball.getBoundingClientRect(); const pR = panel.getBoundingClientRect(); const gap = 14; let left = bR.left, top = bR.top - pR.height - gap; const below = top < DEFAULT_MARGIN; if (below) top = bR.bottom + gap; left = clamp(left, DEFAULT_MARGIN, Math.max(DEFAULT_MARGIN, window.innerWidth - pR.width - DEFAULT_MARGIN)); top = clamp(top, DEFAULT_MARGIN, Math.max(DEFAULT_MARGIN, window.innerHeight - pR.height - DEFAULT_MARGIN)); panel.style.left = `${left}px`; panel.style.top = `${top}px`; panel.style.bottom = 'auto'; panel.style.transformOrigin = below ? 'left top' : 'left bottom'; } function applyUiToElements(ball, panel) { const s = uiSettings.ballSize; ball.style.width = `${s}px`; ball.style.height = `${s}px`; ball.style.fontSize = `${clamp(Math.round(s * 0.2), 11, 16)}px`; ball.style.setProperty('--yan-float-duration', `${uiSettings.floatDuration}s`); ball.style.animation = uiSettings.reducedMotion ? 'none' : `yan-morph ${uiSettings.floatDuration}s ease-in-out infinite, yan-breathe 3.1s ease-in-out infinite`; document.documentElement.style.setProperty('--yan-panel-width', `${uiSettings.panelWidth}px`); document.documentElement.style.setProperty('--yan-panel-opacity', `${uiSettings.panelOpacity / 100}`); } // ============================================================ // 启动 // ============================================================ hookUEditor(); hookPasteEvents(); const startUpdateGrid = () => { const grid = document.getElementById('yan-question-grid'); if (grid && typeof updateQuestionGrid === 'function') { updateQuestionGrid(true); } }; let ballVisible = false; function checkAndShowBall() { if (hasAnswerArea()) { if (!ballVisible) { init(); ballVisible = true; log('🎯 检测到答题区域,AI答题助手已加载', '#55efc4'); } startUpdateGrid(); } else { const ball = document.getElementById('yan-ball'); const panel = document.getElementById('yan-panel'); if (ball) ball.style.display = 'none'; if (panel) panel.style.display = 'none'; ballVisible = false; } } setTimeout(checkAndShowBall, 1000); setInterval(checkAndShowBall, 5000); })();