// ==UserScript==
// @name 超星章节测验 · AI 答题助手(DeepSeek / 硅基流动)
// @namespace chaoxing-work-helper
// @version 2.6.0
// @description 【增强版 v2】自动提取超星学习通"章节测验"页面的题目(含 font-cxsecret 字体混淆还原,异步字形识别不卡顿),调用 DeepSeek 或硅基流动(SiliconFlow)大模型生成答案,逐题标注并可选自动勾选。模型名称由用户在面板自填,不内置任何模型名。v2.3 科技风面板;v2.4 题目与选项展示区 + 一键复制;v2.5 修复换章节串题与流式显示;v2.6 答案徽章改为醒目橙/红 + 新增多套界面风格(青靛 / 科技·暗夜 / 科技·极光)。
// @author you
// @license MIT
// @match *://*.chaoxing.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_setClipboard
// @run-at document-idle
// @noframes
// ==/UserScript==
/*
* 使用说明:
* 1. 浏览器安装 Tampermonkey(油猴)扩展,新建脚本粘贴本文件内容保存。
* 2. 打开超星学习通任意"章节测验"页面(脚本 @match 全站,任意课程/章节均可)。
* 3. 页面右下角默认展开工具面板(点标题栏"—"可最小化为圆形悬浮图标,点图标恢复)。
* 在面板"⚙ 模型设置"中:
* - 选择平台(DeepSeek / 硅基流动)
* - 填入 API Key(DeepSeek: https://platform.deepseek.com 申请;
* 硅基流动: https://cloud.siliconflow.cn 申请)
* - 填写模型名称(不内置模型名,请自行填写,如 deepseek-chat、
* deepseek-ai/DeepSeek-V3、Qwen/Qwen2.5-7B-Instruct 等)
* - 可先点"测试连接"验证 Key
* 4. 面板中部是"当前进度"大区(题号进度 + 耗时 + 进度条),下方是一个
* 智能主按钮:未提取时显示"① 提取题目",提取完成后自动变为"② 开始答题",
* 答题中点击即暂停、再点继续,全部完成后变为"重新答题"。全程进度、
* 当前正在处理的题号与已等待时长实时可见。
* 5. 答题过程中,"实时思考流"窗口会滚动显示模型正在生成的思考/答案文字
* (若服务端不支持流式,也会按秒刷新"第N题 · Xs · N字"的心跳,确保你
* 能看出脚本正在工作)。每题旁会显示答案徽章(含置信度与简要理由);
* 开启"自动勾选"后,生成的答案会自动选中对应选项。确认无误后再手动提交。
* 6. 面板底部操作区:复制答案 / 清空标注 / 模型设置;模型设置内可切平台、
* 填 Key 与模型名、测试连接。面板可拖动并记忆位置,支持深色模式。
* 7. v2.4 新增「提取的题目」区:提取完成后,面板会列出全部识别到的题目与
* 选项,点该区右上角"复制题目"可一键复制"题号+题型+题干+选项"纯文本,
* 方便粘贴到别处核对或另作他用。
* 8. v2.5:切换章节测验后,面板会自动发现题目已变化,清空上一章的题目列表;
* 实时窗口改为分阶段提示(已发送 / 等待模型首字 / 模型思考中 / 接收中),
* 并在"模型设置"中提供"流式输出"开关(关掉即整包返回)。
* 9. v2.6:① 题目旁的"AI 答案"徽章改为醒目的橙/红配色(橙色=正常,红色=置信度
* 偏低或答案越界),字号加大、左侧加粗色条,一眼就能看到;
* ② 新增多套界面风格:顶栏"◑"按钮可一键循环切换,"模型设置"里也能直接选:
* - 青靛(默认):原有配色,跟随系统明暗;
* - 科技·暗夜:深色底 + 荧光青/紫 + 网格纹理;
* - 科技·极光:浅色底 + 天蓝/紫科技配色。
* 选择会被记住,下次打开自动沿用。
*
* 字体混淆还原说明:
* - 快路径:内置已验证的混淆字→明文映射表,字体对得上时秒级还原(当前超星
* 多数课程使用同一套混淆字体,通常直接命中);
* - 通用路径:遇到没见过的混淆字体,脚本异步解析页面内嵌 TTF、光栅化字形并与
* GB2312 一级汉字匹配,分片执行不卡顿(首次约数秒,有进度条);该路径为
* "形近字近似匹配",可能把部分字识别成形近字;
* - 语义兜底:通用路径的错字多为形近字,调用大模型时会明确提示其结合字形、
* 上下文与选项推断题意,由模型完成最终纠错。
*
* v2 增强:
* - 答案本地缓存(同题不重复问模型)、失败自动重试(指数退避)+ 未答题汇总;
* - 答案按置信度分级配色,越界答案标红提醒;模型漏回时按题号/顺序兜底匹配;
* - 解析结果复用;面板可拖动并记忆位置,支持深色模式,新增"复制答案"按钮;
* - 字形候选库持久缓存(IndexedDB),刷新后无需重算。
* - v2.1:流式输出,答题时实时显示模型吐字与思考过程;面板 UI 升级为科技风(玻璃拟态)。
* - v2.2:改回串行(并发实测易触发限流反而更慢);新增按秒心跳与每批耗时,杜绝"卡住"错觉。
* - v2.3:面板全面重构——状态胶囊 + 进度英雄区 + 单一智能主按钮 + 实时思考流,
* 以"看得见的进度"为核心,一屏内完成提取→答题→暂停→重答全流程。
* - v2.4:新增「提取的题目」独立展示区(纯展示题目与选项,可滚动),
* 并提供"复制题目"一键复制按钮(题号+题型+题干+选项)。
* - v2.5:① 解析缓存增加"题目集合指纹",切换章节后不会误用上一章的题目;
* ② 新增题目变化监听,切换章节自动清空列表;
* ③ 流式请求不再带 response_format=json_object(部分服务在该模式下
* 会攒满整包再返回,导致看不到实时吐字),并加 Accept-Encoding: identity;
* ④ 实时窗口改为分阶段状态提示 + 新增"流式输出"开关。
* - v2.6:① 答案徽章改为醒目橙/红配色(橙=正常,红=低置信度或越界),
* 加粗左侧色条 + 大号答案字 + 置信度胶囊;
* ② 新增界面风格系统(青靛 / 科技·暗夜 / 科技·极光),
* 顶栏 ◑ 快捷循环切换,设置内可选,选择持久化。
*
* 注意:本脚本仅辅助学习自查,不自动提交作业;请遵守平台规则与学术诚信要求。
*/
(function () {
'use strict';
// ============================================================
// 0. 常量
// ============================================================
var PLATFORMS = {
deepseek: {
label: 'DeepSeek',
baseUrl: 'https://api.deepseek.com',
hint: '模型示例:deepseek-chat / deepseek-reasoner'
},
siliconflow: {
label: '硅基流动 SiliconFlow',
baseUrl: 'https://api.siliconflow.cn/v1',
hint: '模型示例:deepseek-ai/DeepSeek-V3、Qwen/Qwen2.5-7B-Instruct'
}
};
var CFG = {
platform: 'cxai_platform',
apiKey: 'cxai_apiKey',
baseUrl: 'cxai_baseUrl',
model: 'cxai_model',
system: 'cxai_system',
autoFill: 'cxai_autoFill',
streamOn: 'cxai_streamOn'
};
// v2:集中管理的可调参数(原散落在各处)
var CONFIG = {
BATCH: 5, // 每批题目数
CONCURRENCY: 1, // 并发请求数(1=串行最稳;实测并发易触发限流反而更慢,故默认 1)
TIMEOUT: 120000, // 单次请求超时(ms)
TEST_TIMEOUT: 30000, // 测试连接超时(ms)
MAX_RETRY: 2, // 失败重试次数(v2 新增)
RETRY_DELAY: 1200, // 重试基础延迟(ms),指数退避
RASTER_N: 256, // 字形光栅化画布尺寸
FEAT_N: 64, // 归一化特征网格
CAND_BATCH: 150, // 候选库每批渲染字数
SIM_THRESHOLD: 0.4, // 字形匹配相似度阈值
LOG_MAX: 200, // 日志最多保留条数(v2 新增)
REUSE_MS: 30000, // 解析结果复用有效期(ms)(v2 新增)
CACHE_MAX: 800, // 答案缓存最多条数(v2 新增)
ANSWER_CACHE_KEY: 'cxai_answerCache', // 答案缓存存储键
PANEL_POS_KEY: 'cxai_panelPos' // 面板位置存储键
};
var DEFAULT_SYSTEM = [
'你是一名严谨的大学课程测验答题助手。',
'用户会提供若干道题目(含题干、选项、题型),请给出最合理的答案。',
'硬性要求:',
'1. 只返回一个 JSON 对象,不要输出任何解释、Markdown 或多余文字。',
'2. JSON 格式为:{"answers":[{"id":"题目ID","answer":"选项字母,如 A 或 A,C","confidence":0.9,"reason":"一句话理由"}]}',
'3. 单选题/判断题只给一个选项字母;多选题用逗号分隔多个字母(如 "A,C")。',
'4. 判断题选项通常为"正确/错误",请映射为对应字母 A 或 B。',
'5. 题干和选项可能存在较多形近错别字(来自页面字体混淆的自动还原,错字多为形近字,如"圃"可能是"国"、"锐"可能是"说"),请结合字形、上下文、选项与常识推断题意,不要机械照抄错字。',
'6. 无法确定的题目给出最可能的答案并降低 confidence(如 0.5)。'
].join('\n');
var ANSWER_TYPES = { '0': '单选题', '1': '多选题', '2': '判断题', '3': '填空题', '4': '简答题' };
// v2.6:界面风格(至少两套:现有青靛 + 科技风配色)
var THEMES = [
{ id: 'qingdian', label: '青靛(默认)' },
{ id: 'tech', label: '科技·暗夜' },
{ id: 'aurora', label: '科技·极光' }
];
var THEME_KEY = 'cxai_theme';
var _themeId = 'qingdian';
// ============================================================
// 1. 配置存取(GM 存储,Key 不写入页面)
// ============================================================
function loadConfig() {
var cfg = {
platform: GM_getValue(CFG.platform, 'deepseek'),
apiKey: GM_getValue(CFG.apiKey, ''),
baseUrl: GM_getValue(CFG.baseUrl, PLATFORMS.deepseek.baseUrl),
model: GM_getValue(CFG.model, ''),
system: GM_getValue(CFG.system, DEFAULT_SYSTEM),
autoFill: GM_getValue(CFG.autoFill, false),
streamOn: GM_getValue(CFG.streamOn, true)
};
var plat = PLATFORMS[cfg.platform] || PLATFORMS.deepseek;
if (cfg.baseUrl === PLATFORMS.deepseek.baseUrl && cfg.platform === 'siliconflow') {
cfg.baseUrl = PLATFORMS.siliconflow.baseUrl;
}
if (cfg.baseUrl === PLATFORMS.siliconflow.baseUrl && cfg.platform === 'deepseek') {
cfg.baseUrl = PLATFORMS.deepseek.baseUrl;
}
return cfg;
}
function saveConfig(cfg) {
GM_setValue(CFG.platform, cfg.platform);
GM_setValue(CFG.apiKey, cfg.apiKey);
GM_setValue(CFG.baseUrl, cfg.baseUrl);
GM_setValue(CFG.model, cfg.model);
GM_setValue(CFG.system, cfg.system);
GM_setValue(CFG.autoFill, !!cfg.autoFill);
GM_setValue(CFG.streamOn, cfg.streamOn === undefined ? true : !!cfg.streamOn);
}
// ============================================================
// 2. 工具:定位题目所在 document(同源 iframe 链遍历)
// ============================================================
function findWorkDocuments() {
var results = [];
(function walk(win) {
try {
var d = win.document;
if (d && d.querySelectorAll && d.querySelectorAll('.TiMu').length > 0) {
results.push(d);
}
} catch (e) { /* 跨域 frame 忽略 */ }
try {
for (var i = 0; i < win.frames.length; i++) {
walk(win.frames[i]);
}
} catch (e) { /* 忽略 */ }
})(window.top);
return results;
}
// ============================================================
// 3. 字体混淆还原(快路径静态映射 + 通用路径运行时字形识别)
// ============================================================
// 3.0 内置静态解码映射表
// 来源:已用 fontTools + PIL 字形匹配并在真实页面验证(课程 1.1 章节测验)。
// 其中 坅→犬、坉→侗 两处按题干上下文验证修正为 大、们。
var CXSECRET_MAP = {
'\u571D': '关', '\u571E': '中', '\u5720': '说', '\u5721': '以', '\u5722': '下',
'\u5724': '形', '\u5725': '错', '\u5726': '平', '\u5727': '者', '\u572A': '界',
'\u572B': '世', '\u572C': '建', '\u572E': '和', '\u572F': '展', '\u5731': '球',
'\u5732': '全', '\u5733': '贡', '\u5734': '发', '\u5735': '序', '\u5736': '际',
'\u5738': '国', '\u573B': '秩', '\u573C': '融', '\u5741': '局', '\u5745': '大',
'\u5746': '个', '\u5748': '指', '\u5749': '们', '\u574B': '社', '\u574C': '史',
'\u574D': '阶', '\u5752': '历', '\u57E2': '主', '\u58D9': '维', '\u6B6E': '两',
'\u6C78': '是', '\u9D2A': '金'
};
// 3.1 候选字库:GB2312 一级汉字 3755 个(用于运行时字形匹配)
var GB2312_L1 = '啊阿埃挨哎唉哀皑癌蔼矮艾碍爱隘鞍氨安俺按暗岸胺案肮昂盎凹敖熬翱袄傲奥懊澳芭捌扒叭吧笆八疤巴拔跋靶把耙坝霸罢爸白柏百摆佰败拜稗斑班搬扳般颁板版扮拌伴瓣半办绊邦帮梆榜膀绑棒磅蚌镑傍谤苞胞包褒剥薄雹保堡饱宝抱报暴豹鲍爆杯碑悲卑北辈背贝钡倍狈备惫焙被奔苯本笨崩绷甭泵蹦迸逼鼻比鄙笔彼碧蓖蔽毕毙毖币庇痹闭敝弊必辟壁臂避陛鞭边编贬扁便变卞辨辩辫遍标彪膘表鳖憋别瘪彬斌濒滨宾摈兵冰柄丙秉饼炳病并玻菠播拨钵波博勃搏铂箔伯帛舶脖膊渤泊驳捕卜哺补埠不布步簿部怖擦猜裁材才财睬踩采彩菜蔡餐参蚕残惭惨灿苍舱仓沧藏操糙槽曹草厕策侧册测层蹭插叉茬茶查碴搽察岔差诧拆柴豺搀掺蝉馋谗缠铲产阐颤昌猖场尝常长偿肠厂敞畅唱倡超抄钞朝嘲潮巢吵炒车扯撤掣彻澈郴臣辰尘晨忱沉陈趁衬撑称城橙成呈乘程惩澄诚承逞骋秤吃痴持匙池迟弛驰耻齿侈尺赤翅斥炽充冲虫崇宠抽酬畴踌稠愁筹仇绸瞅丑臭初出橱厨躇锄雏滁除楚础储矗搐触处揣川穿椽传船喘串疮窗幢床闯创吹炊捶锤垂春椿醇唇淳纯蠢戳绰疵茨磁雌辞慈瓷词此刺赐次聪葱囱匆从丛凑粗醋簇促蹿篡窜摧崔催脆瘁粹淬翠村存寸磋撮搓措挫错搭达答瘩打大呆歹傣戴带殆代贷袋待逮怠耽担丹单郸掸胆旦氮但惮淡诞弹蛋当挡党荡档刀捣蹈倒岛祷导到稻悼道盗德得的蹬灯登等瞪凳邓堤低滴迪敌笛狄涤翟嫡抵底地蒂第帝弟递缔颠掂滇碘点典靛垫电佃甸店惦奠淀殿碉叼雕凋刁掉吊钓调跌爹碟蝶迭谍叠丁盯叮钉顶鼎锭定订丢东冬董懂动栋侗恫冻洞兜抖斗陡豆逗痘都督毒犊独读堵睹赌杜镀肚度渡妒端短锻段断缎堆兑队对墩吨蹲敦顿囤钝盾遁掇哆多夺垛躲朵跺舵剁惰堕蛾峨鹅俄额讹娥恶厄扼遏鄂饿恩而儿耳尔饵洱二贰发罚筏伐乏阀法珐藩帆番翻樊矾钒繁凡烦反返范贩犯饭泛坊芳方肪房防妨仿访纺放菲非啡飞肥匪诽吠肺废沸费芬酚吩氛分纷坟焚汾粉奋份忿愤粪丰封枫蜂峰锋风疯烽逢冯缝讽奉凤佛否夫敷肤孵扶拂辐幅氟符伏俘服浮涪福袱弗甫抚辅俯釜斧脯腑府腐赴副覆赋复傅付阜父腹负富讣附妇缚咐噶嘎该改概钙盖溉干甘杆柑竿肝赶感秆敢赣冈刚钢缸肛纲岗港杠篙皋高膏羔糕搞镐稿告哥歌搁戈鸽胳疙割革葛格蛤阁隔铬个各给根跟耕更庚羹埂耿梗工攻功恭龚供躬公宫弓巩汞拱贡共钩勾沟苟狗垢构购够辜菇咕箍估沽孤姑鼓古蛊骨谷股故顾固雇刮瓜剐寡挂褂乖拐怪棺关官冠观管馆罐惯灌贯光广逛瑰规圭硅归龟闺轨鬼诡癸桂柜跪贵刽辊滚棍锅郭国果裹过哈骸孩海氦亥害骇酣憨邯韩含涵寒函喊罕翰撼捍旱憾悍焊汗汉夯杭航壕嚎豪毫郝好耗号浩呵喝荷菏核禾和何合盒貉阂河涸赫褐鹤贺嘿黑痕很狠恨哼亨横衡恒轰哄烘虹鸿洪宏弘红喉侯猴吼厚候后呼乎忽瑚壶葫胡蝴狐糊湖弧虎唬护互沪户花哗华猾滑画划化话槐徊怀淮坏欢环桓还缓换患唤痪豢焕涣宦幻荒慌黄磺蝗簧皇凰惶煌晃幌恍谎灰挥辉徽恢蛔回毁悔慧卉惠晦贿秽会烩汇讳诲绘荤昏婚魂浑混豁活伙火获或惑霍货祸击圾基机畸稽积箕肌饥迹激讥鸡姬绩缉吉极棘辑籍集及急疾汲即嫉级挤几脊己蓟技冀季伎祭剂悸济寄寂计记既忌际妓继纪嘉枷夹佳家加荚颊贾甲钾假稼价架驾嫁歼监坚尖笺间煎兼肩艰奸缄茧检柬碱硷拣捡简俭剪减荐槛鉴践贱见键箭件健舰剑饯渐溅涧建僵姜将浆江疆蒋桨奖讲匠酱降蕉椒礁焦胶交郊浇骄娇嚼搅铰矫侥脚狡角饺缴绞剿教酵轿较叫窖揭接皆秸街阶截劫节桔杰捷睫竭洁结解姐戒藉芥界借介疥诫届巾筋斤金今津襟紧锦仅谨进靳晋禁近烬浸尽劲荆兢茎睛晶鲸京惊精粳经井警景颈静境敬镜径痉靖竟竞净炯窘揪究纠玖韭久灸九酒厩救旧臼舅咎就疚鞠拘狙疽居驹菊局咀矩举沮聚拒据巨具距踞锯俱句惧炬剧捐鹃娟倦眷卷绢撅攫抉掘倔爵觉决诀绝均菌钧军君峻俊竣浚郡骏喀咖卡咯开揩楷凯慨刊堪勘坎砍看康慷糠扛抗亢炕考拷烤靠坷苛柯棵磕颗科壳咳可渴克刻客课肯啃垦恳坑吭空恐孔控抠口扣寇枯哭窟苦酷库裤夸垮挎跨胯块筷侩快宽款匡筐狂框矿眶旷况亏盔岿窥葵奎魁傀馈愧溃坤昆捆困括扩廓阔垃拉喇蜡腊辣啦莱来赖蓝婪栏拦篮阑兰澜谰揽览懒缆烂滥琅榔狼廊郎朗浪捞劳牢老佬姥酪烙涝勒乐雷镭蕾磊累儡垒擂肋类泪棱楞冷厘梨犁黎篱狸离漓理李里鲤礼莉荔吏栗丽厉励砾历利傈例俐痢立粒沥隶力璃哩俩联莲连镰廉怜涟帘敛脸链恋炼练粮凉梁粱良两辆量晾亮谅撩聊僚疗燎寥辽潦了撂镣廖料列裂烈劣猎琳林磷霖临邻鳞淋凛赁吝拎玲菱零龄铃伶羚凌灵陵岭领另令溜琉榴硫馏留刘瘤流柳六龙聋咙笼窿隆垄拢陇楼娄搂篓漏陋芦卢颅庐炉掳卤虏鲁麓碌露路赂鹿潞禄录陆戮驴吕铝侣旅履屡缕虑氯律率滤绿峦挛孪滦卵乱掠略抡轮伦仑沦纶论萝螺罗逻锣箩骡裸落洛骆络妈麻玛码蚂马骂嘛吗埋买麦卖迈脉瞒馒蛮满蔓曼慢漫谩芒茫盲氓忙莽猫茅锚毛矛铆卯茂冒帽貌贸么玫枚梅酶霉煤没眉媒镁每美昧寐妹媚门闷们萌蒙檬盟锰猛梦孟眯醚靡糜迷谜弥米秘觅泌蜜密幂棉眠绵冕免勉娩缅面苗描瞄藐秒渺庙妙蔑灭民抿皿敏悯闽明螟鸣铭名命谬摸摹蘑模膜磨摩魔抹末莫墨默沫漠寞陌谋牟某拇牡亩姆母墓暮幕募慕木目睦牧穆拿哪呐钠那娜纳氖乃奶耐奈南男难囊挠脑恼闹淖呢馁内嫩能妮霓倪泥尼拟你匿腻逆溺蔫拈年碾撵捻念娘酿鸟尿捏聂孽啮镊镍涅您柠狞凝宁拧泞牛扭钮纽脓浓农弄奴努怒女暖虐疟挪懦糯诺哦欧鸥殴藕呕偶沤啪趴爬帕怕琶拍排牌徘湃派攀潘盘磐盼畔判叛乓庞旁耪胖抛咆刨炮袍跑泡呸胚培裴赔陪配佩沛喷盆砰抨烹澎彭蓬棚硼篷膨朋鹏捧碰坯砒霹批披劈琵毗啤脾疲皮匹痞僻屁譬篇偏片骗飘漂瓢票撇瞥拼频贫品聘乒坪苹萍平凭瓶评屏坡泼颇婆破魄迫粕剖扑铺仆莆葡菩蒲埔朴圃普浦谱曝瀑期欺栖戚妻七凄漆柒沏其棋奇歧畦崎脐齐旗祈祁骑起岂乞企启契砌器气迄弃汽泣讫掐恰洽牵扦钎铅千迁签仟谦乾黔钱钳前潜遣浅谴堑嵌欠歉枪呛腔羌墙蔷强抢橇锹敲悄桥瞧乔侨巧鞘撬翘峭俏窍切茄且怯窃钦侵亲秦琴勤芹擒禽寝沁青轻氢倾卿清擎晴氰情顷请庆琼穷秋丘邱球求囚酋泅趋区蛆曲躯屈驱渠取娶龋趣去圈颧权醛泉全痊拳犬券劝缺炔瘸却鹊榷确雀裙群然燃冉染瓤壤攘嚷让饶扰绕惹热壬仁人忍韧任认刃妊纫扔仍日戎茸蓉荣融熔溶容绒冗揉柔肉茹蠕儒孺如辱乳汝入褥软阮蕊瑞锐闰润若弱撒洒萨腮鳃塞赛三叁伞散桑嗓丧搔骚扫嫂瑟色涩森僧莎砂杀刹沙纱傻啥煞筛晒珊苫杉山删煽衫闪陕擅赡膳善汕扇缮墒伤商赏晌上尚裳梢捎稍烧芍勺韶少哨邵绍奢赊蛇舌舍赦摄射慑涉社设砷申呻伸身深娠绅神沈审婶甚肾慎渗声生甥牲升绳省盛剩胜圣师失狮施湿诗尸虱十石拾时什食蚀实识史矢使屎驶始式示士世柿事拭誓逝势是嗜噬适仕侍释饰氏市恃室视试收手首守寿授售受瘦兽蔬枢梳殊抒输叔舒淑疏书赎孰熟薯暑曙署蜀黍鼠属术述树束戍竖墅庶数漱恕刷耍摔衰甩帅栓拴霜双爽谁水睡税吮瞬顺舜说硕朔烁斯撕嘶思私司丝死肆寺嗣四伺似饲巳松耸怂颂送宋讼诵搜艘擞嗽苏酥俗素速粟僳塑溯宿诉肃酸蒜算虽隋随绥髓碎岁穗遂隧祟孙损笋蓑梭唆缩琐索锁所塌他它她塔獭挞蹋踏胎苔抬台泰酞太态汰坍摊贪瘫滩坛檀痰潭谭谈坦毯袒碳探叹炭汤塘搪堂棠膛唐糖倘躺淌趟烫掏涛滔绦萄桃逃淘陶讨套特藤腾疼誊梯剔踢锑提题蹄啼体替嚏惕涕剃屉天添填田甜恬舔腆挑条迢眺跳贴铁帖厅听烃汀廷停亭庭挺艇通桐酮瞳同铜彤童桶捅筒统痛偷投头透凸秃突图徒途涂屠土吐兔湍团推颓腿蜕褪退吞屯臀拖托脱鸵陀驮驼椭妥拓唾挖哇蛙洼娃瓦袜歪外豌弯湾玩顽丸烷完碗挽晚皖惋宛婉万腕汪王亡枉网往旺望忘妄威巍微危韦违桅围唯惟为潍维苇萎委伟伪尾纬未蔚味畏胃喂魏位渭谓尉慰卫瘟温蚊文闻纹吻稳紊问嗡翁瓮挝蜗涡窝我斡卧握沃巫呜钨乌污诬屋无芜梧吾吴毋武五捂午舞伍侮坞戊雾晤物勿务悟误昔熙析西硒矽晰嘻吸锡牺稀息希悉膝夕惜熄烯溪汐犀檄袭席习媳喜铣洗系隙戏细瞎虾匣霞辖暇峡侠狭下厦夏吓掀锨先仙鲜纤咸贤衔舷闲涎弦嫌显险现献县腺馅羡宪陷限线相厢镶香箱襄湘乡翔祥详想响享项巷橡像向象萧硝霄削哮嚣销消宵淆晓小孝校肖啸笑效楔些歇蝎鞋协挟携邪斜胁谐写械卸蟹懈泄泻谢屑薪芯锌欣辛新忻心信衅星腥猩惺兴刑型形邢行醒幸杏性姓兄凶胸匈汹雄熊休修羞朽嗅锈秀袖绣墟戌需虚嘘须徐许蓄酗叙旭序畜恤絮婿绪续轩喧宣悬旋玄选癣眩绚靴薛学穴雪血勋熏循旬询寻驯巡殉汛训讯逊迅压押鸦鸭呀丫芽牙蚜崖衙涯雅哑亚讶焉咽阉烟淹盐严研蜒岩延言颜阎炎沿奄掩眼衍演艳堰燕厌砚雁唁彦焰宴谚验殃央鸯秧杨扬佯疡羊洋阳氧仰痒养样漾邀腰妖瑶摇尧遥窑谣姚咬舀药要耀椰噎耶爷野冶也页掖业叶曳腋夜液一壹医揖铱依伊衣颐夷遗移仪胰疑沂宜姨彝椅蚁倚已乙矣以艺抑易邑屹亿役臆逸肄疫亦裔意毅忆义益溢诣议谊译异翼翌绎茵荫因殷音阴姻吟银淫寅饮尹引隐印英樱婴鹰应缨莹萤营荧蝇迎赢盈影颖硬映哟拥佣臃痈庸雍踊蛹咏泳涌永恿勇用幽优悠忧尤由邮铀犹油游酉有友右佑釉诱又幼迂淤于盂榆虞愚舆余俞逾鱼愉渝渔隅予娱雨与屿禹宇语羽玉域芋郁吁遇喻峪御愈欲狱育誉浴寓裕预豫驭鸳渊冤元垣袁原援辕园员圆猿源缘远苑愿怨院曰约越跃钥岳粤月悦阅耘云郧匀陨允运蕴酝晕韵孕匝砸杂栽哉灾宰载再在咱攒暂赞赃脏葬遭糟凿藻枣早澡蚤躁噪造皂灶燥责择则泽贼怎增憎曾赠扎喳渣札轧铡闸眨栅榨咋乍炸诈摘斋宅窄债寨瞻毡詹粘沾盏斩辗崭展蘸栈占战站湛绽樟章彰漳张掌涨杖丈帐账仗胀瘴障招昭找沼赵照罩兆肇召遮折哲蛰辙者锗蔗这浙珍斟真甄砧臻贞针侦枕疹诊震振镇阵蒸挣睁征狰争怔整拯正政帧症郑证芝枝支吱蜘知肢脂汁之织职直植殖执值侄址指止趾只旨纸志挚掷至致置帜峙制智秩稚质炙痔滞治窒中盅忠钟衷终种肿重仲众舟周州洲诌粥轴肘帚咒皱宙昼骤珠株蛛朱猪诸诛逐竹烛煮拄瞩嘱主著柱助蛀贮铸筑住注祝驻抓爪拽专砖转撰赚篆桩庄装妆撞壮状椎锥追赘坠缀谆准捉拙卓桌琢茁酌啄着灼浊兹咨资姿滋淄孜紫仔籽滓子自渍字鬃棕踪宗综总纵邹走奏揍租足卒族祖诅阻组钻纂嘴醉最罪尊遵昨左佐柞做作坐座';
// 3.2 从 document 的样式表中提取 font-cxsecret 字体(base64 TTF)
function getSecretFontBase64(doc) {
var found = '';
var sheets = doc.styleSheets || [];
for (var i = 0; i < sheets.length; i++) {
var rules;
try { rules = sheets[i].cssRules; } catch (e) { continue; }
if (!rules) continue;
for (var j = 0; j < rules.length; j++) {
var cssText = rules[j] && rules[j].cssText ? rules[j].cssText : '';
if (cssText.indexOf('font-cxsecret') > -1 && cssText.indexOf('base64,') > -1) {
var m = cssText.match(/base64,([A-Za-z0-9+/=]+)/);
if (m && m[1]) { found = m[1]; break; }
}
}
if (found) break;
}
// 兜底:内联 ',
'
AI
',
'',
'
',
'
AI答题助手
',
'
',
' 空闲',
' ◑',
' —',
'
',
'
',
'
',
'
当前进度
',
'
-- / --尚未提取题目
',
'
',
'
① 提取题目
',
'
',
'
',
'
',
' 提取的题目 · 共 0 题',
' ',
'
',
'
',
'
',
'
',
'
',
' ',
' ',
' ',
'
',
'
',
'
',
'
',
'
',
'
',
'
',
'
',
'
',
'
',
'
',
' ',
' ',
'
',
'
',
'
',
'
'
].join('');
document.body.appendChild(root);
var fab = root.querySelector('.cxai-fab');
var panel = root.querySelector('.cxai-panel');
function minimize() { panel.style.display = 'none'; fab.style.display = 'flex'; }
function expand() { panel.style.display = 'block'; fab.style.display = 'none'; }
expand(); // 面板默认展开
fab.addEventListener('click', expand);
root.querySelector('.cxai-min').addEventListener('click', minimize);
// v2:面板可拖动 + 记忆位置
(function enableDrag() {
var saved = null;
try { saved = GM_getValue(CONFIG.PANEL_POS_KEY, null); } catch (e) { saved = null; }
if (saved && typeof saved.x === 'number' && typeof saved.y === 'number') {
root.style.left = saved.x + 'px';
root.style.top = saved.y + 'px';
root.style.right = 'auto';
root.style.bottom = 'auto';
}
var header = root.querySelector('.cxai-top');
var dragging = false, sx = 0, sy = 0, ox = 0, oy = 0;
var _rc = window.top === window.self ? window : window.top;
function onMove(e) {
if (!dragging) return;
var cx = e.touches ? e.touches[0].clientX : e.clientX;
var cy = e.touches ? e.touches[0].clientY : e.clientY;
var nx = Math.max(0, Math.min(window.innerWidth - 60, ox + cx - sx));
var ny = Math.max(0, Math.min(window.innerHeight - 40, oy + cy - sy));
root.style.left = nx + 'px';
root.style.top = ny + 'px';
root.style.right = 'auto';
root.style.bottom = 'auto';
if (e.cancelable) e.preventDefault();
}
function onUp() {
if (!dragging) return;
dragging = false;
try {
GM_setValue(CONFIG.PANEL_POS_KEY, {
x: parseInt(root.style.left, 10) || 0,
y: parseInt(root.style.top, 10) || 0
});
} catch (e) { /* 忽略 */ }
}
function startDrag(cx, cy, e) {
if (e.target && e.target.classList && (e.target.classList.contains('cxai-min') || e.target.classList.contains('cxai-theme'))) return;
dragging = true;
sx = cx; sy = cy;
var r = root.getBoundingClientRect();
ox = r.left; oy = r.top;
if (e.cancelable) e.preventDefault();
}
header.addEventListener('mousedown', function (e) { startDrag(e.clientX, e.clientY, e); });
header.addEventListener('touchstart', function (e) {
startDrag(e.touches[0].clientX, e.touches[0].clientY, e);
}, { passive: false });
_rc.document.addEventListener('mousemove', onMove, true);
_rc.document.addEventListener('mouseup', onUp, true);
_rc.document.addEventListener('touchmove', onMove, { passive: false, capture: true });
_rc.document.addEventListener('touchend', onUp, true);
})();
// 模型设置折叠(v2.3:按钮文案随状态切换)
var settings = root.querySelector('#cxai-settings');
var settingsToggle = root.querySelector('#cxai-settings-toggle');
function toggleSettings(forceOpen) {
var open = forceOpen === undefined ? settings.style.display === 'none' : forceOpen;
settings.style.display = open ? 'block' : 'none';
settingsToggle.textContent = open ? '收起设置' : '模型设置';
}
settingsToggle.addEventListener('click', function () { toggleSettings(); });
root.querySelector('#cxai-save').addEventListener('click', onSave);
root.querySelector('#cxai-test').addEventListener('click', onTest);
root.querySelector('#cxai-cta').addEventListener('click', onCta);
root.querySelector('#cxai-copy').addEventListener('click', onCopy);
root.querySelector('#cxai-clear').addEventListener('click', onClear);
// v2.4:题目列表区的"复制题目"按钮
var qcopyBtn = root.querySelector('#cxai-qcopy');
if (qcopyBtn) qcopyBtn.addEventListener('click', onCopyQuestions);
// v2.6:界面风格切换(顶栏快捷按钮 + 设置内芯片)
var themeBtn = root.querySelector('#cxai-theme-btn');
if (themeBtn) themeBtn.addEventListener('click', cycleTheme);
var themeBox = root.querySelector('#cxai-themes');
if (themeBox) buildThemeChips(themeBox);
var savedTheme = 'qingdian';
try { savedTheme = GM_getValue(THEME_KEY, 'qingdian'); } catch (e) { savedTheme = 'qingdian'; }
applyTheme(savedTheme, true);
var platBox = root.querySelector('#cxai-platforms');
Object.keys(PLATFORMS).forEach(function (key) {
var lb = document.createElement('label');
lb.style.cssText = 'display:inline-flex;align-items:center;gap:4px;margin-right:12px;cursor:pointer;';
lb.innerHTML = ' ' + PLATFORMS[key].label;
lb.querySelector('input').addEventListener('change', function () {
if (this.checked) {
root.querySelector('#cxai-baseurl').value = PLATFORMS[key].baseUrl;
root.querySelector('#cxai-model').placeholder = PLATFORMS[key].hint;
}
});
platBox.appendChild(lb);
});
var cfg = loadConfig();
var checked = root.querySelector('input[name="cxai-platform"][value="' + cfg.platform + '"]');
if (checked) checked.checked = true;
root.querySelector('#cxai-baseurl').value = cfg.baseUrl;
root.querySelector('#cxai-apikey').value = cfg.apiKey;
root.querySelector('#cxai-model').value = cfg.model;
root.querySelector('#cxai-system').value = cfg.system;
root.querySelector('#cxai-autofill').checked = !!cfg.autoFill;
root.querySelector('#cxai-streamon').checked = cfg.streamOn !== false;
root.querySelector('#cxai-model').placeholder = (PLATFORMS[cfg.platform] || PLATFORMS.deepseek).hint;
// 首次使用(未配置 Key)自动展开模型设置
if (!cfg.apiKey) toggleSettings(true);
root.__minimize = minimize;
root.__expand = expand;
return root;
}
function log(msg) {
var root = document.getElementById('cxai-root');
var box = root && root.querySelector('#cxai-log');
if (!box) { console.log('[CXAI]', msg); return; }
var line = document.createElement('div');
line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg;
box.appendChild(line);
// v2:日志上限,避免长会话无限增长导致卡顿
while (box.childNodes.length > CONFIG.LOG_MAX) box.removeChild(box.firstChild);
box.scrollTop = box.scrollHeight;
}
// v2.1:实时输出窗口(显示模型流式吐字 / 思考过程,避免"像没动静")
function clearStream() {
var box = document.getElementById('cxai-stream');
if (!box) return;
while (box.firstChild) box.removeChild(box.firstChild);
box.style.display = 'none';
}
function ensureStreamHead(box) {
var head = box.querySelector('.cxai-stream-head');
if (!head) {
head = document.createElement('div');
head.className = 'cxai-stream-head';
var a = document.createElement('span');
a.textContent = '实时输出';
var b = document.createElement('span');
b.className = 'cxai-stream-meta';
head.appendChild(a);
head.appendChild(b);
box.appendChild(head);
}
return head;
}
function ensureStreamLine(key, label) {
var box = document.getElementById('cxai-stream');
if (!box) return null;
box.style.display = 'block';
ensureStreamHead(box);
var el = box.querySelector('.cxai-stream-line[data-key="' + key + '"]');
if (!el) {
el = document.createElement('div');
el.className = 'cxai-stream-line';
el.setAttribute('data-key', key);
var tag = document.createElement('span');
tag.className = 'cxai-stream-tag';
tag.textContent = label;
var txt = document.createElement('span');
txt.className = 'cxai-stream-txt';
txt.textContent = '等待模型响应…';
el.appendChild(tag);
el.appendChild(txt);
box.appendChild(el);
}
box.scrollTop = box.scrollHeight;
return el;
}
function updateStreamLine(el, text) {
if (!el) return;
var txt = el.querySelector('.cxai-stream-txt');
if (!txt) return;
var s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
if (s.length > 180) s = '…' + s.slice(-180);
txt.textContent = s || '等待模型响应…';
var box = document.getElementById('cxai-stream');
if (box) box.scrollTop = box.scrollHeight;
}
// v2.2:更新实时窗口左侧标签(用于显示"已等待 Xs / 已收 N 字"心跳)
function updateStreamTag(el, text) {
if (el) {
var tag = el.querySelector('.cxai-stream-tag');
if (tag) tag.textContent = text;
}
var meta = document.querySelector('#cxai-stream .cxai-stream-meta');
if (meta) meta.textContent = text || '';
}
// v2.5:若整批都没收到流式分片,只提示一次,说明"这是接口/环境所致,不影响答案"
var _noStreamNoted = false;
function noteNoStream() {
if (_noStreamNoted) return;
_noStreamNoted = true;
log('提示:本次未收到流式增量(接口未推送分片或压缩被缓冲),已直接用整包结果,不影响答案。可在"模型设置"里关闭流式输出。');
}
// v2.4:题目列表区 —— 只呈现提取出的题目与选项,并提供一键复制
var lastItems = []; // 最近一次提取的题目(含 doc/q),供列表与复制复用
function renderQuestionList(items) {
lastItems = items || [];
var box = document.getElementById('cxai-qbox');
var list = document.getElementById('cxai-qlist');
var cnt = document.getElementById('cxai-qcount');
if (!box || !list) return;
if (!lastItems.length) {
box.style.display = 'none';
list.innerHTML = '';
if (cnt) cnt.textContent = '0';
return;
}
if (cnt) cnt.textContent = String(lastItems.length);
var html = '';
for (var i = 0; i < lastItems.length; i++) {
var it = lastItems[i];
var q = (it && it.q) ? it.q : it;
if (!q) continue;
var opts = q.options || [];
html += '';
html += '
' + escapeHtml(q.type || '') + ''
+ escapeHtml(q.index || (i + 1)) + '. ' + escapeHtml(q.stem || '') + '
';
if (opts.length) {
html += '
';
for (var k = 0; k < opts.length; k++) {
html += '
' + escapeHtml((opts[k].key || '') + '. ' + (opts[k].text || '')) + '
';
}
html += '
';
}
html += '
';
}
list.innerHTML = html;
list.scrollTop = 0;
box.style.display = 'block';
}
function clearQuestionList() {
renderQuestionList([]);
}
// 拼装"题目 + 选项"纯文本,供一键复制
function buildQuestionsText(items) {
var lines = [];
for (var i = 0; i < items.length; i++) {
var it = items[i];
var q = (it && it.q) ? it.q : it;
if (!q) continue;
var opts = q.options || [];
lines.push((q.index || (i + 1)) + '. 【' + (q.type || '') + '】' + (q.stem || ''));
for (var k = 0; k < opts.length; k++) {
lines.push(' ' + (opts[k].key || '') + '. ' + (opts[k].text || ''));
}
lines.push('');
}
return lines.join('\n').replace(/\n+$/, '');
}
function onCopyQuestions() {
if (!lastItems.length) { log('暂无可复制的题目,请先点主按钮提取题目。'); return; }
var ok = copyText(buildQuestionsText(lastItems));
if (ok) log('已复制 ' + lastItems.length + ' 题(含选项)到剪贴板。');
else log('复制失败,请手动选中列表内容复制。');
}
// v2.5:监听页面题目变化(切换章节测验等),自动清掉上一章的题目列表与解析缓存
var _watchSig = '';
var _watchPending = '';
function watchQuestionChanges() {
setInterval(function () {
if (ui.phase === 'answering' || ui.phase === 'paused' || ui.phase === 'extracting') return;
var sig;
try { sig = docsSignature(findWorkDocuments()); } catch (e) { return; }
if (!sig) return; // 页面暂无题目,忽略
if (_watchSig === '') { _watchSig = sig; return; } // 首次仅记录,不清空
if (sig === _watchSig) { _watchPending = ''; return; }
if (sig !== _watchPending) { _watchPending = sig; return; } // 连续两轮一致才认定变化,避免误判
_watchSig = sig;
_watchPending = '';
clearQuestionList();
_lastCollect = null; // 强制下次重新解析
ui.phase = 'idle';
ui.total = 0; ui.done = 0;
setStatus('题目已切换', 'warn');
setCta('① 提取题目');
setHero('-- / --', '检测到新题目,请重新提取', 0);
log('检测到页面题目已变化(可能切换了章节测验),已清空上一章的题目列表与缓存。');
}, 4000);
}
function readCfgFromUi() {
var root = document.getElementById('cxai-root');
var platformEl = root.querySelector('input[name="cxai-platform"]:checked');
return {
platform: platformEl ? platformEl.value : 'deepseek',
apiKey: root.querySelector('#cxai-apikey').value.trim(),
baseUrl: root.querySelector('#cxai-baseurl').value.trim(),
model: root.querySelector('#cxai-model').value.trim(),
system: root.querySelector('#cxai-system').value,
autoFill: root.querySelector('#cxai-autofill').checked,
streamOn: root.querySelector('#cxai-streamon').checked
};
}
function validateCfg(cfg) {
if (!cfg.baseUrl) return '请填写 Base URL';
if (!cfg.apiKey) return '请填写 API Key';
if (!cfg.model) return '请填写模型名称(如 deepseek-chat)';
return '';
}
function onSave() {
var cfg = readCfgFromUi();
var err = validateCfg(cfg);
if (err) { log('保存失败:' + err); return; }
saveConfig(cfg);
log('配置已保存 → ' + PLATFORMS[cfg.platform].label + ' / ' + cfg.model);
}
function onTest() {
var cfg = readCfgFromUi();
var err = validateCfg(cfg);
if (err) { log('测试失败:' + err); return; }
var url = cfg.baseUrl.replace(/\/+$/, '') + '/models';
log('正在测试连接 ' + url + ' …');
GM_xmlhttpRequest({
method: 'GET',
url: url,
headers: { 'Authorization': 'Bearer ' + cfg.apiKey },
timeout: CONFIG.TEST_TIMEOUT,
onload: function (res) {
if (res.status >= 200 && res.status < 300) {
var names = [];
try {
var data = JSON.parse(res.responseText);
if (data.data) {
names = data.data.slice(0, 5).map(function (m) { return m.id; });
}
} catch (e) { /* 忽略 */ }
log('连接成功(HTTP ' + res.status + ')' + (names.length ? ',可用模型示例:' + names.join(', ') : ''));
} else {
log('连接失败:HTTP ' + res.status + ' ' + String(res.responseText || '').slice(0, 200));
}
},
onerror: function () { log('连接失败:网络错误'); },
ontimeout: function () { log('连接失败:超时'); }
});
}
// 异步收集所有题目文档(含字体解码),返回 [{doc, q, dmSize}]
// v2:结果在有效期内复用,避免"提取题目 / 开始答题"重复跑一遍字体识别
// v2.5:缓存增加"题目集合指纹",切换章节测验后不会误用上一章的解析结果
var _lastCollect = null;
// 取题目元素的"原始文本",并排除自身注入的答案徽章,保证指纹稳定
function tiRawText(ti) {
var out = [];
for (var n = ti.firstChild; n; n = n.nextSibling) {
if (n.nodeType === 1 && n.className && String(n.className).indexOf('cxai-answer-badge') !== -1) continue;
out.push(n.textContent || '');
}
return out.join('');
}
function docsSignature(docs) {
var parts = [];
for (var i = 0; i < docs.length; i++) {
var t = [];
try { t = docs[i].querySelectorAll('.TiMu'); } catch (e) { t = []; }
parts.push(t.length);
var lim = Math.min(t.length, 4);
for (var j = 0; j < lim; j++) {
parts.push(tiRawText(t[j]).replace(/\s+/g, '').slice(0, 120));
}
}
return parts.join('|');
}
function collectQuestions(onProgress, cb, force) {
var docs = findWorkDocuments();
var sig = docsSignature(docs);
if (!force && _lastCollect && _lastCollect.sig === sig
&& (Date.now() - _lastCollect.time) < CONFIG.REUSE_MS && _lastCollect.items.length) {
if (onProgress) onProgress(90, '复用上次解析结果…');
cb(_lastCollect.items, true);
return;
}
var collected = [];
var idx = 0;
function nextDoc() {
if (idx >= docs.length) {
_lastCollect = { time: Date.now(), sig: sig, items: collected };
cb(collected, false);
return;
}
var doc = docs[idx++];
getDecodeMapAsync(doc, onProgress, function (dm) {
var qs = parseQuestions(doc, dm);
for (var j = 0; j < qs.length; j++) {
collected.push({ doc: doc, q: qs[j], dmSize: Object.keys(dm).length });
}
nextDoc();
});
}
nextDoc();
}
// ============================================================
// 7.5 v2.3:面板状态机(重构 UI:状态胶囊 / 进度英雄区 / 智能主按钮)
// ============================================================
var ui = { phase: 'idle', total: 0, done: 0, startAt: 0, pausedAt: 0, ticker: 0 };
var paused = false;
var answerPump = null; // 由 onAnswer 注入,供暂停/继续使用
function setStatus(text, tone) {
var el = document.getElementById('cxai-status');
if (!el) return;
el.textContent = text;
el.className = 'cxai-status' + (tone ? ' ' + tone : '');
}
function setHero(numText, subText, pct) {
var n = document.getElementById('cxai-hero-num');
var s = document.getElementById('cxai-hero-sub');
var b = document.getElementById('cxai-hero-bar');
if (n && numText != null) n.textContent = numText;
if (s && subText != null) s.textContent = subText;
if (b && pct != null) b.style.width = Math.max(0, Math.min(100, pct)) + '%';
}
function setCta(text, mode) {
var el = document.getElementById('cxai-cta');
if (!el) return;
el.textContent = text;
el.className = 'cxai-cta' + (mode === 'busy' ? ' busy' : (mode === 'waiting' ? ' waiting' : ''));
}
function stopUiTicker() {
if (ui.ticker) { clearInterval(ui.ticker); ui.ticker = 0; }
}
function startUiTicker() {
stopUiTicker();
ui.ticker = setInterval(function () {
if (ui.phase !== 'answering') return;
var sec = Math.round((Date.now() - ui.startAt) / 1000);
setHero(ui.done + ' / ' + ui.total, '已完成 · 已用 ' + sec + 's', ui.total ? (ui.done / ui.total) * 100 : 0);
}, 1000);
}
// 主按钮:按当前阶段分发(提取 → 答题 → 暂停/继续)
function onCta() {
if (ui.phase === 'extracting') return;
if (ui.phase === 'answering') {
paused = true;
ui.phase = 'paused';
ui.pausedAt = Date.now();
stopUiTicker();
setStatus('已暂停', 'warn');
setCta('已暂停,点击继续', 'waiting');
return;
}
if (ui.phase === 'paused') {
paused = false;
ui.phase = 'answering';
if (ui.pausedAt) { ui.startAt += (Date.now() - ui.pausedAt); ui.pausedAt = 0; }
setStatus('答题中 ' + ui.done + '/' + ui.total, 'on');
setCta('正在作答,点击暂停');
startUiTicker();
if (answerPump) answerPump();
return;
}
if (ui.phase === 'ready' || ui.phase === 'done') { onAnswer(); return; }
onExtract();
}
// 解析阶段的进度显示改走 hero 区
function updateProgress(pct, msg, hide) {
if (hide) return;
var p = Math.round(pct);
setHero(p + '%', msg || '解析中', p);
}
function setBusy(busy) {
if (busy) setCta('处理中…', 'busy');
}
function onExtract() {
var docs = findWorkDocuments();
if (!docs.length) { log('未找到题目(.TiMu)。请确认当前在章节测验页面。'); return; }
clearStream();
clearQuestionList(); // v2.4:重新提取前先清空旧列表
ui.phase = 'extracting';
setStatus('解析中', 'on');
setCta('解析中…', 'busy');
setHero('0%', '正在解析题目', 0);
log('正在解析题目…');
collectQuestions(function (pct, msg) { updateProgress(pct, msg); }, function (items) {
if (!items.length) {
ui.phase = 'idle';
setStatus('未识别到题目', 'warn');
setCta('① 提取题目');
setHero('-- / --', '未识别到题目', 0);
clearQuestionList(); // v2.4
log('未识别到题目。');
return;
}
ui.phase = 'ready';
ui.total = items.length;
ui.done = 0;
var dmSize = items[0].dmSize;
setStatus('已就绪 ' + items.length + ' 题', 'on');
setCta('② 开始答题');
setHero('0 / ' + items.length, '已识别题目 · 待作答', 0);
renderQuestionList(items); // v2.4:把提取结果呈现在"题目与选项"区
log('识别到 ' + items.length + ' 题' + (dmSize ? '(字体还原映射 ' + dmSize + ' 条)' : '(未发现混淆字体,原文直读)') + ':');
for (var j = 0; j < items.length; j++) {
var q = items[j].q;
log(q.index + '. [' + q.type + '] ' + q.stem.slice(0, 40) + (q.answered ? '(已作答)' : ''));
}
log('共 ' + items.length + ' 题。点主按钮开始答题。');
});
}
var answering = false;
function onAnswer() {
if (ui.phase === 'answering' || ui.phase === 'paused') { log('正在答题中,请稍候…'); return; }
var cfg = readCfgFromUi();
var err = validateCfg(cfg);
if (err) { log('无法开始:' + err + '(请先在"模型设置"中配置)'); return; }
saveConfig(cfg);
var docs = findWorkDocuments();
if (!docs.length) { log('未找到题目(.TiMu)。'); return; }
ui.phase = 'extracting';
setStatus('准备中', 'on');
setCta('正在解析题目…', 'busy');
log('正在解析题目…');
collectQuestions(function (pct, msg) { updateProgress(pct, msg); }, function (items, reused) {
if (reused) log('(复用上次解析结果)');
renderQuestionList(items); // v2.4:答题前也确保"题目与选项"区可用
var all = items.filter(function (it) { return !it.q.answered; });
if (!all.length) {
ui.phase = 'done';
setStatus('无待答题', 'warn');
setCta('重新答题');
setHero('0 / 0', '没有未作答的题目', 0);
log('没有未作答的题目(都已作答,或被判定为已答)。');
return;
}
// v2:先查答案缓存,命中题直接复用,未命中才调模型
var cache = loadAnswerCache();
var cacheDirty = false;
var hits = 0;
var todo = [];
for (var ci = 0; ci < all.length; ci++) {
var cv = cache[questionKey(all[ci].q)];
if (cv && cv.answer) {
renderAnswer(all[ci].doc, all[ci].q.id, cv);
if (cfg.autoFill) fillAnswer(all[ci].doc, all[ci].q.id, cv.answer);
hits++;
} else {
todo.push(all[ci]);
}
}
if (hits) log('答案缓存命中 ' + hits + ' 题,已直接复用。');
ui.total = all.length;
ui.done = hits;
ui.startAt = Date.now();
ui.pausedAt = 0;
paused = false;
if (!todo.length) {
ui.phase = 'done';
setStatus('已完成', 'on');
setCta('重新答题');
setHero(ui.total + ' / ' + ui.total, '全部由缓存作答完成', 100);
log('全部由缓存作答完成。请核对答案后再提交。');
return;
}
ui.phase = 'answering';
setStatus('答题中 ' + ui.done + '/' + ui.total, 'on');
setCta('正在作答,点击暂停');
setHero(ui.done + ' / ' + ui.total, '已完成 · 已用 0s', ui.total ? (ui.done / ui.total) * 100 : 0);
startUiTicker();
clearStream();
log('准备调用 ' + cfg.model + ',待答题数:' + todo.length + '(串行,每批 ' + CONFIG.BATCH + ')…');
answering = true;
var batches = [];
for (var bi = 0; bi < todo.length; bi += CONFIG.BATCH) {
batches.push(todo.slice(bi, bi + CONFIG.BATCH));
}
var nextIdx = 0;
var running = 0;
var failedList = [];
// v2:答案匹配——先按 id,再按题号,最后按批次顺序兜底
function matchAnswer(parsed, batch, i) {
var b = batch[i];
var arr = (parsed && parsed.answers) ? parsed.answers : [];
var k;
for (k = 0; k < arr.length; k++) {
if (arr[k] && String(arr[k].id) === String(b.q.id)) return arr[k];
}
for (k = 0; k < arr.length; k++) {
if (arr[k] && String(arr[k].index) === String(b.q.index)) return arr[k];
}
if (arr.length === batch.length && arr[i] && arr[i].answer !== undefined) return arr[i];
return null;
}
function pump() {
if (!paused) {
while (running < CONFIG.CONCURRENCY && nextIdx < batches.length) {
launchBatch(batches[nextIdx++]);
}
}
if (running === 0 && nextIdx >= batches.length) {
if (cacheDirty) saveAnswerCache(cache);
answering = false;
stopUiTicker();
ui.phase = 'done';
var secs = Math.round((Date.now() - ui.startAt) / 1000);
setStatus(failedList.length ? ('完成(' + failedList.length + ' 题未答)') : '已完成', failedList.length ? 'warn' : 'on');
setCta('重新答题');
setHero(ui.done + ' / ' + ui.total, '已完成 · 耗时 ' + secs + 's', 100);
if (failedList.length) {
log('以下 ' + failedList.length + ' 题未获得答案:' + failedList.join('、'));
}
log('全部完成。请核对答案后再提交。');
}
}
answerPump = pump;
function launchBatch(batch) {
running++;
var qObjs = batch.map(function (b) { return b.q; });
var attempt = 0;
var lineEl = ensureStreamLine('b' + batch[0].q.index, '第' + batch[0].q.index + '题');
var stats = { chars: 0, start: Date.now(), stopped: false, streamed: false };
log('请求中:第 ' + batch[0].q.index + ' 题起,共 ' + batch.length + ' 题…');
// v2.5:分阶段文案 + 每秒心跳。即使接口不推送分片,也能看出"现在处于哪一步、等了多久"
function phaseOf(sec) {
if (sec < 3) return '已发送,建立连接';
if (sec < 15) return '等待模型首字';
return '模型思考中';
}
function waitHint(sec) {
if (sec < 3) return '已发送请求,正在建立连接…';
if (sec < 15) return '等待模型返回首个字…';
return '模型正在思考(推理类模型首字较慢,请耐心等待)…';
}
updateStreamTag(lineEl, '第' + batch[0].q.index + '题 · ' + phaseOf(0) + ' · 0s · 0字');
var timer = setInterval(function () {
if (stats.stopped) return;
var sec = Math.round((Date.now() - stats.start) / 1000);
var ph = stats.chars ? '接收中' : phaseOf(sec);
updateStreamTag(lineEl, '第' + batch[0].q.index + '题 · ' + ph + ' · ' + sec + 's · ' + stats.chars + '字');
// 尚无内容时,用阶段性提示替代干巴巴的"等待模型响应…"
if (!stats.chars) updateStreamLine(lineEl, waitHint(sec));
}, 1000);
function stopLine(txt) {
stats.stopped = true;
clearInterval(timer);
updateStreamLine(lineEl, txt);
}
function done() { running--; pump(); }
function attemptCall() {
attempt++;
stats.start = Date.now();
callChat(cfg, qObjs, function (parsed) {
var secs = Math.round((Date.now() - stats.start) / 1000);
for (var i = 0; i < batch.length; i++) {
var b = batch[i];
var ans = matchAnswer(parsed, batch, i);
if (!ans || ans.answer === undefined || ans.answer === null || ans.answer === '') {
failedList.push(b.q.index);
continue;
}
renderAnswer(b.doc, b.q.id, ans);
if (cfg.autoFill) fillAnswer(b.doc, b.q.id, ans.answer);
cache[questionKey(b.q)] = { answer: String(ans.answer), confidence: ans.confidence, reason: ans.reason };
cacheDirty = true;
log('第 ' + b.q.index + ' 题 → ' + ans.answer + (typeof ans.confidence === 'number' ? '(' + Math.round(ans.confidence * 100) + '%)' : ''));
}
ui.done += batch.length;
if (!stats.streamed) noteNoStream();
stopLine('已完成,耗时 ' + secs + 's,共 ' + batch.length + ' 题');
done();
}, function (errMsg) {
if (attempt <= CONFIG.MAX_RETRY) {
var delay = CONFIG.RETRY_DELAY * attempt;
updateStreamLine(lineEl, '请求失败,' + Math.round(delay / 1000) + 's 后重试…');
log('第 ' + batch[0].q.index + ' 题起请求失败,' + Math.round(delay / 1000) + 's 后重试(第 ' + attempt + '/' + CONFIG.MAX_RETRY + ' 次):' + errMsg);
setTimeout(attemptCall, delay);
return;
}
log('第 ' + batch[0].q.index + ' 题起最终失败:' + errMsg);
stopLine('失败:' + errMsg);
for (var i = 0; i < batch.length; i++) failedList.push(batch[i].q.index);
ui.done += batch.length;
done();
}, cfg.streamOn === false ? null : function (state) {
stats.chars = (state.reasoning ? state.reasoning.length : 0) + (state.content ? state.content.length : 0);
if (state.content || state.reasoning) {
stats.streamed = true;
updateStreamLine(lineEl, state.reasoning ? ('[思考] ' + state.reasoning) : ('[生成] ' + state.content));
}
});
}
attemptCall();
}
pump();
});
}
function onCopy() {
var docs = findWorkDocuments();
var lines = [];
for (var i = 0; i < docs.length; i++) {
var badges = docs[i].querySelectorAll('.cxai-answer-badge');
for (var j = 0; j < badges.length; j++) {
var t = (badges[j].textContent || '').replace(/\s+/g, ' ').trim();
if (t) lines.push(t);
}
}
if (!lines.length) { log('暂无可复制的答案,请先"开始答题"。'); return; }
var ok = copyText(lines.join('\n'));
log(ok ? ('已复制 ' + lines.length + ' 条答案到剪贴板。') : '复制失败,请手动选择复制。');
}
function onClear() {
var docs = findWorkDocuments();
for (var i = 0; i < docs.length; i++) {
var badges = docs[i].querySelectorAll('.cxai-answer-badge');
for (var j = 0; j < badges.length; j++) badges[j].remove();
}
log('已清除答案标注。');
}
// ============================================================
// 8. 入口
// ============================================================
function init() {
if (window.top !== window.self) return;
if (!/chaoxing\.com/.test(location.hostname)) return;
ensureUi();
log('AI 答题助手已就绪。');
watchQuestionChanges(); // v2.5:监听题目变化,切换章节时自动清空列表
setTimeout(function () {
var docs = findWorkDocuments();
if (docs.length) log('检测到章节测验页面,可点"① 提取题目"。');
}, 1500);
}
try {
GM_registerMenuCommand('显示/隐藏 AI 答题面板', function () {
var root = document.getElementById('cxai-root');
if (!root) { init(); return; }
var panel = root.querySelector('.cxai-panel');
if (panel.style.display === 'none') root.__expand(); else root.__minimize();
});
} catch (e) { /* 忽略 */ }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();