// ==UserScript== // @name 学习通 AI 问答助手 // @namespace local.chaoxing-ai // @version 2.7.0 // @description 学习通页面常驻可拖拽悬浮窗,三个页签:日志 / 答题记录 / 脚本配置。自动播放任务点视频、播完自动跳下一节;并支持「自动答题」——进入章节测验/作业/考试页自动识别题目、调用大模型作答并填入(兼容新版作业页/旧版内嵌作业、支持连线题/排序题、提交阈值与低于阈值自动暂存;支持 DeepSeek/豆包/通义千问/智谱GLM/Kimi/硅基流动 等一键切换)。「脚本配置」把大模型 / 自动答题 / 挂机三块合并在一页,顶部提供「正常模式 / 仅视频 / 仅答题」一键模式切换;「答题记录」逐题展示题干、选项与已填入的答案。作答采用 temperature=0 确定性输出、单选题选项乱序去偏、可选「答案复核」(备用模型独立作答交叉验证、不一致时仲裁),并把课程/章节背景注入提示词以提高正确率。备用模型可独立选择其它厂商(自带接口地址与 Key);模型标签带消耗档位彩色小字(免费/超低/低/中/高/超高)。 // @match *://*.chaoxing.com/* // @grant GM_xmlhttpRequest // @grant unsafeWindow // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant GM_getResourceText // @require https://scriptcat.org/lib/668/2.0/TyprMd5.js // @resource Table https://www.forestpolice.org/ttf/2.0/table.json // @connect * // @run-at document-idle // @license All Rights Reserved // ==/UserScript== (function () { 'use strict'; const isTop = window === window.top; // 页面真实 window:脚本带 @grant 时跑在沙箱里,页面自己挂的全局(如作业页的 btnBlueSubmit、UEditor 的 UE) // 只能通过 unsafeWindow 取到,否则拿到的永远是 undefined。 const pageWin = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; // 防止重复注入 if (window.__cxAiInjected__) return; window.__cxAiInjected__ = true; const STORE_KEY = 'cx_ai_settings'; const UI_KEY = 'cx_ai_ui'; // ---------- 服务商预设 ---------- // 说明:models 只是常用示例,输入框可手改成任意模型名;各家的免费额度/模型名常变,以官网为准。 const PROVIDERS = [ { id: 'glm', name: '智谱 GLM(glm-4.7-flash 免费)', baseUrl: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', models: ['glm-4.7-flash', 'glm-4-flash-250414', 'glm-4-flash', 'glm-4.7-flashx', 'glm-4.5-air', 'glm-4.6', 'glm-4.7'], cost: { 'glm-4.7-flash': 'free', 'glm-4-flash-250414': 'free', 'glm-4-flash': 'free', 'glm-4.7-flashx': 'low', 'glm-4.5-air': 'mid', 'glm-4.6': 'high', 'glm-4.7': 'high' }, tip: '推荐默认:glm-4.7-flash 是当前免费模型(200K 上下文,输入/输出/缓存均免费),注册拿到 Key 即可零成本调用。glm-4-flash-250414、glm-4-flash 也是免费版;带 FlashX/Plus 后缀及 glm-4.5-air / glm-4.6 / glm-4.7 一般为收费。免费政策以官网为准。' }, { id: 'doubao', name: '豆包·火山方舟(新用户免费额度)', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions', models: ['doubao-seed-1-6-flash-250615', 'doubao-1-5-lite-32k-250115', 'doubao-seed-1-6-250615', 'doubao-1-5-pro-32k-250115'], cost: { 'doubao-seed-1-6-flash-250615': 'ultra-low', 'doubao-1-5-lite-32k-250115': 'ultra-low', 'doubao-seed-1-6-250615': 'mid', 'doubao-1-5-pro-32k-250115': 'mid' }, tip: '字节跳动豆包。新用户一般有免费赠送额度(非永久,以官网为准)。模型名也可填控制台里的「推理接入点 ID」(ep- 开头)。' }, { id: 'qwen', name: '通义千问·百炼(新用户免费额度)', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', models: ['qwen-turbo', 'qwen-plus', 'qwen3-max', 'qwen-max'], cost: { 'qwen-turbo': 'ultra-low', 'qwen-plus': 'low', 'qwen3-max': 'high', 'qwen-max': 'high' }, tip: '阿里云百炼。新用户有免费额度(非永久,以官网为准),qwen-turbo 最便宜。' }, { id: 'siliconflow', name: '硅基流动 SiliconFlow(含免费模型)', baseUrl: 'https://api.siliconflow.cn/v1/chat/completions', models: ['Qwen/Qwen3-8B', 'Qwen/Qwen2.5-7B-Instruct', 'THUDM/glm-4-9b-chat', 'deepseek-ai/DeepSeek-V3', 'deepseek-ai/DeepSeek-R1'], cost: { 'Qwen/Qwen3-8B': 'free', 'Qwen/Qwen2.5-7B-Instruct': 'free', 'THUDM/glm-4-9b-chat': 'free', 'deepseek-ai/DeepSeek-V3': 'mid', 'deepseek-ai/DeepSeek-R1': 'high' }, tip: '模型聚合市场,标了「免费」的模型可零成本调用(有调用频率限制,以官网为准)。' }, { id: 'deepseek', name: 'DeepSeek(官方 API 收费)', baseUrl: 'https://api.deepseek.com/chat/completions', models: ['deepseek-v4.1-flash', 'deepseek-chat', 'deepseek-reasoner'], cost: { 'deepseek-v4.1-flash': 'ultra-low', 'deepseek-chat': 'low', 'deepseek-reasoner': 'mid' }, tip: '官方直连,无免费额度、按量计费(单价低)。deepseek-v4.1-flash 为最新旗舰(Flash 档,速度快、单价超低);deepseek-chat 通用别名,deepseek-reasoner 带推理思考。注意:其「网页版」不是 API,本脚本无法直接用。' }, { id: 'kimi', name: 'Kimi·月之暗面', baseUrl: 'https://api.moonshot.cn/v1/chat/completions', models: ['moonshot-v1-8k', 'moonshot-v1-32k', 'kimi-k2-0905-preview'], cost: { 'moonshot-v1-8k': 'low', 'moonshot-v1-32k': 'mid', 'kimi-k2-0905-preview': 'mid' }, tip: '长上下文见长,需充值。' }, { id: 'openrouter', name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1/chat/completions', models: ['deepseek/deepseek-chat-v3.1:free', 'qwen/qwen3-8b:free'], cost: { 'deepseek/deepseek-chat-v3.1:free': 'free', 'qwen/qwen3-8b:free': 'free' }, tip: '国际聚合站,模型名带 :free 后缀的通常可免费调用,需要能访问外网。' }, { id: 'ollama', name: '本地 Ollama', baseUrl: 'http://localhost:11434/v1/chat/completions', models: ['qwen2.5:7b', 'llama3.1:8b'], cost: { 'qwen2.5:7b': 'free', 'llama3.1:8b': 'free' }, tip: '本机部署,不花钱,Key 随便填。注意浏览器可能拦截本地 http 请求。' }, { id: 'custom', name: '自定义', baseUrl: '', models: [], tip: '自己填任意 OpenAI 兼容的接口地址与模型名。' } ]; const DEFAULT_PROVIDER = 'glm'; const DEFAULT_PROVIDER_OBJ = PROVIDERS.find((p) => p.id === DEFAULT_PROVIDER); // settings 结构:{ provider, baseUrl, model, apiKey, fallback:{model,baseUrl,apiKey}, cache:{服务商id:{...}}, auto:{...}, answer:{...} } const DEFAULTS = { provider: DEFAULT_PROVIDER, baseUrl: DEFAULT_PROVIDER_OBJ.baseUrl, model: DEFAULT_PROVIDER_OBJ.models[0], apiKey: '', fallback: { provider: '', model: '', baseUrl: '', apiKey: '' }, // 备用模型:provider 为空=跟随主服务商;model 为空=不启用 cache: {}, auto: { autoplay: false, nextChapter: false, rate: 1, jumpOnUnanswered: false, muted: true }, answer: { enabled: false, autoSubmit: false, review: false, interval: 1, skipAnswered: true, threshold: 85 } }; // ui 结构:窗口位置 / 尺寸 / 是否收起 / 是否隐藏 const UI_DEFAULTS = { left: null, top: null, width: 380, height: 520, minimized: false, hidden: false }; function loadSettings() { const s = Object.assign({}, DEFAULTS); try { const raw = GM_getValue(STORE_KEY, ''); if (raw) Object.assign(s, JSON.parse(raw)); } catch (e) { /* 配置损坏则回落到默认 */ } if (!PROVIDERS.some((p) => p.id === s.provider)) s.provider = DEFAULT_PROVIDER; s.cache = (s.cache && typeof s.cache === 'object') ? Object.assign({}, s.cache) : {}; s.fallback = Object.assign({}, DEFAULTS.fallback, (s.fallback && typeof s.fallback === 'object') ? s.fallback : {}); s.auto = Object.assign({}, DEFAULTS.auto, (s.auto && typeof s.auto === 'object') ? s.auto : {}); s.answer = Object.assign({}, DEFAULTS.answer, (s.answer && typeof s.answer === 'object') ? s.answer : {}); // 老配置里可能没有 threshold,或存成了非数字;这里统一洗成 0~100 的数字,避免 NaN 让阈值判断失效 const th = Number(s.answer.threshold); s.answer.threshold = isNaN(th) ? DEFAULTS.answer.threshold : Math.max(0, Math.min(100, th)); return s; } function saveSettings() { GM_setValue(STORE_KEY, JSON.stringify(settings)); } let settings = loadSettings(); let ui = Object.assign({}, UI_DEFAULTS); try { Object.assign(ui, JSON.parse(GM_getValue(UI_KEY, '{}'))); } catch (e) { /* 忽略 */ } let uiTimer = null; function saveUi(patch) { if (patch) Object.assign(ui, patch); clearTimeout(uiTimer); uiTimer = setTimeout(() => GM_setValue(UI_KEY, JSON.stringify(ui)), 250); } function getProvider(id) { return PROVIDERS.find((p) => p.id === id) || PROVIDERS[PROVIDERS.length - 1]; } // ---------- 日志(同时写面板日志区和浏览器控制台) ---------- // 参考 pp助手:日志是首页主视图,分级着色、实时计数、自动滚动、可复制/清空。 let logBox = null; let logStatEl = null; let logTabEl = null; let logAutoScroll = true; const LOG_MAX = 200; // 面板里最多保留多少条:**超过就自动清空**,避免长时间挂机把面板撑爆、越滚越卡 const logStats = { total: 0, err: 0, warn: 0 }; function refreshLogMeta() { if (logStatEl) { logStatEl.textContent = `共 ${logStats.total} 条 · 错误 ${logStats.err} · 警告 ${logStats.warn}`; } if (logTabEl) logTabEl.classList.toggle('has-err', logStats.err > 0); } function log(msg, type) { const level = type === 'err' ? 'err' : type === 'warn' ? 'warn' : type === 'ok' ? 'ok' : 'info'; const t = new Date().toLocaleTimeString('zh-CN', { hour12: false }); const line = `[${t}] ${msg}`; console.log('[学习通AI]', line); logStats.total++; if (level === 'err') logStats.err++; if (level === 'warn') logStats.warn++; refreshLogMeta(); if (!logBox) return; const div = document.createElement('div'); div.className = 'cxai-log-line ' + level; div.textContent = line; logBox.appendChild(div); // 条数超过上限 → 整块清空(不是逐条丢最旧)。挂机跑久了日志会到几百上千条, // 一次性清掉比每来一条就 removeChild 省事,也保证面板里永远只有「最近这一段」。 // 清空后补一条提示并保留触发清空的那行,免得用户以为日志凭空没了。 if (logBox.childElementCount > LOG_MAX) { logBox.innerHTML = ''; const tip = document.createElement('div'); tip.className = 'cxai-log-line warn'; tip.textContent = `[${t}] 日志已超过 ${LOG_MAX} 条,自动清空(更早的内容不再保留)`; logBox.appendChild(tip); logBox.appendChild(div); } if (logAutoScroll) logBox.scrollTop = logBox.scrollHeight; } // 写日志的通用提示:顶层直接写面板;iframe 里同时把文案转发给顶层,避免只出现在 console function notice(text, type) { log(text, type); if (window.top !== window) { try { window.top.postMessage({ __cxAiAnswer: 1, href: location.href, text }, '*'); } catch (e) { /* 忽略 */ } } } // 复制全部日志到剪贴板(供反馈排查用) function copyLogs() { if (!logBox) return; const text = Array.prototype.map.call(logBox.querySelectorAll('.cxai-log-line'), (d) => d.textContent).join('\n'); const done = () => log('日志已复制到剪贴板(共 ' + logStats.total + ' 条)', 'ok'); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(text, done)); } else { fallbackCopy(text, done); } } function fallbackCopy(text, done) { try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); done(); } catch (e) { log('复制失败,请手动选择日志文本', 'err'); } } // ===== 媒体挂机核心:顶层窗口与各 iframe 都会执行 ===== // 学习通常把视频放进 iframe(有时还是跨源的子域),顶层窗口读不到, // 所以每个 frame 里也跑一遍,各自驱动本节点里的播放器。 // 本节点内所有可读的 document(主文档 + 同源子 iframe,最多 4 层) // 只用于找「下一节」按钮(它可能在被嵌的同源页面里),不参与媒体统计 function collectDocs() { const docs = []; (function walk(doc, depth) { if (!doc || depth > 4) return; docs.push(doc); let frames = []; try { frames = doc.querySelectorAll('iframe'); } catch (e) { return; } frames.forEach((f) => { try { const d = f.contentDocument; if (d) walk(d, depth + 1); } catch (e) { /* 跨源跳过 */ } }); })(document, 0); return docs; } // 只统计本 document 自己直接包含的媒体元素,不递归子 iframe: // 否则同一个