// ==UserScript== // @name 51外语课堂 AI 根据原文答题助手 // @namespace https://scriptcat.org/ // @version 1.2.0 // @description 在 51外语课堂 unit.htm 页面使用 OpenAI 兼容接口填写填空、选择、判断和文本简答题,支持跨页面读取原文材料并生成证据解释,可选自动提交与翻页。 // @author WorkBuddy // @match https://ketang.51learning.com.cn/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant unsafeWindow // @connect * // @run-at document-idle // ==/UserScript== (function () { "use strict"; const STORAGE_KEY = "51learning_ai_helper_config_v1"; const SESSION_KEY = "__51LEARNING_AI_AUTO_SESSION__"; const SUPPORTED_TYPES = new Set([1, 2, 3, 7, 11]); const TYPE_NAMES = { 1: "填空题", 2: "单选题", 3: "多选题", 7: "判断题", 11: "简答题" }; const DEFAULT_CONFIG = { endpoint: "https://api.deepseek.com/chat/completions", apiKey: "", model: "deepseek-chat", autoSubmit: false, autoNext: true, autoNextUnit: false, maxPages: 20, delayMs: 1500 }; const pageWindow = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; const state = { running: false, processing: false, runToken: 0, config: { ...DEFAULT_CONFIG }, panel: null, statusEl: null, logEl: null, settingsEl: null }; function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function waitFor(check, timeoutMs = 20000, intervalMs = 250) { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { const value = check(); if (value) return value; } catch (_) { // 页面尚未初始化完成时继续等待。 } await delay(intervalMs); } throw new Error("等待题目页面就绪超时,请刷新后重试"); } async function storageGet(key, fallback) { if (typeof GM_getValue === "function") { return GM_getValue(key, fallback); } if (typeof GM !== "undefined" && GM.getValue) { return GM.getValue(key, fallback); } try { const value = localStorage.getItem(key); return value === null ? fallback : JSON.parse(value); } catch (_) { return fallback; } } async function storageSet(key, value) { if (typeof GM_setValue === "function") { return GM_setValue(key, value); } if (typeof GM !== "undefined" && GM.setValue) { return GM.setValue(key, value); } localStorage.setItem(key, JSON.stringify(value)); } function gmRequest(options) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest === "function") { GM_xmlhttpRequest({ method: options.method || "POST", url: options.url, headers: options.headers || {}, data: options.data, timeout: options.timeout || 90000, anonymous: true, onload: response => { if (response.status >= 200 && response.status < 300) { resolve(response.responseText); } else { reject(new Error(`AI 接口 HTTP ${response.status}: ${String(response.responseText || "").slice(0, 500)}`)); } }, onerror: () => reject(new Error("AI 接口网络请求失败,请检查地址、网络和 ScriptCat 的 @connect 授权")), ontimeout: () => reject(new Error("AI 接口请求超时")) }); return; } fetch(options.url, { method: options.method || "POST", headers: options.headers || {}, body: options.data, credentials: "omit" }).then(async response => { const text = await response.text(); if (!response.ok) { throw new Error(`AI 接口 HTTP ${response.status}: ${text.slice(0, 500)}`); } resolve(text); }).catch(reject); }); } function setStatus(text, color = "#1f2937") { if (!state.statusEl) return; state.statusEl.textContent = text; state.statusEl.style.color = color; } function log(message, level = "info") { const time = new Date().toLocaleTimeString(); const line = `[${time}] ${message}`; console[level === "error" ? "error" : "log"]("[51AI]", message); if (!state.logEl) return; const item = document.createElement("div"); item.textContent = line; item.style.color = level === "error" ? "#dc2626" : level === "success" ? "#15803d" : "#374151"; state.logEl.appendChild(item); while (state.logEl.children.length > 100) { state.logEl.removeChild(state.logEl.firstChild); } state.logEl.scrollTop = state.logEl.scrollHeight; } function getSession() { try { return JSON.parse(sessionStorage.getItem(SESSION_KEY) || "null"); } catch (_) { return null; } } function saveSession(session) { sessionStorage.setItem(SESSION_KEY, JSON.stringify(session)); } function clearSession() { sessionStorage.removeItem(SESSION_KEY); } function getVm() { const app = pageWindow.document.querySelector("#app") || document.querySelector("#app"); return app && app.__vue__ ? app.__vue__ : null; } function getQuestionSignature(vm) { const nodeId = vm.showQuestionItem && vm.showQuestionItem.id ? vm.showQuestionItem.id : vm.showTemplate && vm.showTemplate.id; const ids = (vm.questions || []).map(question => question.id).join(","); return `${nodeId || "none"}:${ids}`; } async function waitForQuestions() { return waitFor(() => { const vm = getVm(); if (!vm || !Array.isArray(vm.questions) || vm.questions.length === 0) return null; if (!vm.userdata || !vm.userdata.role) return null; const bodies = document.querySelectorAll(".exercise-body"); if (bodies.length < vm.questions.length) return null; return vm; }); } function htmlToText(html) { const template = document.createElement("template"); template.innerHTML = String(html || ""); template.content.querySelectorAll("script,style").forEach(element => element.remove()); template.content.querySelectorAll("input").forEach((input, index) => { const placeholder = input.getAttribute("placeholder") || String(index + 1); input.replaceWith(document.createTextNode(`[填空 ${placeholder}]`)); }); template.content.querySelectorAll("br,p,div,li").forEach(element => { element.appendChild(document.createTextNode("\n")); }); return (template.content.textContent || "") .replace(/\r/g, "") .replace(/[ \t]+/g, " ") .replace(/\n\s*\n+/g, "\n") .trim(); } function getExerciseElement(vm, question) { const index = vm.questions.findIndex(item => String(item.id) === String(question.id)); return document.querySelectorAll(".exercise-body")[index] || null; } function getBlankInputs(vm, question) { const element = getExerciseElement(vm, question); if (!element) return []; return Array.from(element.querySelectorAll("input")).sort((a, b) => { const getOrder = input => { const match = String(input.placeholder || "").match(/\d+/); return match ? Number(match[0]) : 999999; }; return getOrder(a) - getOrder(b); }); } function getSubjectiveTextarea(vm, question) { const element = getExerciseElement(vm, question); if (!element) return null; return Array.from(element.querySelectorAll("textarea.input_subject_textarea[name=\"textarea\"]")) .find(textarea => !textarea.disabled) || null; } function isSupportedQuestion(question) { const type = Number(question.type); if (!SUPPORTED_TYPES.has(type)) return false; if (type === 11) { // 仅处理带文本输入区域的简答题;纯录音/纯上传题仍需人工完成。 return String(question.attachment || "").split(",").includes("text"); } return true; } function collectTemplateMaterials(nodes, seen = new Set()) { const materials = []; for (const node of Array.isArray(nodes) ? nodes : [nodes]) { if (!node || node.id === undefined || seen.has(String(node.id))) continue; seen.add(String(node.id)); const name = htmlToText(node.name || node.title || node.parentTitle || ""); const description = htmlToText(node.description); const note = htmlToText(node.note); const text = [name, description, note].filter(Boolean).join("\n"); if (text) { materials.push({ id: String(node.id), name: name || String(node.id), text }); } if (Array.isArray(node.children) && node.children.length > 0) { materials.push(...collectTemplateMaterials(node.children, seen)); } } return materials; } function prioritizeCoreMaterials(materials) { const coreKeywords = /(core\s*text|reading\s*text|passage|article|text\s*a|text\s*b|原文|课文)/i; return [ ...materials.filter(material => coreKeywords.test(material.name) || coreKeywords.test(material.text.slice(0, 120))), ...materials.filter(material => !coreKeywords.test(material.name) && !coreKeywords.test(material.text.slice(0, 120))) ]; } function buildQuestionPayload(vm) { const stems = vm.questions .filter(question => Number(question.type) === 0) .map(question => htmlToText(question.body)) .filter(Boolean); const context = [ htmlToText(vm.parentTemplate && vm.parentTemplate.title), htmlToText(vm.parentTemplate && vm.parentTemplate.description), htmlToText(vm.parentTemplate && vm.parentTemplate.note), htmlToText(vm.showTemplate && vm.showTemplate.title), htmlToText(vm.showTemplate && vm.showTemplate.description), htmlToText(vm.showTemplate && vm.showTemplate.note), htmlToText(vm.showQuestionItem && vm.showQuestionItem.description), ...stems ].filter(Boolean).join("\n"); const materialNodes = [ ...(Array.isArray(vm.templateList) ? vm.templateList : []), vm.parentTemplate, vm.showTemplate, vm.showQuestionItem ].filter(Boolean); const materials = prioritizeCoreMaterials(collectTemplateMaterials(materialNodes)); const questions = vm.questions .filter(isSupportedQuestion) .map(question => { const item = { id: String(question.id), orderId: question.orderId || null, type: Number(question.type), typeName: TYPE_NAMES[Number(question.type)], body: htmlToText(question.body) }; if (Number(question.type) === 1) { item.blanks = getBlankInputs(vm, question).map(input => String(input.placeholder || "")); } if ([2, 3].includes(Number(question.type))) { item.options = (question.questionOptions || []).map(option => ({ title: String(option.optionTitle), body: htmlToText(option.optionBody) })); } if (Number(question.type) === 7) { item.options = [ { title: "A", body: "True/正确" }, { title: "B", body: "False/错误" } ]; } return item; }); // 截图中的证据文本框没有重复题干,需明确关联到它前面的选择题。 const itemById = new Map(questions.map(item => [item.id, item])); let previousChoice = null; for (const question of vm.questions) { const type = Number(question.type); if ([2, 3, 7].includes(type) && isSupportedQuestion(question)) { previousChoice = question; continue; } if (type === 11 && isSupportedQuestion(question)) { const item = itemById.get(String(question.id)); if (item && !item.body && previousChoice) { item.relatedQuestionId = String(previousChoice.id); item.responseTask = "evidence_for_related_choice"; item.relatedQuestion = { id: String(previousChoice.id), body: htmlToText(previousChoice.body), options: (previousChoice.questionOptions || []).map(option => ({ title: String(option.optionTitle), body: htmlToText(option.optionBody) })) }; } previousChoice = null; } } return { context, materials, questions }; } function parseAiJson(content) { let text = String(content || "").trim(); text = text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim(); try { return JSON.parse(text); } catch (_) { const start = text.indexOf("{"); const end = text.lastIndexOf("}"); if (start >= 0 && end > start) { return JSON.parse(text.slice(start, end + 1)); } throw new Error("AI 未返回有效 JSON"); } } function normalizeEndpoint(endpoint) { let value = String(endpoint || "").trim().replace(/\/+$/, ""); if (!/\/chat\/completions$/i.test(value)) { value += "/chat/completions"; } return value; } async function callAi(vm) { const config = state.config; if (!config.apiKey || !config.endpoint || !config.model) { throw new Error("请先填写 AI 接口地址、API Key 和模型名称"); } const payload = buildQuestionPayload(vm); if (payload.questions.length === 0) { throw new Error("当前页没有可自动处理的题型"); } if (payload.materials.length === 0) { log("未在当前单元收集到原文材料,AI 可能无法完成依据原文作答的题目", "error"); } const systemPrompt = [ "你是严谨的英语阅读理解与写作助手。必须先阅读 materials 中的原文材料,再作答;materials 是本单元可用的原文、说明和注释。", "只输出一个 JSON 对象,不要输出 Markdown、解释或额外文字。", "格式为 {\"answers\":[{\"id\":\"题目id\",\"answer\":答案}] }。", "填空题 type=1:answer 必须是字符串数组,数组顺序严格对应 blanks/题目中的空位顺序,每项只写该空答案。", "单选题 type=2:answer 必须是一个选项 title,例如 A;True/False/Not Given 题也必须返回实际选项 title。", "多选题 type=3:answer 必须是选项 title 数组,例如 [\"A\",\"C\"]。", "判断题 type=7:正确返回 A,错误返回 B。", "文本简答题 type=11:answer 必须是可直接填入 textarea 的完整字符串,不要使用数组或对象。严格遵守题干要求的语言、字数、文体和论证要求。", "句子改写题应只给改写后的句子;开放问答题必须基于 materials 中的原文作答,并直接给出完整答案,不要复述题目或添加‘参考答案’等前缀。", "若简答题包含 relatedQuestionId,则它是前一道 True/False/Not Given 或选择题的证据解释框。answer 必须按以下两行格式填写:", "第一行 Evidence: \"<从 materials 中逐字摘出的英文原句>\"", "第二行 Explanation: <基于该原句的一句话解释,说明它如何支持或反驳 relatedQuestion 中的陈述>", "若判断为 Not Given 且 materials 中确实没有对应证据,则填写:Evidence: \"Not given in the text.\"\\nExplanation: The materials do not provide information about this statement.", "不得编造引文;若 materials 中找不到原文,必须在 Explanation 中明确说明,不要臆造。", "所有答案都必须基于 materials、context 和题目信息。必须覆盖每一道题,不得返回题目 id 之外的内容,不得留空。" ].join("\n"); const userPrompt = [ "请先阅读以下原文材料,再完成题目。materials 是可引用的原文来源;context 是当前页说明。", JSON.stringify(payload) ].join("\n\n"); log(`正在请求 AI,共 ${payload.questions.length} 道题,已提供 ${payload.materials.length} 份原文材料...`); const requestBody = JSON.stringify({ model: config.model, temperature: 0.1, messages: [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt } ] }); const responseText = await gmRequest({ method: "POST", url: normalizeEndpoint(config.endpoint), headers: { "Content-Type": "application/json", "Authorization": `Bearer ${config.apiKey}` }, data: requestBody, timeout: 90000 }); let response; try { response = JSON.parse(responseText); } catch (_) { throw new Error(`AI 接口返回的不是 JSON: ${responseText.slice(0, 300)}`); } if (response.error) { throw new Error(response.error.message || JSON.stringify(response.error)); } const content = response.choices && response.choices[0] && response.choices[0].message && response.choices[0].message.content; if (!content) { throw new Error("AI 响应中缺少 choices[0].message.content"); } const parsed = parseAiJson(content); if (!Array.isArray(parsed.answers)) { throw new Error("AI JSON 中缺少 answers 数组"); } return parsed.answers; } function normalizeChoiceAnswer(rawAnswer, question, multiple) { const validOptions = Number(question.type) === 7 ? ["A", "B"] : (question.questionOptions || []).map(option => String(option.optionTitle)); let candidates = Array.isArray(rawAnswer) ? rawAnswer.map(item => String(item).trim()) : [String(rawAnswer == null ? "" : rawAnswer).trim()]; if (Number(question.type) === 7) { const value = candidates.join("").toLowerCase(); if (["true", "t", "yes", "y", "对", "正确", "√"].includes(value)) candidates = ["A"]; if (["false", "f", "no", "n", "错", "错误", "×"].includes(value)) candidates = ["B"]; } let result = []; for (const candidate of candidates) { if (validOptions.includes(candidate)) { result.push(candidate); continue; } const upper = candidate.toUpperCase(); if (validOptions.includes(upper)) { result.push(upper); continue; } if (multiple) { const letters = upper.match(/[A-Z]/g) || []; result.push(...letters.filter(letter => validOptions.includes(letter))); } else { const matched = upper.match(/[A-Z]/); if (matched && validOptions.includes(matched[0])) result.push(matched[0]); } } result = Array.from(new Set(result)); result.sort((a, b) => validOptions.indexOf(a) - validOptions.indexOf(b)); if (result.length === 0 || (!multiple && result.length !== 1)) { throw new Error(`题目 ${question.id} 的选项答案无法识别: ${JSON.stringify(rawAnswer)}`); } return multiple ? result : result[0]; } function dispatchInputEvent(input, eventName) { const EventConstructor = pageWindow.Event || Event; input.dispatchEvent(new EventConstructor(eventName, { bubbles: eventName !== "blur" })); } function fillBlank(vm, question, rawAnswer) { const inputs = getBlankInputs(vm, question); if (inputs.length === 0) { throw new Error(`填空题 ${question.id} 未找到输入框`); } let answers; if (Array.isArray(rawAnswer)) { answers = rawAnswer.map(item => String(item).trim()); } else if (rawAnswer && typeof rawAnswer === "object") { answers = inputs.map(input => String(rawAnswer[input.placeholder] == null ? "" : rawAnswer[input.placeholder]).trim()); } else if (inputs.length === 1) { answers = [String(rawAnswer == null ? "" : rawAnswer).trim()]; } else if (typeof rawAnswer === "string" && rawAnswer.includes("||")) { answers = rawAnswer.split("||").map(item => item.trim()); } else { throw new Error(`填空题 ${question.id} 必须返回答案数组`); } if (answers.length !== inputs.length || answers.some(answer => !answer)) { throw new Error(`填空题 ${question.id} 需要 ${inputs.length} 个非空答案,实际为 ${JSON.stringify(answers)}`); } inputs.forEach((input, index) => { input.readOnly = false; input.focus(); input.value = answers[index]; input.size = Math.max(1, Array.from(answers[index]).reduce((width, char) => width + (/^[\x00-\xff]$/.test(char) ? 1 : 2), 0)); dispatchInputEvent(input, "input"); dispatchInputEvent(input, "change"); input.blur(); dispatchInputEvent(input, "blur"); input.style.borderBottom = "2px solid #2563eb"; }); return answers; } function normalizeSubjectiveAnswer(rawAnswer) { let answer = rawAnswer; if (Array.isArray(rawAnswer)) { throw new Error("简答题答案必须是字符串,不能是数组"); } if (rawAnswer && typeof rawAnswer === "object") { answer = rawAnswer.textarea; } answer = String(answer == null ? "" : answer).trim(); if (!answer) throw new Error("简答题答案为空"); return answer; } function fillSubjective(vm, question, rawAnswer) { const textarea = getSubjectiveTextarea(vm, question); if (!textarea) { throw new Error(`简答题 ${question.id} 未找到可编辑的文本框,或当前答案已不可修改`); } const answer = normalizeSubjectiveAnswer(rawAnswer); textarea.focus(); textarea.value = answer; dispatchInputEvent(textarea, "input"); dispatchInputEvent(textarea, "change"); textarea.blur(); textarea.style.border = "1px solid #2563eb"; return answer; } function getChoiceInputs(question) { const expectedType = Number(question.type) === 3 ? "checkbox" : "radio"; return Array.from(document.querySelectorAll(`input[type="${expectedType}"]`)) .filter(input => String(input.name) === String(question.id)); } function fillChoice(question, rawAnswer) { const multiple = Number(question.type) === 3; const normalized = normalizeChoiceAnswer(rawAnswer, question, multiple); const desired = multiple ? normalized : [normalized]; const inputs = getChoiceInputs(question); if (inputs.length === 0) { throw new Error(`题目 ${question.id} 未找到选项输入框`); } for (const input of inputs) { const shouldCheck = desired.includes(String(input.value)); if (input.checked !== shouldCheck) { input.click(); } if (input.checked !== shouldCheck) { throw new Error(`题目 ${question.id} 的选项 ${input.value} 设置失败`); } } return normalized; } function verifyAnswers(vm, normalizedAnswers) { const userAnswers = vm.userAnsJson || {}; for (const question of vm.questions.filter(isSupportedQuestion)) { const id = question.id; const expected = normalizedAnswers.get(String(id)); const actual = userAnswers[id]; if (Number(question.type) === 1) { const inputs = getBlankInputs(vm, question); if (!actual || typeof actual !== "object") { throw new Error(`填空题 ${id} 的答案未同步到页面`); } inputs.forEach((input, index) => { if (String(actual[input.placeholder] == null ? "" : actual[input.placeholder]).trim() !== expected[index]) { throw new Error(`填空题 ${id} 第 ${index + 1} 空未同步到页面`); } }); } else if (Number(question.type) === 11) { let actualObject = actual; if (typeof actualObject === "string") { try { actualObject = JSON.parse(actualObject); } catch (_) { actualObject = null; } } const actualText = actualObject && typeof actualObject === "object" ? String(actualObject.textarea == null ? "" : actualObject.textarea).trim() : ""; const textarea = getSubjectiveTextarea(vm, question); if (actualText !== expected || !textarea || textarea.value.trim() !== expected) { throw new Error(`简答题 ${id} 的答案未同步到页面`); } } else if (Number(question.type) === 3) { const actualList = String(actual == null ? "" : actual).split("").filter(Boolean); const expectedList = String(expected.join("")).split("").filter(Boolean); if (actualList.slice().sort().join(",") !== expectedList.slice().sort().join(",")) { throw new Error(`多选题 ${id} 的答案未同步到页面`); } } else if (String(actual == null ? "" : actual) !== String(expected)) { throw new Error(`题目 ${id} 的答案未同步到页面`); } } } async function fillCurrentPage(token) { if (state.processing) throw new Error("当前已有任务正在执行"); state.processing = true; try { const vm = await waitForQuestions(); const signature = getQuestionSignature(vm); const unsupported = vm.questions.filter(question => Number(question.type) !== 0 && !isSupportedQuestion(question)); if (unsupported.length > 0) { log(`当前页含 ${unsupported.length} 道暂不支持的题型,自动提交将被阻止`, "error"); } setStatus("AI 正在作答...", "#1d4ed8"); const aiAnswers = await callAi(vm); if (token !== undefined && token !== state.runToken) throw new Error("任务已停止"); const answerMap = new Map(aiAnswers.map(item => [String(item.id), item.answer])); const normalizedAnswers = new Map(); const displayAnswers = []; for (const question of vm.questions.filter(isSupportedQuestion)) { if (!answerMap.has(String(question.id))) { throw new Error(`AI 缺少题目 ${question.id} 的答案`); } const rawAnswer = answerMap.get(String(question.id)); let normalized; if (Number(question.type) === 1) { normalized = fillBlank(vm, question, rawAnswer); } else if (Number(question.type) === 11) { normalized = fillSubjective(vm, question, rawAnswer); } else { normalized = fillChoice(question, rawAnswer); } normalizedAnswers.set(String(question.id), normalized); displayAnswers.push(`${TYPE_NAMES[Number(question.type)]} ${question.orderId || question.id}: ${Array.isArray(normalized) ? normalized.join(" | ") : normalized}`); } await delay(350); verifyAnswers(vm, normalizedAnswers); displayAnswers.forEach(text => log(text)); setStatus("本页答案已填写并校验", "#15803d"); log("本页答案已填写并同步到页面", "success"); return { vm, signature, unsupported, normalizedAnswers }; } finally { state.processing = false; } } async function submitCurrentPage(vm) { if (vm.disableSubmit !== 0) throw new Error("当前作业已截止或已超过提交次数"); if (vm.subType !== 0) throw new Error("当前作业不是待提交状态,已停止以避免重复提交"); const template = vm.showQuestionItem || vm.showTemplate; if (!template || !template.id || !template.type) { throw new Error("未获取到当前模板 ID,无法提交"); } if (!pageWindow.request || typeof pageWindow.request.post !== "function") { throw new Error("未找到页面自身的 request.post 方法"); } const formData = new URLSearchParams(); formData.append("json", JSON.stringify(vm.userAnsJson || {})); formData.append("templateId", template.id); formData.append("type", template.type.toString()); formData.append("cost", String(vm.cost || 0)); setStatus("正在提交本页...", "#b45309"); const response = await pageWindow.request.post("score/setScore.html", formData); if (!response || response.code !== 0 || !response.obj) { throw new Error(`提交失败: ${response && (response.error || response.msg) || "未知响应"}`); } if (response.obj.flag === 3) { throw new Error(`提交失败: ${response.obj.result || "服务器拒绝"}`); } vm.subType = 1; vm.key = response.obj.key || {}; vm.questionAns = response.obj.result || {}; log(`提交成功${response.obj.score !== undefined ? `,得分 ${response.obj.score}` : ""}`, "success"); return response; } function goToNextPage(vm) { const children = vm.showTemplate && Array.isArray(vm.showTemplate.children) ? vm.showTemplate.children : []; if (children.length > 0 && vm.showQuestionItem) { const index = children.findIndex(item => String(item.id) === String(vm.showQuestionItem.id)); if (index >= 0 && index + 1 < children.length) { vm.showItem(children[index + 1]); log(`切换到下一页: ${children[index + 1].name || children[index + 1].id}`); return "local"; } } const topId = vm.parentTemplate && vm.parentTemplate.id ? vm.parentTemplate.id : vm.showTemplate && vm.showTemplate.id; const templates = Array.isArray(vm.templateList) ? vm.templateList : []; const topIndex = templates.findIndex(item => String(item.id) === String(topId)); if (topIndex >= 0 && topIndex + 1 < templates.length) { vm.showMenu(templates[topIndex + 1]); log(`切换到下一节: ${templates[topIndex + 1].name || templates[topIndex + 1].id}`); return "local"; } if (state.config.autoNextUnit && vm.nextUnit && vm.nextUnit.template && vm.nextUnit.template.id) { log(`跳转到下一单元: ${vm.nextUnit.template.name || vm.nextUnit.template.id}`); vm.getUnit(); return "navigate"; } return "none"; } function readNumberInput(id, fallback, min, max) { const element = document.getElementById(id); const value = Number(element && element.value); if (!Number.isFinite(value)) return fallback; return Math.min(max, Math.max(min, Math.round(value))); } async function saveConfigFromPanel() { const get = id => document.getElementById(id); state.config = { endpoint: get("ai51-endpoint").value.trim(), apiKey: get("ai51-key").value.trim(), model: get("ai51-model").value.trim(), autoSubmit: get("ai51-auto-submit").checked, autoNext: get("ai51-auto-next").checked, autoNextUnit: get("ai51-auto-next-unit").checked, maxPages: readNumberInput("ai51-max-pages", DEFAULT_CONFIG.maxPages, 1, 100), delayMs: readNumberInput("ai51-delay", DEFAULT_CONFIG.delayMs, 500, 30000) }; await storageSet(STORAGE_KEY, state.config); return state.config; } function stopAutomation(message = "已停止自动任务") { state.runToken += 1; state.running = false; clearSession(); setStatus(message, "#b45309"); log(message); } async function runAutomation(resume = false) { if (state.running || state.processing) return; await saveConfigFromPanel(); if (!state.config.apiKey) { state.settingsEl.style.display = "block"; throwErrorInPanel("请先配置 API Key"); return; } if (state.config.autoSubmit && !resume) { const confirmed = pageWindow.confirm( "自动提交会消耗答题次数且通常不可撤销。\n\n请确认已先使用“仅填写本页”测试,并自行核对 AI 答案。是否继续?" ); if (!confirmed) return; } const previousSession = getSession(); const session = { active: true, pages: resume && previousSession ? previousSession.pages || 0 : 0, maxPages: state.config.maxPages }; saveSession(session); state.running = true; const token = ++state.runToken; let navigated = false; try { while (state.running && token === state.runToken) { if (session.pages >= session.maxPages) { log(`已达到本次最大提交页数 ${session.maxPages},自动停止`); break; } const result = await fillCurrentPage(token); if (!state.config.autoSubmit) { log("自动提交未开启:仅填写当前页,任务结束"); break; } if (result.unsupported.length > 0) { throw new Error("当前页包含不支持的题型,已填写可处理部分,但不会自动提交"); } await delay(state.config.delayMs); if (token !== state.runToken) break; await submitCurrentPage(result.vm); session.pages += 1; saveSession(session); if (!state.config.autoNext) break; const previousSignature = result.signature; const action = goToNextPage(result.vm); if (action === "none") { log("没有更多可跳转页面,任务结束", "success"); break; } if (action === "navigate") { navigated = true; setStatus("正在跳转,下一单元将自动继续", "#1d4ed8"); break; } await waitFor(() => { const vm = getVm(); return vm && vm.questions && vm.questions.length > 0 && getQuestionSignature(vm) !== previousSignature; }); await delay(500); } } catch (error) { setStatus(error.message, "#dc2626"); log(error.message, "error"); } finally { state.running = false; if (!navigated) clearSession(); } } function throwErrorInPanel(message) { setStatus(message, "#dc2626"); log(message, "error"); } function createPanel() { if (document.getElementById("ai51-panel")) return; const panel = document.createElement("div"); panel.id = "ai51-panel"; panel.innerHTML = `
51课堂 AI 答题助手
就绪,请先配置 AI 接口
支持填空、单选、多选、判断、文本简答,并会收集本单元原文材料用于证据解释。题目文本会发送到你配置的 AI 服务;API Key 保存在 ScriptCat 中。
`; const style = document.createElement("style"); style.textContent = ` #ai51-panel{position:fixed;right:18px;bottom:18px;width:390px;max-width:calc(100vw - 36px);z-index:2147483647;background:#fff;color:#1f2937;border:1px solid #cbd5e1;border-radius:10px;box-shadow:0 8px 30px rgba(15,23,42,.2);font:13px/1.5 Arial,"Microsoft YaHei",sans-serif} #ai51-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:#1e40af;color:#fff;border-radius:9px 9px 0 0;cursor:move} #ai51-header button{width:26px;padding:0;background:transparent;color:#fff;border:0;font-size:18px;cursor:pointer} #ai51-body{padding:10px 12px} #ai51-status{margin-bottom:8px;font-weight:600;word-break:break-all} #ai51-actions{display:flex;gap:6px;flex-wrap:wrap} #ai51-panel button{padding:6px 9px;border:1px solid #94a3b8;border-radius:5px;background:#f8fafc;color:#1e293b;cursor:pointer} #ai51-panel button:hover{background:#e2e8f0} #ai51-start{background:#2563eb!important;color:#fff!important;border-color:#2563eb!important} #ai51-stop{background:#fff1f2!important;color:#be123c!important} #ai51-settings{display:none;margin-top:10px;padding-top:8px;border-top:1px solid #e2e8f0} #ai51-settings label{display:block;margin:6px 0} #ai51-settings input[type=text],#ai51-settings input[type=password],#ai51-settings input[type=number]{box-sizing:border-box;width:100%;margin-top:3px;padding:5px 7px;border:1px solid #cbd5e1;border-radius:4px;background:#fff} #ai51-settings .ai51-check{display:flex;align-items:center;gap:6px} #ai51-settings .ai51-check input{width:auto;margin:0} #ai51-settings .ai51-row{display:grid;grid-template-columns:1fr 1fr;gap:8px} #ai51-settings .ai51-tip{margin-top:8px;color:#64748b;font-size:12px} #ai51-log{height:145px;overflow:auto;margin-top:10px;padding:7px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:5px;white-space:pre-wrap;word-break:break-all;font-size:12px} `; document.head.appendChild(style); document.body.appendChild(panel); state.panel = panel; state.statusEl = panel.querySelector("#ai51-status"); state.logEl = panel.querySelector("#ai51-log"); state.settingsEl = panel.querySelector("#ai51-settings"); const config = state.config; panel.querySelector("#ai51-endpoint").value = config.endpoint; panel.querySelector("#ai51-key").value = config.apiKey; panel.querySelector("#ai51-model").value = config.model; panel.querySelector("#ai51-auto-submit").checked = config.autoSubmit; panel.querySelector("#ai51-auto-next").checked = config.autoNext; panel.querySelector("#ai51-auto-next-unit").checked = config.autoNextUnit; panel.querySelector("#ai51-max-pages").value = config.maxPages; panel.querySelector("#ai51-delay").value = config.delayMs; if (!config.apiKey) state.settingsEl.style.display = "block"; panel.querySelector("#ai51-toggle-settings").addEventListener("click", () => { state.settingsEl.style.display = state.settingsEl.style.display === "none" ? "block" : "none"; }); panel.querySelector("#ai51-save").addEventListener("click", async () => { await saveConfigFromPanel(); setStatus("设置已保存", "#15803d"); log("设置已保存", "success"); }); panel.querySelector("#ai51-fill").addEventListener("click", async () => { try { await saveConfigFromPanel(); await fillCurrentPage(); } catch (error) { throwErrorInPanel(error.message); } }); panel.querySelector("#ai51-start").addEventListener("click", () => runAutomation(false)); panel.querySelector("#ai51-stop").addEventListener("click", () => stopAutomation()); panel.querySelector("#ai51-minimize").addEventListener("click", () => { const body = panel.querySelector("#ai51-body"); body.style.display = body.style.display === "none" ? "block" : "none"; }); makePanelDraggable(panel, panel.querySelector("#ai51-header")); } function makePanelDraggable(panel, handle) { let dragging = false; let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; handle.addEventListener("mousedown", event => { if (event.target.tagName === "BUTTON") return; dragging = true; const rect = panel.getBoundingClientRect(); startX = event.clientX; startY = event.clientY; startLeft = rect.left; startTop = rect.top; panel.style.left = `${startLeft}px`; panel.style.top = `${startTop}px`; panel.style.right = "auto"; panel.style.bottom = "auto"; event.preventDefault(); }); document.addEventListener("mousemove", event => { if (!dragging) return; panel.style.left = `${Math.max(0, Math.min(window.innerWidth - 60, startLeft + event.clientX - startX))}px`; panel.style.top = `${Math.max(0, Math.min(window.innerHeight - 40, startTop + event.clientY - startY))}px`; }); document.addEventListener("mouseup", () => { dragging = false; }); } async function init() { if (!/\/unit\.htm$/i.test(location.pathname)) return; const stored = await storageGet(STORAGE_KEY, {}); state.config = { ...DEFAULT_CONFIG, ...(stored || {}) }; createPanel(); setStatus("就绪。建议先使用“仅填写本页”测试"); log("脚本已加载,支持填空、单选、多选、判断、文本简答,并会收集本单元原文材料用于证据解释题"); const session = getSession(); if (session && session.active && session.pages < session.maxPages) { log(`检测到跨单元自动任务,将继续(已提交 ${session.pages}/${session.maxPages} 页)`); setTimeout(() => runAutomation(true), 1800); } else if (session) { clearSession(); } } init().catch(error => { console.error("[51AI] 初始化失败", error); }); })();