// ==UserScript==
// @name 雨课堂刷题助手1.1.2
// @namespace deepseek-ai
// @version 0.1
// @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 http://yuketang.cn/favicon.ico
// @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',
// 每题一张图: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;
box-sizing: border-box;
padding: 12px;
border: 1px solid #dfe3e8;
border-radius: 10px;
background: rgba(255,255,255,.97);
box-shadow: 0 8px 28px rgba(15,23,42,.18);
color: #111827;
font: 12px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Microsoft YaHei", sans-serif;
}
.hwai-panel[data-side="deepseek"] {
top: 12px;
right: 16px;
width: 250px;
}
.hwai-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-size: 13px;
font-weight: 700;
letter-spacing: .2px;
}
.hwai-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #94a3b8;
flex: 0 0 auto;
}
.hwai-status {
min-height: 36px;
padding: 7px 9px;
border-radius: 7px;
background: #f8fafc;
color: #64748b;
word-break: break-word;
}
.hwai-status[data-kind="ok"] { background: #ecfdf5; color: #047857; }
.hwai-status[data-kind="busy"] { background: #eff6ff; color: #1d4ed8; }
.hwai-status[data-kind="error"] { background: #fef2f2; color: #b91c1c; }
.hwai-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.hwai-btn {
flex: 1 1 0;
border: 1px solid #d1d5db;
border-radius: 7px;
padding: 7px 9px;
background: #fff;
color: #374151;
font: inherit;
cursor: pointer;
}
.hwai-btn:hover { background: #f9fafb; }
.hwai-btn[data-primary="1"] {
border-color: #2563eb;
background: #2563eb;
color: #fff;
}
.hwai-btn[data-primary="1"]:hover { background: #1d4ed8; }
.hwai-btn:disabled {
cursor: not-allowed;
opacity: .55;
}
`;
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);
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;
if (item.primary) button.dataset.primary = '1';
button.addEventListener('click', item.onClick);
actions.appendChild(button);
buttons[item.action] = button;
}
root.append(head, statusEl);
if ((options.buttons || []).length) root.append(actions);
document.documentElement.appendChild(root);
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';
},
setBusy(busy) {
Object.values(buttons).forEach((button) => {
if (button.dataset.action === 'openDeepSeek') 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.timeoutTimer = 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('准备就绪。点击“开始识别并求解”,脚本会自动过滤题号导航并识别题目。');
}
buildPanel() {
this.panel = createPanel({
side: 'homework',
title: '作业 AI 助手',
buttons: [
{
label: '开始识别并求解',
action: 'start',
primary: true,
onClick: () => this.onStartClick(),
},
{
label: '打开 DeepSeek',
action: 'openDeepSeek',
onClick: () => {
// 必须在用户点击的同步调用栈里 open,否则可能被弹窗拦截
window.open('https://chat.deepseek.com/', 'hwai_deepseek');
},
},
],
});
}
async onStartClick() {
if (this.busy) return;
this.busy = true;
this.panel.setBusy(true);
try {
await this.runTask();
} catch (err) {
console.error('[HomeworkAI] start failed:', err);
this.panel.setStatus(`启动失败:${err.message || err}`, '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);
try {
for (let offset = 0; offset < questions.length; offset += groupSize) {
const groupQuestions = questions.slice(offset, offset + groupSize);
const groupIndex = groups.length + 1;
const groupId = `${taskId}_g${groupIndex}`;
this.panel.setStatus(
`正在截取第 ${groupIndex} 组(题号 ${groupQuestions[0].id}-${
groupQuestions[groupQuestions.length - 1].id
},共 ${groupQuestions.length} 张图)...`,
'busy'
);
// 每题单独截图,返回 [{ question, dataUrl }, ...]
const captures = await this.captureGroup(groupQuestions);
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,
})),
});
}
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);
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') {
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);
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 = [];
for (const [qid, question] of this.questions) {
const item = answerMap.get(qid);
if (!item) {
failed += 1;
failedIds.push(qid);
continue;
}
const block = this.getQuestionBlock(qid);
if (!block) {
failed += 1;
failedIds.push(qid);
continue;
}
try {
await this.fillQuestion(block, question, item.answer);
filled += 1;
} catch (err) {
console.warn(`[HomeworkAI] 第 ${qid} 题填写失败:`, err);
failed += 1;
failedIds.push(qid);
}
await sleep(CONFIG.homework.fillDelayMs);
}
return { filled, failed, failedIds };
}
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.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);
this.panel.setStatus('已连接。等待左侧作业页面发送任务。');
}
buildPanel() {
this.panel = createPanel({
side: 'deepseek',
title: 'DeepSeek 作业助手',
buttons: [],
});
}
async considerTask(task) {
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.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) {
const group = task.groups[index];
const progress = `正在处理第 ${index + 1}/${task.groups.length} 组`;
await this.updateTask(task.taskId, { progress });
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');
// 只有等到能解析出本组所有题号后才算生成结束,
// 避免流式输出途中的半截 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);
}
}
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) {
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();
}
})();