// ==UserScript== // @name 英语听力填空助手(超星学习通) // @namespace chaoxing-fill-blank // @version 4.5.2 // @author fysh // @description 超星学习通英语听力填空题助手:章节扫描音频建立任务队列,逐条「转写→匹配标红→填空」;4.5.1 增强多电脑兼容,修复部分环境音频下载 HTTP 403,并支持 403 后刷新临时音频地址重试。 // @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 // ==/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: '', // 手动粘贴/转写的英文原文 originalTextChapter: '', // 原文所属章节;跨章节时自动失效,避免把上一章原文带到新章节 autoFill: false, // 是否自动填空(默认关闭:只有显式勾选「自动填写」或手动点按钮才会填写) answerInterval: 1, // 每个空之间的填写间隔(秒) audioUrl: '', // 听力音频地址(留空则自动识别页面上的音频) audioAuto: false, // 进入页面后是否自动转写音频 autoNext: false, // 所有音频处理完成后是否自动进入下一章 whisperModel: 'Xenova/whisper-small.en', // 语音识别模型(只保留最准的 small.en) modelHost: 'https://hf-mirror.com', // 模型下载源(国内镜像更稳;官方为 https://huggingface.co) }; // 只保留最准的模型,避免「base 不准→换 small 重试→重复下载」的流程 const WHISPER_MODEL = 'Xenova/whisper-small.en'; 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 */ } } } /* ========================================================================= * 三、核心匹配算法 * * 四层匹配: * ① 文本规范化:统一大小写、撇号、用于匹配的“归一化词形” * ② 题目结构:把片段切成「词 + 空」,相邻上下文即为锚点 * ③ 容错对齐:带相似度加权的序列对齐(容忍 Whisper 的拼写/漏词误差) * ④ 消歧/置信度:按对齐结果逐空取词,并给出匹配置信度 * =======================================================================*/ /** 匹配用的词形归一化(不改动展示原文) */ function normalizeWord(w) { return String(w).toLowerCase().replace(/[\u2018\u2019\u02bc]/g, "'"); } /** 编辑距离 */ function levenshteinDistance(a, b) { const n = a.length; const m = b.length; if (!n) return m; if (!m) return n; let prev = new Array(m + 1); let cur = new Array(m + 1); for (let j = 0; j <= m; j++) prev[j] = j; for (let i = 1; i <= n; i++) { cur[0] = i; for (let j = 1; j <= m; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost); } const t = prev; prev = cur; cur = t; } return prev[m]; } function stringSimilarity(a, b) { const maxLen = Math.max(a.length, b.length); if (!maxLen) return 1; return 1 - levenshteinDistance(a, b) / maxLen; } /** 两个词的匹配得分:精确 3;近似 1~2;否则 0 */ function tokenScore(a, b) { if (a === b) return 3; if (a.length >= 4 && b.length >= 4 && Math.abs(a.length - b.length) <= 2) { const sim = stringSimilarity(a, b); if (sim >= 0.82) return 2; if (sim >= 0.7) return 1; } return 0; } /** 把文本切成英文单词数组(保留原样,含撇号/连字符) */ 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: normalizeWord(w) })); }); return tokens; } /** * 容错序列对齐:返回 { map, matched } * map[i] = 片段第 i 个非空词匹配到的原文下标(-1 表示未匹配) * matched = 成功匹配(含近似匹配)的次数,用于置信度 */ function lcsMatch(fTokens, oTokens) { const n = fTokens.length; const m = oTokens.length; if (!n || !m) return { map: new Int32Array(n).fill(-1), matched: 0 }; const fNorm = fTokens.map((t) => t.norm); const oNorm = oTokens.map((t) => normalizeWord(t)); 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++) { const s = tokenScore(fn, oNorm[j - 1]); const best = Math.max(prev[j], row[j - 1], s > 0 ? prev[j - 1] + s : 0); row[j] = best; } } const map = new Int32Array(n).fill(-1); let matched = 0; let i = n; let j = m; while (i > 0 && j > 0) { // 若当前原文词对最优分数没有贡献,先跳过它:让锚点尽量靠前, // 这样「缺失词」会留在空位区间里,而不是被遗漏在第一个锚点之前。 if (dp[i][j - 1] === dp[i][j]) { j--; continue; } const s = tokenScore(fNorm[i - 1], oNorm[j - 1]); if (s > 0 && dp[i][j] === dp[i - 1][j - 1] + s) { map[i - 1] = j - 1; matched++; i--; j--; } else if (dp[i - 1][j] >= dp[i][j - 1]) { i--; } else { j--; } } return { map: map, matched: matched }; } /** * 清洗原文:去掉答案键里的序号(形如 "1." "2." … 的递增编号)。 * 这些序号是「答案列表的编号」,不是原文内容,留着会被当成词参与匹配, * 从而把空的分配挤错位(例如 "mountain 6 climbing")。 * 只删「非字母数字且非点号」前缀 + 1~3 位数字 + 点号 + 空格,且数值必须正好接上 1,2,3… 的递增序列, * 避免误删 "0.69"、"150." 之类的真实数字。 */ function cleanOriginalText(text) { let expected = 1; return String(text == null ? '' : text).replace(/(^|[^\w.])(\d{1,3})\.(?=\s)/g, function (m, pre, num) { const n = parseInt(num, 10); // 允许跳过 1~2 个编号(原文偶尔漏抄),避免序列一旦断开就再也不清洗 if (n >= expected && n <= expected + 2) { expected = n + 1; return pre; } return m; }); } /** * 求解:把「题干片段」和「原文」做 diff,**原文里对不上的词 = 答案**。 * 关键模型:**一个「序号」= 一个答案组 = 一个输入框**,组内可以是一段短语(多个词)。 * 序号后面跟几个下划线不影响分组(下划线只表示“这里有空”,不用来数词数)。 * * 做法:按题干顺序走,遇到序号就开始一个新组;遇到空位就标记“当前组有空”; * 遇到已对齐的锚点词,就把「上一个锚点 ~ 这个锚点」之间对不上的原文词全部塞进当前组。 * 片段里的纯数字(序号 "1." "2." …)不参与对齐,避免吃掉原文里的数字答案。 * * @returns {{groups: Array<{no:number,words:string[],positions:number[]}>, matched:number, wordCount:number}} */ function matchAnswers(fragmentText, originalText) { const ft = tokenizeFragment(fragmentText); const ot = tokenize(originalText); const isSerial = (raw) => /^\d+$/.test(raw); const fWords = ft.filter((t) => !t.blank && !isSerial(t.raw)); const aligned = lcsMatch(fWords, ot); const used = new Uint8Array(ot.length); for (let i = 0; i < aligned.map.length; i++) { const oi = aligned.map[i]; if (oi >= 0) used[oi] = 1; } // 注意:groups 不能丢掉“没有识别出答案”的空。 // 否则例如 15~20 整段漏转写后,21 会被顺位写入第 15 个输入框。 const groups = []; // { no, idxs, blanks, blankFPos, reliable, reason } let current = null; let openedInGap = []; let lastO = -1; let fi = 0; // 已走过的题干非空、非序号词数量 let seqNo = 0; function newGroup(no) { const g = { no: (no == null ? ++seqNo : no), idxs: [], blanks: 0, blankFPos: [], reliable: true, reason: '', }; groups.push(g); current = g; return g; } // 处理 [lastO+1, nextOi-1]:把原文中额外出现的词分配给本区间里的空。 function flush(nextOi) { if (openedInGap.length) { const missing = []; for (let i = lastO + 1; i <= nextOi - 1; i++) if (!used[i]) missing.push(i); if (missing.length) { if (openedInGap.length === 1) { for (let k = 0; k < missing.length; k++) openedInGap[0].idxs.push(missing[k]); } else { const totalB = openedInGap.reduce((sum, g) => sum + (g.blanks || 0), 0) || openedInGap.length; let pos = 0; for (let gi = 0; gi < openedInGap.length; gi++) { const g = openedInGap[gi]; const share = Math.round(missing.length * ((g.blanks || 1) / totalB)); const part = missing.slice(pos, Math.min(pos + share, missing.length)); for (let k = 0; k < part.length; k++) g.idxs.push(part[k]); pos += part.length; } for (let k = pos; k < missing.length; k++) openedInGap[openedInGap.length - 1].idxs.push(missing[k]); } } } openedInGap = []; } for (const tok of ft) { if (tok.blank) { if (!current) newGroup(null); current.blanks++; // 记录这个空位位于第几个“已知题干词”之后,用于后面的局部断裂判断。 current.blankFPos.push(fi); if (openedInGap.indexOf(current) < 0) openedInGap.push(current); continue; } if (isSerial(tok.raw)) { newGroup(parseInt(tok.raw, 10)); continue; } const oi = aligned.map[fi++]; if (oi >= 0) { flush(oi); lastO = oi; } } flush(ot.length); // --------------------------------------------------------------------- // 局部断裂检测 // 正常填空:题干相邻锚点之间,原文通常会“多出”答案词,因此 oGap >= fGap。 // Whisper 整段漏转写:题干两个锚点之间还有很多正常已知词,但原文里它们几乎消失, // 会出现 fGap 很大、oGap 很小。此时该区间里的空全部标记为不可靠,禁止写入。 // --------------------------------------------------------------------- function prevMatchedIndex(pos) { for (let i = Math.min(pos - 1, aligned.map.length - 1); i >= 0; i--) { if (aligned.map[i] >= 0) return i; } return -1; } function nextMatchedIndex(pos) { for (let i = Math.max(0, pos); i < aligned.map.length; i++) { if (aligned.map[i] >= 0) return i; } return -1; } groups.forEach(function (g) { if (!g.blanks) return; const bpos = g.blankFPos.length ? g.blankFPos[0] : 0; const pf = prevMatchedIndex(bpos); const nf = nextMatchedIndex(bpos); if (pf < 0 || nf < 0 || nf <= pf) return; const po = aligned.map[pf]; const no = aligned.map[nf]; if (po < 0 || no < 0 || no <= po) return; const fGap = nf - pf - 1; // 两个锚点之间题干仍有多少个正常已知词 const oGap = no - po - 1; // 两个锚点之间转写原文有多少个词 // 保守阈值:至少丢了 5 个正常题干词,并且原文只保留不到约 30%。 // 这样不会把普通 1~2 个 Whisper 拼写/漏词误判成“整段缺失”。 const collapsed = fGap >= 6 && (fGap - oGap) >= 5 && oGap <= Math.max(1, Math.floor(fGap * 0.30)); if (collapsed) { g.reliable = false; g.reason = '疑似转写断层:题干锚点间有 ' + fGap + ' 个已知词,但转写仅剩 ' + oGap + ' 个'; } }); // 关键:所有真正含空位的组都要保留,即使 words=[]。 // 这样第 21 空永远仍对应第 21 个输入框,不会因为 15~20 缺失而前移。 const out = []; for (let gi = 0; gi < groups.length; gi++) { const g = groups[gi]; if (!g.blanks) continue; out.push({ no: g.no, words: g.idxs.map((i) => ot[i]), positions: g.idxs.slice(), reliable: g.reliable !== false, reason: g.reason || '', }); } return { groups: out, matched: aligned.matched, wordCount: fWords.length, 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'; /* ------------------------------------------------------------------------- * 音频下载(4.5.1) * * 某些学习通/CDN 音频会校验 Cookie、Referer 或临时签名。原版直接用 * GM_xmlhttpRequest 再请求一次,在另一台电脑上可能出现 HTTP 403。 * 现在采用: * ① 页面 fetch(优先复用当前页面登录态/Cookie/浏览器 Referer) * ② GM_xmlhttpRequest(跨域兜底,并尽量带当前音频所在页面 Referer) * ③ 上层若收到 401/403,会重新扫描当前章节拿最新 URL 后再试一次 * -----------------------------------------------------------------------*/ const _audioMeta = new Map(); // url -> { referer },扫描音频时记录其所在页面 function rememberAudioMeta(url, referer) { if (!url) return; try { const r = String(referer || '').trim(); _audioMeta.set(String(url), { referer: r || location.href }); } catch (e) { /* ignore */ } } function audioReferer(url) { try { const m = _audioMeta.get(String(url)); if (m && m.referer) return m.referer; } catch (e) { /* ignore */ } try { return location.href; } catch (e) { return ''; } } function shortSafeUrl(url) { try { const u = new URL(String(url), location.href); return u.origin + u.pathname; // 日志不打印 query,避免把临时签名/token 暴露出来 } catch (e) { return String(url || '').split('?')[0].split('#')[0]; } } function httpDownloadError(prefix, status, url, finalUrl) { const shown = shortSafeUrl(finalUrl || url); const err = new Error(prefix + ' HTTP ' + status + (shown ? ';' + shown : '')); err.status = Number(status) || 0; err.url = url || ''; err.finalUrl = finalUrl || ''; return err; } function isAuthDownloadError(err) { const s = Number(err && err.status); return s === 401 || s === 403; } /** 方式一:使用页面自己的 fetch,优先复用页面登录态 */ async function getBinaryViaPageFetch(url, timeoutMs) { const timeout = timeoutMs || 120000; const pageFetch = PAGE && typeof PAGE.fetch === 'function' ? PAGE.fetch.bind(PAGE) : null; if (!pageFetch) throw new Error('页面 fetch 不可用'); const AC = (PAGE && PAGE.AbortController) || (typeof AbortController !== 'undefined' ? AbortController : null); const controller = AC ? new AC() : null; const timer = controller ? setTimeout(function () { try { controller.abort(); } catch (e) { /* ignore */ } }, timeout) : null; try { const res = await pageFetch(url, { method: 'GET', credentials: 'include', cache: 'no-store', redirect: 'follow', signal: controller ? controller.signal : undefined, }); if (!res || !res.ok) throw httpDownloadError('页面请求失败', res ? res.status : 0, url, res && res.url); const buf = await res.arrayBuffer(); if (!buf || !buf.byteLength) throw new Error('页面请求返回空音频'); return buf; } finally { if (timer) clearTimeout(timer); } } /** 方式二:GM_xmlhttpRequest 跨域兜底 */ function getBinaryViaGM(url, timeoutMs) { const timeout = timeoutMs || 120000; const referer = audioReferer(url); return new Promise((resolve, reject) => { try { const headers = { 'Accept': 'audio/*,application/octet-stream;q=0.9,*/*;q=0.8', 'Cache-Control': 'no-cache', 'Pragma': 'no-cache', 'Range': 'bytes=0-', // 尽量模拟浏览器媒体请求,部分音频 CDN 对 Range 更兼容 }; // Tampermonkey/Violentmonkey 若允许设置 Referer,则使用音频所在页面; // 若浏览器扩展层忽略该受限请求头,也不会影响请求本身。 if (referer) headers['Referer'] = referer; GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'arraybuffer', timeout: timeout, anonymous: false, headers: headers, onload: (res) => { if (res.status >= 200 && res.status < 300 && res.response && res.response.byteLength) { resolve(res.response); } else { reject(httpDownloadError('油猴请求失败', res.status, url, res.finalUrl)); } }, onerror: (res) => { const err = new Error('油猴请求网络错误;' + shortSafeUrl((res && res.finalUrl) || url)); err.status = Number(res && res.status) || 0; reject(err); }, ontimeout: () => reject(new Error('音频下载超时;' + shortSafeUrl(url))), }); } catch (e) { reject(e); } }); } /** 页面登录态优先;失败后自动切 GM 请求。两个方式都失败时保留更有意义的 HTTP 状态 */ async function getBinary(url, timeoutMs) { let pageErr = null; try { log('尝试使用页面登录态下载音频…'); const buf = await getBinaryViaPageFetch(url, timeoutMs); log('页面登录态下载成功', 'success'); return buf; } catch (e) { pageErr = e; log('页面下载未成功:' + ((e && e.message) || e) + ',改用油猴请求…', 'info'); } try { const buf = await getBinaryViaGM(url, timeoutMs); log('油猴请求下载成功', 'success'); return buf; } catch (gmErr) { // 若 GM 明确返回了 HTTP 状态,以 GM 状态为主;否则保留页面 fetch 的 401/403。 if (gmErr && gmErr.status) throw gmErr; if (pageErr && pageErr.status) throw pageErr; throw gmErr || pageErr || new Error('音频下载失败'); } } /** 把音频解码为 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); } }); } const VIDEO_URL_RE = /\.(mp4|m4v|mov|avi|mkv|flv|webm|m3u8|ts)(\?|#|$)/i; /** blob:/data:/about:/javascript:、视频格式地址不参与转写 */ function isNonAudioUrl(u) { return !u || /^blob:/i.test(u) || /^data:/i.test(u) || /^about:/i.test(u) || /^javascript:/i.test(u) || VIDEO_URL_RE.test(u); } /** * 从单个 document 里按「文档顺序」收集