// ==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)}
` : '