// ==UserScript== // @name 英语填空题自动填写助手(超星学习通) // @namespace chaoxing-fill-blank // @version 3.3.0 // @author fysh // @description 专注超星学习通英语填空题:读取含空位的题目片段与原文做序列对齐,自动为每空匹配填写答案。支持浏览器内 Whisper 转写听力音频、多音频依次处理、URL 下拉选择、全部完成后自动下一章。 // @icon https://www.google.com/s2/favicons?domain=chaoxing.com // @match *://*.chaoxing.com/* // @match *://*.edu.cn/* // @match *://*.nbdlib.cn/* // @match *://*.hnsyu.net/* // @match *://*.gdhkmooc.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @grant unsafeWindow // @connect * // @run-at document-end // @license MIT // ==/UserScript== (function () { 'use strict'; /* ========================================================================= * 一、基础配置与工具 * =======================================================================*/ const CONFIG_KEY = 'cx_fill_blank_config'; const MARKER = '@@BLANK@@'; // 片段中代表一个空位的占位标记 const CONTAINER_SELECTOR = '.TiMu, .questionLi, .mark_table'; const WORD_RE = /[A-Za-z0-9]+(?:['\u2019\-][A-Za-z0-9]+)*/g; const defaultConfig = { originalText: '', // 手动粘贴的英文原文 autoFill: true, // 是否自动填空 answerInterval: 1, // 每个空之间的填写间隔(秒) audioUrl: '', // 听力音频地址(留空则自动识别页面上的音频) audioAuto: false, // 进入页面后是否自动转写音频 autoNext: false, // 所有音频处理完成后是否自动进入下一章 whisperModel: 'Xenova/whisper-base.en', // 语音识别模型(base 比 tiny 准很多,长音频建议 base) modelHost: 'https://hf-mirror.com', // 模型下载源(国内镜像更稳;官方为 https://huggingface.co) }; const IS_TOP = (() => { try { return window.self === window.top; } catch (e) { return false; } })(); // 页面真实 window:油猴沙箱里 window.UE 取不到,必须用 unsafeWindow const PAGE = (() => { try { return (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; } catch (e) { return window; } })(); // 安全获取页面上的 UEditor 库 function getUE() { try { if (PAGE && PAGE.UE) return PAGE.UE; } catch (e) { /* ignore */ } try { if (window.UE) return window.UE; } catch (e) { /* ignore */ } return null; } // 等待 UEditor 就绪(最多 timeoutMs 毫秒) async function ensureUE(timeoutMs) { if (getUE()) return true; const deadline = Date.now() + (timeoutMs || 0); while (Date.now() < deadline) { await sleep(0.2); if (getUE()) return true; } return !!getUE(); } function getConfig() { try { const saved = GM_getValue(CONFIG_KEY, {}); return Object.assign({}, defaultConfig, saved || {}); } catch (e) { return Object.assign({}, defaultConfig); } } function saveConfig(patch) { const cfg = Object.assign(getConfig(), patch); try { GM_setValue(CONFIG_KEY, cfg); } catch (e) { /* ignore */ } return cfg; } const sleep = (sec) => new Promise((r) => setTimeout(r, Math.max(0, sec) * 1000)); function withTimeout(promise, ms, message) { return new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error(message || '操作超时')), ms); Promise.resolve(promise).then( (v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); } ); }); } /* ========================================================================= * 二、日志(子 iframe 的日志会转发给顶层面板) * =======================================================================*/ const LOG_STYLE = { info: 'color:#409eff;font-weight:bold', success: 'color:#67c23a;font-weight:bold', error: 'color:#f56c6c;font-weight:bold', }; function log(msg, level = 'info') { try { console.log('%c[填空助手] ' + msg, LOG_STYLE[level] || LOG_STYLE.info); } catch (e) { /* ignore */ } if (IS_TOP) { appendLog(msg, level); } else { try { window.top.postMessage({ __cxfill: true, type: 'log', msg, level }, '*'); } catch (e) { /* ignore */ } } } /* ========================================================================= * 三、核心匹配算法 * * 思路:把「题目片段」看作「原文」挖掉若干单词后的结果。 * 1) 对片段与原文分别分词(只保留英文单词/数字,忽略中文乱码与标点); * 2) 用最长公共子序列(LCS)把片段中的保留词对齐到原文的单词上; * 3) 原文里没有被对齐上的词,就是被挖掉的内容; * 4) 按空位出现顺序,把每一段被挖掉的词依次分配给对应的空(每空一个单词)。 * =======================================================================*/ /** 把文本切成英文单词数组(保留原样,含撇号/连字符) */ function tokenize(text) { const out = []; const re = new RegExp(WORD_RE.source, 'g'); let m; while ((m = re.exec(text)) !== null) out.push(m[0]); return out; } /** 把带占位标记的片段切成 token 序列:空白处为 {blank:true} */ function tokenizeFragment(text) { const parts = String(text).split(MARKER); const tokens = []; parts.forEach((part, i) => { if (i > 0) tokens.push({ blank: true }); tokenize(part).forEach((w) => tokens.push({ raw: w, norm: w.toLowerCase() })); }); return tokens; } /** * LCS 对齐:返回数组 map,map[i] = 片段第 i 个非空词匹配到的原文下标(-1 表示未匹配) */ function lcsMatch(fTokens, oTokens) { const n = fTokens.length; const m = oTokens.length; if (!n || !m) return new Int32Array(n).fill(-1); const fNorm = fTokens.map((t) => t.norm); const oNorm = oTokens.map((t) => t.toLowerCase()); const dp = []; for (let i = 0; i <= n; i++) dp.push(new Int32Array(m + 1)); for (let i = 1; i <= n; i++) { const row = dp[i]; const prev = dp[i - 1]; const fn = fNorm[i - 1]; for (let j = 1; j <= m; j++) { row[j] = fn === oNorm[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], row[j - 1]); } } const map = new Int32Array(n).fill(-1); let i = n; let j = m; while (i > 0 && j > 0) { if (fNorm[i - 1] === oNorm[j - 1]) { map[i - 1] = j - 1; i--; j--; } else if (dp[i - 1][j] >= dp[i][j - 1]) { i--; } else { j--; } } return map; } /** * 计算每个空的答案 * @param {Array} fragmentTokens tokenizeFragment 的结果 * @param {Array} oTokens 原文分词结果 * @returns {Array} 按空位顺序排列的答案 */ function solveBlanks(fragmentTokens, oTokens) { const fWords = fragmentTokens.filter((t) => !t.blank); const match = lcsMatch(fWords, oTokens); const answers = []; const positions = []; // 每个空 -> 命中单词在原文中的下标数组(用于高亮) let pending = 0; // 等待分配答案的空位数 let lastO = -1; // 上一个已对齐的原文下标 let fi = 0; // 片段非空词的游标 // 把原文 [lastO+1, nextOi-1] 区间分配给等待中的空 function flush(nextOi) { if (pending <= 0) return; const words = []; const poss = []; for (let idx = lastO + 1; idx <= nextOi - 1; idx++) { words.push(oTokens[idx]); poss.push(idx); } if (words.length <= pending) { // 词数不超过空数:每个空一个词 for (let k = 0; k < pending; k++) { if (k < words.length) { answers.push(words[k]); positions.push([poss[k]]); } else { answers.push(''); positions.push([]); } } } else { // 词数多于空数:前面的空各一个词,最后一个空收下剩余所有词(短语) for (let k = 0; k < pending - 1; k++) { answers.push(words[k]); positions.push([poss[k]]); } answers.push(words.slice(pending - 1).join(' ')); positions.push(poss.slice(pending - 1)); } pending = 0; } for (const tok of fragmentTokens) { if (tok.blank) { pending++; continue; } const oi = match[fi++]; if (oi >= 0) { flush(oi); lastO = oi; } } flush(oTokens.length); // 处理结尾处的空 return { answers, positions }; } /** 完整求解:从片段文本 + 原文得到答案数组与答案在原文中的位置 */ function matchAnswers(fragmentText, originalText) { const ft = tokenizeFragment(fragmentText); const ot = tokenize(originalText); const r = solveBlanks(ft, ot); return { answers: r.answers, positions: r.positions, blankCount: ft.filter((t) => t.blank).length, originalWordCount: ot.length, }; } /* ========================================================================= * 四、页面识别与片段提取 * =======================================================================*/ /** 找出题目容器(章节测验 / 作业 / 考试),并剔除包含其它容器的外层,保留最内层 */ function findContainers(root) { let all = []; try { all = Array.from(root.querySelectorAll(CONTAINER_SELECTOR)); } catch (e) { return []; } return all.filter((el) => !all.some((other) => other !== el && el.contains(other))); } /** 去重(按元素引用) */ function uniqEls(arr) { const seen = new Set(); return arr.filter((el) => { if (seen.has(el)) return false; seen.add(el); return true; }); } /** 通用输入框查找(兜底用) */ function findEditors(root) { const textareas = Array.from(root.querySelectorAll('textarea')); if (textareas.length) return textareas; return Array.from(root.querySelectorAll('.eidtDiv, [contenteditable="true"], input[type="text"]')); } /** * 找出填空题真正用于输入答案的控件(按文档顺序),即每个空一个: * 超星标准结构为 .blankItemDiv 内的 UEditor 源 textarea(name="answerEditor...") */ function findBlankEditors(container) { // 1) 超星标准填空题:每个空一个隐藏 textarea(UEditor 源) const ta = uniqEls(Array.from(container.querySelectorAll('.blankItemDiv textarea, textarea[name^="answerEditor"]'))); if (ta.length) return ta; // 2) 备选:可见的可编辑区域 const ce = uniqEls(Array.from(container.querySelectorAll('.blankItemDiv .eidtDiv, .blankItemInp [contenteditable="true"], .InpDIV'))); if (ce.length) return ce; // 3) 兜底 return findEditors(container); } /** 找到题干元素(章节/作业/考试三种结构) */ function findStemElement(container) { return container.querySelector('.Zy_TItle .fontLabel') || container.querySelector('.Zy_TItle .newZy_TItle') || container.querySelector('.Zy_TItle') || container.querySelector('.mark_name') || container.querySelector('.fontLabel') || container; } /** 判断是否填空题 */ function isFillBlank(container) { // 1) 题型编号为 2(填空题) try { const inputs = container.querySelectorAll('input[name^="answertype"], input[name^="type"]'); for (const inp of inputs) { if (String(inp.value) === '2') return true; } if (container.getAttribute && container.getAttribute('data') === '2' && findBlankEditors(container).length) return true; } catch (e) { /* ignore */ } // 2) 题干里出现“填空题” const text = container.innerText || container.textContent || ''; if (/填空题/.test(text)) return true; // 3) 兜底:出现 2 个及以上输入框 return findBlankEditors(container).length >= 2; } /** * 从题干中提取「带空位标记的片段文本」。 * 只取题干(.Zy_TItle 等),忽略下方独立的答题区,避免重复计数。 * 空的表示形式:连续下划线,或题干里的内联输入框。 */ function extractFragment(container, editorCount) { const stem = findStemElement(container); const clone = stem.cloneNode(true); // 把题干里的内联输入框替换为内联占位(\u0001),稍后与下划线一起当作空位 Array.from(clone.querySelectorAll('textarea, input[type="text"], .eidtDiv, [contenteditable="true"]')).forEach((el) => { if (el.parentNode) el.parentNode.replaceChild(document.createTextNode(' \u0001 '), el); }); // 临时挂到页面外,利用 innerText 保留元素之间的空格/换行 clone.style.cssText = 'position:fixed;left:-99999px;top:0;width:800px;opacity:0;pointer-events:none;'; const host = document.body || document.documentElement; host.appendChild(clone); let text = clone.innerText || clone.textContent || ''; host.removeChild(clone); text = text.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' '); // 收集所有「空位片段」:连续下划线 + 内联输入框占位 const spans = []; const runRe = /[__]{2,}/g; let m; while ((m = runRe.exec(text)) !== null) spans.push({ start: m.index, end: m.index + m[0].length, inline: false }); let p = -1; while ((p = text.indexOf('\u0001', p + 1)) !== -1) spans.push({ start: p, end: p + 1, inline: true }); spans.sort((a, b) => a.start - b.start); if (!spans.length) return { text: text.replace(/\u0001/g, ' ').trim(), blankCount: 0 }; // 以「输入框数量」为准:片段多了就把最靠近的相邻片段合并(处理被空格拆开的同一个空), // 片段不多于输入框则保持独立(处理「一个编号对应多个空」)。 let markers = spans.slice(); const target = editorCount || markers.length; while (markers.length > target) { let bi = -1; let bg = Infinity; for (let i = 0; i < markers.length - 1; i++) { const gap = markers[i + 1].start - markers[i].end; if (gap < bg) { bg = gap; bi = i; } } if (bi < 0) break; markers[bi] = { start: markers[bi].start, end: markers[bi + 1].end, inline: markers[bi].inline && markers[bi + 1].inline }; markers.splice(bi + 1, 1); } let out = ''; let last = 0; for (const mk of markers) { out += text.slice(last, mk.start) + ' ' + MARKER + ' '; last = mk.end; } out += text.slice(last); return { text: out.replace(/\u0001/g, ' ').replace(/\s{2,}/g, ' ').trim(), blankCount: markers.length }; } /* ========================================================================= * 五、填写 * =======================================================================*/ function fireEvent(el, type, isInput) { try { if (isInput && typeof InputEvent === 'function') { el.dispatchEvent(new InputEvent(type, { bubbles: true })); } else { el.dispatchEvent(new Event(type, { bubbles: true })); } } catch (e) { /* 跨域/事件构造失败时忽略 */ } } function setBlankValue(el, value, win) { try { const tag = (el.tagName || '').toLowerCase(); const UE = getUE(); if (tag === 'textarea') { const name = el.getAttribute('name') || el.id; // 超星填空题使用 UEditor:必须通过页面 window 上的 UE 实例写入 if (UE && name && typeof UE.getEditor === 'function') { const editor = UE.getEditor(name); if (editor) { const write = () => { try { editor.setContent(value); } catch (e) { el.value = value; } }; if (typeof editor.ready === 'function') editor.ready(write); // 已就绪会立即执行 write(); // 立即尝试一次 el.value = value; // 同步隐藏源 textarea fireEvent(el, 'input'); fireEvent(el, 'change'); return true; } } el.value = value; fireEvent(el, 'input'); fireEvent(el, 'change'); return true; } if (el.isContentEditable || (el.className && String(el.className).indexOf('eidtDiv') !== -1)) { try { el.focus(); } catch (e) { /* ignore */ } el.textContent = value; fireEvent(el, 'input', true); fireEvent(el, 'change'); return true; } el.value = value; fireEvent(el, 'input'); fireEvent(el, 'change'); return true; } catch (e) { return false; } } /* ========================================================================= * 五·五、音频转写(浏览器内置 Whisper,无需 API key) * * 思路: * 1) 用 GM_xmlhttpRequest 下载音频(绕过跨域); * 2) 用 Web Audio 解码并重采样为 16kHz 单声道 Float32; * 3) 动态加载 transformers.js,用 Whisper 英文模型转写为文本; * 4) 转写结果写入「原文」框,再走原来的对齐填空流程。 * =======================================================================*/ const TRANSFORMERS_URL = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js'; /** 用油猴请求下载二进制数据 */ function getBinary(url, timeoutMs) { return new Promise((resolve, reject) => { try { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'arraybuffer', timeout: timeoutMs || 120000, onload: (res) => { if (res.status >= 200 && res.status < 300 && res.response) resolve(res.response); else reject(new Error('下载失败 HTTP ' + res.status)); }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('下载超时')), }); } catch (e) { reject(e); } }); } /** 把音频解码为 16kHz 单声道 Float32Array */ async function decodeAudioTo16k(arrayBuffer) { const AC = (PAGE && PAGE.AudioContext) || (PAGE && PAGE.webkitAudioContext) || window.AudioContext || window.webkitAudioContext; const OAC = (PAGE && PAGE.OfflineAudioContext) || window.OfflineAudioContext || window.webkitOfflineAudioContext; if (!AC || !OAC) throw new Error('当前浏览器不支持 Web Audio'); const ctx = new AC(); let decoded; try { decoded = await new Promise((res, rej) => { const p = ctx.decodeAudioData(arrayBuffer.slice(0), res, rej); if (p && typeof p.then === 'function') p.then(res, rej); }); } finally { try { ctx.close(); } catch (e) { /* ignore */ } } const targetRate = 16000; const length = Math.max(1, Math.ceil(decoded.duration * targetRate)); const offline = new OAC(1, length, targetRate); const src = offline.createBufferSource(); src.buffer = decoded; src.connect(offline.destination); src.start(0); const rendered = await offline.startRendering(); return rendered.getChannelData(0); } /** 方式一:注入 type=module 脚本,把 transformers.js 导出挂到页面 window 上 */ function importViaScript() { return new Promise((resolve, reject) => { try { const key = '__cxTransformers_' + Math.random().toString(36).slice(2); const doc = (PAGE && PAGE.document) || document; const s = doc.createElement('script'); s.type = 'module'; s.textContent = 'import * as M from ' + JSON.stringify(TRANSFORMERS_URL) + ';\n' + 'window[' + JSON.stringify(key) + '] = M;'; doc.documentElement.appendChild(s); const start = Date.now(); const timer = setInterval(() => { let m; try { m = PAGE[key]; } catch (e) { m = undefined; } if (m) { clearInterval(timer); try { s.remove(); } catch (e) { /* ignore */ } resolve(m); } else if (Date.now() - start > 60000) { clearInterval(timer); reject(new Error('transformers.js 加载超时')); } }, 150); } catch (e) { reject(e); } }); } /** 方式二:通过 new Function 动态 import(避免在脚本正文里出现 import 语法) */ async function importViaFunction() { const importer = new Function('u', 'return import(u)'); const ns = await importer(TRANSFORMERS_URL); return (ns && ns.pipeline) ? ns : (ns && ns.default) ? ns.default : ns; } /** 加载 transformers.js(优先注入模块脚本,其次动态 import) */ async function loadTransformers() { if (loadTransformers._mod) return loadTransformers._mod; let mod = null; try { mod = await importViaScript(); } catch (e) { try { console.warn('[填空助手] 注入模块脚本加载失败,尝试动态 import:', e && e.message); } catch (err) { /* ignore */ } } if (!mod) { try { mod = await importViaFunction(); } catch (e) { /* ignore */ } } if (!mod || !mod.pipeline) throw new Error('无法加载 transformers.js(可能被页面 CSP 拦截)'); loadTransformers._mod = mod; return mod; } // ---------- 主线程兜底方案(worker 不可用时才用)---------- let _transcriber = null; let _transcriberModel = ''; async function createTranscriber(model, host, onProgress) { const mod = await loadTransformers(); const env = mod.env; try { env.allowLocalModels = false; } catch (e) { /* ignore */ } try { env.useBrowserCache = true; } catch (e) { /* ignore */ } try { env.backends.onnx.wasm.numThreads = 1; } catch (e) { /* ignore */ } try { env.backends.onnx.wasm.proxy = false; } catch (e) { /* ignore */ } try { env.remoteHost = host; } catch (e) { /* ignore */ } try { env.remotePathTemplate = '{model}/resolve/{revision}/'; } catch (e) { /* ignore */ } return await mod.pipeline('automatic-speech-recognition', model, { quantized: true, progress_callback: onProgress, }); } function modelHosts() { const primary = (getConfig().modelHost || 'https://hf-mirror.com').replace(/\/+$/, ''); const backup = primary.indexOf('hf-mirror.com') !== -1 ? 'https://huggingface.co' : 'https://hf-mirror.com'; return [primary, backup]; } async function getTranscriberMain(model, onProgress) { if (_transcriber && _transcriberModel === model) return _transcriber; let lastErr = null; for (const host of modelHosts()) { try { _transcriber = await createTranscriber(model, host, onProgress); _transcriberModel = model; return _transcriber; } catch (e) { lastErr = e; log('主线程加载失败(源=' + host + '):' + ((e && e.message) || e), 'error'); } } throw lastErr || new Error('模型加载失败'); } // ---------- 优先方案:在独立 Web Worker 里跑 Whisper,避免页面卡死 ---------- const TRANSFORMERS_ESM = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js'; let _worker = null; function workerCode() { return [ "import { pipeline, env } from " + JSON.stringify(TRANSFORMERS_ESM) + ";", "env.allowLocalModels = false;", "env.useBrowserCache = true;", "try { env.backends.onnx.wasm.numThreads = 1; } catch (e) {}", "let transcriber = null; let curModel = '';", "self.onmessage = async function (e) {", " var d = e.data || {};", " if (d.type !== 'transcribe') return;", " try {", " if (d.host) { try { env.remoteHost = d.host; env.remotePathTemplate = '{model}/resolve/{revision}/'; } catch (e2) {} }", " if (!transcriber || curModel !== d.model) {", " transcriber = await pipeline('automatic-speech-recognition', d.model, { quantized: true, progress_callback: function (p) { self.postMessage({ type: 'progress', p: p && { status: p.status, file: p.file, progress: p.progress } }); } });", " curModel = d.model;", " }", " var res = await transcriber(d.audio, { chunk_length_s: 30, stride_length_s: 5, return_timestamps: false });", " self.postMessage({ type: 'result', text: (res && res.text) || '' });", " } catch (err) { self.postMessage({ type: 'error', message: String((err && err.message) || err) }); }", "};", ].join('\n'); } function ensureWorker() { if (_worker) return _worker; const BlobC = (PAGE && PAGE.Blob) || Blob; const URLC = (PAGE && PAGE.URL) || URL; const WorkerC = (PAGE && PAGE.Worker) || Worker; const blob = new BlobC([workerCode()], { type: 'text/javascript' }); const url = URLC.createObjectURL(blob); _worker = new WorkerC(url, { type: 'module' }); return _worker; } function killWorker() { if (_worker) { try { _worker.terminate(); } catch (e) { /* ignore */ } _worker = null; } } function transcribeWithWorker(f32, model, host, onProgress) { return new Promise((resolve, reject) => { let w; try { w = ensureWorker(); } catch (e) { reject(e); return; } const onMsg = (ev) => { const d = ev.data || {}; if (d.type === 'progress') { if (onProgress) onProgress(d.p); } else if (d.type === 'result') { cleanup(); resolve(d.text); } else if (d.type === 'error') { cleanup(); killWorker(); reject(new Error(d.message)); } }; const onErr = () => { cleanup(); killWorker(); reject(new Error('Worker 加载失败(可能被页面 CSP 拦截)')); }; function cleanup() { try { w.removeEventListener('message', onMsg); } catch (e) { /* ignore */ } try { w.removeEventListener('error', onErr); } catch (e) { /* ignore */ } } w.addEventListener('message', onMsg); w.addEventListener('error', onErr); try { w.postMessage({ type: 'transcribe', model, host, audio: f32 }); } catch (e) { cleanup(); reject(e); } }); } /** 识别页面上的所有音频地址(一个章节可能有多个音频;排除视频) */ function detectAudioUrls() { const urls = []; const push = (u) => { if (u && urls.indexOf(u) === -1) urls.push(u); }; const isVideo = (u) => /\.(mp4|m4v|mov|avi|mkv|flv|webm|m3u8|ts)(\?|$)/i.test(u); try { // 只看