AI自动答题脚本 - 支持Anthropic/OpenAI API
// ==UserScript==
// @license MIT
// @name GoTest AI Auto Answer
// @namespace http://tampermonkey.net/
// @version 3.1
// @description AI自动答题脚本 - 支持Anthropic/OpenAI API
// @match https://dodo.hznu.edu.cn/GoTest/*
// @match http://dodo.hznu.edu.cn/GoTest/*
// @match *://*.hznu.edu.cn/*/GoTest/*
// @match *://*.hznu.edu.cn/GoTest/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @connect *
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ========== 工具函数 ==========
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const jQuery = pageWindow.jQuery;
const $ = jQuery;
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// 下载图片转base64
function fetchImageAsBase64(url) {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET', url, responseType: 'blob', timeout: 15000,
onload(resp) {
if (resp.status !== 200) { resolve(null); return; }
const reader = new FileReader();
reader.onloadend = () => {
const dataUrl = reader.result; // data:image/png;base64,...
resolve(dataUrl);
};
reader.onerror = () => resolve(null);
reader.readAsDataURL(resp.response);
},
onerror() { resolve(null); },
ontimeout() { resolve(null); },
});
});
}
// ========== 配置系统 ==========
const DEFAULT_CONFIG = {
apiUrl: '',
apiKey: '',
model: 'claude-sonnet-4-20250514',
apiFormat: 'openai',
language: 'Python',
batchSizeChoice: 20,
batchSizeCode: 3,
enableCodeQuestions: false,
enableImageVision: false,
apiTimeout: 120000,
fetchDelay: 300,
};
function loadConfig() {
try {
const saved = GM_getValue('ai_config', null);
return saved ? { ...DEFAULT_CONFIG, ...JSON.parse(saved) } : { ...DEFAULT_CONFIG };
} catch { return { ...DEFAULT_CONFIG }; }
}
function saveConfig(cfg) { GM_setValue('ai_config', JSON.stringify(cfg)); }
const CODE_TYPES = ['SHORT_ANSWER', 'PROGRAM_DESIGN', 'PROGRAM_CORRECT', 'DB_SQL_DESIGN', 'DESIGN'];
function isCodeType(type) { return CODE_TYPES.includes(type); }
// ========== 配置面板 UI ==========
function showConfigPanel() {
const cfg = loadConfig();
const existing = document.getElementById('ai-config-panel');
if (existing) { existing.remove(); document.getElementById('ai-config-overlay')?.remove(); return; }
const overlay = document.createElement('div');
overlay.id = 'ai-config-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:99999;';
overlay.addEventListener('click', () => { overlay.remove(); panel.remove(); });
const panel = document.createElement('div');
panel.id = 'ai-config-panel';
Object.assign(panel.style, {
position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
zIndex: '100000', background: '#fff', borderRadius: '12px', padding: '24px',
boxShadow: '0 8px 40px rgba(0,0,0,0.3)', width: '440px', fontFamily: 'sans-serif',
maxHeight: '90vh', overflowY: 'auto',
});
const lbl = 'display:block;margin-bottom:10px;';
const sp = 'font-size:13px;color:#666;';
const inp = 'width:100%;padding:7px;margin-top:3px;border:1px solid #ddd;border-radius:6px;box-sizing:border-box;';
panel.innerHTML = `
<h3 style="margin:0 0 14px;font-size:18px;color:#333;">AI 答题配置</h3>
<label style="${lbl}"><span style="${sp}">API 格式</span>
<select id="cfg-format" style="${inp}">
<option value="openai" ${cfg.apiFormat==='openai'?'selected':''}>OpenAI 兼容</option>
<option value="anthropic" ${cfg.apiFormat==='anthropic'?'selected':''}>Anthropic</option>
</select></label>
<label style="${lbl}"><span style="${sp}">API URL</span>
<input id="cfg-url" type="text" value="${escapeHtml(cfg.apiUrl)}" placeholder="https://api.openai.com" style="${inp}"></label>
<label style="${lbl}"><span style="${sp}">API Key</span>
<input id="cfg-key" type="password" value="${escapeHtml(cfg.apiKey)}" style="${inp}"></label>
<label style="${lbl}"><span style="${sp}">模型名称</span>
<input id="cfg-model" type="text" value="${escapeHtml(cfg.model)}" style="${inp}"></label>
<label style="${lbl}"><span style="${sp}">编程语言 (影响代码题和填空题的回答)</span>
<input id="cfg-lang" type="text" value="${escapeHtml(cfg.language)}" placeholder="Python / C++ / Java ..." style="${inp}"></label>
<div style="display:flex;gap:10px;">
<label style="${lbl}flex:1;"><span style="${sp}">选择/填空 每批题数</span>
<input id="cfg-batch-choice" type="number" value="${cfg.batchSizeChoice}" min="1" max="50" style="${inp}"></label>
<label style="${lbl}flex:1;"><span style="${sp}">代码题 每批题数</span>
<input id="cfg-batch-code" type="number" value="${cfg.batchSizeCode}" min="1" max="10" style="${inp}"></label>
</div>
<label style="${lbl}"><span style="${sp}">API 超时 (秒)</span>
<input id="cfg-timeout" type="number" value="${Math.round(cfg.apiTimeout/1000)}" min="30" max="600" style="${inp}"></label>
<label style="${lbl}cursor:pointer;display:flex;align-items:center;gap:8px;">
<input id="cfg-enable-code" type="checkbox" ${cfg.enableCodeQuestions?'checked':''} style="width:18px;height:18px;">
<span style="${sp}">启用代码/简答题 AI 作答</span></label>
<label style="${lbl}cursor:pointer;display:flex;align-items:center;gap:8px;">
<input id="cfg-enable-image" type="checkbox" ${cfg.enableImageVision?'checked':''} style="width:18px;height:18px;">
<span style="${sp}">启用图片识别 (需多模态模型,耗token较多)</span></label>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:12px;">
<button id="cfg-cancel" style="padding:8px 20px;border:1px solid #ddd;border-radius:6px;background:#fff;cursor:pointer;">取消</button>
<button id="cfg-save" style="padding:8px 20px;border:none;border-radius:6px;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;cursor:pointer;font-weight:bold;">保存</button>
</div>`;
document.body.appendChild(overlay);
document.body.appendChild(panel);
panel.querySelector('#cfg-save').addEventListener('click', () => {
saveConfig({
apiFormat: panel.querySelector('#cfg-format').value,
apiUrl: panel.querySelector('#cfg-url').value.trim(),
apiKey: panel.querySelector('#cfg-key').value.trim(),
model: panel.querySelector('#cfg-model').value.trim(),
language: panel.querySelector('#cfg-lang').value.trim() || 'Python',
batchSizeChoice: parseInt(panel.querySelector('#cfg-batch-choice').value) || 20,
batchSizeCode: parseInt(panel.querySelector('#cfg-batch-code').value) || 3,
apiTimeout: (parseInt(panel.querySelector('#cfg-timeout').value) || 120) * 1000,
enableCodeQuestions: panel.querySelector('#cfg-enable-code').checked,
enableImageVision: panel.querySelector('#cfg-enable-image').checked,
fetchDelay: cfg.fetchDelay,
});
overlay.remove(); panel.remove();
log('配置已保存', 'success');
});
panel.querySelector('#cfg-cancel').addEventListener('click', () => { overlay.remove(); panel.remove(); });
}
// ========== 解除页面限制 ==========
function unlockPage() {
document.body.onselectstart = null;
document.body.ondrag = null;
document.oncontextmenu = null;
window.onkeydown = null;
document.onkeydown = null;
if (typeof jQuery !== 'undefined') {
jQuery(document).unbind('contextmenu');
jQuery(document).unbind('keydown');
}
document.body.style.userSelect = 'auto';
document.body.style.webkitUserSelect = 'auto';
const style = document.createElement('style');
style.textContent = '* { user-select: auto !important; -webkit-user-select: auto !important; }';
document.head.appendChild(style);
log('页面限制已解除');
}
// ========== 日志 & 进度 ==========
let statusPanel = null;
function createStatusPanel() {
if (statusPanel) return;
statusPanel = document.createElement('div');
statusPanel.id = 'ai-status-panel';
Object.assign(statusPanel.style, {
position: 'fixed', bottom: '20px', right: '220px', width: '360px',
minHeight: '60px', maxHeight: '500px', background: '#1a1a2e',
color: '#0f0', fontFamily: 'Consolas, monospace', fontSize: '12px',
borderRadius: '8px', zIndex: '99999', resize: 'both', overflow: 'hidden',
boxShadow: '0 4px 20px rgba(0,0,0,0.5)', border: '1px solid #333',
display: 'flex', flexDirection: 'column',
});
statusPanel.innerHTML = `
<div id="ai-status-header" style="color:#0ff;font-weight:bold;padding:8px 12px;cursor:move;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;">
<span>AI Auto Answer v2</span>
<button id="ai-status-toggle" style="background:none;border:none;color:#0ff;font-size:16px;cursor:pointer;line-height:1;">−</button>
</div>
<div id="ai-status-body" style="flex:1;overflow-y:auto;padding:0 12px 12px;"><div id="ai-log"></div></div>`;
document.body.appendChild(statusPanel);
// 折叠
statusPanel.querySelector('#ai-status-toggle').addEventListener('click', function () {
const body = document.getElementById('ai-status-body');
if (body.style.display === 'none') { body.style.display = ''; this.textContent = '−'; }
else { body.style.display = 'none'; this.textContent = '+'; }
});
// 拖拽
let dragging = false, dx = 0, dy = 0;
const header = statusPanel.querySelector('#ai-status-header');
header.addEventListener('mousedown', e => {
if (e.target.id === 'ai-status-toggle') return;
dragging = true; dx = e.clientX - statusPanel.offsetLeft; dy = e.clientY - statusPanel.offsetTop;
statusPanel.style.bottom = 'auto'; statusPanel.style.right = 'auto';
});
document.addEventListener('mousemove', e => { if (!dragging) return; statusPanel.style.left = (e.clientX - dx) + 'px'; statusPanel.style.top = (e.clientY - dy) + 'px'; });
document.addEventListener('mouseup', () => { dragging = false; });
}
function log(msg, type = 'info') {
console.log(`[AI] ${msg}`);
createStatusPanel();
const logDiv = document.getElementById('ai-log');
if (!logDiv) return;
const colors = { info: '#0f0', warn: '#ff0', error: '#f44', success: '#0ff' };
const line = document.createElement('div');
line.style.color = colors[type] || '#0f0';
line.style.marginBottom = '2px';
line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
logDiv.appendChild(line);
logDiv.scrollTop = logDiv.scrollHeight;
}
function updateProgress(current, total, phase) {
createStatusPanel();
let bar = document.getElementById('ai-progress-bar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'ai-progress-bar';
bar.style.cssText = 'margin:6px 0;background:#333;border-radius:4px;height:18px;position:relative;overflow:hidden;';
bar.innerHTML = '<div id="ai-progress-fill" style="height:100%;background:linear-gradient(90deg,#00c6ff,#0072ff);border-radius:4px;transition:width 0.3s;"></div><span id="ai-progress-text" style="position:absolute;top:0;left:0;right:0;text-align:center;line-height:18px;font-size:11px;color:#fff;"></span>';
const logDiv = document.getElementById('ai-log');
if (logDiv) logDiv.parentElement.insertBefore(bar, logDiv);
}
const pct = Math.round((current / total) * 100);
document.getElementById('ai-progress-fill').style.width = pct + '%';
document.getElementById('ai-progress-text').textContent = `${phase}: ${current}/${total} (${pct}%)`;
}
// ========== Step 2: 抓取题目 ==========
async function fetchAllQuestions() {
const questionNodes = document.querySelectorAll('.bundle-item dd');
const total = questionNodes.length;
log(`发现 ${total} 道题目,开始抓取...`);
const questions = [];
for (let i = 0; i < total; i++) {
const node = questionNodes[i];
const qId = node.getAttribute('data-val-id');
try {
updateProgress(i + 1, total, '抓题');
const resp = await fetch(`/GoTest/QuestionOne?id=${qId}&mode=4&tail=${Date.now()}`);
const html = await resp.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const questionItem = doc.querySelector('.question-item');
if (!questionItem) { log(`第${i+1}题解析失败`, 'warn'); continue; }
const qType = questionItem.getAttribute('data-type') || '';
const questionFace = doc.querySelector('.question-face');
const qText = questionFace ? questionFace.innerText.trim() : '';
const images = [];
const imageBase64 = [];
if (questionFace) {
questionFace.querySelectorAll('img').forEach(img => {
const src = img.getAttribute('src');
if (src) {
if (src.startsWith('data:')) {
// 已经是 base64 data URI,直接作为 imageBase64 用
imageBase64.push(src);
} else {
images.push(src.startsWith('http') ? src : location.origin + src);
}
}
});
}
// 下载图片转base64(如果启用了图片识别)
if (images.length > 0 && loadConfig().enableImageVision) {
for (const imgUrl of images) {
const b64 = await fetchImageAsBase64(imgUrl);
if (b64) imageBase64.push(b64);
else log(`题${i+1}: 图片下载失败 ${imgUrl}`, 'warn');
}
}
const q = { index: i + 1, id: qId, type: qType, text: qText, images, imageBase64, options: [] };
if (['SINGLE_CHIOCE', 'MULIT_CHIOCE', 'JUDGE'].includes(qType)) {
const inputClass = qType === 'MULIT_CHIOCE' ? '.question-multi-input' : '.question-option-input';
doc.querySelectorAll(inputClass).forEach(input => {
const val = input.value || '';
const label = input.closest('label') || input.parentElement;
q.options.push({ value: val, text: label ? label.innerText.trim() : val });
});
}
if (['FILL_BLANK', 'PROGRAM_FILL_BLANK'].includes(qType)) {
q.blankCount = doc.querySelectorAll('.question-blank-input').length;
}
if (qType === 'COMPLEX') {
q.subQuestions = [];
doc.querySelectorAll('.sub-question').forEach((sub, si) => {
const subType = sub.getAttribute('data-type') || '';
const subText = sub.querySelector('.question-face')?.innerText.trim() || sub.innerText.substring(0, 200);
const subQ = { index: si + 1, type: subType, text: subText, options: [] };
if (['SINGLE_CHIOCE', 'MULIT_CHIOCE', 'JUDGE'].includes(subType)) {
const cls = subType === 'MULIT_CHIOCE' ? '.question-multi-input' : '.sub-question-option-input, .question-option-input';
sub.querySelectorAll(cls).forEach(input => {
const label = input.closest('label') || input.parentElement;
subQ.options.push({ value: input?.value || '', text: label?.innerText.trim() || '' });
});
}
if (subType === 'FILL_BLANK') subQ.blankCount = sub.querySelectorAll('.sub-question-blank-input').length;
q.subQuestions.push(subQ);
});
}
questions.push(q);
log(`第${i+1}/${total}题 [${qType}] 抓取成功`, 'success');
} catch (err) { log(`第${i+1}题请求失败: ${err.message}`, 'error'); }
await sleep(loadConfig().fetchDelay);
}
log(`抓取完成,共 ${questions.length} 题`);
return questions;
}
// ========== Step 3: AI API 调用 ==========
// 构建prompt - 返回 { text, hasImages, contentParts }
function buildPrompt(batch) {
const cfg = loadConfig();
const hasVisionImages = cfg.enableImageVision && batch.some(q => q.imageBase64?.length > 0);
let textPrompt = `你是一个答题助手。请根据以下题目给出答案。
当前课程的编程语言是 ${cfg.language},所有代码题、程序填空题请使用 ${cfg.language} 作答。
严格按照JSON数组格式返回,不要有任何多余文字,只返回JSON。
格式:[{"id": "题目ID", "answer": "答案"}]
answer规则:
- 单选题(SINGLE_CHIOCE):返回选项字母,如 "A"
- 多选题(MULIT_CHIOCE):返回选项字母数组,如 ["A","C"]
- 判断题(JUDGE):返回 "对" 或 "错"
- 填空题(FILL_BLANK/PROGRAM_FILL_BLANK):返回数组,如 ["答案1","答案2"]
- 编程/简答/设计题:返回完整的 ${cfg.language} 代码或文字答案字符串
- 综合题(COMPLEX):answer为数组,每个元素对应一个子题的答案
题目列表:
`;
// 如果有图片,构建多模态 content 数组
if (hasVisionImages) {
const parts = []; // content array for multimodal
parts.push({ type: 'text', text: textPrompt });
batch.forEach(q => {
let qText = `\n--- 第${q.index}题 [ID:${q.id}] [类型:${q.type}] ---\n${q.text}\n`;
if (q.options.length > 0) {
qText += '选项:\n';
q.options.forEach((o, j) => qText += ` ${String.fromCharCode(65 + j)}. ${o.text}\n`);
}
if (q.blankCount) qText += `(共${q.blankCount}个空)\n`;
if (q.subQuestions) {
qText += '子题:\n';
q.subQuestions.forEach((sq, si) => {
qText += ` 子题${si+1} [类型:${sq.type}]: ${sq.text}\n`;
sq.options?.forEach((o, j) => qText += ` ${String.fromCharCode(65 + j)}. ${o.text}\n`);
});
}
parts.push({ type: 'text', text: qText });
// 插入图片
if (q.imageBase64?.length > 0) {
q.imageBase64.forEach(dataUrl => {
const match = dataUrl.match(/^data:(image\/\w+);base64,(.+)$/);
if (!match) return;
const mediaType = match[1];
const b64data = match[2];
if (cfg.apiFormat === 'anthropic') {
parts.push({ type: 'image', source: { type: 'base64', media_type: mediaType, data: b64data } });
} else {
parts.push({ type: 'image_url', image_url: { url: dataUrl } });
}
});
}
});
return { hasImages: true, contentParts: parts };
}
// 纯文本模式
batch.forEach(q => {
textPrompt += `\n--- 第${q.index}题 [ID:${q.id}] [类型:${q.type}] ---\n${q.text}\n`;
if (q.images?.length > 0 && !cfg.enableImageVision) textPrompt += `(本题含${q.images.length}张图片,无法显示,请根据文字尽量作答)\n`;
if (q.options.length > 0) {
textPrompt += '选项:\n';
q.options.forEach((o, j) => textPrompt += ` ${String.fromCharCode(65 + j)}. ${o.text}\n`);
}
if (q.blankCount) textPrompt += `(共${q.blankCount}个空)\n`;
if (q.subQuestions) {
textPrompt += '子题:\n';
q.subQuestions.forEach((sq, si) => {
textPrompt += ` 子题${si+1} [类型:${sq.type}]: ${sq.text}\n`;
sq.options?.forEach((o, j) => textPrompt += ` ${String.fromCharCode(65 + j)}. ${o.text}\n`);
if (sq.blankCount) textPrompt += ` (共${sq.blankCount}个空)\n`;
});
}
});
return { hasImages: false, text: textPrompt };
}
function callAI(promptData) {
const cfg = loadConfig();
const content = promptData.hasImages ? promptData.contentParts : promptData.text;
return new Promise((resolve, reject) => {
let url, headers, body;
if (cfg.apiFormat === 'anthropic') {
url = cfg.apiUrl.replace(/\/$/, '') + '/v1/messages';
headers = { 'Content-Type': 'application/json', 'x-api-key': cfg.apiKey, 'anthropic-version': '2023-06-01' };
body = JSON.stringify({ model: cfg.model, max_tokens: 8192, messages: [{ role: 'user', content }] });
} else {
url = cfg.apiUrl.replace(/\/$/, '');
if (!url.endsWith('/chat/completions')) url += '/v1/chat/completions';
headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${cfg.apiKey}` };
body = JSON.stringify({ model: cfg.model, messages: [{ role: 'user', content }], max_tokens: 8192, temperature: 0.1 });
}
GM_xmlhttpRequest({
method: 'POST', url, headers, data: body,
timeout: cfg.apiTimeout || 120000,
onload(resp) {
try {
if (resp.status !== 200) {
reject(new Error(`HTTP ${resp.status}: ${resp.responseText?.substring(0, 300)}`));
return;
}
const data = JSON.parse(resp.responseText);
let text = cfg.apiFormat === 'anthropic'
? (data.content?.[0]?.text || '')
: (data.choices?.[0]?.message?.content || '');
console.log('[AI] 原始返回:', text.substring(0, 500));
if (!text) { reject(new Error('AI返回空内容, resp: ' + resp.responseText?.substring(0, 300))); return; }
let parsed = null;
try { parsed = JSON.parse(text); } catch {}
if (!parsed) { const m = text.match(/```(?:json)?\s*([\s\S]*?)```/); if (m) try { parsed = JSON.parse(m[1].trim()); } catch {} }
if (!parsed) { const m = text.match(/\[[\s\S]*\]/); if (m) try { parsed = JSON.parse(m[0]); } catch {} }
if (parsed && Array.isArray(parsed)) resolve(parsed);
else reject(new Error('AI返回格式异常: ' + text.substring(0, 300)));
} catch (e) { reject(new Error('解析失败: ' + e.message + ' | resp: ' + resp.responseText?.substring(0, 200))); }
},
onerror(err) { reject(new Error('网络错误: ' + JSON.stringify(err).substring(0, 200))); },
ontimeout() { reject(new Error('API请求超时 (' + Math.round((cfg.apiTimeout||120000)/1000) + 's)')); },
});
});
}
async function batchCallAI(questions, batchSize) {
const allAnswers = [];
const totalBatches = Math.ceil(questions.length / batchSize);
for (let i = 0; i < questions.length; i += batchSize) {
const batchIndex = Math.floor(i / batchSize) + 1;
const batch = questions.slice(i, i + batchSize);
log(`AI请求 第${batchIndex}/${totalBatches}批 (${batch.length}题)...`);
updateProgress(batchIndex, totalBatches, 'AI请求');
try {
const answers = await callAI(buildPrompt(batch));
allAnswers.push(...answers);
log(`第${batchIndex}批 返回 ${answers.length} 个答案`, 'success');
} catch (err) { log(`第${batchIndex}批 失败: ${err.message}`, 'error'); }
}
return allAnswers;
}
// ========== Step 4: 填入答案 ==========
function waitForElement(selector, timeout = 5000) {
return new Promise((resolve, reject) => {
const el = document.querySelector(selector);
if (el) return resolve(el);
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) { observer.disconnect(); resolve(el); }
});
observer.observe(document.getElementById('c-grid-ajax') || document.body, { childList: true, subtree: true });
setTimeout(() => { observer.disconnect(); reject(new Error('超时')); }, timeout);
});
}
// ========== 代码答案浮动面板 ==========
let codePanel = null;
function getCodePanel() {
if (codePanel) return codePanel;
codePanel = document.createElement('div');
codePanel.id = 'ai-code-panel';
Object.assign(codePanel.style, {
position: 'fixed', top: '50px', left: '10px', width: '420px',
maxHeight: 'calc(100vh - 70px)', overflowY: 'auto', background: '#fff',
borderRadius: '10px', zIndex: '99998', fontFamily: 'sans-serif',
boxShadow: '0 4px 24px rgba(0,0,0,0.25)', border: '1px solid #ddd',
});
codePanel.innerHTML = `
<div id="ai-code-header" style="background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;padding:8px 14px;border-radius:10px 10px 0 0;display:flex;justify-content:space-between;align-items:center;cursor:move;">
<span style="font-weight:bold;font-size:14px;">代码/简答题 参考答案</span>
<button id="ai-code-toggle" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;line-height:1;">−</button>
</div>
<div id="ai-code-body" style="padding:0;"></div>`;
document.body.appendChild(codePanel);
// 折叠/展开
codePanel.querySelector('#ai-code-toggle').addEventListener('click', function () {
const body = document.getElementById('ai-code-body');
if (body.style.display === 'none') { body.style.display = ''; this.textContent = '−'; }
else { body.style.display = 'none'; this.textContent = '+'; }
});
// 拖拽
let dragging = false, dx = 0, dy = 0;
const header = codePanel.querySelector('#ai-code-header');
header.addEventListener('mousedown', e => { dragging = true; dx = e.clientX - codePanel.offsetLeft; dy = e.clientY - codePanel.offsetTop; });
document.addEventListener('mousemove', e => { if (!dragging) return; codePanel.style.left = (e.clientX - dx) + 'px'; codePanel.style.top = (e.clientY - dy) + 'px'; });
document.addEventListener('mouseup', () => { dragging = false; });
return codePanel;
}
function addCodeAnswer(qIndex, qText, code) {
const panel = getCodePanel();
const body = panel.querySelector('#ai-code-body');
const item = document.createElement('div');
item.style.cssText = 'border-bottom:1px solid #eee;';
const shortText = qText.length > 60 ? qText.substring(0, 60) + '...' : qText;
item.innerHTML = `
<div style="background:#f8f8f8;padding:6px 12px;font-size:12px;color:#666;display:flex;justify-content:space-between;align-items:center;cursor:pointer;" class="ai-code-item-header">
<span>第${qIndex}题: ${escapeHtml(shortText)}</span>
<button class="ai-copy-btn" style="background:#667eea;color:#fff;border:none;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">复制</button>
</div>
<pre class="ai-code-content" style="margin:0;padding:8px 12px;background:#fff;white-space:pre-wrap;word-break:break-all;max-height:250px;overflow-y:auto;font-size:12px;font-family:Consolas,monospace;user-select:text!important;display:none;">${escapeHtml(code)}</pre>`;
// 点击标题展开/折叠
item.querySelector('.ai-code-item-header').addEventListener('click', function (e) {
if (e.target.classList.contains('ai-copy-btn')) return;
const pre = item.querySelector('.ai-code-content');
pre.style.display = pre.style.display === 'none' ? '' : 'none';
});
// 复制按钮
item.querySelector('.ai-copy-btn').addEventListener('click', function () {
navigator.clipboard.writeText(code).then(() => { this.textContent = '已复制'; setTimeout(() => { this.textContent = '复制'; }, 1500); });
});
body.appendChild(item);
}
// 模糊匹配选项:支持字母索引 + 文本内容匹配
function fuzzyMatchOption(inputs, ansStr, qType) {
const upper = ansStr.trim().toUpperCase();
// 1. 按字母索引匹配 (A/B/C/D)
if (/^[A-Z]$/.test(upper)) {
const idx = upper.charCodeAt(0) - 65;
if (idx >= 0 && idx < inputs.length) {
$(inputs[idx]).prop('checked', true).click();
return true;
}
}
// 2. 判断题特殊匹配
if (qType === 'JUDGE') {
const trueSet = ['对', 'TRUE', 'T', '正确', '是', 'YES', 'Y', '1', 'A'];
const falseSet = ['错', 'FALSE', 'F', '错误', '否', 'NO', 'N', '0', 'B'];
const isTrue = trueSet.includes(upper);
const isFalse = falseSet.includes(upper);
for (let i = 0; i < inputs.length; i++) {
const val = ($(inputs[i]).val() || '').toUpperCase();
if ((isTrue && trueSet.includes(val)) || (isFalse && falseSet.includes(val))) {
$(inputs[i]).prop('checked', true).click();
return true;
}
}
}
// 3. 文本内容模糊匹配
const cleanAnswer = ansStr.trim().replace(/^[A-Z][.、\s]+/, '').trim();
for (let i = 0; i < inputs.length; i++) {
const label = $(inputs[i]).closest('label').length ? $(inputs[i]).closest('label') : $(inputs[i]).parent();
const optText = label.text().trim().replace(/^[A-Z][.、\s]+/, '').trim();
if (optText === cleanAnswer || optText.includes(cleanAnswer) || cleanAnswer.includes(optText)) {
$(inputs[i]).prop('checked', true).click();
return true;
}
}
return false;
}
async function fillOneQuestion(qData, answer) {
const qId = qData.id;
const navLink = document.querySelector(`dd[data-val-id="${qId}"] a`);
if (!navLink) { log(`题${qData.index}: 找不到导航`, 'error'); return false; }
navLink.click();
await sleep(500);
try { await waitForElement(`.question-item[id="${qId}"]`); } catch { log(`题${qData.index}: 加载超时`, 'error'); return false; }
await sleep(300);
const questionItem = $(`.question-item[id="${qId}"]`);
const qType = questionItem.attr('data-type');
try {
if (isCodeType(qType)) {
const answerStr = typeof answer === 'string' ? answer : JSON.stringify(answer, null, 2);
addCodeAnswer(qData.index, qData.text, answerStr);
log(`题${qData.index} [${qType}] 答案已添加到面板`, 'success');
return 'codebox';
}
if (qType === 'SINGLE_CHIOCE' || qType === 'JUDGE') {
const inputs = questionItem.find('input.question-option-input');
if (!fuzzyMatchOption(inputs, String(answer), qType)) {
log(`题${qData.index}: 未匹配到选项 "${answer}"`, 'warn');
}
} else if (qType === 'MULIT_CHIOCE') {
const inputs = questionItem.find('input.question-multi-input');
const ansArr = Array.isArray(answer) ? answer : [answer];
ansArr.forEach(a => fuzzyMatchOption(inputs, String(a), qType));
} else if (qType === 'FILL_BLANK' || qType === 'PROGRAM_FILL_BLANK') {
const blanks = questionItem.find('.question-blank-input');
const ansArr = Array.isArray(answer) ? answer : [answer];
blanks.each(function (idx) {
if (idx < ansArr.length) {
$(this).val(ansArr[idx]);
// 触发原生事件让页面感知到值变化
this.dispatchEvent(new Event('input', { bubbles: true }));
this.dispatchEvent(new Event('change', { bubbles: true }));
this.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, keyCode: 13 }));
}
});
} else if (qType === 'COMPLEX') {
const ansArr = Array.isArray(answer) ? answer : [answer];
questionItem.find('.sub-question').each(function (si) {
const subType = $(this).attr('data-type');
const subAns = ansArr[si];
if (subAns === undefined) return;
if (isCodeType(subType)) { addCodeAnswer(qData.index + '-' + (si+1), qData.text, typeof subAns === 'string' ? subAns : JSON.stringify(subAns, null, 2)); return; }
if (subType === 'SINGLE_CHIOCE' || subType === 'JUDGE') {
fuzzyMatchOption($(this).find('input.question-option-input, input.sub-question-option-input'), String(subAns), subType);
} else if (subType === 'MULIT_CHIOCE') {
(Array.isArray(subAns) ? subAns : [subAns]).forEach(a => fuzzyMatchOption($(this).find('input.question-multi-input'), String(a), subType));
} else if (subType === 'FILL_BLANK') {
const arr = Array.isArray(subAns) ? subAns : [subAns];
$(this).find('.sub-question-blank-input').each(function (idx) {
if (idx < arr.length) {
$(this).val(arr[idx]);
this.dispatchEvent(new Event('input', { bubbles: true }));
this.dispatchEvent(new Event('change', { bubbles: true }));
this.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, keyCode: 13 }));
}
});
}
});
}
// 保存(代码题不自动保存)
if (!isCodeType(qType)) {
await sleep(300);
if (typeof pageWindow.saveQuestionOne === 'function') {
pageWindow.saveQuestionOne(qId, () => { $(`dd[data-val-id="${qId}"]`).addClass('finish').removeClass('undo'); });
}
}
log(`题${qData.index} [${qType}] 填入完成`, 'success');
return true;
} catch (err) { log(`题${qData.index} 填入出错: ${err.message}`, 'error'); return false; }
}
// ========== Step 5: 答案映射 & 填入 ==========
async function fillAllAnswers(questions, answers) {
const answerMap = {};
answers.forEach(a => { if (a.id) answerMap[String(a.id)] = a.answer; });
console.log('[AI] 答案映射:', Object.keys(answerMap));
console.log('[AI] 题目IDs:', questions.map(q => q.id));
const hasIdMatch = questions.some(q => answerMap[q.id] !== undefined);
if (!hasIdMatch && answers.length > 0) log('ID匹配失败,改用顺序匹配', 'warn');
const total = questions.length;
const failed = [];
let ansIdx = 0;
for (let i = 0; i < total; i++) {
const q = questions[i];
updateProgress(i + 1, total, '填答');
let answer = answerMap[q.id];
if (answer === undefined && !hasIdMatch && ansIdx < answers.length) { answer = answers[ansIdx].answer; ansIdx++; }
if (answer === undefined) { log(`题${q.index}: 无答案,跳过`, 'warn'); failed.push(q); continue; }
const result = await fillOneQuestion(q, answer);
if (!result) failed.push(q);
await sleep(800);
}
if (failed.length > 0) log(`完成!${failed.length}题失败: ${failed.map(q => '第'+q.index+'题').join(', ')}`, 'warn');
else log('全部完成!', 'success');
}
// ========== 主流程 ==========
async function startAutoAnswer() {
const cfg = loadConfig();
if (!cfg.apiUrl || !cfg.apiKey) { alert('请先配置 API URL 和 API Key!'); showConfigPanel(); return; }
log('开始自动答题...');
try {
unlockPage();
const allQuestions = await fetchAllQuestions();
if (allQuestions.length === 0) { log('没有抓到题目', 'error'); return; }
// 按类型分组
const choiceQs = allQuestions.filter(q => !isCodeType(q.type));
const codeQs = allQuestions.filter(q => isCodeType(q.type));
log(`分类: ${choiceQs.length}道选择/填空, ${codeQs.length}道代码/简答`);
let allAnswers = [];
// 选择/填空题
if (choiceQs.length > 0) {
log(`开始处理选择/填空题 (每批${cfg.batchSizeChoice}题)...`);
const choiceAnswers = await batchCallAI(choiceQs, cfg.batchSizeChoice);
allAnswers.push(...choiceAnswers);
log(`选择/填空题 AI返回 ${choiceAnswers.length} 个答案`);
}
// 代码/简答题
if (codeQs.length > 0 && cfg.enableCodeQuestions) {
log(`开始处理代码/简答题 (每批${cfg.batchSizeCode}题)...`);
const codeAnswers = await batchCallAI(codeQs, cfg.batchSizeCode);
allAnswers.push(...codeAnswers);
log(`代码题 AI返回 ${codeAnswers.length} 个答案`);
} else if (codeQs.length > 0) {
log(`跳过 ${codeQs.length} 道代码题 (未启用)`, 'warn');
}
log(`AI共返回 ${allAnswers.length} 个答案`);
await fillAllAnswers(allQuestions, allAnswers);
} catch (err) { log(`流程异常: ${err.message}`, 'error'); }
}
// ========== 注册菜单 & 按钮 ==========
GM_registerMenuCommand('AI答题配置', showConfigPanel);
GM_registerMenuCommand('开始自动答题', startAutoAnswer);
GM_registerMenuCommand('解除页面限制', unlockPage);
function addStartButton() {
const bar = document.createElement('div');
bar.id = 'ai-toolbar';
Object.assign(bar.style, {
position: 'fixed', top: '10px', left: '50%', transform: 'translateX(-50%)', zIndex: '99999',
display: 'flex', gap: '8px', alignItems: 'center',
});
const btnStyle = 'padding:8px 16px;border:none;border-radius:20px;cursor:pointer;font-size:13px;color:#fff;font-weight:bold;';
bar.innerHTML = `
<button id="ai-btn-start" style="${btnStyle}background:linear-gradient(135deg,#667eea,#764ba2);box-shadow:0 4px 15px rgba(102,126,234,0.4);">AI 自动答题</button>
<button id="ai-btn-unlock" style="${btnStyle}background:#e67e22;">解除限制</button>
<button id="ai-btn-config" style="${btnStyle}background:#555;">配置</button>`;
document.body.appendChild(bar);
bar.querySelector('#ai-btn-start').addEventListener('click', startAutoAnswer);
bar.querySelector('#ai-btn-unlock').addEventListener('click', () => { unlockPage(); alert('页面限制已解除!可以自由复制、右键了。'); });
bar.querySelector('#ai-btn-config').addEventListener('click', showConfigPanel);
}
if (document.readyState === 'complete') addStartButton();
else window.addEventListener('load', addStartButton);
})();