// ==UserScript== // @name 雨课堂刷题助手1.0 // @namespace deepseek-ai // @version 0.2 // @description 左侧作业页按题截图(5 题一组),右侧 DeepSeek 一次性上传 5 张图并回传答案,左侧自动填答。 // @author jmkc // @match https://examination.xuetangx.com/* // @match https://*.xuetangx.com/* // @match https://*.yuketang.cn/* // @match https://chat.deepseek.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener // @grant GM_addStyle // @grant unsafeWindow // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA5NiA5NiI+PHJlY3Qgd2lkdGg9Ijk2IiBoZWlnaHQ9Ijk2IiByeD0iMjIiIGZpbGw9IiNFMDNBM0UiLz48cGF0aCBkPSJNNDggMjBjOSAxMyAxOCAyMiAxOCAzM2ExOCAxOCAwIDAgMS0zNiAwYzAtMTEgOS0yMCAxOC0zM3oiIGZpbGw9IiNmZmYiLz48cGF0aCBkPSJNNDggNDRsMyA3IDcgMy03IDMtMyA3LTMtNy03LTMgNy0zeiIgZmlsbD0iI0UwM0EzRSIvPjwvc3ZnPg== // @require https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js // @noframes true // @run-at document-idle // @license MIT // ==/UserScript== /* * ============================ 使用前必读 ============================ * * 1. 已内置雨课堂/学堂在线域名: * @match https://*.xuetangx.com/* * @match https://*.yuketang.cn/* * 如果你的作业系统在别的域名,再补一条 @match 即可。 * * 注意:强烈建议“一个脚本 + 两个 @match”,而不是拆成两个独立脚本。 * 多数管理器(Tampermonkey)的 GM_setValue 存储是按“脚本”隔离的, * 两个独立脚本默认无法共享 GM 存储;同一个脚本的两个标签页则可以。 * 如果必须拆成两个脚本,请确认你的管理器支持共享存储 * (例如 ScriptCat 的 @storageName),否则跨页通信会失败。 * * 2. @require 已经引入固定版本的 html2canvas。若 CDN 被墙,可以: * - 下载 html2canvas.min.js 到本地,然后用 file:// 或你自己的静态服务器; * - 或换成 unpkg / 国内 CDN,但务必固定版本,不要用 latest。 * * 3. 本脚本默认只“填答案”,不会自动提交。需要自动提交时, * 打开 CONFIG.homework.autoSubmit 并配置 submitSelector。 * * 4. 所有带【改】的 CSS 选择器基本都需要按你的平台调整。 * 建议先用浏览器开发者工具选中一道题,确认“每题一个最外层容器”。 * 脚本会额外过滤左侧题号导航、答题卡等窄容器,避免把“题目 10”这种 * 导航项当成题目截下来。 * * 5. 截图策略:默认每 5 道题一组,每道题单独截一张清晰图片, * 右侧 DeepSeek 一次性上传这 5 张图,只发一条消息。 * * 6. 仅用于个人学习与复习场景;请遵守作业平台和学校的使用规则。 * ===================================================================== */ (function () { 'use strict'; const IS_DEEPSEEK = location.hostname === 'chat.deepseek.com'; // Tampermonkey 在授予 GM 权限后会运行在沙箱里;如需访问页面自己的 // UEditor / TinyMCE 等全局对象,必须通过 unsafeWindow。 const PAGE_WINDOW = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; // ======================= 全局配置:按平台修改 ======================= const CONFIG = { bridge: { // GM_addValueChangeListener 失效时的轮询间隔 pollMs: 1200, // 右侧进程崩溃/刷新后,超过这个时间允许重新领取任务 staleTaskMs: 3 * 60 * 1000, // 左侧等待答案的总超时 taskTimeoutMs: 10 * 60 * 1000, }, homework: { /* ============ 【改 1】题目容器选择器 ============ */ // 目标:每个元素 = 一道完整题目(题干 + 选项 + 输入框)。 // 如果匹配到“题目列表外层容器”,请把它换成更精确的选择器。 questionSelector: [ '[data-question-id]', '.question-item', '.questionItem', '.subject-item', '.exam-question', '.timu-item', '.question', ].join(','), // 题号导航、答题卡、目录、侧边栏等区域内的节点一律不作为题目。 // 雨课堂/学习通的左侧题号列表通常就在 aside、nav 或 *-nav / *-card 容器里。 excludeSelector: [ 'aside', 'nav', '[role="navigation"]', '[class*="question-nav"]', '[class*="questionNav"]', '[class*="answer-sheet"]', '[class*="answerSheet"]', '[class*="answer-card"]', '[class*="answerCard"]', '[class*="catalog"]', '[class*="sidebar"]', '[class*="side-bar"]', '[class*="sideBar"]', ].join(','), // 题目正文容器至少要有这么宽(CSS 像素)。题号导航通常只有 150~260px, // 主试卷区一般在 600px 以上。 minQuestionWidth: 320, minQuestionHeight: 40, // 题干文字太短的大概率是“题目 10”这类导航项 minQuestionTextLength: 10, /* ============ 【改 2】各题型内部元素选择器 ============ */ selectors: { // 题干(可选)。拿不到时会退化为整个题目容器的文本。 stem: '.question-title, .title, .stem, .question-stem, [class*="stem"], [class*="title"]', // 选项容器。label 和 .option 同时匹配时,代码会自动去重嵌套节点。 options: 'label, .option, .option-item, .choice, .choice-item, ' + '[role="radio"], [role="checkbox"], [class*="option"], [class*="choice"]', // 填空题输入框 fill: 'input[type="text"], input[type="number"], input:not([type]), textarea', // 主观题富文本编辑器 richText: '[contenteditable]:not([contenteditable="false"]), ' + '.ql-editor, .ProseMirror, .tox-edit-area__iframe, .ue-edit, ' + '[role="textbox"], [class*="ck-editor__editable"], ' + '[class*="wangEditor"], [class*="tiptap"], [class*="slate"]', }, /* ============ 【改 3】截图参数 ============ */ groupSize: 5, // 每题单独一张图,5 张图为 1 组,一次性发给 DeepSeek captureScale: 2, // html2canvas 放大倍数 maxImageWidth: 2000, // 单张图最大宽度;常规题目不会被压缩,保证清晰度 imageType: 'image/jpeg', imageQuality: 0.9, scrollBeforeCapture: true, // 先滚动到题目,触发图片懒加载 imageLoadTimeoutMs: 12000, // 直接合并给 html2canvas 的额外参数。 // 公式渲染问题可尝试:foreignObjectRendering: true, // 但它对 Firefox/跨域字体/复杂 SVG 的兼容性并不稳定,默认不开启。 html2canvasOptions: { useCORS: true, allowTaint: false, backgroundColor: '#ffffff', logging: false, imageTimeout: 15000, }, // 在 html2canvas 克隆文档里做二次处理,例如隐藏固定工具栏: // onClone: (clonedDoc) => { // clonedDoc.querySelectorAll('.fixed-header').forEach(el => el.remove()); // }, onClone: null, /* ============ 【改 4】自动填报参数 ============ */ fillDelayMs: 120, // 每题之间的间隔,避免事件风暴 allowDirectInnerTextFallback: false, // 不建议开启,见文末“富文本最佳实践” autoSubmit: false, submitSelector: '', // 例如 '.submit-btn, button[type="submit"]' /* ============ 可选钩子:平台差异极大时使用 ============ */ // (block, question) => 'single'|'multiple'|'judge'|'fill'|'subjective'|null customTypeDetector: null, // (block, question, answer, helpers) => true 表示已自行完成填写 customFiller: null, // (editor, text) => true 表示已自行完成富文本填写 customRichTextFill: null, typeLabels: { single: '单选题', multiple: '多选题', judge: '判断题', fill: '填空题', subjective: '主观题', }, }, deepseek: { /* ============ 【改 5】DeepSeek 页面选择器 ============ */ // 输入框:textarea 或 contenteditable editorSelector: [ 'textarea[placeholder*="DeepSeek"]', 'textarea[placeholder*="发送"]', 'textarea[placeholder*="消息"]', 'div[contenteditable="true"]', 'textarea', ].join(','), fileInputSelector: 'input[type="file"]', uploadButtonSelector: [ 'button[aria-label*="上传"]', 'button[aria-label*="附件"]', 'button[aria-label*="图片"]', '[role="button"][aria-label*="上传"]', '[role="button"][aria-label*="附件"]', 'button[class*="upload"]', ].join(','), sendButtonSelector: [ 'button[aria-label*="发送"]', 'button[aria-label*="Send"]', 'button[type="submit"]', '[role="button"][aria-label*="发送"]', 'button[class*="send"]', '[class*="send"] button', '[role="button"][class*="send"]', ].join(','), stopButtonSelector: [ 'button[aria-label*="停止"]', 'button[aria-label*="Stop"]', '[role="button"][aria-label*="停止"]', 'button[class*="stop"]', '[role="button"][class*="stop"]', ].join(','), // 助手回复块。DeepSeek 改版后这里通常需要调整。 assistantMessageSelector: '.ds-markdown, [class*="ds-markdown"], [class*="markdown"]', // 图片附件预览。用于确认文件确实被页面接收。 attachmentSelector: '[class*="attachment"], [class*="file-preview"], ' + '[class*="image-preview"], [class*="upload-item"], img[src^="blob:"]', uploadWaitMs: 2500, // 触发上传后等待页面处理 sendEnableTimeoutMs: 60000, // 5 张图上传较慢时,等待发送按钮可用 replyTimeoutMs: 180000, // 单组图片等待回复的最长时间 replyStableMs: 2500, // 回复文本稳定多久才视为可能结束 replyGraceMs: 15000, // 文本稳定但 JSON 还不完整时,再额外等待的时间 minReplyWaitMs: 90000, // 未解析到完整 JSON 前,至少等待这么久(避开“思考中”阶段) betweenGroupsMs: 600, // 多组任务之间稍作停顿 }, }; // ======================= GM 存储与跨页通信 ======================= const KEYS = { task: 'HWAI_TASK_V1', result: 'HWAI_RESULT_V1', // DeepSeek 页面定时写入的心跳,左侧据此显示“已连接 / 未连接” heartbeat: 'HWAI_HEARTBEAT_V1', // 每题一张图:groupId + 组内序号,例如 HWAI_IMG_V1_task123_g1_3 image: (groupId, index) => `HWAI_IMG_V1_${groupId}_${index}`, }; const Bridge = (() => { /** * 个别管理器可能把对象序列化成字符串再返回;这里做一次兼容解析。 * data:image/... 的 Base64 字符串不会被误解析。 */ function normalizeStoredValue(value) { if (typeof value !== 'string') return value; const text = value.trim(); if ( (text.startsWith('{') && text.endsWith('}')) || (text.startsWith('[') && text.endsWith(']')) ) { try { return JSON.parse(text); } catch { return value; } } return value; } async function get(key, fallback = null) { try { // await 同时兼容 Tampermonkey 的同步 API 和部分管理器的 Promise API return normalizeStoredValue(await GM_getValue(key, fallback)); } catch (err) { console.warn('[HomeworkAI] GM_getValue failed:', key, err); return fallback; } } async function set(key, value) { try { await GM_setValue(key, value); return true; } catch (err) { console.error('[HomeworkAI] GM_setValue failed:', key, err); return false; } } async function del(key) { try { if (typeof GM_deleteValue === 'function') await GM_deleteValue(key); } catch (err) { console.warn('[HomeworkAI] GM_deleteValue failed:', key, err); } } /** * 监听 GM 值。优先 GM_addValueChangeListener,同时用轮询兜底。 * 同一个值在轮询和监听中重复到达时,会用 JSON 签名去重。 */ function watch(key, callback, options = {}) { const intervalMs = options.intervalMs || CONFIG.bridge.pollMs; let stopped = false; let lastSignature = '__HWAI_INIT__'; let listenerId = null; function deliver(value) { if (stopped) return; let signature; try { signature = JSON.stringify(value); } catch { signature = String(value); } if (signature === lastSignature) return; lastSignature = signature; Promise.resolve() .then(() => callback(value)) .catch((err) => console.error('[HomeworkAI] watch callback failed:', err)); } if (typeof GM_addValueChangeListener === 'function') { try { listenerId = GM_addValueChangeListener(key, (_name, _oldValue, newValue, remote) => { // remote=false 通常是本标签页写入,跨页场景只处理远端变化 if (remote) deliver(normalizeStoredValue(newValue)); }); } catch (err) { console.warn('[HomeworkAI] GM_addValueChangeListener unavailable:', err); } } const timer = setInterval(async () => { const value = await get(key, null); deliver(value); }, intervalMs); return () => { stopped = true; clearInterval(timer); if (listenerId != null && typeof GM_removeValueChangeListener === 'function') { try { GM_removeValueChangeListener(listenerId); } catch { // ignore } } }; } return { get, set, del, watch }; })(); // ======================= 通用工具 ======================= const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function uid(prefix = 'id') { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } async function waitFor(fn, timeoutMs, intervalMs = 200) { const end = Date.now() + timeoutMs; while (Date.now() < end) { try { const value = await fn(); if (value) return value; } catch { // 元素可能正在重渲染,继续重试 } await sleep(intervalMs); } return null; } function isVisible(el) { if (!el || !el.isConnected) return false; const rect = el.getBoundingClientRect(); if (rect.width <= 1 || rect.height <= 1) return false; const style = getComputedStyle(el); return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; } function removeNestedElements(elements) { return elements.filter((el) => { return !elements.some((other) => other !== el && other.contains(el)); }); } function lowestCommonAncestor(nodes) { const list = nodes.filter(Boolean); if (!list.length) return null; let ancestor = list[0].parentElement; while (ancestor) { if (list.every((node) => ancestor.contains(node))) return ancestor; ancestor = ancestor.parentElement; } return null; } function normalizeText(text) { return String(text || '') .replace(/\u00a0/g, ' ') .replace(/\s+/g, ' ') .trim(); } function escapeHtml(text) { return String(text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function textToHtml(text) { return escapeHtml(text).replace(/\r?\n/g, '
'); } function makeDataTransfer() { return new DataTransfer(); } /** * Firefox 对 ClipboardEvent/DragEvent 构造器的 clipboardData/dataTransfer * 支持不完全一致,这里做属性兜底。 */ function createTransferEvent(type, dataTransfer, eventCtorName) { const Ctor = window[eventCtorName] || Event; let event; try { event = new Ctor(type, { bubbles: true, cancelable: true, clipboardData: dataTransfer, dataTransfer, }); } catch { event = new Ctor(type, { bubbles: true, cancelable: true }); } const key = eventCtorName === 'ClipboardEvent' ? 'clipboardData' : 'dataTransfer'; if (!event[key]) { try { Object.defineProperty(event, key, { value: dataTransfer }); } catch { // ignore } } return event; } /** * 给 React/Vue 受控输入框赋值。 * 必须走原型上的 value setter,直接 el.value=... 有时不会触发 React 状态更新。 */ function setNativeValue(el, value) { const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const descriptor = Object.getOwnPropertyDescriptor(proto, 'value'); if (descriptor && descriptor.set) descriptor.set.call(el, value); else el.value = value; } function dispatchInputChange(el, value) { try { el.dispatchEvent( new InputEvent('input', { bubbles: true, inputType: 'insertText', data: String(value ?? ''), }) ); } catch { el.dispatchEvent(new Event('input', { bubbles: true })); } el.dispatchEvent(new Event('change', { bubbles: true })); } /** * 读取编辑器文本。contenteditable 用 innerText,input/textarea 用 value。 */ function getEditorText(el) { if (!el) return ''; if ('value' in el && typeof el.value === 'string') return el.value; return el.innerText || el.textContent || ''; } /** * 富文本填写:选择内容 -> 模拟 paste -> execCommand insertText 兜底。 * 这是 ProseMirror/Quill/UEditor 等最容易接受的方式。 */ async function fillContentEditable(target, text) { if (!target) return false; let root = target; let doc = document; let win = window; if (target instanceof HTMLIFrameElement) { try { doc = target.contentDocument; win = target.contentWindow; if (!doc) return false; root = doc.querySelector('[contenteditable="true"]') || doc.body; } catch { return false; } } else { doc = target.ownerDocument || document; win = doc.defaultView || window; } if (!root) return false; try { root.focus(); const selection = win.getSelection(); if (selection) { selection.removeAllRanges(); const range = doc.createRange(); range.selectNodeContents(root); selection.addRange(range); } } catch { // 某些编辑器不允许直接操作 selection,继续尝试 } const expectedPrefix = normalizeText(text).slice(0, 16); const html = textToHtml(text); const dataTransfer = makeDataTransfer(); dataTransfer.setData('text/plain', String(text)); dataTransfer.setData('text/html', html); try { const pasteEvent = createTransferEvent('paste', dataTransfer, 'ClipboardEvent'); root.dispatchEvent(pasteEvent); } catch (err) { console.warn('[HomeworkAI] synthetic paste failed:', err); } // paste 可能同步或异步处理,等待一小段时间后按文本前缀校验,避免重复插入 await sleep(120); if (expectedPrefix && normalizeText(getEditorText(root)).includes(expectedPrefix)) { dispatchInputChange(root, text); return true; } // 某些编辑器不会处理 paste,这时用 insertText/insertHTML 兜底。 try { doc.execCommand('insertText', false, String(text)); } catch { // ignore } if (expectedPrefix && normalizeText(getEditorText(root)).includes(expectedPrefix)) { dispatchInputChange(root, text); return true; } // 最后尝试 insertHTML,兼容只响应富文本命令的编辑器 try { doc.execCommand('insertHTML', false, html); } catch { // ignore } if (expectedPrefix && normalizeText(getEditorText(root)).includes(expectedPrefix)) { dispatchInputChange(root, text); return true; } return false; } function injectStyle(css) { if (typeof GM_addStyle === 'function') { GM_addStyle(css); return; } const style = document.createElement('style'); style.textContent = css; document.documentElement.appendChild(style); } const PANEL_CSS = ` .hwai-panel { position: fixed; top: 84px; right: 16px; z-index: 2147483647; width: 286px; min-width: 240px; max-width: 95vw; min-height: 120px; max-height: 90vh; box-sizing: border-box; padding: 12px; border: 1px solid rgba(255, 255, 255, .08); border-radius: 14px; /* 顶部一抹紫蓝光晕 + 近乎纯黑的渐变底 */ background: radial-gradient(120% 55% at 100% 0%, rgba(99, 102, 241, .18), transparent 62%), linear-gradient(160deg, #161922 0%, #0b0d12 72%); box-shadow: 0 24px 60px rgba(0, 0, 0, .55), 0 2px 8px rgba(0, 0, 0, .35), inset 0 1px 0 rgba(255, 255, 255, .06); color: #e6e8ee; font: 12px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; color-scheme: dark; display: flex; flex-direction: column; scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, .18) transparent; /* 允许拖动右下角调整大小;overflow 不能是 visible */ resize: both; overflow: auto; } /* 作业页面板初始就展开得高一些,打开就能看到更多内容 */ .hwai-panel[data-side="homework"] { height: 430px; } /* 右下角缩放提示:深色背景下用淡蓝色,避免看不见 */ .hwai-resize-hint { position: fixed; left: -9999px; top: -9999px; z-index: 2147483647; width: 16px; height: 16px; pointer-events: none; opacity: .95; background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12'%3E%3Cpath d='M11 3L3 11M11 7L7 11' stroke='%2393c5fd' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat center/12px 12px; } .hwai-panel::-webkit-scrollbar { width: 6px; height: 6px; } .hwai-panel::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .16); border-radius: 999px; } .hwai-panel::-webkit-scrollbar-track { background: transparent; } .hwai-panel[data-side="deepseek"] { top: 12px; right: 16px; width: 250px; } .hwai-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; /* 负边距让标题栏铺满面板顶部 */ margin: -12px -12px 10px; padding: 12px 12px 9px; position: sticky; top: -12px; z-index: 1; font-size: 13px; font-weight: 600; letter-spacing: .5px; color: #f5f6fa; background: rgba(13, 15, 21, .94); backdrop-filter: blur(10px); box-shadow: 0 1px 0 rgba(255, 255, 255, .06); /* 标题栏用于拖动窗口 */ cursor: move; user-select: none; touch-action: none; } .hwai-dot { width: 8px; height: 8px; border-radius: 50%; background: #64748b; box-shadow: 0 0 0 3px rgba(148, 163, 184, .12); flex: 0 0 auto; } .hwai-status { min-height: 38px; padding: 8px 10px; border-radius: 9px; border: 1px solid rgba(255, 255, 255, .06); background: rgba(255, 255, 255, .035); color: #9aa3b5; word-break: break-word; transition: background .2s ease, color .2s ease, border-color .2s ease; } .hwai-status[data-kind="ok"] { border-color: rgba(52, 211, 153, .25); background: rgba(52, 211, 153, .09); color: #6ee7b7; } .hwai-status[data-kind="busy"] { border-color: rgba(129, 140, 248, .3); background: rgba(99, 102, 241, .12); color: #a5b4fc; } .hwai-status[data-kind="error"] { border-color: rgba(248, 113, 113, .3); background: rgba(248, 113, 113, .1); color: #fca5a5; } .hwai-actions { display: flex; gap: 8px; /* 按钮固定在面板底部,面板初始变高时不会留下空白 */ margin-top: auto; padding-top: 10px; flex: 0 0 auto; } .hwai-btn { flex: 1 1 0; border: 1px solid rgba(255, 255, 255, .12); border-radius: 9px; padding: 7px 9px; background: rgba(255, 255, 255, .05); color: #d7dbe6; font: inherit; letter-spacing: .3px; white-space: nowrap; cursor: pointer; transition: background .18s ease, border-color .18s ease, transform .12s ease, box-shadow .18s ease; } .hwai-btn:hover { background: rgba(255, 255, 255, .09); border-color: rgba(255, 255, 255, .22); } .hwai-btn:active { transform: translateY(1px); } .hwai-btn[data-primary="1"] { border-color: rgba(129, 140, 248, .55); background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff; box-shadow: 0 6px 18px rgba(99, 102, 241, .35); } .hwai-btn[data-primary="1"]:hover { filter: brightness(1.08); box-shadow: 0 8px 22px rgba(99, 102, 241, .45); } .hwai-btn[data-action="stop"] { border-color: rgba(248, 113, 113, .35); background: rgba(248, 113, 113, .1); color: #fca5a5; } .hwai-btn[data-action="stop"]:hover { background: rgba(248, 113, 113, .18); } .hwai-btn:disabled { cursor: not-allowed; opacity: .45; box-shadow: none; } .hwai-conn { display: flex; align-items: center; gap: 5px; margin: -2px 0 10px; font-size: 11px; color: #6f7788; } .hwai-conn::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #4b5262; flex: 0 0 auto; } .hwai-conn[data-online="1"] { color: #6ee7b7; } .hwai-conn[data-online="1"]::before { background: #34d399; box-shadow: 0 0 8px rgba(52, 211, 153, .7); } .hwai-progress { margin-bottom: 10px; } .hwai-progress-bar { height: 5px; border-radius: 999px; background: rgba(255, 255, 255, .08); overflow: hidden; } .hwai-progress-fill { width: 0; height: 100%; border-radius: 999px; background: linear-gradient(90deg, #6366f1, #a78bfa); box-shadow: 0 0 12px rgba(139, 92, 246, .6); transition: width .3s ease; } .hwai-progress-text { display: flex; justify-content: space-between; gap: 8px; margin-top: 5px; font-size: 11px; color: #7b8496; } .hwai-thumbs { display: flex; gap: 4px; margin-bottom: 10px; } .hwai-thumbs img { flex: 1 1 0; min-width: 0; width: auto; height: 38px; object-fit: cover; object-position: top; border: 1px solid rgba(255, 255, 255, .12); border-radius: 6px; background: #0b0d12; cursor: zoom-in; transition: transform .15s ease, border-color .15s ease; } .hwai-thumbs img:hover { transform: translateY(-2px); border-color: rgba(129, 140, 248, .6); } .hwai-results { max-height: 132px; overflow: auto; margin-bottom: 8px; border: 1px solid rgba(255, 255, 255, .07); border-radius: 9px; background: rgba(255, 255, 255, .02); scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, .15) transparent; } .hwai-result { display: flex; justify-content: space-between; gap: 6px; padding: 5px 8px; font-size: 11px; color: #b6bdcb; cursor: pointer; border-bottom: 1px solid rgba(255, 255, 255, .05); transition: background .15s ease; } .hwai-result:last-child { border-bottom: none; } .hwai-result:hover { background: rgba(255, 255, 255, .055); } .hwai-result-state { color: #8b94a6; } .hwai-result[data-ok="1"] .hwai-result-state { color: #34d399; } .hwai-result[data-ok="0"] .hwai-result-state { color: #f87171; } .hwai-overlay { position: fixed; inset: 0; z-index: 2147483647; display: flex; align-items: center; justify-content: center; background: rgba(4, 5, 9, .82); backdrop-filter: blur(6px); cursor: zoom-out; } .hwai-overlay img { max-width: 92vw; max-height: 92vh; border-radius: 10px; background: #fff; box-shadow: 0 24px 80px rgba(0, 0, 0, .65); } /* 这些元素的 class 里设置了 display,需要显式让 [hidden] 生效 */ .hwai-conn[hidden], .hwai-progress[hidden], .hwai-thumbs[hidden], .hwai-results[hidden], .hwai-overlay[hidden] { display: none !important; } `; const PANEL_STATE_KEY = (side) => `HWAI_PANEL_V1_${side}`; /** * 面板拖动:按住标题栏移动,拖动右下角调整大小。 * 位置和尺寸会写入 GM 存储,刷新后自动恢复。 */ function enablePanelDrag(panel, handle, stateKey, onChange) { let dragging = false; let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; let saveTimer = null; const clamp = (value, min, max) => Math.min(Math.max(value, min), max); const notify = () => { if (typeof onChange === 'function') onChange(); }; const saveState = () => { clearTimeout(saveTimer); saveTimer = setTimeout(() => { const rect = panel.getBoundingClientRect(); const state = { left: Math.round(rect.left), top: Math.round(rect.top), }; // 只有用户手动拖拽过右下角时,style 里才会有 width/height if (panel.style.width) state.width = panel.style.width; if (panel.style.height) state.height = panel.style.height; Bridge.set(stateKey, state); }, 300); }; const moveTo = (left, top) => { panel.style.left = `${left}px`; panel.style.top = `${top}px`; panel.style.right = 'auto'; }; handle.addEventListener('pointerdown', (event) => { if (event.button !== 0) return; // 避免拖动时选中标题文字 event.preventDefault(); const rect = panel.getBoundingClientRect(); moveTo(rect.left, rect.top); startX = event.clientX; startY = event.clientY; startLeft = rect.left; startTop = rect.top; dragging = true; handle.setPointerCapture?.(event.pointerId); }); handle.addEventListener('pointermove', (event) => { if (!dragging) return; const maxLeft = Math.max(0, window.innerWidth - 80); const maxTop = Math.max(0, window.innerHeight - 40); moveTo( clamp(startLeft + event.clientX - startX, 0, maxLeft), clamp(startTop + event.clientY - startY, 0, maxTop) ); notify(); }); const endDrag = (event) => { if (!dragging) return; dragging = false; handle.releasePointerCapture?.(event.pointerId); saveState(); }; handle.addEventListener('pointerup', endDrag); handle.addEventListener('pointercancel', endDrag); // 用户拖拽右下角调整尺寸后,也顺带记住大小 if (typeof ResizeObserver === 'function') { const observer = new ResizeObserver(() => { notify(); if (panel.style.width || panel.style.height) saveState(); }); observer.observe(panel); } // 恢复上次保存的位置和大小 (async () => { const saved = await Bridge.get(stateKey, null); if (!saved || typeof saved !== 'object') return; const width = Number.parseFloat(saved.width); const height = Number.parseFloat(saved.height); const left = Number(saved.left); const top = Number(saved.top); if (Number.isFinite(width) && width >= 200) { panel.style.width = `${width}px`; } if (Number.isFinite(height) && height >= 100) { panel.style.height = `${height}px`; } if (Number.isFinite(left) && Number.isFinite(top)) { const maxLeft = Math.max(0, window.innerWidth - 80); const maxTop = Math.max(0, window.innerHeight - 40); moveTo(clamp(left, 0, maxLeft), clamp(top, 0, maxTop)); } notify(); })(); } function createPanel(options) { injectStyle(PANEL_CSS); const root = document.createElement('div'); root.className = 'hwai-panel'; root.dataset.side = options.side || 'homework'; const head = document.createElement('div'); head.className = 'hwai-title'; const title = document.createElement('span'); title.textContent = options.title; const dot = document.createElement('span'); dot.className = 'hwai-dot'; head.append(title, dot); // 连接状态:左侧根据 DeepSeek 心跳显示在线/离线 const conn = document.createElement('div'); conn.className = 'hwai-conn'; conn.textContent = '正在检测连接...'; if (options.side === 'deepseek') conn.hidden = true; // 进度条:第 N/M 组 + 当前阶段 const progress = document.createElement('div'); progress.className = 'hwai-progress'; progress.hidden = true; const progressBar = document.createElement('div'); progressBar.className = 'hwai-progress-bar'; const progressFill = document.createElement('div'); progressFill.className = 'hwai-progress-fill'; progressBar.appendChild(progressFill); const progressText = document.createElement('div'); progressText.className = 'hwai-progress-text'; const progressLabel = document.createElement('span'); const progressPercent = document.createElement('span'); progressText.append(progressLabel, progressPercent); progress.append(progressBar, progressText); // 当前组的截图缩略图,点击可放大确认 const thumbs = document.createElement('div'); thumbs.className = 'hwai-thumbs'; thumbs.hidden = true; // 填报结果清单,点击可定位到对应题目 const results = document.createElement('div'); results.className = 'hwai-results'; results.hidden = true; const overlay = document.createElement('div'); overlay.className = 'hwai-overlay'; overlay.hidden = true; const overlayImg = document.createElement('img'); overlay.appendChild(overlayImg); overlay.addEventListener('click', () => { overlay.hidden = true; overlayImg.removeAttribute('src'); }); const statusEl = document.createElement('div'); statusEl.className = 'hwai-status'; statusEl.dataset.kind = 'info'; const actions = document.createElement('div'); actions.className = 'hwai-actions'; const buttons = {}; for (const item of options.buttons || []) { const button = document.createElement('button'); button.type = 'button'; button.className = 'hwai-btn'; button.textContent = item.label; button.dataset.action = item.action || ''; if (item.primary) button.dataset.primary = '1'; // 停止按钮只在任务进行中显示 if (item.action === 'stop') button.hidden = true; button.addEventListener('click', item.onClick); actions.appendChild(button); buttons[item.action] = button; } root.append(head, conn, progress, statusEl, thumbs, results); if ((options.buttons || []).length) root.append(actions); // 右下角缩放手柄提示(淡蓝色),跟随面板位置实时移动 const resizeHint = document.createElement('div'); resizeHint.className = 'hwai-resize-hint'; resizeHint.title = '拖动调整大小'; const syncResizeHint = () => { const rect = root.getBoundingClientRect(); resizeHint.style.left = `${Math.round(rect.right - 18)}px`; resizeHint.style.top = `${Math.round(rect.bottom - 18)}px`; }; overlay.addEventListener('click', () => { resizeHint.style.display = ''; }); document.documentElement.append(root, overlay, resizeHint); requestAnimationFrame(syncResizeHint); // 标题栏拖动移动窗口,右下角拖动调整大小 head.title = '按住标题拖动窗口,拖动右下角调整大小'; enablePanelDrag(root, head, PANEL_STATE_KEY(root.dataset.side), syncResizeHint); return { root, buttons, setStatus(text, kind = 'info') { statusEl.textContent = text; statusEl.dataset.kind = kind; dot.style.background = kind === 'ok' ? '#10b981' : kind === 'busy' ? '#3b82f6' : kind === 'error' ? '#ef4444' : '#94a3b8'; }, setConnection(online, text) { conn.hidden = false; conn.dataset.online = online ? '1' : '0'; conn.textContent = text; }, setProgress(current, total, phase = '') { if (!total || current <= 0) { progress.hidden = true; return; } progress.hidden = false; const percent = Math.round(Math.min(1, current / total) * 100); progressFill.style.width = `${percent}%`; progressLabel.textContent = phase ? `${phase} · 第 ${current}/${total} 组` : `第 ${current}/${total} 组`; progressPercent.textContent = `${percent}%`; }, setThumbnails(items) { thumbs.textContent = ''; if (!items || !items.length) { thumbs.hidden = true; return; } thumbs.hidden = false; items.forEach((item) => { const img = document.createElement('img'); img.src = item.src; img.title = item.label || '点击放大'; img.alt = item.label || '题目截图'; img.addEventListener('click', () => { overlayImg.src = item.src; overlay.hidden = false; resizeHint.style.display = 'none'; }); thumbs.appendChild(img); }); }, setResults(items, onClick) { results.textContent = ''; if (!items || !items.length) { results.hidden = true; return; } results.hidden = false; items.forEach((item) => { const row = document.createElement('div'); row.className = 'hwai-result'; row.dataset.ok = item.ok ? '1' : '0'; const name = document.createElement('span'); name.textContent = `第 ${item.id} 题`; const state = document.createElement('span'); state.className = 'hwai-result-state'; state.textContent = item.ok ? '已填写' : item.reason || '失败'; row.append(name, state); if (typeof onClick === 'function') { row.addEventListener('click', () => onClick(item.id)); } results.appendChild(row); }); }, setBusy(busy) { Object.values(buttons).forEach((button) => { const action = button.dataset.action; if (action === 'openDeepSeek') return; if (action === 'stop') { button.hidden = !busy; button.disabled = !busy; return; } button.disabled = !!busy; }); }, }; } // ======================= 答案格式归一化 ======================= const TYPE_ALIASES = { single: 'single', single_choice: 'single', '单选': 'single', singlechoice: 'single', multiple: 'multiple', multiple_choice: 'multiple', multi_choice: 'multiple', '多选': 'multiple', judge: 'judge', true_false: 'judge', '判断': 'judge', fill: 'fill', blank: 'fill', fill_blank: 'fill', '填空': 'fill', subjective: 'subjective', essay: 'subjective', short_answer: 'subjective', '主观': 'subjective', }; function normalizeQuestionType(type) { const key = String(type || '').trim().toLowerCase(); return TYPE_ALIASES[key] || 'single'; } function parseChoiceLetters(answer) { const raw = Array.isArray(answer) ? answer.join(' ') : String(answer ?? ''); const letters = raw.toUpperCase().match(/[A-H]/g) || []; return [...new Set(letters)]; } function parseJudgeValue(answer) { if (typeof answer === 'boolean') return answer; const raw = normalizeText(answer); if (!raw) return null; // 否定表达必须先判断,避免“不正确”被 /正确/ 命中 if (/不正确|错误|错|不对|否|×|✗|FALSE|^F$/i.test(raw)) return false; if (/正确|对|是|√|✓|TRUE|^T$/i.test(raw)) return true; if (/^A$/i.test(raw) || /^1$/.test(raw)) return true; if (/^B$/i.test(raw) || /^2$/.test(raw)) return false; return null; } function normalizeFillAnswers(answer, blankCount) { if (Array.isArray(answer)) { return answer.map((item) => String(item ?? '').trim()); } const raw = String(answer ?? '').trim(); if (raw.startsWith('[')) { try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.map((item) => String(item ?? '').trim()); } catch { // ignore } } if (blankCount <= 1) return [raw]; // 多个空时,优先识别模型可能返回的分隔格式 const parts = raw .split(/\s*(?:\|\|||||;|;|\r?\n)\s*/) .map((item) => item.trim()) .filter(Boolean); if (parts.length === blankCount) return parts; return [raw]; } function stringifySubjectiveAnswer(answer) { if (Array.isArray(answer)) return answer.map(String).join('\n'); if (answer == null) return ''; return String(answer); } // ======================= 左侧:作业页面 ======================= class HomeworkAgent { constructor() { this.panel = null; this.refs = new Map(); // qid -> 题目 DOM this.questions = new Map(); // qid -> 题目元数据 this.currentTaskId = null; this.currentGroups = []; this.busy = false; this.resultApplied = false; this.aborted = false; this.timeoutTimer = null; this.connTimer = null; this.unwatchResult = null; this.unwatchTask = null; } start() { this.buildPanel(); this.unwatchResult = Bridge.watch( KEYS.result, (result) => this.onResult(result), { intervalMs: CONFIG.bridge.pollMs } ); this.unwatchTask = Bridge.watch( KEYS.task, (task) => this.onTaskState(task), { intervalMs: CONFIG.bridge.pollMs * 2 } ); this.panel.setStatus('准备就绪。点击“开始识别并求解”,脚本会自动过滤题号导航并识别题目。'); // 定时检查 DeepSeek 心跳,右侧没开时提前提醒 this.refreshConnection(); clearInterval(this.connTimer); this.connTimer = setInterval(() => this.refreshConnection(), 4000); } async refreshConnection() { let online = false; try { const beat = await Bridge.get(KEYS.heartbeat, null); online = !!beat && Date.now() - Number(beat.at || 0) < 15000; } catch { online = false; } this.panel.setConnection( online, online ? 'DeepSeek 已连接' : 'DeepSeek 未连接,请打开右侧标签页' ); } buildPanel() { this.panel = createPanel({ side: 'homework', title: '作业 AI 助手', buttons: [ { label: '开始识别', action: 'start', primary: true, onClick: () => this.onStartClick(), }, { label: '停止', action: 'stop', onClick: () => this.onStopClick(), }, { label: 'DeepSeek', action: 'openDeepSeek', onClick: () => { // 必须在用户点击的同步调用栈里 open,否则可能被弹窗拦截 window.open('https://chat.deepseek.com/', 'hwai_deepseek'); }, }, ], }); } async onStartClick() { if (this.busy) return; this.busy = true; this.aborted = false; this.panel.setBusy(true); this.panel.setResults(null); this.panel.setThumbnails(null); this.panel.setProgress(0, 0); try { await this.runTask(); } catch (err) { if (err && err.aborted) { // 用户主动停止,不算错误 this.finishTask(); return; } console.error('[HomeworkAI] start failed:', err); this.panel.setStatus(`启动失败:${err.message || err}`, 'error'); this.finishTask(); } } async onStopClick() { if (!this.busy) return; this.aborted = true; // 阻止迟到的结果继续回填 this.resultApplied = true; this.panel.setStatus('已手动停止,正在清理...', 'error'); this.panel.setProgress(0, 0); try { const latest = await Bridge.get(KEYS.task, null); if (latest && latest.taskId === this.currentTaskId) { await Bridge.set(KEYS.task, { ...latest, status: 'failed', aborted: true, error: '用户手动停止', }); } } catch { // 忽略存储异常,停止流程不因此中断 } await this.cleanupTaskImages(); this.panel.setStatus('已手动停止。', 'error'); this.finishTask(); } async runTask() { if (typeof html2canvas !== 'function') { throw new Error('html2canvas 未加载,请检查 @require 或 CDN 是否可访问'); } this.panel.setStatus('正在查找题目...', 'busy'); const questions = this.collectQuestions(); if (!questions.length) { throw new Error('未找到题目,请检查 CONFIG.homework.questionSelector'); } const taskId = uid('task'); this.currentTaskId = taskId; this.currentGroups = []; this.resultApplied = false; const groups = []; const createdImageKeys = []; const groupSize = Math.max(1, Number(CONFIG.homework.groupSize) || 5); const totalGroups = Math.ceil(questions.length / groupSize); try { for (let offset = 0; offset < questions.length; offset += groupSize) { if (this.aborted) { const abortedError = new Error('已手动停止'); abortedError.aborted = true; throw abortedError; } const groupQuestions = questions.slice(offset, offset + groupSize); const groupIndex = groups.length + 1; const groupId = `${taskId}_g${groupIndex}`; this.panel.setProgress(groupIndex, totalGroups, '正在截图'); this.panel.setStatus( `正在截取第 ${groupIndex} 组(题号 ${groupQuestions[0].id}-${ groupQuestions[groupQuestions.length - 1].id },共 ${groupQuestions.length} 张图)...`, 'busy' ); // 每题单独截图,返回 [{ question, dataUrl }, ...] const captures = await this.captureGroup(groupQuestions); // 把本组截图显示在面板里,方便确认有没有截错区域 this.panel.setThumbnails( captures.map((capture) => ({ label: `第 ${capture.question.id} 题`, src: capture.dataUrl, })) ); this.panel.setProgress(groupIndex, totalGroups, '截图完成'); const images = []; for (let i = 0; i < captures.length; i += 1) { const { question, dataUrl } = captures[i]; const imageKey = KEYS.image(groupId, i + 1); const saved = await Bridge.set(imageKey, dataUrl); if (!saved) { throw new Error(`第 ${question.id} 题截图写入 GM 存储失败,可能是图片过大`); } createdImageKeys.push(imageKey); images.push({ key: imageKey, questionId: question.id }); } groupQuestions.forEach((q) => { q.groupId = groupId; q.groupIndex = groupIndex; }); groups.push({ groupId, images, questionIds: groupQuestions.map((q) => q.id), questions: groupQuestions.map((q) => ({ id: q.id, type: q.type, text: q.text, })), }); } if (this.aborted) { const abortedError = new Error('已手动停止'); abortedError.aborted = true; throw abortedError; } const task = { schema: 2, taskId, status: 'pending', createdAt: Date.now(), pageTitle: document.title, pageUrl: location.href, groups, }; // 先写图片,最后写任务描述,避免右侧拿到任务时图片还没准备好 const savedTask = await Bridge.set(KEYS.task, task); if (!savedTask) throw new Error('任务写入 GM 存储失败'); this.currentGroups = groups; const imageCount = groups.reduce((sum, group) => sum + group.images.length, 0); this.panel.setStatus( `已发送 ${questions.length} 道题(${groups.length} 组,共 ${imageCount} 张图片),等待 DeepSeek 回答...`, 'busy' ); this.startTimeout(); } catch (err) { // 失败时清理已经写入的图片,避免 GM 存储里留下垃圾 for (const key of createdImageKeys) await Bridge.del(key); // 如果停止操作发生在任务写入之后,这里再兜底标记一次, // 防止 pending 任务被右侧领取后继续跑 if (err && err.aborted) { const latest = await Bridge.get(KEYS.task, null); if (latest && latest.taskId === taskId && latest.status !== 'answered') { await Bridge.set(KEYS.task, { ...latest, status: 'failed', aborted: true, error: '用户手动停止', }); } } throw err; } } startTimeout() { clearTimeout(this.timeoutTimer); this.timeoutTimer = setTimeout(() => { if (this.resultApplied) return; this.panel.setStatus( '等待 DeepSeek 超时。请确认右侧标签页已打开脚本并保持登录。', 'error' ); this.finishTask(); }, CONFIG.bridge.taskTimeoutMs); } finishTask() { clearTimeout(this.timeoutTimer); this.timeoutTimer = null; this.busy = false; this.panel.setBusy(false); } collectQuestions() { const nodes = this.collectQuestionNodes(); const questions = []; this.refs.clear(); this.questions.clear(); // 清掉上一次的运行标记,避免页面局部更新后反查到旧节点 document.querySelectorAll('[data-hwai-qid]').forEach((el) => { el.removeAttribute('data-hwai-qid'); }); if (nodes.length) { console.info( '[HomeworkAI] 识别到题目', nodes.length, nodes.slice(0, 5).map((el) => `${el.tagName.toLowerCase()}.${String(el.className || '') .split(/\s+/) .filter(Boolean) .slice(0, 2) .join('.')}:${normalizeText(el.innerText).slice(0, 40)}` ) ); } nodes.forEach((block, index) => { const id = index + 1; const type = this.detectQuestionType(block); const text = this.extractQuestionText(block); // 用属性反查,页面局部重渲染后还能重新定位题目 block.setAttribute('data-hwai-qid', String(id)); this.refs.set(id, block); this.questions.set(id, { id, type, text }); questions.push({ id, type, text }); }); return questions; } collectQuestionNodes() { let nodes; try { nodes = [...document.querySelectorAll(CONFIG.homework.questionSelector)]; } catch (err) { throw new Error(`题目选择器无效:${err.message}`); } const primary = removeNestedElements( nodes.filter((el) => this.isLikelyQuestionBlock(el)) ); if (primary.length) return primary; // 兜底:有些平台的选择器只命中了左侧题号导航,真正的题目正文 // 没有对应 class。这时从答题控件反推最近的题目容器。 const fallback = this.discoverQuestionBlocksFromControls(); if (fallback.length) { console.warn( '[HomeworkAI] questionSelector 未命中题目正文,已改用答题控件兜底识别,共', fallback.length, '题' ); } return fallback; } /** * 从 radio / checkbox / 输入框等答题控件反推“每题一个容器”。 * 作为 questionSelector 失效时的兜底方案。 */ discoverQuestionBlocksFromControls() { const controlSelector = [ 'input[type="radio"]', 'input[type="checkbox"]', 'input[type="text"]', 'input[type="number"]', 'input:not([type])', 'textarea', 'select', '[contenteditable="true"]', '[role="radio"]', '[role="checkbox"]', ].join(','); let controls; try { controls = [...document.querySelectorAll(controlSelector)]; } catch { return []; } controls = controls.filter((el) => { if (!isVisible(el) || el.disabled) return false; // 排除页头页尾的搜索框、工具栏等无关控件 if (el.closest('header, footer, [role="search"]')) return false; try { if (el.closest(CONFIG.homework.excludeSelector)) return false; } catch { // ignore } return true; }); if (!controls.length) return []; const tokenMap = new WeakMap(); let tokenSeq = 0; const token = (el) => { if (!el) return 'none'; if (!tokenMap.has(el)) tokenMap.set(el, `n${tokenSeq++}`); return tokenMap.get(el); }; const groups = new Map(); const addGroup = (key, members) => { if (!groups.has(key)) groups.set(key, []); groups.get(key).push(...members); }; for (const control of controls) { const type = control.matches('input[type="radio"], [role="radio"]') ? 'radio' : control.matches('input[type="checkbox"], [role="checkbox"]') ? 'checkbox' : 'text'; if (type === 'text') { addGroup(`text:${token(control)}`, [control]); continue; } const name = control.getAttribute('name'); if (name) { addGroup(`${type}:name:${name}`, [control]); continue; } // 没有 name 的选项:找到最近的、至少包含两个同类控件的祖先 const sameSelector = type === 'radio' ? 'input[type="radio"], [role="radio"]' : 'input[type="checkbox"], [role="checkbox"]'; let cluster = null; let parent = control.parentElement; for ( let depth = 0; depth < 5 && parent; depth += 1, parent = parent.parentElement ) { try { if (parent.closest(CONFIG.homework.excludeSelector)) break; } catch { // ignore } const same = [...parent.querySelectorAll(sameSelector)].filter(isVisible); if (same.length >= 2) { cluster = parent; break; } } addGroup( `${type}:ancestor:${token(cluster || control.parentElement)}`, [control] ); } const containers = []; for (const members of groups.values()) { const lca = lowestCommonAncestor(members); if (!lca) continue; let container = lca; let parent = container.parentElement; // 最多向上扩 2 层:把题干也包含进来,但绝不吞并其他题目的控件 for (let depth = 0; depth < 2 && parent; depth += 1) { try { if (parent.closest(CONFIG.homework.excludeSelector)) break; } catch { // ignore } const rect = parent.getBoundingClientRect(); if (rect.width < Number(CONFIG.homework.minQuestionWidth || 320)) break; if (rect.height < Number(CONFIG.homework.minQuestionHeight || 40)) break; const hasForeignControl = controls.some( (c) => !members.includes(c) && parent.contains(c) ); if (hasForeignControl) break; container = parent; parent = parent.parentElement; } if (!container.isConnected) continue; const rect = container.getBoundingClientRect(); if (rect.width < Number(CONFIG.homework.minQuestionWidth || 320)) continue; const text = normalizeText(container.innerText); if (text.length < Number(CONFIG.homework.minQuestionTextLength || 10)) continue; containers.push(container); } // 合并互相包含的容器,只保留外层(例如填空题里的多个空) const deduped = containers.filter( (el) => !containers.some((other) => other !== el && other.contains(el)) ); return deduped.sort((a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1 ); } /** * 过滤掉题号导航、答题卡、目录等“假题目”。 * 雨课堂左侧导航项通常又窄又短,只显示“题目 10”, * 如果直接截图就会得到一张很窄的白色竖条。 */ isLikelyQuestionBlock(block) { if (!isVisible(block)) return false; const rect = block.getBoundingClientRect(); if (rect.width < Number(CONFIG.homework.minQuestionWidth || 320)) return false; if (rect.height < Number(CONFIG.homework.minQuestionHeight || 40)) return false; // 位于侧边栏 / 题号导航 / 答题卡里的节点直接排除 try { if (block.closest(CONFIG.homework.excludeSelector)) return false; } catch { // 选择器写错时不阻塞主流程 } // 如果这个节点内部还包含多个“题目候选”,说明它是题目列表外层容器, // 不是单道题;交给内层节点去截图。 try { const nestedCandidates = block.querySelectorAll( CONFIG.homework.questionSelector ); if (nestedCandidates.length >= 2) return false; } catch { // ignore } const text = normalizeText(block.innerText); if (!text) return false; // 纯数字、“题目 10”、“第 10 题”都不是题目正文 if (/^(?:题目|试题|第)?\s*\d{1,3}\s*(?:题)?$/.test(text)) return false; if (text.length < Number(CONFIG.homework.minQuestionTextLength || 10)) { return false; } // 单题至少要有一个答题控件;否则要求题干足够长(纯图片题除外) const hasControl = !!block.querySelector( 'input, textarea, select, [contenteditable="true"], ' + '[role="radio"], [role="checkbox"], .option, .option-item, ' + '.choice, .choice-item, label' ); const hasImage = !!block.querySelector('img, canvas, svg'); return hasControl || hasImage || text.length >= 20; } detectQuestionType(block) { if (typeof CONFIG.homework.customTypeDetector === 'function') { const custom = CONFIG.homework.customTypeDetector(block, { normalize: normalizeQuestionType, }); if (custom) return normalizeQuestionType(custom); } const declared = block.getAttribute('data-type') || block.getAttribute('data-question-type'); if (declared) return normalizeQuestionType(declared); const text = normalizeText(block.innerText).slice(0, 1500); if (/主观题|简答题|简答|论述题|问答题|分析题|作文/.test(text)) return 'subjective'; if (/多选题|多项选择|不定项选择|多选/.test(text)) return 'multiple'; if (/判断题|判断正误|判断对错|是否正确|对错题/.test(text)) return 'judge'; if (/填空题|填空|____/.test(text)) return 'fill'; // 先看控件类型。选择题题干里也常见“( )”, // 所以不要因为括号就优先判定为填空题。 if (block.querySelector('input[type="checkbox"], [role="checkbox"]')) return 'multiple'; if (block.querySelector('input[type="radio"], [role="radio"]')) return 'single'; if ( block.querySelector( 'textarea, [contenteditable="true"], .ql-editor, .ProseMirror, [role="textbox"], [class*="editor"]' ) ) { return 'subjective'; } if ( block.querySelector( 'input[type="text"], input[type="number"], input:not([type])' ) ) { return 'fill'; } if (/(\s*)|\(\s*\)/.test(text)) return 'fill'; return 'single'; } extractQuestionText(block) { let stem = null; try { stem = block.querySelector(CONFIG.homework.selectors.stem); } catch { stem = null; } const source = stem && normalizeText(stem.innerText) ? stem : block; return normalizeText(source.innerText).slice(0, 500); } async captureGroup(groupQuestions) { const savedX = window.scrollX; const savedY = window.scrollY; const captures = []; try { await this.waitForDocumentReady(); for (const question of groupQuestions) { const block = this.getQuestionBlock(question.id); if (!block) throw new Error(`第 ${question.id} 题的 DOM 已丢失`); if (CONFIG.homework.scrollBeforeCapture) { block.scrollIntoView({ block: 'center', inline: 'nearest' }); // 等待懒加载图片和固定布局稳定,避免截到半张图 await sleep(220); } await this.waitForImages(block); const canvas = await html2canvas(block, { scale: Number(CONFIG.homework.captureScale) || 2, useCORS: true, allowTaint: false, backgroundColor: '#ffffff', logging: false, imageTimeout: 15000, ...CONFIG.homework.html2canvasOptions, ...(typeof CONFIG.homework.onClone === 'function' ? { onclone: CONFIG.homework.onClone } : {}), }); // 每题一张独立的清晰图片 captures.push({ question, dataUrl: this.canvasToDataUrl(canvas) }); // 及时释放单题 canvas 的显存占用 canvas.width = 0; canvas.height = 0; } } finally { // 截图完恢复用户原本的滚动位置 window.scrollTo(savedX, savedY); } if (!captures.length) throw new Error('本组没有可用截图'); return captures; } /** * 单题 canvas 转 Base64。 * 只有宽度超过 maxImageWidth 时才等比压缩,常规题目保持原始清晰度。 */ canvasToDataUrl(canvas) { const maxWidth = Number(CONFIG.homework.maxImageWidth) || 2000; let source = canvas; let resized = null; if (canvas.width > maxWidth) { const ratio = maxWidth / canvas.width; resized = document.createElement('canvas'); resized.width = maxWidth; resized.height = Math.round(canvas.height * ratio); const ctx = resized.getContext('2d'); if (!ctx) throw new Error('无法创建缩放 Canvas'); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(canvas, 0, 0, resized.width, resized.height); source = resized; } const dataUrl = source.toDataURL( CONFIG.homework.imageType, CONFIG.homework.imageQuality ); if (resized) { resized.width = 0; resized.height = 0; } return dataUrl; } async waitForDocumentReady() { try { if (document.fonts && document.fonts.ready) await document.fonts.ready; } catch { // ignore } await new Promise((resolve) => requestAnimationFrame(resolve)); await sleep(80); } async waitForImages(root) { const images = [...root.querySelectorAll('img')]; if (!images.length) return; const pending = images .filter((img) => !img.complete) .map( (img) => new Promise((resolve) => { const done = () => resolve(); img.addEventListener('load', done, { once: true }); img.addEventListener('error', done, { once: true }); }) ); if (!pending.length) return; await Promise.race([ Promise.all(pending), sleep(CONFIG.homework.imageLoadTimeoutMs), ]); } getQuestionBlock(qid) { const cached = this.refs.get(qid); if (cached && cached.isConnected) return cached; const found = document.querySelector(`[data-hwai-qid="${CSS.escape(String(qid))}"]`); if (found) { this.refs.set(qid, found); return found; } return null; } onTaskState(task) { if (!task || !this.currentTaskId || task.taskId !== this.currentTaskId) return; if (this.resultApplied) return; if (task.status === 'processing') { if (task.progressMeta) { this.panel.setProgress( Number(task.progressMeta.index) || 0, Number(task.progressMeta.total) || 0, task.progressMeta.phase || '' ); } this.panel.setStatus(task.progress || 'DeepSeek 正在处理...', 'busy'); } else if (task.status === 'failed' && task.error) { this.panel.setStatus(`DeepSeek 处理失败:${task.error}`, 'error'); } } async onResult(result) { if (!result || !this.currentTaskId) return; if (result.taskId !== this.currentTaskId) return; if (this.resultApplied) return; this.resultApplied = true; this.panel.setStatus('收到答案,开始自动填报...', 'busy'); try { const answers = Array.isArray(result.answers) ? result.answers : []; if (!answers.length) { const message = result.errors?.join(';') || 'DeepSeek 没有返回可解析的答案'; this.panel.setStatus(`填报失败:${message}`, 'error'); return; } const summary = await this.applyAnswers(answers); // 每题结果清单,点击可滚动定位到对应题目 this.panel.setResults(summary.results, (id) => this.focusQuestion(id)); if (this.currentGroups.length) { this.panel.setProgress( this.currentGroups.length, this.currentGroups.length, '填报完成' ); } const errorText = result.errors?.length ? `;异常:${result.errors.join(';')}` : ''; const failedText = summary.failedIds.length ? `(题号:${summary.failedIds.join('、')})` : ''; this.panel.setStatus( `填报完成:成功 ${summary.filled} 题,未匹配/失败 ${summary.failed} 题${failedText}${errorText}`, summary.failed ? 'error' : 'ok' ); await this.markTaskDone(); await this.cleanupTaskImages(); if (CONFIG.homework.autoSubmit && CONFIG.homework.submitSelector) { await this.clickSubmit(); } } catch (err) { console.error('[HomeworkAI] fill failed:', err); this.panel.setStatus(`自动填报异常:${err.message || err}`, 'error'); } finally { this.finishTask(); } } async applyAnswers(answers) { const answerMap = new Map(); for (const item of answers) { const qid = typeof item?.id === 'number' ? item.id : Number(String(item?.id ?? '').match(/\d+/)?.[0]); if (Number.isFinite(qid)) answerMap.set(qid, item); } let filled = 0; let failed = 0; const failedIds = []; const results = []; for (const [qid, question] of this.questions) { const item = answerMap.get(qid); if (!item) { failed += 1; failedIds.push(qid); results.push({ id: qid, ok: false, reason: '未匹配答案' }); continue; } const block = this.getQuestionBlock(qid); if (!block) { failed += 1; failedIds.push(qid); results.push({ id: qid, ok: false, reason: '未找到题目' }); continue; } try { await this.fillQuestion(block, question, item.answer); filled += 1; results.push({ id: qid, ok: true }); } catch (err) { console.warn(`[HomeworkAI] 第 ${qid} 题填写失败:`, err); failed += 1; failedIds.push(qid); results.push({ id: qid, ok: false, reason: '填写失败' }); } await sleep(CONFIG.homework.fillDelayMs); } return { filled, failed, failedIds, results }; } /** * 点击填报结果时滚动到对应题目,并短暂高亮,方便人工核对。 */ focusQuestion(qid) { const block = this.getQuestionBlock(qid); if (!block) return; block.scrollIntoView({ behavior: 'smooth', block: 'center' }); const previousOutline = block.style.outline; const previousOffset = block.style.outlineOffset; block.style.outline = '3px solid #3b82f6'; block.style.outlineOffset = '2px'; setTimeout(() => { block.style.outline = previousOutline; block.style.outlineOffset = previousOffset; }, 1600); } async fillQuestion(block, question, answer) { if (typeof CONFIG.homework.customFiller === 'function') { const handled = await CONFIG.homework.customFiller(block, question, answer, { typeLabels: CONFIG.homework.typeLabels, setNativeValue, dispatchInputChange, fillContentEditable, sleep, }); if (handled) return; } const type = normalizeQuestionType(question.type); if (type === 'single' || type === 'multiple' || type === 'judge') { await this.fillChoice(block, type, answer); return; } if (type === 'fill') { await this.fillBlanks(block, answer); return; } await this.fillSubjective(block, answer); } getOptions(block) { let options; try { options = [...block.querySelectorAll(CONFIG.homework.selectors.options)]; } catch { options = []; } options = options.filter(isVisible); options = removeNestedElements(options); if (!options.length) { const inputs = [...block.querySelectorAll('input[type="radio"], input[type="checkbox"]')]; options = inputs.filter(isVisible).map( (input) => input.closest('label') || input.parentElement ); } return options.filter((el) => { const text = normalizeText(el.innerText); return text || el.querySelector('input[type="radio"], input[type="checkbox"]'); }); } getOptionLetter(option, index) { const input = option.querySelector('input[type="radio"], input[type="checkbox"]'); const value = input && String(input.value || '').trim(); if (/^[A-Ha-h]$/.test(value)) return value.toUpperCase(); const text = normalizeText(option.innerText); const matched = text.match(/^[\s((【[]*([A-Ha-h])(?=[.、.::))】\]\s]|$)/); if (matched) return matched[1].toUpperCase(); return String.fromCharCode(65 + index); } findOptionByLetter(options, letter) { return options.find((option, index) => this.getOptionLetter(option, index) === letter); } findJudgeOption(options, positive) { const positivePattern = /^(?:√|✓|对|正确|是|T|TRUE|YES)$/i; const negativePattern = /^(?:×|✗|错|错误|否|F|FALSE|NO)$/i; for (const option of options) { const text = normalizeText(option.innerText).replace( /^[\s((【[]*[A-Ha-h][.、.::))】\]\s]*/, '' ); if (positive ? positivePattern.test(text) : negativePattern.test(text)) { return option; } } // 选项没有文字,只剩 A/B 时按常见约定:A=正确,B=错误 if (options.length >= 2) return options[positive ? 0 : 1]; return null; } async fillChoice(block, type, answer) { const options = this.getOptions(block); if (!options.length) throw new Error('未找到选项元素'); if (type === 'judge') { const value = parseJudgeValue(answer); if (value == null) throw new Error(`无法解析判断题答案:${answer}`); const target = this.findJudgeOption(options, value); if (!target) throw new Error('未找到对应判断题选项'); await this.clickChoiceOption(target, 'single'); return; } const letters = parseChoiceLetters(answer); if (!letters.length) throw new Error(`无法解析选项答案:${answer}`); if (type === 'single') { const target = this.findOptionByLetter(options, letters[0]); if (!target) throw new Error(`未找到选项 ${letters[0]}`); await this.clickChoiceOption(target, 'single'); return; } // 多选题:先选中答案项,再取消答案之外的已选项,保证状态正确 const desired = new Set(letters); for (const [index, option] of options.entries()) { const letter = this.getOptionLetter(option, index); const shouldCheck = desired.has(letter); const selected = this.isOptionChecked(option); if (shouldCheck && !selected) await this.clickChoiceOption(option, 'multiple'); if (!shouldCheck && selected) await this.clickChoiceOption(option, 'multiple'); } } isOptionChecked(option) { const input = option.querySelector('input[type="radio"], input[type="checkbox"]'); if (input) return !!input.checked; const roleEl = option.matches('[role="radio"], [role="checkbox"]') ? option : option.querySelector('[role="radio"], [role="checkbox"]'); if (roleEl) return roleEl.getAttribute('aria-checked') === 'true'; return false; } async clickChoiceOption(option, mode) { option.scrollIntoView({ block: 'nearest', inline: 'nearest' }); await sleep(40); const input = option.querySelector('input[type="radio"], input[type="checkbox"]'); if (input) { if (mode === 'single' && input.checked) return; if (mode === 'multiple') { // 由调用方决定是否需要点击;这里直接 click 会切换状态 input.click(); return; } input.click(); return; } const roleEl = option.matches('[role="radio"], [role="checkbox"]') ? option : option.querySelector('[role="radio"], [role="checkbox"]'); (roleEl || option).click(); } async fillBlanks(block, answer) { let blanks; try { blanks = [...block.querySelectorAll(CONFIG.homework.selectors.fill)]; } catch { blanks = []; } blanks = blanks.filter((el) => { if (!isVisible(el)) return false; if (el.matches('input[type="radio"], input[type="checkbox"]')) return false; return true; }); if (!blanks.length) throw new Error('未找到填空输入框'); const values = normalizeFillAnswers(answer, blanks.length); for (let i = 0; i < blanks.length; i += 1) { const el = blanks[i]; const value = values[i]; if (value == null) continue; el.focus(); if (el.isContentEditable) { const ok = await fillContentEditable(el, value); if (!ok) throw new Error(`第 ${i + 1} 个富文本空填写失败`); } else { setNativeValue(el, value); dispatchInputChange(el, value); el.blur(); } await sleep(40); } } async fillSubjective(block, answer) { const text = stringifySubjectiveAnswer(answer); if (!text.trim()) throw new Error('主观题答案为空'); if (typeof CONFIG.homework.customRichTextFill === 'function') { const editor = await this.findSubjectiveEditor(block); const handled = await CONFIG.homework.customRichTextFill(editor, text); if (handled) return; } const editor = await this.findSubjectiveEditor(block); if (!editor) throw new Error('未找到主观题输入框/富文本编辑器'); if ( editor instanceof HTMLTextAreaElement || editor instanceof HTMLInputElement ) { editor.focus(); setNativeValue(editor, text); dispatchInputChange(editor, text); editor.blur(); return; } // 富文本:先尝试平台 API,再走 paste/execCommand if (await this.tryPlatformRichTextApi(editor, text)) return; const ok = await fillContentEditable(editor, text); if (!ok && CONFIG.homework.allowDirectInnerTextFallback) { const root = this.getEditableRoot(editor); if (root) { root.innerText = text; dispatchInputChange(root, text); return; } } if (!ok) throw new Error('富文本编辑器拒绝 paste/insertText,请配置 customRichTextFill'); } /** * 找到主观题编辑器。很多考试平台默认只渲染一个“简答”占位区域, * 必须先在占位区点一下,真正可编辑的 contenteditable/textarea 才会出现。 */ async findSubjectiveEditor(block) { let editor = this.findRichEditor(block); if (editor) { this.logRichEditor(editor); return editor; } const target = this.findSubjectivePlaceholder(block); if (target) { console.info( '[HomeworkAI] 主观题编辑器未出现,点击占位区域尝试激活:', target.tagName, String(target.className || '') ); try { target.scrollIntoView({ block: 'center', inline: 'nearest' }); const mouseInit = { bubbles: true, cancelable: true, view: window, clientX: target.getBoundingClientRect().left + 8, clientY: target.getBoundingClientRect().top + 8, }; target.dispatchEvent(new MouseEvent('mousedown', mouseInit)); target.dispatchEvent(new MouseEvent('mouseup', mouseInit)); target.click(); target.focus?.(); } catch (err) { console.warn('[HomeworkAI] 激活主观题编辑器失败:', err); } await sleep(350); editor = this.findRichEditor(block) || this.findGlobalRichEditor(target); if (editor) { this.logRichEditor(editor); return editor; } } return null; } logRichEditor(editor) { try { console.info( '[HomeworkAI] 主观题编辑器:', editor?.tagName, String(editor?.className || '') ); } catch { // ignore } } findRichEditor(block) { let nodes = []; try { nodes = [...block.querySelectorAll(CONFIG.homework.selectors.richText)].filter( isVisible ); } catch { nodes = []; } // 1. 真正可编辑的节点 const direct = nodes.find((el) => this.isDirectlyEditable(el)); if (direct) return direct; // 2. 容器内部的可编辑 iframe(UEditor / TinyMCE 等) for (const node of nodes) { const iframe = node instanceof HTMLIFrameElement ? node : node.querySelector?.('iframe'); if (iframe && this.isEditableFrame(iframe)) return iframe; } // 3. 题目块内任意可编辑 iframe(编辑器可能没有 ue-edit 之类的 class) const frames = [...block.querySelectorAll('iframe')].filter(isVisible); for (const frame of frames) { if (this.isEditableFrame(frame)) return frame; } // 4. 可见 textarea / 文本输入框 const textareas = [ ...block.querySelectorAll( 'textarea, input[type="text"], input:not([type])' ), ].filter(isVisible); if (textareas.length) return textareas[0]; return null; } isDirectlyEditable(el) { if (!el) return false; if (el instanceof HTMLIFrameElement) return this.isEditableFrame(el); if (el instanceof HTMLTextAreaElement) { return !el.disabled && !el.readOnly; } if (el instanceof HTMLInputElement) { return !el.disabled && !el.readOnly && el.type !== 'hidden'; } if (el.isContentEditable) return true; const attr = el.getAttribute?.('contenteditable'); return attr != null && attr !== 'false'; } isEditableFrame(frame) { try { const doc = frame.contentDocument; if (!doc) return false; if (doc.designMode === 'on') return true; if (doc.body?.isContentEditable) return true; return !!doc.querySelector('[contenteditable]:not([contenteditable="false"])'); } catch { return false; } } /** * 找到“简答 / 请输入答案”这类占位区域。点击它可以让懒加载的编辑器出现。 */ findSubjectivePlaceholder(block) { const placeholderRe = /^(简答|简答题|请输入答案|请输入作答|请作答|在此输入|输入答案|点击输入|写下答案|写答案)/; const nodes = [ ...block.querySelectorAll( 'div, span, p, section, article, textarea, [role="textbox"], [contenteditable]' ), ].filter(isVisible); let best = null; let bestArea = Infinity; for (const el of nodes) { const text = normalizeText(el.textContent || ''); if (!text || text.length > 24) continue; if (!placeholderRe.test(text)) continue; const rect = el.getBoundingClientRect(); const area = rect.width * rect.height; if (area > 0 && area < bestArea) { best = el; bestArea = area; } } if (best) return best; // 其次:点击明显的答题区域容器 const areas = [ ...block.querySelectorAll( '[class*="answer"], [class*="editor"], [class*="input"], [class*="text"]' ), ] .filter(isVisible) .filter((el) => { const rect = el.getBoundingClientRect(); return rect.width > 200 && rect.height > 60; }); return areas[0] || null; } /** * 取得编辑器真正可写入的根节点:iframe 取内部 contenteditable/body, * 普通 contenteditable 直接返回自身。用于 paste/execCommand 全部失败时的兜底。 */ getEditableRoot(editor) { if (!editor) return null; if (editor instanceof HTMLIFrameElement) { try { const doc = editor.contentDocument; return ( doc?.querySelector('[contenteditable]:not([contenteditable="false"])') || doc?.body || null ); } catch { return null; } } return editor; } /** * 全局兜底:有些平台点击后会通过 portal 把编辑器挂到 body 上。 * anchor 存在时优先选择离占位区域最近的编辑器,避免填到别的主观题。 */ findGlobalRichEditor(anchor = null) { const nodes = [ ...document.querySelectorAll( '[contenteditable]:not([contenteditable="false"]), textarea, [role="textbox"]' ), ].filter(isVisible); const candidates = nodes.filter((el) => { if (el.closest('.hwai-panel, header, nav, footer, aside')) return false; const rect = el.getBoundingClientRect(); return rect.width > 200 && rect.height > 40 && this.isDirectlyEditable(el); }); if (!candidates.length) return null; if (!anchor) return candidates[0]; const anchorRect = anchor.getBoundingClientRect(); const anchorX = anchorRect.left + anchorRect.width / 2; const anchorY = anchorRect.top + anchorRect.height / 2; let best = null; let bestDistance = Infinity; for (const el of candidates) { const rect = el.getBoundingClientRect(); const dx = rect.left + rect.width / 2 - anchorX; const dy = rect.top + rect.height / 2 - anchorY; const distance = dx * dx + dy * dy; if (distance < bestDistance) { best = el; bestDistance = distance; } } return best; } /** * 可选:平台原生富文本 API。默认只尝试“能明确对应到当前编辑器”的情况。 * 如果你知道 UEditor/TinyMCE 的实例 id,建议直接写 customRichTextFill。 */ async tryPlatformRichTextApi(editor, text) { try { // UEditor: 常见结构是 textarea + iframe,父级带 ue-edit const ueWrap = editor.closest?.('.ue-edit, [class*="ueditor"]'); if (ueWrap) { const id = ueWrap.querySelector('textarea')?.id || ueWrap.querySelector('iframe')?.id?.replace(/_iframe$/, ''); const ue = PAGE_WINDOW.UE && id && typeof PAGE_WINDOW.UE.getEditor === 'function' ? PAGE_WINDOW.UE.getEditor(id) : null; if (ue && typeof ue.setContent === 'function') { ue.setContent(text); return true; } } // TinyMCE: iframe id 通常是 "_ifr" if (editor instanceof HTMLIFrameElement && PAGE_WINDOW.tinymce) { const editorId = String(editor.id || '').replace(/_ifr$/, ''); const instance = PAGE_WINDOW.tinymce.get?.(editorId) || (PAGE_WINDOW.tinymce.activeEditor?.id === editorId ? PAGE_WINDOW.tinymce.activeEditor : null); if (instance && typeof instance.setContent === 'function') { instance.setContent(text); return true; } } } catch (err) { console.warn('[HomeworkAI] platform rich text API failed:', err); } return false; } async markTaskDone() { const task = await Bridge.get(KEYS.task, null); if (!task || task.taskId !== this.currentTaskId) return; await Bridge.set(KEYS.task, { ...task, status: 'done', filledAt: Date.now(), }); } async cleanupTaskImages() { for (const group of this.currentGroups) { const images = Array.isArray(group.images) ? group.images : []; for (const image of images) { if (image?.key) await Bridge.del(image.key); } } } async clickSubmit() { const button = await waitFor( () => document.querySelector(CONFIG.homework.submitSelector), 5000, 200 ); if (button) { button.scrollIntoView({ block: 'center' }); button.click(); this.panel.setStatus('答案已填写,并已点击提交按钮。', 'ok'); } else { this.panel.setStatus('答案已填写,但未找到提交按钮。', 'error'); } } } // ======================= 右侧:DeepSeek ======================= class DeepSeekAgent { constructor() { this.panel = null; this.busy = false; this.abortRequested = false; this.heartbeatTimer = null; this.processedTasks = new Set(); this.agentId = uid('ds'); this.unwatchTask = null; } start() { this.buildPanel(); this.unwatchTask = Bridge.watch( KEYS.task, (task) => this.considerTask(task), { intervalMs: CONFIG.bridge.pollMs } ); // 页面刚加载时主动检查一次,避免错过 listener 初始化前的写入 setTimeout(async () => { const task = await Bridge.get(KEYS.task, null); this.considerTask(task); }, 1000); // 定时心跳,让左侧面板显示“DeepSeek 已连接” this.beat(); clearInterval(this.heartbeatTimer); this.heartbeatTimer = setInterval(() => this.beat(), 4000); this.panel.setStatus('已连接。等待左侧作业页面发送任务。'); } async beat() { try { await Bridge.set(KEYS.heartbeat, { agentId: this.agentId, at: Date.now(), busy: this.busy, }); } catch { // 心跳失败不影响主流程 } } buildPanel() { this.panel = createPanel({ side: 'deepseek', title: 'DeepSeek 作业助手', buttons: [], }); } async considerTask(task) { // 左侧点击“停止”后会写入 aborted,这里立刻中断等待 if (task && task.aborted) this.abortRequested = true; if (this.busy) return; if (!task || task.schema !== 2 || !task.taskId || !Array.isArray(task.groups)) return; if (this.processedTasks.has(task.taskId)) return; if (task.status === 'answered' || task.status === 'failed' || task.status === 'done') { return; } const claimedAt = Number(task.claimedAt || 0); const stale = Date.now() - claimedAt > CONFIG.bridge.staleTaskMs; if (task.status !== 'pending' && !stale) return; this.busy = true; this.panel.setStatus('发现新任务,正在领取...', 'busy'); try { const claimedTask = await this.claimTask(task); if (!claimedTask) { this.busy = false; this.panel.setStatus('任务已被其他 DeepSeek 标签页领取。'); return; } this.abortRequested = Boolean(claimedTask.aborted); this.processedTasks.add(task.taskId); await this.processTask(claimedTask); } catch (err) { console.error('[HomeworkAI] DeepSeek task failed:', err); await this.failTask(task, err); } finally { this.busy = false; } } async claimTask(task) { const latest = await Bridge.get(KEYS.task, null); if (!latest || latest.taskId !== task.taskId) return null; const stale = Date.now() - Number(latest.claimedAt || 0) > CONFIG.bridge.staleTaskMs; if (latest.status !== 'pending' && !stale) return null; await Bridge.set(KEYS.task, { ...latest, status: 'processing', claimedBy: this.agentId, claimedAt: Date.now(), progress: '已领取任务,准备上传图片...', }); // 简易“抢占确认”:两人同时写时,最后写入者生效,另一人主动退出 await sleep(250); const confirmed = await Bridge.get(KEYS.task, null); if ( !confirmed || confirmed.taskId !== task.taskId || confirmed.claimedBy !== this.agentId ) { return null; } return confirmed; } async processTask(task) { const editor = await waitFor( () => this.findEditor(), 30000, 300 ); if (!editor) throw new Error('找不到 DeepSeek 输入框,请检查 editorSelector'); const answers = []; const errors = []; const rawTexts = []; let fatalError = false; for (let index = 0; index < task.groups.length; index += 1) { if (this.abortRequested) { errors.push('任务已被手动停止'); break; } const group = task.groups[index]; const total = task.groups.length; const progress = `正在处理第 ${index + 1}/${total} 组`; await this.updateTask(task.taskId, { progress, progressMeta: { index: index + 1, total, phase: '上传图片' }, }); this.panel.setStatus(`${progress},正在上传图片...`, 'busy'); const images = Array.isArray(group.images) ? group.images : []; try { if (!images.length) throw new Error('本组没有图片'); const files = []; for (let i = 0; i < images.length; i += 1) { const image = images[i]; const dataUrl = await Bridge.get(image.key, null); if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/')) { throw new Error(`图片 ${image.key} 不存在或格式错误`); } files.push( await this.dataUrlToFile( dataUrl, `homework-g${index + 1}-${i + 1}-q${image.questionId}.jpg` ) ); } this.panel.setStatus( `${progress},正在一次性上传 ${files.length} 张图片...`, 'busy' ); // 关键:5 张图放进同一个 DataTransfer,一次性交给 input.files, // 只触发一次 change,DeepSeek 会把它们作为一条消息的多个附件。 await this.uploadImages(files, editor); await sleep(400); const prompt = this.buildPrompt(group, index, task.groups.length); await this.setEditorText(editor, prompt); const before = this.snapshotAssistant(); await this.sendEditor(editor); this.panel.setStatus(`${progress},等待 DeepSeek 生成答案...`, 'busy'); await this.updateTask(task.taskId, { progress: `${progress},等待 DeepSeek 生成答案...`, progressMeta: { index: index + 1, total, phase: '生成答案' }, }); // 只有等到能解析出本组所有题号后才算生成结束, // 避免流式输出途中的半截 JSON 被当成最终答案。 const reply = await this.waitForReply(before, (text) => { const trimmed = text.trim(); // 正式答案一般以数组/对象结尾;思考过程中引用的示例 JSON 不算完成 if (!trimmed.endsWith(']') && !trimmed.endsWith('}')) return null; try { const parsed = this.parseGroupAnswers(text, group); return parsed.missing.length === 0 ? true : null; } catch { return null; } }); rawTexts.push(reply.slice(0, 10000)); const parsed = this.parseGroupAnswers(reply, group); answers.push(...parsed.answers); if (parsed.missing.length) { errors.push( `第 ${index + 1} 组缺少题号:${parsed.missing.join(', ')}` ); } } catch (err) { const message = `第 ${index + 1} 组:${err.message || err}`; console.error('[HomeworkAI] group failed:', message, err); errors.push(message); // 消息没确认发送就继续下一组,会把上一组的残留图片一起发出去, // 导致后续答案错乱,所以这里必须终止。 if (err && err.fatal) { fatalError = true; } } if (fatalError) { errors.push('已终止后续分组:上一条消息未确认发送,避免污染后续图片'); break; } if (index < task.groups.length - 1) { await sleep(CONFIG.deepseek.betweenGroupsMs); } } // 左侧已点击停止:不再回传结果,避免覆盖左侧状态 if (this.abortRequested) { this.panel.setStatus('任务已被左侧手动停止。', 'error'); return; } const result = { schema: 1, taskId: task.taskId, answers, errors, rawTexts, createdAt: Date.now(), }; const finalStatus = answers.length ? 'answered' : 'failed'; await this.updateTask(task.taskId, { status: finalStatus, progress: answers.length ? '答案已回传' : '未能生成答案', resultAt: Date.now(), error: errors.join(';') || undefined, }); // 先写任务状态,再写结果;左侧以结果 key 作为触发填答的信号 await Bridge.set(KEYS.result, result); this.panel.setStatus( answers.length ? `完成:回传 ${answers.length} 个答案${errors.length ? `,${errors.length} 个异常` : ''}` : `失败:${errors.join(';') || '未解析到答案'}`, answers.length ? 'ok' : 'error' ); } async updateTask(taskId, patch) { const latest = await Bridge.get(KEYS.task, null); if (!latest || latest.taskId !== taskId) return; await Bridge.set(KEYS.task, { ...latest, ...patch }); } async failTask(task, err) { const result = { schema: 1, taskId: task.taskId, answers: [], errors: [String(err?.message || err)], rawTexts: [], createdAt: Date.now(), }; await this.updateTask(task.taskId, { status: 'failed', error: result.errors[0], resultAt: Date.now(), }); await Bridge.set(KEYS.result, result); this.panel.setStatus(`处理失败:${result.errors[0]}`, 'error'); } // ---------------- DeepSeek DOM 定位 ---------------- queryVisible(selector) { try { return [...document.querySelectorAll(selector)].filter(isVisible); } catch { return []; } } findEditor() { const candidates = this.queryVisible(CONFIG.deepseek.editorSelector); if (!candidates.length) return null; let best = null; let bestScore = -Infinity; for (const el of candidates) { const rect = el.getBoundingClientRect(); const placeholder = el.getAttribute('placeholder') || el.getAttribute('aria-placeholder') || el.getAttribute('data-placeholder') || ''; let score = rect.width * rect.height; if (/deepseek|发送|消息|message|ask/i.test(placeholder)) score += 1_000_000; if (el.isContentEditable) score += 300_000; if (el.tagName === 'TEXTAREA') score += 100_000; if (el.closest('header, nav, aside')) score -= 500_000; if (score > bestScore) { bestScore = score; best = el; } } return best; } findFileInput() { const all = [...document.querySelectorAll(CONFIG.deepseek.fileInputSelector)]; const imageInputs = all.filter((input) => { const accept = String(input.accept || ''); return !accept || /image/i.test(accept); }); // 尽量选择输入框附近的 file input,避免误选头像上传等无关控件 const editor = this.findEditor(); const scope = editor?.closest('form, [class*="composer"], [class*="input"]') || document; const nearScope = imageInputs.filter((input) => scope.contains(input)); return nearScope[0] || imageInputs[0] || all[0] || null; } async ensureFileInput() { let input = this.findFileInput(); if (input) return input; const trigger = this.findUploadButton(); if (trigger) { trigger.click(); input = await waitFor(() => this.findFileInput(), 2000, 150); } return input || null; } findUploadButton() { const nodes = this.queryVisible(CONFIG.deepseek.uploadButtonSelector); if (nodes.length) return nodes[0]; return this.findButtonByPattern(/上传|附件|图片|upload|attach|image/i); } isButtonDisabled(node) { if (!node) return true; return !!( node.disabled || node.getAttribute?.('aria-disabled') === 'true' || /(^|\s)(disabled|is-disabled)(\s|$)/i.test(String(node.className || '')) ); } getButtonLabel(node) { return [ node.getAttribute?.('aria-label'), node.getAttribute?.('title'), node.textContent, ] .filter(Boolean) .join(' ') .trim(); } /** * 从输入框向上找包含多个按钮的输入区容器。 * DeepSeek 的发送按钮往往没有 aria-label,只能靠“输入区最右侧按钮”来定位。 */ findComposerContainer() { const editor = this.findEditor(); if (!editor) return null; let node = editor; for (let depth = 0; depth < 8 && node; depth += 1, node = node.parentElement) { if (node.querySelectorAll('button, [role="button"]').length >= 2) { return node; } } return editor.closest('form'); } findSendButton() { const composer = this.findComposerContainer(); if (composer) { const buttons = [...composer.querySelectorAll('button, [role="button"]')].filter( isVisible ); // 排除上传、附件、语音、深度思考、联网搜索等左侧功能键 const usable = buttons.filter((node) => { const label = this.getButtonLabel(node); const cls = String(node.className || ''); return !/上传|附件|图片|upload|attach|image|语音|voice|mic|深度|联网/i.test( label + ' ' + cls ); }); const labelled = usable.find((node) => /发送|send|submit/i.test(this.getButtonLabel(node))); if (labelled) return labelled; // DeepSeek 的发送键通常是输入区最右侧的按钮 if (usable.length) return usable[usable.length - 1]; } const nodes = this.queryVisible(CONFIG.deepseek.sendButtonSelector); if (nodes.length) return nodes[nodes.length - 1]; return this.findButtonByPattern(/发送|send/i); } findStopButton() { const composer = this.findComposerContainer(); if (composer) { const buttons = [...composer.querySelectorAll('button, [role="button"]')].filter( isVisible ); const matched = buttons.find((node) => /停止|stop/i.test(this.getButtonLabel(node) + ' ' + String(node.className || '')) ); if (matched) return matched; } const nodes = this.queryVisible(CONFIG.deepseek.stopButtonSelector); if (nodes.length) return nodes[0]; return this.findButtonByPattern(/停止|stop/i); } findButtonByPattern(pattern) { const nodes = [...document.querySelectorAll('button, [role="button"]')].filter( isVisible ); return ( nodes.find((node) => { const label = this.getButtonLabel(node); return pattern.test(label); }) || null ); } attachmentCount() { if (!CONFIG.deepseek.attachmentSelector) return 0; try { return document.querySelectorAll(CONFIG.deepseek.attachmentSelector).length; } catch { return 0; } } async dataUrlToFile(dataUrl, filename) { const matched = dataUrl.match(/^data:([^;,]+)?(;base64)?,(.*)$/s); if (!matched) throw new Error('图片 Base64 格式错误'); const mime = matched[1] || 'image/jpeg'; const isBase64 = !!matched[2]; const payload = matched[3]; let blob; if (isBase64) { const binary = atob(payload); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); blob = new Blob([bytes], { type: mime }); } else { blob = await (await fetch(dataUrl)).blob(); } return new File([blob], filename, { type: mime, lastModified: Date.now() }); } /** * 一次性上传多张图片。 * 所有 File 放进同一个 DataTransfer,只给 input.files 赋值一次、 * 只触发一次 change,DeepSeek 才会把它们当作同一条消息的多个附件。 */ async uploadImages(files, editor) { if (!Array.isArray(files) || !files.length) { throw new Error('uploadImages 需要至少一张图片'); } const input = await this.ensureFileInput(); const beforeCount = this.attachmentCount(); if (input) { try { const dataTransfer = makeDataTransfer(); files.forEach((file) => dataTransfer.items.add(file)); // 部分页面的 file input 没有 multiple 属性,先补上, // 否则浏览器可能只接收第一张图片。 try { input.multiple = true; } catch { // ignore } // DataTransfer.files 是 FileList,一次性赋给 input.files input.files = dataTransfer.files; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); // 等待附件预览出现;多图时给页面更长时间渲染缩略图 const found = await this.waitForAttachmentCount( beforeCount, CONFIG.deepseek.uploadWaitMs + 2000 ); console.info( '[HomeworkAI] 图片附件数量:', beforeCount, '->', this.attachmentCount() ); // 如果配置了附件选择器且数量没有增加,说明页面可能没接收,走兜底 const canConfirm = !!CONFIG.deepseek.attachmentSelector; if (!canConfirm || found) { return true; } console.warn( '[HomeworkAI] file input change 未产生附件预览,尝试 paste/drop 兜底' ); } catch (err) { console.warn('[HomeworkAI] multi file upload failed:', err); } } return this.uploadByPasteOrDrop(files, editor); } async waitForAttachmentCount(beforeCount, timeoutMs) { if (!CONFIG.deepseek.attachmentSelector) { await sleep(CONFIG.deepseek.uploadWaitMs); return true; } const found = await waitFor( () => (this.attachmentCount() > beforeCount ? true : null), timeoutMs, 200 ); // 至少出现一个附件后,再多等一会儿让同一批图片全部落位 if (found) await sleep(600); return !!found; } async uploadByPasteOrDrop(files, editor) { const list = Array.isArray(files) ? files : [files]; if (!list.length) throw new Error('没有可上传的图片'); const beforeCount = this.attachmentCount(); const dataTransfer = makeDataTransfer(); list.forEach((file) => dataTransfer.items.add(file)); // 方案 A:模拟粘贴图片 try { const pasteEvent = createTransferEvent( 'paste', dataTransfer, 'ClipboardEvent' ); editor.dispatchEvent(pasteEvent); } catch (err) { console.warn('[HomeworkAI] image paste failed:', err); } // 方案 B:模拟拖放 try { const dropTarget = editor.closest('[class*="input"], [class*="composer"], form') || editor; for (const type of ['dragenter', 'dragover', 'drop']) { dropTarget.dispatchEvent(createTransferEvent(type, dataTransfer, 'DragEvent')); } } catch (err) { console.warn('[HomeworkAI] image drop failed:', err); } await sleep(CONFIG.deepseek.uploadWaitMs); const afterCount = this.attachmentCount(); if (CONFIG.deepseek.attachmentSelector && afterCount <= beforeCount) { throw new Error('图片上传失败:未检测到附件预览'); } return true; } async setEditorText(editor, text) { const current = this.findEditor(); const target = current || editor; if ( target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement ) { target.focus(); setNativeValue(target, text); dispatchInputChange(target, text); } else { const ok = await fillContentEditable(target, text); if (!ok) throw new Error('无法写入 DeepSeek 输入框'); } await sleep(120); const actual = normalizeText(getEditorText(target)); if (!actual.includes(normalizeText(text).slice(0, 20))) { throw new Error('提示词写入后校验失败,可能被页面清空'); } } async sendEditor(editor) { const target = this.findEditor() || editor; const currentText = () => normalizeText(getEditorText(this.findEditor() || target)); const isGenerating = () => !!this.findStopButton(); // 输入框清空或出现“停止生成”按钮,都说明消息已经发出 const waitForSendResult = (timeoutMs) => waitFor(() => { if (!currentText()) return true; if (isGenerating()) return true; return null; }, timeoutMs, 250); let button = this.findSendButton(); if (button && this.isButtonDisabled(button)) { button = await waitFor( () => { const current = this.findSendButton(); return current && !this.isButtonDisabled(current) ? current : null; }, Number(CONFIG.deepseek.sendEnableTimeoutMs) || 60000, 200 ); } if (button) { console.info( '[HomeworkAI] 发送按钮:', this.getButtonLabel(button) || String(button.className || '') ); button.scrollIntoView({ block: 'nearest' }); button.click(); } else { console.warn('[HomeworkAI] 未找到发送按钮,改用 Enter 发送'); } // 5 张图片可能还在后台上传,发送按钮会延迟生效,这里多等一会儿 await sleep(400); if (await waitForSendResult(20000)) { console.info('[HomeworkAI] 消息已发送'); return; } // 点击没生效时,再补一次 Enter try { target.focus(); } catch { // ignore } const keydown = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true, }); target.dispatchEvent(keydown); const keyup = new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, }); target.dispatchEvent(keyup); await sleep(300); if (await waitForSendResult(10000)) { console.info('[HomeworkAI] 消息已通过 Enter 发送'); return; } const err = new Error('消息未能发送,请检查 sendButtonSelector'); err.fatal = true; throw err; } findAssistantMessages() { try { // 优先使用 DeepSeek 的专用 class;[class*="markdown"] 太宽泛, // 只在找不到专用 class 时才作为兜底,避免把嵌套块或用户消息当成回复。 let nodes = [ ...document.querySelectorAll('.ds-markdown, [class*="ds-markdown"]'), ].filter(isVisible); if (!nodes.length) { nodes = [...document.querySelectorAll(CONFIG.deepseek.assistantMessageSelector)] .filter(isVisible); } nodes = nodes.filter( // 防止某些改版把用户消息也套上 markdown 容器; // 命中我们自己的提示词时直接排除,避免把提示词里的示例 JSON 当答案。 (el) => !/你是严谨的作业解答器|输出要求(必须严格遵守)|本组题目清单/.test( el.innerText ) ); // 只保留最外层的消息块,避免同一回答被拆成多个嵌套节点后取到片段 nodes = removeNestedElements(nodes); return nodes.sort((a, b) => { if (a === b) return 0; return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; }); } catch { return []; } } snapshotAssistant() { const messages = this.findAssistantMessages(); const last = messages[messages.length - 1]; return { count: messages.length, lastText: last ? normalizeText(getEditorText(last)) : '', }; } async waitForReply(before, validator = null) { const start = Date.now(); let lastText = before.lastText; let stableSince = 0; let lastValidatedText = ''; // MutationObserver 用于确认页面仍在变化;真正的结束判定 // 依赖“最新回复文本稳定 + 停止生成按钮消失 + 可解析的完整 JSON”。 let lastMutationAt = Date.now(); let mutationObserver = null; try { mutationObserver = new MutationObserver(() => { lastMutationAt = Date.now(); }); mutationObserver.observe(document.body, { childList: true, subtree: true, characterData: true, }); } catch { mutationObserver = null; } try { while (Date.now() - start < CONFIG.deepseek.replyTimeoutMs) { if (this.abortRequested) { const abortedError = new Error('任务已被手动停止'); abortedError.fatal = true; throw abortedError; } await sleep(200); const messages = this.findAssistantMessages(); const latest = messages[messages.length - 1]; if (!latest) continue; const text = normalizeText(getEditorText(latest)); const hasNewMessage = messages.length > before.count || text !== before.lastText; if (!hasNewMessage || !text) continue; if (text !== lastText) { lastText = text; stableSince = Date.now(); continue; } // 停止按钮存在时明确仍在生成,不结束 if (this.findStopButton()) { stableSince = Date.now(); continue; } // DeepSeek 思考阶段可能只有“正在思考…”这类提示,文本不增长 if (/正在思考|思考中|Thinking/i.test(text) && !/\[|\{/.test(text)) { stableSince = Date.now(); continue; } if (!stableSince) stableSince = Date.now(); const stableMs = Date.now() - stableSince; if (stableMs < CONFIG.deepseek.replyStableMs) continue; // 文本稳定后先看能不能解析出这一组所有答案;能解析立即返回, // 避免把流式输出中途的 JSON 当成最终结果。 // 同一段文本只解析一次,避免思考阶段反复解析长文本卡住页面。 if (validator && text !== lastValidatedText) { lastValidatedText = text; if (validator(text)) return text; } else if (!validator) { return text; } const elapsed = Date.now() - start; const graceMs = Number(CONFIG.deepseek.replyGraceMs) || 15000; const minWaitMs = Number(CONFIG.deepseek.minReplyWaitMs) || 60000; // 已经稳定但 JSON 还缺题:再给一段时间,防止只是生成间隙。 // 同时,未解析到 JSON 前至少等 minReplyWaitMs,因为 DeepSeek // 思考阶段可能长时间不输出正文,不能太早判定结束。 if ( elapsed >= minWaitMs && stableMs >= CONFIG.deepseek.replyStableMs + graceMs ) { return text; } // 读一下时间,避免某些浏览器把未使用的变量优化掉(也便于调试) void lastMutationAt; } } finally { mutationObserver?.disconnect(); } throw new Error( `等待 DeepSeek 回复超时(${Math.round( CONFIG.deepseek.replyTimeoutMs / 1000 )} 秒)` ); } buildPrompt(group, groupIndex, groupCount) { const images = Array.isArray(group.images) ? group.images : []; const orderList = images .map((image, i) => `第 ${i + 1} 张图 → id=${image.questionId}`) .join('\n'); const list = group.questions .map((q) => { const type = normalizeQuestionType(q.type); const label = CONFIG.homework.typeLabels[type] || type; const text = q.text ? `;题干摘要:${q.text.slice(0, 160)}` : ''; return `- id=${q.id},题型=${type}(${label})${text}`; }) .join('\n'); return [ '你是严谨的作业解答器。请阅读随消息一次性上传的所有图片,完成其中所有题目。', `这是本次任务第 ${groupIndex + 1}/${groupCount} 组,本组共 ${images.length} 张图片。`, '', '图片与题目 id 的对应关系(按上传顺序):', orderList, '', '本组题目清单:', list, '', '注意:每张图片只包含一道题,图片顺序就是题目清单顺序,请逐图作答,不要合并题目。', '', '输出要求(必须严格遵守):', '1. 只输出一个 JSON 数组,不要输出 Markdown 代码块,不要解释,不要使用中文全角引号。', '2. 数组元素格式:{"id":1,"type":"single","answer":"A"}。', '3. type 只能是:single、multiple、judge、fill、subjective。', '4. single:answer 只填选项字母,如 "A"。', '5. multiple:answer 用字母数组,如 ["A","C"]。', '6. judge:answer 只能填 "T" 或 "F"。', '7. fill:answer 按空格顺序用字符串数组,如 ["第一空","第二空"];只有一个空时也用数组。', '8. subjective:answer 填完整作答文本。', '9. 必须覆盖题目清单中的每一个 id,不要遗漏;实在无法作答时 answer 设为 null,但 id 仍要保留。', '', '示例(仅示意格式):', '[{"id":1,"type":"single","answer":"A"},' + '{"id":2,"type":"multiple","answer":["A","C"]},' + '{"id":3,"type":"judge","answer":"T"},' + '{"id":4,"type":"fill","answer":["答案1","答案2"]},' + '{"id":5,"type":"subjective","answer":"完整答案文本"}]', ].join('\n'); } // ---------------- 回复 JSON 解析 ---------------- parseGroupAnswers(reply, group) { const expectedIds = group.questions.map((q) => q.id); const candidates = this.extractJsonCandidates(reply); let best = null; let bestScore = -1; for (const candidate of candidates) { const payloads = [candidate]; try { payloads.push(candidate.replace(/,\s*([}\]])/g, '$1')); } catch { // ignore } try { // 模型有时会在 answer 字符串里直接输出换行,导致 JSON 非法 payloads.push(this.repairJsonText(candidate)); } catch { // ignore } for (const raw of payloads) { let parsed; try { parsed = JSON.parse(raw); } catch { continue; } const normalized = this.normalizeAnswerPayload(parsed, expectedIds); if (!normalized) continue; const score = normalized.filter((item) => expectedIds.includes(item.id)).length * 10 + normalized.length; // 分数相同取后面的候选,通常是模型最后给出的正式答案 if (score >= bestScore) { best = normalized; bestScore = score; } } } if (!best) { throw new Error('无法从回复中解析出 JSON 答案数组'); } // 兼容模型把第二组又写成 1/2/3 的情况: // 如果整组答案没有一个命中期望 id,但数量完全一致,就按数组顺序重映射。 const hasAnyExpectedId = best.some((item) => expectedIds.includes(item.id)); if ( !hasAnyExpectedId && best.length === expectedIds.length ) { best = best.map((item, index) => ({ ...item, id: expectedIds[index], })); } const answerMap = new Map(best.map((item) => [item.id, item])); const missing = expectedIds.filter((id) => !answerMap.has(id)); return { answers: best.filter((item) => expectedIds.includes(item.id)), missing, }; } /** * 修复模型常见的非法 JSON: * - answer 字符串内直接出现真实换行 / 制表符(JSON.parse 会报错) * - 对象或数组末尾多余逗号 */ repairJsonText(raw) { let output = ''; let inString = false; let escaped = false; for (let i = 0; i < raw.length; i += 1) { const char = raw[i]; if (inString) { if (escaped) { output += char; escaped = false; continue; } if (char === '\\') { output += char; escaped = true; continue; } if (char === '"') { output += char; inString = false; continue; } if (char === '\n') { output += '\\n'; continue; } if (char === '\r') continue; if (char === '\t') { output += '\\t'; continue; } output += char; continue; } if (char === '"') inString = true; output += char; } return output.replace(/,\s*([}\]])/g, '$1'); } extractJsonCandidates(text) { let source = String(text || ''); source = source.replace(/```(?:json)?/gi, '').replace(/```/g, ''); const candidates = []; for (let i = 0; i < source.length; i += 1) { const first = source[i]; if (first !== '[' && first !== '{') continue; let depth = 0; let inString = false; let quote = ''; let escaped = false; for (let j = i; j < source.length; j += 1) { const char = source[j]; if (inString) { if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === quote) inString = false; } else if (char === '"' || char === "'") { inString = true; quote = char; } else if (char === '[' || char === '{') { depth += 1; } else if (char === ']' || char === '}') { depth -= 1; if (depth === 0) { candidates.push(source.slice(i, j + 1)); i = j; break; } } } } return candidates; } normalizeAnswerPayload(payload, expectedIds) { let items = null; if (Array.isArray(payload)) { items = payload; } else if (payload && typeof payload === 'object') { if (Array.isArray(payload.answers)) { items = payload.answers; } else if (Array.isArray(payload.data)) { items = payload.data; } else if ( payload.id != null || payload.qid != null || payload.number != null || payload.题号 != null ) { // 模型也可能只输出单个 {"id":1,"type":"...","answer":"..."} 对象 items = [payload]; } else { // 也兼容 {"1":"A","2":"B"} 这种题号映射 const keys = Object.keys(payload).filter((key) => { const qid = Number(String(key).match(/\d+/)?.[0]); return Number.isFinite(qid); }); if (keys.length) { items = keys.map((key) => ({ id: key, answer: payload[key] })); } } } if (!items) return null; const result = []; let sequential = 0; for (const item of items) { if (item == null) continue; if (typeof item === 'string' || typeof item === 'number') { const id = expectedIds[sequential]; if (id == null) continue; sequential += 1; result.push({ id, answer: item }); continue; } if (typeof item !== 'object') continue; const rawId = item.id ?? item.qid ?? item.number ?? item.题号 ?? item.question; const parsedId = rawId == null ? expectedIds[sequential] : typeof rawId === 'number' ? rawId : Number(String(rawId).match(/\d+/)?.[0]); if (rawId == null) sequential += 1; if (!Number.isFinite(parsedId)) continue; const answer = item.answer ?? item.answers ?? item.value ?? item.content ?? item.result ?? item.答案; if (answer === undefined) continue; result.push({ id: parsedId, type: item.type ?? item.题型, answer, }); } return result.length ? result : null; } } // ======================= 启动 ======================= if (IS_DEEPSEEK) { new DeepSeekAgent().start(); } else { new HomeworkAgent().start(); } })();