// ==UserScript== // @name 太钢职工教育助手 // @namespace https://mooc.tisco.com.cn/helper // @version 1.0.1 // @description 太钢职工教育培训网络平台 AI 自动答题脚本 · DeepSeek 驱动 · 单选/多选/判断自动作答 // @author 叶屿 // @license GPL-3.0 // @antifeature payment AI 解析功能需要验证码(免费看广告获取)或激活码(付费),用户需自备 DeepSeek API Key // @match *://mooc.tisco.com.cn/* // @match *://lms.ouchn.cn/* // @match *://*.lms.ouchn.cn/* // @match *://*.ouchn.cn/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_addStyle // @connect api.deepseek.com // @connect qsy.iano.cn // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ============== 配置 ============== const Config = { version: '1.0.1', appName: '太钢职工教育助手', platformName: '太钢职工教育培训网络平台', // DeepSeek 官方端点 deepseekBaseUrl: 'https://api.deepseek.com/v1/chat/completions', // 模型说明:DeepSeek 官方 API 仅有 deepseek-chat(V3 对话)与 deepseek-reasoner(思考)两个端点 models: { v3: { name: 'DeepSeek V3(对话)', modelId: 'deepseek-chat', maxTokens: 512, hint: 'V3 对话模型,速度快,可对比正确率' }, v4: { name: 'DeepSeek V4(极速)', modelId: 'deepseek-chat', maxTokens: 512, hint: '日常题秒回,单选/填空首选' }, v4pro: { name: 'DeepSeek V4 Pro(思考)', modelId: 'deepseek-reasoner', maxTokens: 8192, hint: '推理型,多选/计算/分析题更准(思考占 tokens 多,需 8K+)' }, }, // 解析节奏(可在设置里改) interQuestionMs: 1800, // 题间间隔 afterClickMs: 600, // AI 返回后等待 aiTimeoutMs: 60000, // AI 单次调用最长 60s(思考版可能久) aiRetryMax: 2, // 失败重试次数 aiMinIntervalMs: 500, // 两次 AI 调用最小间隔 // 微信 wechat: 'C919irt', // 验证码 verifyUrl: 'https://qsy.iano.cn/index.php?s=/api/code/verify', verifyQrUrl: 'https://qsy.iano.cn/yzm.png', // 公告 announceUrl: 'https://qsy.iano.cn/index.php?s=/admin/unified_manage/scriptannouncementapi', }; // ============== Utils ============== const Utils = { sleep: (ms) => new Promise(r => setTimeout(r, ms)), safeText: (el) => { if (!el) return ''; return (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim(); }, // 触发 React/Vue 框架能感知到的事件 fireInputEvents(el) { try { el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); el.dispatchEvent(new Event('blur', { bubbles: true })); } catch (_) {} }, // 框架反查 setter(React 等会拦截 .value 赋值) nativeSet(el, value) { const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; if (setter) setter.call(el, value); else el.value = value; this.fireInputEvents(el); }, safeClick(el) { if (!el) return false; try { if (typeof el.click === 'function') el.click(); el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); el.dispatchEvent(new MouseEvent('click', { bubbles: true })); return true; } catch (_) { return false; } }, // 只触发一次点击(翻页/导航按钮用)。safeClick 会同时 el.click() + dispatch click // 等于点两下,对"下一题"按钮会一次跳两题,所以导航必须用这个单击版。 singleClick(el) { if (!el) return false; try { if (typeof el.click === 'function') { el.click(); return true; } el.dispatchEvent(new MouseEvent('click', { bubbles: true })); return true; } catch (_) { return false; } }, // 把 radio/checkbox 切换到目标状态。多策略 fallback: // 1) 点击包装(label / 选项 div)— 最贴近真人操作 // 2) 点击 input 自己 // 3) 原生 setter + dispatch change(React 控件兜底) // 每步后检查状态是否符合期望,符合就 return true async toggleChecked(input, node, want) { if (!input) return false; if (!!input.checked === want) return true; const tryAndVerify = async (clickTarget) => { if (!clickTarget) return false; this.safeClick(clickTarget); await this.sleep(120); return !!input.checked === want; }; // 1) 包装(label / div) if (node && node !== input && await tryAndVerify(node)) return true; // 2) input 本身 if (await tryAndVerify(input)) return true; // 3) 原生 setter + dispatch try { const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'checked')?.set; if (setter) { setter.call(input, want); input.dispatchEvent(new Event('click', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } await this.sleep(120); if (!!input.checked === want) return true; } catch (_) {} return false; }, async copyText(text) { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; } } catch (_) {} try { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none;'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); document.body.removeChild(ta); return ok; } catch (_) { return false; } }, // 简单的字符归一化(用于答案 ↔ 选项匹配) normalize(s) { return String(s || '') .replace(/^\s*[A-Ha-h]\s*[.、.::))]\s*/, '') // 剥 "A." / "B、" .toLowerCase() .replace(/[\s.、,。,;;::!!??'"'""\(\)()\-_]/g, ''); }, isOwnPanel(el) { return !!el?.closest?.('#ouchn-panel, #ouchn-settings, [data-ouchn-helper]'); }, // 判断一个 input 是否"可用于答题"。 // ⚠️ 很多平台(如太钢)把原生 radio/checkbox 用 CSS 隐藏,只显示样式化的 // label/选项 div,此时 input.offsetParent 为 null 但题目其实是可见的。 // 所以这里检查它的"包装元素"是否被渲染出来(有尺寸),而不是看 input 自身。 isUsableInput(inp) { if (!inp || this.isOwnPanel(inp)) return false; if (inp.disabled) return false; const wrap = inp.closest('label, li, [class*="option"], [class*="Option"], .answer') || inp.parentElement || inp; try { const rect = wrap.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) return true; } catch (_) {} // 兜底:input 自身可见也算 return inp.offsetParent !== null; }, }; // ============== Store(GM 存储)============== const Store = { get(k, def) { try { const v = GM_getValue(k); return v === undefined ? def : v; } catch (_) { return def; } }, set(k, v) { try { GM_setValue(k, v); } catch (_) {} }, del(k) { try { GM_deleteValue(k); } catch (_) {} }, getApiKey() { return this.get('ouchn_dsk_key', ''); }, setApiKey(v) { this.set('ouchn_dsk_key', v || ''); }, getModel() { return this.get('ouchn_model', 'v4'); }, setModel(v) { this.set('ouchn_model', v); }, getAutoNext() { return !!this.get('ouchn_autoNext', false); }, setAutoNext(v) { this.set('ouchn_autoNext', !!v); }, getMaxPages() { return this.get('ouchn_maxPages', 200); }, getInterMs() { return this.get('ouchn_interMs', Config.interQuestionMs); }, setInterMs(v) { this.set('ouchn_interMs', Math.max(500, parseInt(v) || Config.interQuestionMs)); }, // ---- 刷课 ---- getPlayRate() { return parseFloat(this.get('ouchn_playRate', 1)) || 1; }, setPlayRate(v) { let r = parseFloat(v) || 1; r = Math.max(0.5, Math.min(16, r)); // 0.5x ~ 16x this.set('ouchn_playRate', r); return r; }, getMuted() { return this.get('ouchn_muted', true) !== false; }, // 默认静音(绕过浏览器自动播放限制) setMuted(v) { this.set('ouchn_muted', !!v); }, getAutoNextLesson() { return !!this.get('ouchn_autoNextLesson', true); }, // 播完自动下一节 setAutoNextLesson(v) { this.set('ouchn_autoNextLesson', !!v); }, // ---- 验证码 ---- getVerifyValidUntil() { return parseInt(this.get('ouchn_verifyUntil', 0), 10) || 0; }, setVerifyValidUntil(ts) { this.set('ouchn_verifyUntil', ts || 0); }, isVerifyValid() { return this.getVerifyValidUntil() > Math.floor(Date.now() / 1000); }, getValidUntilStr() { const t = this.getVerifyValidUntil(); return t ? new Date(t * 1000).toLocaleString('zh-CN', { hour12: false }) : ''; }, }; // ============== Verify:4 位验证码 ============== const Verify = { async verifyCode(code) { return new Promise((resolve, reject) => { const c = String(code || '').trim(); if (!c || c.length !== 4) return reject('请输入 4 位验证码'); if (typeof GM_xmlhttpRequest !== 'function') return reject('GM_xmlhttpRequest 不可用'); GM_xmlhttpRequest({ method: 'POST', url: Config.verifyUrl, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: 'code=' + encodeURIComponent(c), timeout: 15000, onload: (resp) => { try { const d = JSON.parse(resp.responseText); if (d.code === 1 && d.data && d.data.valid) { const validUntil = parseInt(d.data.valid_until, 10); Store.setVerifyValidUntil(validUntil); const str = d.data.valid_until_str || new Date(validUntil * 1000).toLocaleString('zh-CN'); resolve({ validUntil, validUntilStr: str }); } else { reject(d.msg || '验证码无效或已过期'); } } catch (_) { reject('验证响应解析失败'); } }, onerror: () => reject('网络错误'), ontimeout: () => reject('请求超时'), }); }); }, }; // ============== Announce:远程公告 ============== const Announce = { fetch(panel) { if (!panel || typeof GM_xmlhttpRequest !== 'function') return; try { GM_xmlhttpRequest({ method: 'GET', url: Config.announceUrl, timeout: 8000, onload: (resp) => { try { const res = JSON.parse(resp.responseText); if (res.code !== 1 || !res.data || !res.data.enabled) return; this.render(panel, res.data); } catch (_) { /* silent */ } }, onerror: () => {}, ontimeout: () => {}, }); } catch (_) {} }, render(panel, data) { const root = panel.querySelector('#oc-announce'); const titleEl = panel.querySelector('#oc-announce-title'); const contentEl = panel.querySelector('#oc-announce-content'); const closeEl = panel.querySelector('#oc-announce-close'); if (!root || !titleEl || !contentEl) return; const type = ['info','warning','error','success'].includes(data.type) ? data.type : 'info'; const icon = { info: '📢', warning: '⚠️', error: '❌', success: '✅' }[type]; const iconEl = root.querySelector('.oc-announce-icon'); if (iconEl) iconEl.textContent = icon; titleEl.textContent = data.title || '公告'; // content 允许富文本,但只信任
这些标签 contentEl.innerHTML = this._sanitize(data.content || ''); root.classList.remove('info', 'warning', 'error', 'success'); root.classList.add(type, 'show'); if (closeEl) closeEl.onclick = () => { root.classList.remove('show'); }; }, _sanitize(html) { // 简易白名单:只保留
const tmp = document.createElement('div'); tmp.innerHTML = String(html); tmp.querySelectorAll('*').forEach(el => { const tag = el.tagName.toLowerCase(); if (!['br','b','i','u','strong','em','span'].includes(tag)) { el.replaceWith(document.createTextNode(el.textContent || '')); return; } // 清掉所有事件属性 [...el.attributes].forEach(attr => { if (/^on/i.test(attr.name) || attr.name === 'style') el.removeAttribute(attr.name); }); }); return tmp.innerHTML; }, }; // ============== 状态 ============== let isRunning = false; let stopRequested = false; let isPaused = false; let stats = { total: 0, success: 0, failed: 0, skipped: 0 }; let lastAiCallAt = 0; // 暂停时阻塞,直到恢复或停止 async function waitWhilePaused(logger) { let told = false; while (isPaused && !stopRequested) { if (!told) { logger?.('⏸ 已暂停(点击 ▶️ 继续 恢复 / ⏹ 停止 终止)', 'warn'); told = true; } await Utils.sleep(300); } } // ============== AI: DeepSeek ============== const AI = { // 题型常量 TYPE_SINGLE: 'single', TYPE_MULTI: 'multi', TYPE_JUDGE: 'judge', TYPE_FILL: 'fill', TYPE_ESSAY: 'essay', TYPE_UNKNOWN: 'unknown', buildPrompt(question, options, type) { const optStr = options?.length ? options.map((o, i) => `${String.fromCharCode(65 + i)}. ${o}`).join('\n') : ''; switch (type) { case this.TYPE_SINGLE: return `你是答题助手。下面是一道单选题,请只输出正确选项的"原文内容"(不要带 A./B./字母编号),如果不确定就选最合理的一项。 【题目】${question} 【选项】 ${optStr} 要求: - 只输出一个选项的原文,不解释 - 不要带字母编号 - 不要带 "答案:" 等前缀`; case this.TYPE_MULTI: return `你是答题助手。下面是一道多选题。 【题目】${question} 【选项】 ${optStr} 输出格式(必须严格遵守,否则视为错答): 最后一行必须是:答案:X||Y||Z 其中 X/Y/Z 是正确选项的"原文内容"(不要字母 ABCD 编号,不要解释,不要省略号)。 示例: 答案:塑性和韧性||硬度和强度||化学成分`; case this.TYPE_JUDGE: return `你是答题助手。下面是一道判断题,请只回答"正确"或"错误"。 【题目】${question} 【选项参考】 ${optStr || '正确 / 错误'} 要求:只输出 "正确" 或 "错误" 两字,不要其他内容`; case this.TYPE_FILL: return `你是答题助手。下面是一道填空题,请直接给出答案。 【题目】${question} 要求: - 若题目含多个空,用 "||" 分隔每空答案 - 不要带空号 / 序号 / 引号 / 答案前缀 - 简洁、准确`; case this.TYPE_ESSAY: return `你是答题助手。下面是一道简答/论述题,请用简洁专业的中文作答。 【题目】${question} 要求: - 200-400 字 - 分点作答(用 "1." "2." 标记或自然分段) - 不要寒暄、不要题目重述`; default: return `请回答下面的题目:\n${question}\n\n${optStr ? '【可选项】\n' + optStr : ''}`; } }, async call(question, options, type, logger) { const apiKey = Store.getApiKey(); if (!apiKey) throw new Error('未配置 DeepSeek API Key,请到 ⚙️ 设置填写'); // 节流 const since = Date.now() - lastAiCallAt; if (since < Config.aiMinIntervalMs) { await Utils.sleep(Config.aiMinIntervalMs - since); } const modelKey = Store.getModel(); const modelConf = Config.models[modelKey] || Config.models.v4; const prompt = this.buildPrompt(question, options, type); const body = { model: modelConf.modelId, messages: [{ role: 'user', content: prompt }], max_tokens: modelConf.maxTokens, temperature: 0.1, stream: false, }; let lastErr; for (let attempt = 1; attempt <= Config.aiRetryMax + 1; attempt++) { try { lastAiCallAt = Date.now(); const raw = await this._request(apiKey, body); const msg = raw?.choices?.[0]?.message || {}; let content = msg.content || ''; const reasoning = msg.reasoning_content || ''; const finish = raw?.choices?.[0]?.finish_reason; // ⚠️ reasoner 截断兜底:思考耗尽 tokens 没产出正式答案 // → 自动换 deepseek-chat(非思考模型)重新跑一次,确保拿到答案 if (modelConf.modelId === 'deepseek-reasoner' && finish === 'length' && !content) { logger?.(` ⚠️ V4 Pro 思考超出 ${modelConf.maxTokens} tokens 仍未给出答案,自动降级到 V4 重试...`); const fbBody = { model: 'deepseek-chat', messages: [{ role: 'user', content: prompt }], max_tokens: 512, temperature: 0.1, stream: false, }; await Utils.sleep(Config.aiMinIntervalMs); lastAiCallAt = Date.now(); const fbRaw = await this._request(apiKey, fbBody); const fbContent = fbRaw?.choices?.[0]?.message?.content || ''; if (fbContent) { const answers = this._parseAnswer(fbContent, type); answers._raw = fbContent; answers._fallback = 'v4'; return answers; } // 兜底也失败,继续走正常流程(用 reasoning 凑答案) } // 正常路径:优先用 content(正式答案),content 空时退而求其次用 reasoning let used = content || reasoning; if (!used) throw new Error('AI 返回内容为空'); // content 没"答案:"行而 reasoning 有,合并以便提取 if (content && reasoning && !/答案[::]/i.test(content) && /答案[::]/i.test(reasoning)) { used = reasoning + '\n' + content; } const answers = this._parseAnswer(used, type); answers._raw = used; return answers; } catch (e) { lastErr = e; logger?.(` ⚠️ AI 第 ${attempt} 次失败:${e.message || e}`); if (attempt <= Config.aiRetryMax) { await Utils.sleep(1000 * attempt); } } } throw lastErr || new Error('AI 调用失败'); }, _request(apiKey, body) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') { return reject(new Error('GM_xmlhttpRequest 不可用,请用 Tampermonkey 安装本脚本')); } GM_xmlhttpRequest({ method: 'POST', url: Config.deepseekBaseUrl, timeout: Config.aiTimeoutMs, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey, }, data: JSON.stringify(body), onload: (resp) => { if (resp.status >= 200 && resp.status < 300) { try { resolve(JSON.parse(resp.responseText)); } catch (e) { reject(new Error('解析响应失败:' + e.message)); } } else { let msg = `HTTP ${resp.status}`; try { const j = JSON.parse(resp.responseText); msg += ' · ' + (j.error?.message || j.message || resp.responseText.slice(0, 200)); } catch (_) { msg += ' · ' + resp.responseText.slice(0, 200); } reject(new Error(msg)); } }, onerror: () => reject(new Error('网络请求失败')), ontimeout: () => reject(new Error('请求超时')), }); }); }, _parseAnswer(content, type) { let raw = String(content || '').trim(); // ESSAY 直接整段返回 if (type === this.TYPE_ESSAY) return [raw]; // 1) 优先抓"答案:..." 整行(reasoner 模型按 prompt 要求会输出这一行) // 支持 全角/半角冒号、答案/正确答案/答 前缀 let txt = ''; const ansLineMatch = raw.match(/(?:^|\n)\s*(?:正确答案|答案|答)\s*[::]\s*([^\n]+)/i); if (ansLineMatch) { txt = ansLineMatch[1].trim(); } else { // 2) 没明确 "答案:" 行 → 取最后一行非空(reasoner 思考完通常把答案放最后) const lines = raw.split('\n').map(s => s.trim()).filter(Boolean); txt = lines[lines.length - 1] || raw; } // 3) 清理常见前缀/包裹 txt = txt .replace(/^[\*\#\-\>\s]+/, '') .replace(/^["'「『((]+/, '') .replace(/["'」』))]+$/, '') .replace(/^答案[::]\s*/i, '') .replace(/^正确答案[::]\s*/i, '') .replace(/^答[::]\s*/i, '') .replace(/^选\s*[::]\s*/i, '') .trim(); // JUDGE 提取"正确/错误" if (type === this.TYPE_JUDGE) { if (/正确|对|是|true|^t$|✓|right|yes/i.test(txt)) return ['正确']; if (/错误|错|否|false|^f$|✗|wrong|no/i.test(txt)) return ['错误']; return [txt]; } // 4) 多答案分隔符兼容:|| / | / 、 / , / , / ;/ ; / 顿号 const splitRe = /\s*(?:\|\||[、,,;;])\s*/; let parts = splitRe.test(txt) ? txt.split(splitRe).map(s => s.trim()).filter(Boolean) : [txt]; // 5) 剥离每项前可能的字母编号:如 "A. 塑性和韧性" → "塑性和韧性" parts = parts.map(p => p.replace(/^[A-Ha-h]\s*[.\.\、\:\:\)\)]\s*/, '').trim()).filter(Boolean); // 单选只取一个 if (type === this.TYPE_SINGLE) return parts.slice(0, 1); return parts; }, }; // ============== Player:视频刷课 ============== const Player = { timer: null, running: false, logger: null, lastSrc: '', stuckCount: 0, quizBusy: false, // 收集所有同源 document(含 iframe)里的 video _allVideos() { const vids = []; const scan = (doc) => { try { doc.querySelectorAll('video').forEach(v => vids.push(v)); doc.querySelectorAll('iframe').forEach(f => { try { if (f.contentDocument) scan(f.contentDocument); } catch (_) {} }); } catch (_) {} }; scan(document); return vids; }, // 取当前正在显示的主视频(优先有尺寸、时长 > 0 的) currentVideo() { const vids = this._allVideos().filter(v => { try { const r = v.getBoundingClientRect(); return r.width > 50 && r.height > 50; } catch (_) { return false; } }); if (!vids.length) return null; // 优先未播完的 const playing = vids.find(v => v.currentTime < (v.duration || Infinity) - 1); return playing || vids[0]; }, applyRate(v, rate) { if (!v) return; try { v.playbackRate = rate; // 有些播放器会在 ratechange 时重置,监听并强制回写 if (!v.__ocRateHook) { v.__ocRateHook = true; v.addEventListener('ratechange', () => { const want = Store.getPlayRate(); if (Math.abs(v.playbackRate - want) > 0.01) { try { v.playbackRate = want; } catch (_) {} } }); } } catch (_) {} }, async play(v) { if (!v) return; try { if (Store.getMuted()) v.muted = true; this.applyRate(v, Store.getPlayRate()); const p = v.play(); if (p && p.catch) p.catch(() => { // 自动播放被拦截 → 静音后重试一次 try { v.muted = true; v.play(); } catch (_) {} }); } catch (_) {} }, // 找"下一节"按钮 / 目录里下一个未学章节 findNextLesson() { const nextRe = /^(下一节|下一集|下一课|下一章|下一个|next)\s*[>›→»]?$/i; const dangerRe = /(交卷|提交|finish|submit)/i; const cands = document.querySelectorAll('button, a, [role="button"], [class*="next"]'); for (const b of cands) { if (Utils.isOwnPanel(b)) continue; const t = (Utils.safeText(b) || b.getAttribute('aria-label') || '').trim(); if (!t || dangerRe.test(t) || !nextRe.test(t)) continue; const r = b.getBoundingClientRect(); if (r.width > 0 && r.height > 0) return b; } return null; }, start(logger) { if (this.running) { logger?.('⚠️ 刷课已在运行'); return; } this.running = true; this.logger = logger; this.stuckCount = 0; this.noVidWarned = false; logger?.(`🎬 开始自动刷课(倍速 ${Store.getPlayRate()}x${Store.getMuted() ? ' · 静音' : ''})`); const all = this._allVideos(); const v = this.currentVideo(); if (v) { logger?.(`📺 找到视频(共 ${all.length} 个),开始播放…`, 'ok'); this.play(v); } else if (all.length) { logger?.(`⚠️ 页面有 ${all.length} 个