// ==UserScript== // @name 学习通 AI 助手(DeepSeek 自动答题) // @namespace deepseek-ai // @version 3.0.0 // @description 学习通(超星)自动答题:作业/考试/章节测验按题截图或发文字给 DeepSeek 网页版,自动回填答案、暂存、交卷;附后台保活(Worker 计时器防节流 + 静音音频保活 + 可见性伪装),学习通与 DeepSeek 两个网页切到后台也持续运行。 // @author - // @match *://*.chaoxing.com/* // @match https://chat.deepseek.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect cdn.ocsjs.com // @grant unsafeWindow // @require https://cdn.bootcdn.net/ajax/libs/html2canvas/1.4.1/html2canvas.min.js // @noframes true // @run-at document-idle // @license MIT // ==/UserScript== /* * ============================ 使用前必读 ============================ * * 【怎么跑】 * 1. 两个标签页,装同一个脚本: * A 标签:学习通作业页(…/mooc2/work/dowork…)或考试整卷预览页 * B 标签:https://chat.deepseek.com/ (先登录) * 同一脚本的两个标签页才能通过 GM 存储互传数据;拆成两个脚本默认不行。 * 2. 右侧 DeepSeek 页保持打开即可,不用操作;左侧点「开始识别」。 * 3. 脚本会:逐题判断题型 → 纯文本题直接把题干+选项发过去,含图片/公式/加密字体的 * 才截图 → 5 题一组发给 DeepSeek → 解析 JSON → 回填 → 显示逐题结果。 * 默认答完自动点「提交」:章节测验(课程任务点)必须提交才会被判定 * 完成;作业/考试页不想自动交卷的话,在面板设置里关掉「答完自动提交」。 * * 【文字优先,截图兜底】 * 纯文本题发文字比发图更准(不用 OCR、不会被压缩糊掉)也更快更省。 * 默认 CONFIG.cx.transport.mode = 'auto': * 1. 先看有没有 .font-cxsecret(学习通私有字体混淆)。有就自动解密: * 把私有字体字形渲染成点阵,与官方特征表 font_ref_cx.bin 逐字比对 * (粗 16×16 取候选 → 细 24×24 定案),还原成真字后摘掉私有字体。 * 算法移植自 OCS 的 font-decrypt 模块(MIT)。 * 2. 解密成功后,题目按纯文本发送。 * 3. 解密失败(特征表下不到 / 字体读不出),或题目里有 img / svg / * mjx-container / MathJax / katex / formula,或文字超过 maxTextLength, * 或选项读不出文字 → 该题改走截图。 * 想强制统一:mode 改成 'text' 或 'image'。 * 注意:强制 'text' 且解密失败时,发给模型的会是乱码。 * * 【各题型怎么填】 * 单选/多选/判断:点 .answerBg(学习通自己的 addChoice 逻辑), * 多选会先读当前选中态,再补齐/取消,重复点也不会翻车。 * 填空:写 .filling_answer .textDIV 里的 textarea,并点 saveQuestion 保存。 * 简答:优先 UEditor API → iframe body → textarea,然后点 saveQuestion。 * 连线/选词/阅读(type 11/14/15 中的特殊结构):不猜,标记为「不支持」。 * * 【后台运行(保活)】 * 学习通页 + DeepSeek 页可以同时切到后台(不在前台、最小化都行), * 脚本照常运行。原理(KeepAlive 模块,两个页面都会自动启用): * 1. 计时防节流:浏览器会把后台标签页的 setTimeout/setInterval 拖慢 * 到最低 1 秒、5 分钟后甚至 1 分钟一次(Chrome intensive throttling), * 直接把轮询/心跳/等待全部拖死。脚本把计时器搬进 Web Worker—— * Worker 里的计时不受标签页前后台影响,到点再回调回主线程。 * 2. 静音音频保活:循环播放一段振幅约 -84dB(人耳不可闻)的正弦波, * 让标签页保持「正在播放声音」状态。正在出声的标签页不会被浏览器 * 冻结、不会被内存节省程序丢弃,也不会被 Edge 睡眠。 * 首次点击页面后自动生效(浏览器要求声音需要一次用户交互)。 * 3. 可见性伪装:把 document.hidden 固定为 false、visibilityState * 固定为 visible,并拦截 visibilitychange 事件(含同源 iframe, * 学习通的题目渲染在 iframe 里),防止页面/脚本发现被切到 * 后台后自动暂停。 * 以上三项在面板设置区各有独立开关(keepAlive.*),出问题可逐项关闭 * 排查。「页面计时器防节流」默认关闭——它会把学习通页面自己的定时器 * 也换成 Worker 版,激进但更彻底,遇到页面异常再开。 * * 【开新对话】 * 何时开由 CONFIG.deepseek.newChatMode 决定(默认 'run':每轮开一个): * 跨轮的题号会从 1 重新编号,是答案污染的主要来源;轮内各组题号全局 * 唯一(1..N 连续),共用同一对话不会混淆,侧边栏也只多一个对话。 * 清空方式三级降级:① Ctrl+J 快捷键 → ② 侧边栏按钮 → ③ 该组答完 * (结果已写入 GM 存储)后整页跳转到新对话,新页面自动接下一组。 * 可选 'group'(每组都开)或 'off'(从不)。 * * 【自动暂存】 * 每组答案回填完成后自动点一次「暂存」(#tempsave),中途断掉、误关页面 * 答案也不丢;章节测验页没有暂存按钮会静默跳过(逐题已自动保存)。 * 不想暂存就在面板设置里关掉「每组填完暂存」。 * * 【断点续传】 * 开始识别时会检测每道题的已答状态:选择题任一选项已选中、填空所有空 * 有值、简答编辑器有内容 → 视为已答,不再发给 DeepSeek,结果列表里 * 标「已有答案,跳过」。上次跑到一半停止、或自己答过几题,再点开始 * 就只补没答的。填空只填一半时照常发问,但回填只写空的空,不覆盖 * 已填内容。不支持的题型(连线/选词等)也不发问,直接标手动。 * * 【回填自愈(重定位)】 * 学习通在每组「暂存」/翻页后经常重渲染题目区:旧 iframe 被隐藏(还 * 挂在 DOM 上)、新 DOM 重新加载,识别时缓存的节点随之失联——旧版 * 脚本会大面积报「未找到选项」。实测还有个更隐蔽的坑:暂存按钮在 * 长列表底部,点击后页面重载并跳回顶部,且**新 DOM 的字体加密会 * 恢复(题干变回乱码)**,识别时记录的题干指纹因此永远匹配不上。 * 现在识别时给每题记录三个稳定标识:学习通原生题号、页面明文题号 * ( 数字,不受加密影响)、解密后的题干指纹。回填遇到「未找到」 * 类错误会自动五段式找回(最多 60 秒): * 0. 对新 DOM 重新做字体解密(幂等,同字体走缓存); * 1. 在所有文档里按标识找**可见**的同题节点; * 2. 题在 DOM 里但被折叠/收起 → 滚动定位 + 点击尝试展开; * 3. 逐题/分页模式 → 点「下一题」逐页翻过去; * 4. 长列表模式 → 逐步滚动所有容器(700px/步,到底回顶), * 触发懒渲染并让目标题进入视口。 * 暂存完成后脚本会自动把页面滑回暂存前的位置。旧隐藏节点不会被 * 误填;连续 2 次找回失败会熔断本组剩余题的翻页等待。若日志出现 * 「重定位失败」,把该题答案人工填上即可。 * * 【改哪里】 * CONFIG.cx.font 字体解密(enabled、refTableUrl、maxChars) * CONFIG.cx.transport 文字/截图策略(mode、maxTextLength、riskySelector) * CONFIG.cx.* 学习通侧:题目容器、分组大小、截图参数、是否自动提交 * CONFIG.deepseek.* DeepSeek 侧:输入框/发送键/附件选择器、等待时长 * 如果学校用的是 *.edu.cn / *.org.cn 的自建超星,自己加一条 @match。 * ===================================================================== */ (function () { 'use strict'; const IS_DEEPSEEK = location.hostname === 'chat.deepseek.com'; // Tampermonkey 沙箱里访问页面全局对象(UEditor 等)必须走 unsafeWindow const PAGE_WINDOW = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; // ======================= 全局配置 ======================= const CONFIG = { bridge: { pollMs: 1200, // GM 监听失效时的轮询兜底 staleTaskMs: 3 * 60 * 1000, // 右侧崩溃后多久允许重新领取 taskTimeoutMs: 12 * 60 * 1000, // 左侧等答案的总超时 }, cx: { /* ---------- 题目容器 ---------- */ // 新版作业/考试:.questionLi // 旧版作业:.TiMu(章节测验、老版 dowork) questionSelector: '.questionLi, .TiMu, .question-item, .subject-item', // 题号导航、答题卡、侧边栏里的节点不能当题目 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*="sideBar"]', ].join(','), // 太窄/太短的多半是题号导航项 minQuestionWidth: 320, minQuestionHeight: 40, minQuestionTextLength: 8, /* ---------- 题型与选项 ---------- */ selectors: { // 题干:新版 .mark_name,旧版 .Zy_TItle stem: '.mark_name, .Zy_TItle .clearfix, .Zy_TItle, h3.mark_name, .question-title, .stem', // 选项容器:新版 .answerBg;兜底 .answer_p / 旧版 ul li // 选项容器。`ul li .after` / `ul li label:not(.before)` 是 OCS 处理 // 学习通章节测验(studentstudy 内嵌作业)用的选择器:字母在 // label.before,内容在 label.after——缺了它就只能读到字母芯片。 option: '.answerBg, .answer_p, .choice-item, .option-item, ul.mark_letter li, .stem_answer label, ul li .after, ul li label:not(.before)', // 选项字母 optionLetter: '.num_option, .num_option_dx, .ans-option, i.num_option', // 选项文本:优先取真正的文字节点,避免把 "A" 一起带出来 optionText: '.answer_p .fl, .fl, .after, .answer_p', // 题型隐藏域:0单选 1多选 2填空 3判断 4-10简答类 11连线 14选词 15阅读 typeInput: 'input[id^="answertype"], input[name^="type"], input[name^="answertype"]', // 填空输入框 blank: '.filling_answer .textDIV textarea, .filling_answer textarea, .textDIV textarea', // 简答编辑器容器 editor: '.eidtDiv, .edui-editor, .filling_answer iframe', // 每空/每题保存按钮 save: '[onclick*="saveQuestion"], [onclick*="saveQuestion()"]', // 交卷按钮(默认不用) submit: '.btnSubmit, #submitBtn, a[onclick*="submit"], .completeBtn', // 暂存按钮:作业/考试页是 #tempsave,其余版本按文本「暂存」兜底 tempSave: '#tempsave, a[onclick*="tempsave"], a[onclick*="tempSave"], [class*="tempsave"], [class*="tempSave"]', }, /* ---------- 截图 ---------- */ groupSize: 5, // 5 题一批(文字题+图片题一起算) captureScale: 2, maxImageWidth: 2000, imageType: 'image/jpeg', imageQuality: 0.9, scrollBeforeCapture: true, imageLoadTimeoutMs: 12000, html2canvasOptions: { useCORS: true, allowTaint: false, backgroundColor: '#ffffff', logging: false, imageTimeout: 15000, }, /* ---------- 题目传输方式 ---------- */ transport: { // auto:能读文字就发文字,含图片/公式/加密字体才截图(推荐) // text:全部发文字(快、省,但遇到字体混淆会出乱码) // image:全部截图(最保险,最慢) mode: 'auto', // 纯文字题最多发多少字,超过就改截图 maxTextLength: 1500, // 命中这些(按类名/结构)就判定为“不能只发文字”,不受尺寸限制 riskySelector: [ 'mjx-container', '.MathJax', '[class*="katex"]', '[class*="formula"]', '.font-cxsecret', // 字体加密:解密失败时才走到这里(成功解密后 class 已被摘掉) ].join(','), // 图片类标签单独判:小于 minMediaSize 的视为图标装饰(学习通题块里 // 到处都是 SVG 小图标),不触发截图 mediaSelector: 'img, svg, canvas, video', minMediaSize: 32, }, /* ---------- 暂存 ---------- */ // 每组答案填完后自动点一次「暂存」,防止中途断掉丢答案 tempSave: true, /* ---------- 暂存 ---------- */ // 每组答案填完后自动点一次「暂存」,防止中途断掉丢答案 tempSave: true, /* ---------- 断点续传 ---------- */ // 开始识别时检测每道题的已答状态(选项已选中 / 填空已有值 / // 简答编辑器有内容),已作答的题不再发给 DeepSeek,直接跳过。 // 上次跑到一半停止、或自己答过几题,再点开始就只补没答的。 // 填空题只填了一半时会照常发问,但回填只写空的空,不覆盖已填内容。 resume: { enabled: true, }, /* ---------- 字体解密 ---------- */ // 学习通会给题干套 font-cxsecret 私有字体做字符混淆。 // 开着它就能把乱码还原成真字,从而走「发文本」而不是截图。 // 解密失败(比如特征表下不到)会自动退回截图,不影响答题。 font: { enabled: true, marker: 'font-cxsecret', // OCS 官方特征表,含常用字的粗/细点阵指纹 refTableUrl: 'https://cdn.ocsjs.com/resources/font/font_ref_cx.bin', timeoutMs: 30000, maxChars: 1200, // 单页最多解密多少个不重复字符,防止卡顿 }, /* ---------- 回填 ---------- */ fillDelayMs: 150, clickOptionWaitMs: 320, // 每次点击选项后的等待(学习通要跑内部逻辑) // 答完全部题目后自动点击「提交」。章节测验(课程里的任务点)必须 // 提交才会被学习通判定完成——只填答案+暂存的话任务点永远不完。 // 作业/考试页若不想自动交卷,可在面板设置里关掉。 autoSubmit: true, autoSubmitSelector: '', // 留空则用 selectors.submit,找不到时按文本「提交」兜底 typeLabels: { single: '单选题', multiple: '多选题', judge: '判断题', fill: '填空题', subjective: '简答题', line: '连线题', reading: '阅读题', }, }, /* ---------- 后台保活(KeepAlive,两个页面共用) ---------- */ keepAlive: { // 计时器搬进 Web Worker:后台标签页里 setTimeout/setInterval 不再被 // 浏览器节流(Chrome 隐藏页最低 1s、5 分钟后 1 次/分钟会把轮询拖死) timer: true, // 循环播放人耳不可闻的极低音量音频,让标签页保持 audible 状态: // 不会被冻结、不会被内存节省程序丢弃、不会被 Edge 睡眠 audio: true, // 把 document.hidden 固定为 false、visibilityState 固定为 visible, // 并拦截 visibilitychange 事件(含同源 iframe),防止后台自动暂停 visibility: true, // 更激进:把学习通页面自己的 setTimeout/setInterval 也换成 Worker // 版(页面进度上报心跳也防节流)。可能影响页面自身逻辑,默认关闭 pageTimers: false, }, deepseek: { 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(','), assistantMessageSelector: '.ds-markdown, [class*="ds-markdown"], [class*="markdown"]', attachmentSelector: '[class*="attachment"], [class*="file-preview"], ' + '[class*="image-preview"], [class*="upload-item"], img[src^="blob:"]', // 何时开新对话(DeepSeek Ctrl+J): // 'run' = 每轮(一次「开始识别」)开一个新对话,轮内各组合用 —— 推荐 // 跨轮的 id 会从 1 重新编号,才是答案污染的主要来源; // 轮内各组 id 全局唯一(1..N 连续),共用不会混淆 // 'group' = 每组都开新对话(上下文最干净,但侧边栏会多很多对话) // 'off' = 从不新建,一直追加 newChatMode: 'run', uploadWaitMs: 2500, sendEnableTimeoutMs: 60000, replyTimeoutMs: 240000, replyStableMs: 2500, replyGraceMs: 15000, minReplyWaitMs: 90000, // 思考阶段可能长时间没正文,别太早判结束 betweenGroupsMs: 800, }, }; // ======================= 后台保活(KeepAlive) ======================= // // 浏览器对后台标签页的三板斧,逐一化解(学习通页 / DeepSeek 页共用): // 1. 计时节流:后台页 setTimeout/setInterval 被拖到最低 1 秒,Chrome // 对静置 5 分钟的页面更会降到约 1 次/分钟(intensive throttling), // 轮询/心跳/等待全部被拖死。 // → 把计时搬进 Web Worker:Worker 里的计时不受标签页前后台影响, // 到点 postMessage 回主线程执行回调。脚本内所有裸调用的 // setTimeout/setInterval/clearTimeout/clearInterval 都会被劫持 // 到这条路径(Worker 创建失败自动回退原生计时器)。 // 2. 冻结/丢弃:Chrome 内存节省程序、Edge 睡眠标签页会把不活跃页面 // 整页冻结甚至丢弃。 // → 循环播放一段振幅约 -84dB 的正弦波(人耳不可闻),标签页保持 // 「正在播放声音」状态即可获得豁免;被暂停时自动恢复。 // 3. 可见性暂停:页面 JS 监听到 visibilitychange 就可能暂停视频。 // → 伪装 document.hidden/visibilityState,并拦截 visibilitychange // 事件(含同源 iframe——学习通的题目渲染在 iframe 里)。 const RAW_TIMERS = { setTimeout: window.setTimeout.bind(window), clearTimeout: window.clearTimeout.bind(window), setInterval: window.setInterval.bind(window), clearInterval: window.clearInterval.bind(window), }; /** Web Worker 计时器主机;Worker 不可用(CSP 限制等)时自动回退原生计时器 */ class WorkerTimerHost { constructor() { this.worker = null; this.map = new Map(); // id -> { fn, args, interval } this.seq = 1e7; // 从大数起步,避免与原生 timer id 冲突 this.failed = false; this.init(); } init() { try { const src = ` const timers = new Map(); self.onmessage = (e) => { const d = e.data || {}; if (d.op === 'timeout') { const t = setTimeout(() => { timers.delete(d.id); self.postMessage({ id: d.id }); }, d.ms); timers.set(d.id, { t, interval: false }); } else if (d.op === 'interval') { const t = setInterval(() => self.postMessage({ id: d.id }), d.ms); timers.set(d.id, { t, interval: true }); } else if (d.op === 'clear') { const rec = timers.get(d.id); if (rec) { (rec.interval ? clearInterval : clearTimeout)(rec.t); timers.delete(d.id); } } }; `; const url = URL.createObjectURL(new Blob([src], { type: 'application/javascript' })); this.worker = new Worker(url); URL.revokeObjectURL(url); // Worker 已加载完脚本,URL 立即回收 this.worker.onmessage = (e) => { const rec = this.map.get(e.data && e.data.id); if (!rec) return; if (!rec.interval) this.map.delete(e.data.id); try { rec.fn(...rec.args); } catch (err) { console.error('[CXAI][保活] 计时回调异常:', err); } }; this.worker.onerror = (e) => { console.warn('[CXAI][保活] Worker 计时器错误:', (e && e.message) || e); }; } catch (err) { this.failed = true; this.worker = null; console.warn('[CXAI][保活] Worker 计时器创建失败,回退原生计时器:', err); } } get available() { return !!this.worker && !this.failed; } setTimeout(fn, ms, ...args) { if (!this.available || (Number(ms) || 0) < 8) { return RAW_TIMERS.setTimeout(fn, ms, ...args); } const id = this.seq++; this.map.set(id, { fn, args, interval: false }); this.worker.postMessage({ op: 'timeout', id, ms: Math.max(0, Number(ms) || 0) }); return id; } setInterval(fn, ms, ...args) { if (!this.available || (Number(ms) || 0) < 8) { return RAW_TIMERS.setInterval(fn, ms, ...args); } const id = this.seq++; this.map.set(id, { fn, args, interval: true }); this.worker.postMessage({ op: 'interval', id, ms: Math.max(8, Number(ms) || 0) }); return id; } clearTimeout(id) { if (this.map.delete(id)) { try { this.worker.postMessage({ op: 'clear', id }); } catch { // ignore } return; } RAW_TIMERS.clearTimeout(id); } clearInterval(id) { if (this.map.delete(id)) { try { this.worker.postMessage({ op: 'clear', id }); } catch { // ignore } return; } RAW_TIMERS.clearInterval(id); } /** 关闭开关时清空 Worker 里挂着的任务并释放线程 */ uninstall() { for (const id of [...this.map.keys()]) this.clearTimeout(id); if (this.worker) { try { this.worker.terminate(); } catch { // ignore } this.worker = null; } } } /** 生成 2 秒、440Hz、振幅约 -84dB 的 WAV data URI:人耳不可闻,但浏览器判定「有声」 */ function buildSilentWavDataUri() { const sampleRate = 8000; const seconds = 2; const total = sampleRate * seconds; const buffer = new ArrayBuffer(44 + total * 2); const view = new DataView(buffer); const writeStr = (offset, str) => { for (let i = 0; i < str.length; i += 1) view.setUint8(offset + i, str.charCodeAt(i)); }; writeStr(0, 'RIFF'); view.setUint32(4, 36 + total * 2, true); writeStr(8, 'WAVE'); writeStr(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); // PCM view.setUint16(22, 1, true); // 单声道 view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true); writeStr(36, 'data'); view.setUint32(40, total * 2, true); for (let i = 0; i < total; i += 1) { view.setInt16(44 + i * 2, Math.round(Math.sin((2 * Math.PI * 440 * i) / sampleRate) * 2), true); } const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]); return 'data:audio/wav;base64,' + btoa(binary); } /** 音频保活:循环播放不可闻音频,防止标签页被冻结/睡眠/丢弃 */ class AudioKeepAlive { constructor() { this.audio = null; this.retryTimer = null; this.kick = null; } start() { if (this.audio) return; try { const audio = document.createElement('audio'); audio.src = buildSilentWavDataUri(); audio.loop = true; audio.volume = 1; // WAV 本身振幅趋近 0,音量保持 1 以确保被判为 audible audio.setAttribute('aria-hidden', 'true'); audio.title = 'CXAI keep-alive'; audio.style.cssText = 'position:fixed;left:-9999px;bottom:0;width:1px;height:1px;opacity:0;pointer-events:none;'; audio.addEventListener('pause', () => { // 被浏览器/页面暂停时自动恢复 RAW_TIMERS.setTimeout(() => this.tryPlay(), 800); }); document.documentElement.appendChild(audio); this.audio = audio; this.kick = () => this.tryPlay(); // 浏览器要求声音需要一次用户交互;交互与定时重试双兜底 window.addEventListener('pointerdown', this.kick, true); window.addEventListener('keydown', this.kick, true); this.retryTimer = RAW_TIMERS.setInterval(() => this.tryPlay(), 15000); this.tryPlay(); } catch (err) { console.warn('[CXAI][保活] 音频保活启动失败:', err); } } tryPlay() { const audio = this.audio; if (!audio || !audio.paused) return; const p = audio.play(); if (p && typeof p.catch === 'function') p.catch(() => {}); } stop() { if (this.retryTimer) { RAW_TIMERS.clearInterval(this.retryTimer); this.retryTimer = null; } if (this.kick) { window.removeEventListener('pointerdown', this.kick, true); window.removeEventListener('keydown', this.kick, true); this.kick = null; } if (this.audio) { try { this.audio.pause(); this.audio.remove(); } catch { // ignore } this.audio = null; } } get active() { return !!this.audio && !this.audio.paused; } } /** 可见性伪装:hidden/visibilityState 固定为可见,visibilitychange 事件被吞掉 */ class VisibilitySpoof { constructor() { this.installed = false; this.scanTimer = null; } start() { if (this.installed) return; this.installed = true; this.spoofDocument(document, window); this.scanFrames(); // 学习通换章节会重建 iframe:定期把新出现的同源 iframe 也伪装掉 this.scanTimer = RAW_TIMERS.setInterval(() => this.scanFrames(), 5000); } spoofDocument(doc, win) { if (!doc || doc.__cxai_spoofed) return; try { const define = (prop, value) => { Object.defineProperty(doc, prop, { get: () => value, configurable: true }); }; define('hidden', false); define('visibilityState', 'visible'); define('webkitHidden', false); define('webkitVisibilityState', 'visible'); doc.__cxai_spoofed = true; } catch { // 个别环境 document 属性只读,跳过 } try { const swallow = (e) => e.stopImmediatePropagation(); win.addEventListener('visibilitychange', swallow, true); doc.addEventListener('visibilitychange', swallow, true); } catch { // ignore } } scanFrames() { try { for (const frame of collectIFrames(PAGE_WINDOW.document)) { try { this.spoofDocument(frame.contentDocument, frame.contentWindow); } catch { // 跨域 iframe } } } catch { // ignore } } stop() { // 伪装的 getter 无法还原(原引用已覆盖),关闭开关仅停止 iframe 扫描;刷新页面即恢复 if (this.scanTimer) { RAW_TIMERS.clearInterval(this.scanTimer); this.scanTimer = null; } this.installed = false; } } /** 收集顶层文档下所有同源 iframe(含嵌套) */ function collectIFrames(rootDoc) { let list = Array.from(rootDoc.querySelectorAll('iframe')); const result = []; while (list.length) { const frame = list.shift(); try { const doc = frame && frame.contentWindow && frame.contentWindow.document; if (doc) { result.push(frame); list = list.concat([...doc.querySelectorAll('iframe')]); } } catch { // 跨域 iframe 跳过 } } return result; } class KeepAliveHost { constructor() { this.workerTimer = new WorkerTimerHost(); this.audio = new AudioKeepAlive(); this.visibility = new VisibilitySpoof(); this.timerHooksInstalled = false; this.pageTimersInstalled = false; } init() { this.refresh(); } /** 按设置开关启停各保活组件(设置变更 / 双端启动时都会调用) */ refresh() { const timerOn = CONFIG.keepAlive.timer !== false; if (timerOn && !this.timerHooksInstalled) this.installTimerHooks(); if (!timerOn && this.timerHooksInstalled) this.uninstallTimerHooks(); const pageTimersOn = CONFIG.keepAlive.pageTimers === true; if (pageTimersOn && !this.pageTimersInstalled) this.installPageTimerHooks(); if (CONFIG.keepAlive.audio !== false) this.audio.start(); else this.audio.stop(); if (CONFIG.keepAlive.visibility !== false) this.visibility.start(); else this.visibility.stop(); } /** 劫持沙箱计时器:脚本内所有裸 setTimeout/setInterval/clear* 全部走 Worker */ installTimerHooks() { const host = this.workerTimer; window.setTimeout = function (fn, ms, ...args) { return host.setTimeout(fn, ms, ...args); }; window.setInterval = function (fn, ms, ...args) { return host.setInterval(fn, ms, ...args); }; window.clearTimeout = (id) => host.clearTimeout(id); window.clearInterval = (id) => host.clearInterval(id); this.timerHooksInstalled = true; } uninstallTimerHooks() { window.setTimeout = RAW_TIMERS.setTimeout; window.setInterval = RAW_TIMERS.setInterval; window.clearTimeout = RAW_TIMERS.clearTimeout; window.clearInterval = RAW_TIMERS.clearInterval; this.workerTimer.uninstall(); this.timerHooksInstalled = false; } /** 更激进:把学习通页面自己的计时器也换成 Worker 版(默认关闭) */ installPageTimerHooks() { try { const uw = PAGE_WINDOW; const host = this.workerTimer; const wrapTimeout = function (fn, ms, ...args) { return host.setTimeout(fn, ms, ...args); }; const wrapInterval = function (fn, ms, ...args) { return host.setInterval(fn, ms, ...args); }; if (typeof exportFunction === 'function') { // Firefox 沙箱需要显式导出函数到页面域 exportFunction(wrapTimeout, uw, { defineAs: 'setTimeout' }); exportFunction(wrapInterval, uw, { defineAs: 'setInterval' }); exportFunction((id) => host.clearTimeout(id), uw, { defineAs: 'clearTimeout' }); exportFunction((id) => host.clearInterval(id), uw, { defineAs: 'clearInterval' }); } else { uw.setTimeout = wrapTimeout; uw.setInterval = wrapInterval; uw.clearTimeout = (id) => host.clearTimeout(id); uw.clearInterval = (id) => host.clearInterval(id); } this.pageTimersInstalled = true; } catch (err) { console.warn('[CXAI][保活] 页面计时器劫持失败(不影响脚本自身):', err); } } /** 面板状态摘要,如「计时✅ 音频✅ 伪装✅」 */ statusText() { const parts = []; const timerOn = CONFIG.keepAlive.timer !== false; parts.push(`计时${timerOn && this.workerTimer.available ? '✅' : timerOn ? '➖' : '关'}`); const audioOn = CONFIG.keepAlive.audio !== false; parts.push(`音频${audioOn ? (this.audio.active ? '✅' : '⏳') : '关'}`); const visOn = CONFIG.keepAlive.visibility !== false; parts.push(`伪装${visOn ? '✅' : '关'}`); return parts.join(' '); } } const KeepAlive = new KeepAliveHost(); // ======================= GM 存储与跨页通信 ======================= const KEYS = { task: 'CXAI_TASK_V1', result: 'CXAI_RESULT_V1', heartbeat: 'CXAI_HEARTBEAT_V1', image: (groupId, index) => `CXAI_IMG_V1_${groupId}_${index}`, panel: (side) => `CXAI_PANEL_V1_${side}`, }; const Bridge = (() => { /** 个别管理器会把对象序列化成字符串返回;这里兼容解析一次 */ 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 { return normalizeStoredValue(await GM_getValue(key, fallback)); } catch (err) { console.warn('[CXAI] GM_getValue failed:', key, err); return fallback; } } async function set(key, value) { try { await GM_setValue(key, value); return true; } catch (err) { console.error('[CXAI] 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('[CXAI] GM_deleteValue failed:', key, err); } } /** 监听 + 轮询双保险,同值去重 */ function watch(key, callback, options = {}) { const intervalMs = options.intervalMs || CONFIG.bridge.pollMs; let stopped = false; let lastSignature = '__CXAI_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('[CXAI] watch callback failed:', err)); } if (typeof GM_addValueChangeListener === 'function') { try { listenerId = GM_addValueChangeListener(key, (_n, _o, newValue, remote) => { if (remote) deliver(normalizeStoredValue(newValue)); }); } catch (err) { console.warn('[CXAI] GM_addValueChangeListener unavailable:', err); } } const timer = setInterval(async () => { deliver(await get(key, null)); }, 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 SETTINGS_KEY = 'CXAI_SETTINGS_V1'; /** 面板里可改的配置项;存 GM 存储,两侧启动时合并进 CONFIG */ const SETTINGS_SCHEMA = [ { key: 'deepseek.newChatMode', label: '开新对话时机', type: 'select', options: [ ['run', '每轮一个(推荐)'], ['group', '每组一个'], ['off', '从不'], ], default: 'run', }, { key: 'cx.groupSize', label: '每组题数', type: 'number', min: 1, max: 10, default: 5 }, { key: 'cx.transport.mode', label: '传输方式', type: 'select', options: [ ['auto', '自动(文字优先)'], ['text', '只发文字'], ['image', '只发截图'], ], default: 'auto', }, { key: 'keepAlive.timer', label: '后台计时防节流', type: 'checkbox', default: true }, { key: 'keepAlive.audio', label: '静音音频保活', type: 'checkbox', default: true }, { key: 'keepAlive.visibility', label: '可见性伪装', type: 'checkbox', default: true }, { key: 'keepAlive.pageTimers', label: '页面计时防节流', type: 'checkbox', default: false }, { key: 'cx.tempSave', label: '每组填完暂存', type: 'checkbox', default: true }, { key: 'cx.resume.enabled', label: '断点续传', type: 'checkbox', default: true }, { key: 'cx.font.enabled', label: '字体解密', type: 'checkbox', default: true }, { key: 'cx.autoSubmit', label: '答完自动提交', type: 'checkbox', default: true }, ]; function setConfigByPath(path, value) { const parts = path.split('.'); let obj = CONFIG; for (let i = 0; i < parts.length - 1; i += 1) { if (typeof obj[parts[i]] !== 'object' || obj[parts[i]] === null) { obj[parts[i]] = {}; } obj = obj[parts[i]]; } obj[parts[parts.length - 1]] = value; } function getConfigByPath(path, fallback) { try { let obj = CONFIG; for (const part of path.split('.')) { if (obj == null) return fallback; obj = obj[part]; } return obj === undefined ? fallback : obj; } catch { return fallback; } } async function loadSettings() { try { const stored = (await Bridge.get(SETTINGS_KEY, {})) || {}; for (const item of SETTINGS_SCHEMA) { const value = stored[item.key] !== undefined ? stored[item.key] : item.default; setConfigByPath(item.key, value); } } catch (err) { console.warn('[CXAI] 读取设置失败,使用默认值:', err); } } async function saveSetting(key, value) { setConfigByPath(key, value); // 立即生效 try { const stored = (await Bridge.get(SETTINGS_KEY, {})) || {}; stored[key] = value; await Bridge.set(SETTINGS_KEY, stored); } catch (err) { console.warn('[CXAI] 设置写入失败:', err); } } // ======================= 通用工具 ======================= 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) => !elements.some((other) => other !== el && other.contains(el)) ); } 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 paragraphs(text) { return String(text) .split(/\r?\n/) .map((line) => (line.trim() ? `

${escapeHtml(line)}

` : '


')) .join(''); } const makeDataTransfer = () => new 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 受控输入框必须走原型 setter,否则状态不更新 */ 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 })); } function getEditorText(el) { if (!el) return ''; if ('value' in el && typeof el.value === 'string') return el.value; return el.innerText || el.textContent || ''; } /** contenteditable 填写:paste 模拟 → insertText → insertHTML */ 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 { root.dispatchEvent(createTransferEvent('paste', dataTransfer, 'ClipboardEvent')); } catch (err) { console.warn('[CXAI] synthetic paste failed:', err); } await sleep(120); if (expectedPrefix && normalizeText(getEditorText(root)).includes(expectedPrefix)) { dispatchInputChange(root, text); return true; } try { doc.execCommand('insertText', false, String(text)); } catch { // ignore } if (expectedPrefix && normalizeText(getEditorText(root)).includes(expectedPrefix)) { dispatchInputChange(root, text); return true; } 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); } // ======================= 面板 UI ======================= const PANEL_CSS = ` .cxai-panel { position: fixed; top: 84px; right: 16px; z-index: 2147483647; width: 292px; 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; resize: both; overflow: auto; } .cxai-panel[data-side="homework"] { height: 430px; } .cxai-panel[data-side="deepseek"] { top: 12px; width: 250px; } .cxai-panel::-webkit-scrollbar { width: 6px; height: 6px; } .cxai-panel::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .16); border-radius: 999px; } .cxai-panel::-webkit-scrollbar-track { background: transparent; } .cxai-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; } .cxai-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; } .cxai-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; } .cxai-status[data-kind="ok"] { border-color: rgba(52, 211, 153, .25); background: rgba(52, 211, 153, .09); color: #6ee7b7; } .cxai-status[data-kind="busy"] { border-color: rgba(129, 140, 248, .3); background: rgba(99, 102, 241, .12); color: #a5b4fc; } .cxai-status[data-kind="error"] { border-color: rgba(248, 113, 113, .3); background: rgba(248, 113, 113, .1); color: #fca5a5; } .cxai-actions { display: flex; gap: 8px; margin-top: auto; padding-top: 10px; flex: 0 0 auto; } .cxai-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; } .cxai-btn:hover { background: rgba(255, 255, 255, .09); border-color: rgba(255, 255, 255, .22); } .cxai-btn:active { transform: translateY(1px); } .cxai-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); } .cxai-btn[data-primary="1"]:hover { filter: brightness(1.08); box-shadow: 0 8px 22px rgba(99, 102, 241, .45); } .cxai-btn[data-action="stop"] { border-color: rgba(248, 113, 113, .35); background: rgba(248, 113, 113, .1); color: #fca5a5; } .cxai-btn[data-action="stop"]:hover { background: rgba(248, 113, 113, .18); } .cxai-btn:disabled { cursor: not-allowed; opacity: .45; box-shadow: none; } .cxai-conn { display: flex; align-items: center; gap: 5px; margin: -2px 0 10px; font-size: 11px; color: #6f7788; } .cxai-conn::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #4b5262; flex: 0 0 auto; } .cxai-conn[data-online="1"] { color: #6ee7b7; } .cxai-conn[data-online="1"]::before { background: #34d399; box-shadow: 0 0 8px rgba(52, 211, 153, .7); } .cxai-progress { margin-bottom: 10px; } .cxai-progress-bar { height: 5px; border-radius: 999px; background: rgba(255, 255, 255, .08); overflow: hidden; } .cxai-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; } .cxai-progress-text { display: flex; justify-content: space-between; gap: 8px; margin-top: 5px; font-size: 11px; color: #7b8496; } .cxai-thumbs { display: flex; gap: 4px; margin-bottom: 10px; } .cxai-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; } .cxai-thumbs img:hover { transform: translateY(-2px); border-color: rgba(129, 140, 248, .6); } .cxai-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; } .cxai-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; } .cxai-result:last-child { border-bottom: none; } .cxai-result:hover { background: rgba(255, 255, 255, .055); } .cxai-result-state { color: #8b94a6; } .cxai-result[data-ok="1"] .cxai-result-state { color: #34d399; } .cxai-result[data-ok="0"] .cxai-result-state { color: #f87171; } .cxai-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; } .cxai-overlay img { max-width: 92vw; max-height: 92vh; border-radius: 10px; background: #fff; box-shadow: 0 24px 80px rgba(0, 0, 0, .65); } .cxai-conn[hidden], .cxai-progress[hidden], .cxai-thumbs[hidden], .cxai-results[hidden], .cxai-overlay[hidden] { display: none !important; } .cxai-mini-btn { width: 22px; height: 22px; border-radius: 6px; border: 1px solid rgba(255, 255, 255, .14); background: rgba(255, 255, 255, .06); color: #cfd4e0; cursor: pointer; font-size: 12px; line-height: 1; padding: 0; flex: 0 0 auto; } .cxai-mini-btn:hover { background: rgba(255, 255, 255, .14); } .cxai-badge { position: fixed; right: 18px; top: 84px; z-index: 2147483647; width: 44px; height: 44px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, .16); background: radial-gradient(120% 55% at 100% 0%, rgba(99, 102, 241, .25), transparent 62%), linear-gradient(160deg, #1a1d26, #0b0d12); color: #e6e8ee; font-size: 18px; display: flex; align-items: center; justify-content: center; cursor: pointer; user-select: none; box-shadow: 0 10px 30px rgba(0, 0, 0, .5); } .cxai-badge:hover { border-color: rgba(129, 140, 248, .6); } .cxai-badge-dot { position: absolute; top: 2px; right: 2px; width: 9px; height: 9px; border-radius: 50%; background: #64748b; box-shadow: 0 0 0 2px rgba(0, 0, 0, .4); } .cxai-badge[hidden] { display: none !important; } .cxai-settings { border-top: 1px solid rgba(255, 255, 255, .08); margin-top: 10px; padding-top: 10px; display: grid; gap: 8px; } .cxai-settings[hidden] { display: none !important; } .cxai-setting-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .cxai-setting-row label { color: #9aa3b5; font-size: 12px; } .cxai-setting-row select, .cxai-setting-row input[type="number"] { background: rgba(255, 255, 255, .06); color: #e6e8ee; border: 1px solid rgba(255, 255, 255, .16); border-radius: 7px; padding: 4px 6px; font: inherit; color-scheme: dark; } .cxai-setting-row input[type="checkbox"] { accent-color: #6366f1; } .cxai-settings[data-busy="1"] { opacity: .55; pointer-events: none; } `; /** 标题栏拖动 + 右下角缩放,位置尺寸写入 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 = (v, min, max) => Math.min(Math.max(v, min), max); const notify = () => 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) }; 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; moveTo( clamp(startLeft + event.clientX - startX, 0, Math.max(0, window.innerWidth - 80)), clamp(startTop + event.clientY - startY, 0, Math.max(0, window.innerHeight - 40)) ); 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)) { moveTo( clamp(left, 0, Math.max(0, window.innerWidth - 80)), clamp(top, 0, Math.max(0, window.innerHeight - 40)) ); } notify(); })(); } function createPanel(options) { injectStyle(PANEL_CSS); const root = document.createElement('div'); root.className = 'cxai-panel'; root.dataset.side = options.side || 'homework'; const head = document.createElement('div'); head.className = 'cxai-title'; const title = document.createElement('span'); title.textContent = options.title; const dot = document.createElement('span'); dot.className = 'cxai-dot'; const miniBtn = document.createElement('button'); miniBtn.type = 'button'; miniBtn.className = 'cxai-mini-btn'; miniBtn.textContent = '–'; miniBtn.title = '最小化为悬浮球'; miniBtn.addEventListener('pointerdown', (e) => e.stopPropagation()); miniBtn.addEventListener('click', (e) => { e.stopPropagation(); setMinimized(true); }); head.append(title, dot, miniBtn); const conn = document.createElement('div'); conn.className = 'cxai-conn'; conn.textContent = '正在检测连接...'; if (options.side === 'deepseek') conn.hidden = true; const progress = document.createElement('div'); progress.className = 'cxai-progress'; progress.hidden = true; const progressBar = document.createElement('div'); progressBar.className = 'cxai-progress-bar'; const progressFill = document.createElement('div'); progressFill.className = 'cxai-progress-fill'; progressBar.appendChild(progressFill); const progressText = document.createElement('div'); progressText.className = 'cxai-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 = 'cxai-thumbs'; thumbs.hidden = true; const results = document.createElement('div'); results.className = 'cxai-results'; results.hidden = true; const overlay = document.createElement('div'); overlay.className = 'cxai-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 = 'cxai-status'; statusEl.dataset.kind = 'info'; const actions = document.createElement('div'); actions.className = 'cxai-actions'; const buttons = {}; for (const item of options.buttons || []) { const button = document.createElement('button'); button.type = 'button'; button.className = 'cxai-btn'; button.textContent = item.label; button.dataset.action = item.action || ''; if (item.primary) button.dataset.primary = '1'; if (item.action === 'stop' || item.action === 'stopStudy') 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); // 设置区(可选):藏在结果列表和按钮之间 let settingsEl = null; if (Array.isArray(options.settings) && options.settings.length) { settingsEl = document.createElement('div'); settingsEl.className = 'cxai-settings'; settingsEl.hidden = true; for (const item of options.settings) { const row = document.createElement('div'); row.className = 'cxai-setting-row'; const label = document.createElement('label'); label.textContent = item.label; let control; if (item.type === 'select') { control = document.createElement('select'); for (const [value, text] of item.options) { const opt = document.createElement('option'); opt.value = value; opt.textContent = text; control.appendChild(opt); } control.value = String(getConfigByPath(item.key, item.default)); control.addEventListener('change', () => { options.onSettingChange?.(item.key, control.value); }); } else if (item.type === 'number') { control = document.createElement('input'); control.type = 'number'; control.min = String(item.min ?? 1); control.max = String(item.max ?? 10); control.value = String(getConfigByPath(item.key, item.default)); control.addEventListener('change', () => { let v = Math.round(Number(control.value) || Number(item.default)); v = Math.min(Number(item.max ?? 10), Math.max(Number(item.min ?? 1), v)); control.value = String(v); options.onSettingChange?.(item.key, v); }); } else { control = document.createElement('input'); control.type = 'checkbox'; control.checked = !!getConfigByPath(item.key, item.default); control.addEventListener('change', () => { options.onSettingChange?.(item.key, control.checked); }); } row.append(label, control); settingsEl.appendChild(row); } } root.append(head, conn, progress, statusEl, thumbs, results); if (settingsEl) root.append(settingsEl); if ((options.buttons || []).length) root.append(actions); // 最小化悬浮球 const badge = document.createElement('div'); badge.className = 'cxai-badge'; badge.hidden = true; badge.title = '点击展开面板'; const badgeDot = document.createElement('span'); badgeDot.className = 'cxai-badge-dot'; badge.append('🤖', badgeDot); badge.addEventListener('click', () => setMinimized(false)); document.documentElement.append(root, overlay, badge); head.title = '按住标题拖动窗口,拖动右下角调整大小'; enablePanelDrag(root, head, KEYS.panel(root.dataset.side)); // 恢复上次的最小化状态 (async () => { try { if (await Bridge.get(`CXAI_MINIMIZED_${root.dataset.side}`, false)) { setMinimized(true); } } catch { // ignore } })(); function statusColor(kind) { return kind === 'ok' ? '#10b981' : kind === 'busy' ? '#3b82f6' : kind === 'error' ? '#ef4444' : '#94a3b8'; } function setMinimized(min) { root.style.display = min ? 'none' : ''; badge.hidden = !min; Bridge.set(`CXAI_MINIMIZED_${root.dataset.side}`, !!min); if (min) { badgeDot.style.background = statusColor(statusEl.dataset.kind || 'info'); } } return { root, buttons, settingsEl, toggleSettings() { if (settingsEl) settingsEl.hidden = !settingsEl.hidden; }, setStatus(text, kind = 'info') { statusEl.textContent = text; statusEl.dataset.kind = kind; dot.style.background = statusColor(kind); if (!badge.hidden) badgeDot.style.background = statusColor(kind); }, 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; }); 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 = 'cxai-result'; row.dataset.ok = item.ok ? '1' : '0'; const name = document.createElement('span'); // 失败的题把答案直接标出来,方便人工照着填 const answerLabel = item.answer ? ` · ${String(item.answer).slice(0, 14)}${String(item.answer).length > 14 ? '…' : ''}` : ''; name.textContent = `第 ${item.id} 题${answerLabel}`; if (item.answer) row.title = `答案:${item.answer}`; const state = document.createElement('span'); state.className = 'cxai-result-state'; state.textContent = item.skipped ? item.reason || '已跳过' : 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' || action === 'settings') return; if (action === 'stop' || action === 'stopStudy') { button.hidden = !busy && action === 'stopStudy' ? true : button.hidden; if (action === 'stop') { button.hidden = !busy; button.disabled = !busy; } else { button.disabled = !busy; } return; } button.disabled = !!busy; }); if (settingsEl) settingsEl.dataset.busy = busy ? '1' : '0'; }, }; } // ======================= 答案归一化 ======================= 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); } // ======================= 学习通字体解密(font-cxsecret)======================= // // 学习通把题干里的部分汉字换成「另一个字 + 私有字体」:页面上看着是原字, // 但 DOM 里拿到的是乱码。做法是把私有字体里的字形渲染成点阵,与官方特征表 // 逐字比对(先粗 16x16 取候选,再细 32x32 定案),从而还原真实字符。 // 算法来自 OCS(online-course-script, MIT)的 font-decrypt 模块,此处精简移植: // 只保留主线程路径,去掉了 Worker 与 ETag 协商缓存。 const FontDecrypt = (() => { const RENDER = 360; // 渲染字号,必须与特征表生成时一致 const SQ = 480; // 画布边长 const BASELINE = 384; const SHORTLIST = 40; // 粗筛候选数 const INK_THRESHOLD = 40; const CACHE_NAME = 'cxai-font-ref-v1'; const TABLE_LIMIT = 30 * 60 * 1000; // 内存缓存半小时 let renderCtx = null; const coverageCache = new Map(); const tableCache = new Map(); function getCtx() { if (!renderCtx) { const canvas = document.createElement('canvas'); canvas.width = SQ; canvas.height = SQ; renderCtx = canvas.getContext('2d', { willReadFrequently: true }); } return renderCtx; } /** 判断该字符是否被私有字体覆盖(没覆盖就说明是正常字,不用解密) */ function isCoveredByFont(family, ch) { const key = family + '\u0000' + ch; if (coverageCache.has(key)) return coverageCache.get(key); const render = (fallback) => { const canvas = document.createElement('canvas'); canvas.width = 200; canvas.height = 200; const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.clearRect(0, 0, 200, 200); ctx.fillStyle = '#fff'; ctx.font = `120px "${family}", ${fallback}`; ctx.textBaseline = 'middle'; ctx.fillText(ch, 10, 100); return ctx.getImageData(0, 0, 200, 200).data; }; const a = render('serif'); const b = render('sans-serif'); let diff = 0; for (let i = 3; i < a.length; i += 4) diff += Math.abs(a[i] - b[i]); const covered = diff < 1000; coverageCache.set(key, covered); return covered; } /** 把字符渲染成粗/细两级二值点阵 */ function renderGlyph(family, ch, table) { const ctx = getCtx(); ctx.clearRect(0, 0, SQ, SQ); ctx.fillStyle = '#fff'; ctx.font = `${RENDER}px "${family}"`; ctx.textBaseline = 'alphabetic'; const advance = ctx.measureText(ch).width; ctx.fillText(ch, (SQ - advance) / 2, BASELINE); const px = ctx.getImageData(0, 0, SQ, SQ).data; let hasInk = false; for (let i = 3; i < px.length; i += 4) { if (px[i] > INK_THRESHOLD) { hasInk = true; break; } } if (!hasInk) return null; // 积分图,便于任意网格快速求和 const W = SQ + 1; const ii = new Uint32Array(W * W); for (let y = 0; y < SQ; y += 1) { let rowSum = 0; const srcRow = y * SQ * 4; const dstRow = (y + 1) * W; const upRow = y * W; for (let x = 0; x < SQ; x += 1) { rowSum += px[srcRow + x * 4 + 3]; ii[dstRow + x + 1] = ii[upRow + x + 1] + rowSum; } } const box = (n) => { const b = SQ / n; const area = b * b; const out = new Uint8Array(n * n); for (let gy = 0; gy < n; gy += 1) { for (let gx = 0; gx < n; gx += 1) { const y0 = gy * b; const x0 = gx * b; const sum = ii[(y0 + b) * W + x0 + b] - ii[y0 * W + x0 + b] - ii[(y0 + b) * W + x0] + ii[y0 * W + x0]; out[gy * n + gx] = sum / area > table.threshold ? 1 : 0; } } return out; }; return { coarse: box(table.gc), fine: box(table.gf) }; } function bitDistance(arr, idx, dims, query) { let s = 0; const base = idx * dims; for (let i = 0; i < dims; i += 1) { if (arr[base + i] !== query[i]) s += 1; } return s; } function crackChar(table, feat) { const { count, cps, coarse, fine, gc, gf } = table; const dists = new Float64Array(count); for (let i = 0; i < count; i += 1) { dists[i] = bitDistance(coarse, i, gc * gc, feat.coarse); } const sorted = Float64Array.from(dists); sorted.sort(); const cutoff = sorted[Math.min(SHORTLIST, count) - 1]; const candidates = []; for (let i = 0; i < count; i += 1) { if (dists[i] <= cutoff) candidates.push(i); } let bestIndex = -1; let best = Infinity; let second = Infinity; for (const i of candidates) { const d = bitDistance(fine, i, gf * gf, feat.fine); if (d < best) { second = best; best = d; bestIndex = i; } else if (d < second) { second = d; } } return { ch: String.fromCodePoint(cps[bestIndex]), margin: second > 0 ? 1 - best / second : 1, }; } function unpackBits(packed, offset, count, dims) { const bytesPerGlyph = dims / 8; const out = new Uint8Array(count * dims); for (let i = 0; i < count; i += 1) { const pBase = offset + i * bytesPerGlyph; const oBase = i * dims; for (let b = 0; b < bytesPerGlyph; b += 1) { const byte = packed[pBase + b]; for (let k = 0; k < 8; k += 1) { out[oBase + b * 8 + k] = (byte >> (7 - k)) & 1; } } } return out; } /** 特征表格式 FRB2:magic + count/threshold/gc/gf + 码点表 + 粗细点阵 */ function parseRefTable(buffer) { const dv = new DataView(buffer); const magic = String.fromCharCode( dv.getUint8(0), dv.getUint8(1), dv.getUint8(2), dv.getUint8(3) ); if (magic !== 'FRB2') throw new Error(`特征表格式无效:${magic}`); const count = dv.getUint32(4, true); const threshold = dv.getUint32(8, true); const gc = dv.getUint32(12, true); const gf = dv.getUint32(16, true); const cps = new Uint32Array(count); let off = 20; for (let i = 0; i < count; i += 1, off += 4) cps[i] = dv.getUint32(off, true); const packed = new Uint8Array(buffer); const coarse = unpackBits(packed, off, count, gc * gc); off += (count * gc * gc) / 8; const fine = unpackBits(packed, off, count, gf * gf); return { count, cps, coarse, fine, gc, gf, threshold }; } async function readCachedBytes(url) { try { if (typeof caches === 'undefined') return null; const cache = await caches.open(CACHE_NAME); const res = await cache.match(url); if (!res) return null; return new Uint8Array(await res.arrayBuffer()); } catch { return null; } } async function writeCachedBytes(url, bytes) { try { if (typeof caches === 'undefined') return; const cache = await caches.open(CACHE_NAME); await cache.put(url, new Response(bytes)); } catch { // 缓存失败无所谓 } } function download(url, timeoutMs) { return new Promise((resolve, reject) => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const fail = (err) => { clearTimeout(timer); reject(err); }; fetch(url, { signal: controller.signal }) .then(async (res) => { clearTimeout(timer); if (!res.ok) throw new Error(`HTTP ${res.status}`); resolve(new Uint8Array(await res.arrayBuffer())); }) .catch((err) => { // fetch 被 CORS/网络拦掉时,用 GM 请求兜底(如果有权限) if (typeof GM_xmlhttpRequest !== 'function') return fail(err); GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', timeout: timeoutMs, onload: (res) => { if (res.status >= 200 && res.status < 300) { clearTimeout(timer); resolve(new Uint8Array(res.response)); } else { fail(new Error(`HTTP ${res.status}`)); } }, onerror: () => fail(err), ontimeout: () => fail(new Error('特征表请求超时')), }); }); }); } async function loadTable(url, timeoutMs) { const cached = tableCache.get(url); if (cached && Date.now() - cached.at < TABLE_LIMIT) return cached.table; const promise = (async () => { let bytes = await readCachedBytes(url); if (!bytes) { bytes = await download(url, timeoutMs); await writeCachedBytes(url, bytes); } const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); return parseRefTable(buffer); })(); tableCache.set(url, { at: Date.now(), table: promise }); try { return await promise; } catch (err) { tableCache.delete(url); throw err; } } /** 从内联