// ==UserScript== // @name Via 网页翻译(Bilibili Index-Translate) // @namespace via.index.translate // @version 1.9.10 // @description 右侧按钮组:整页外文译中文(双语/仅译文)+ 图片识图翻译 + 模型切换。引擎:Bilibili Index-Translate // @author NoteGPT // @match http://*/* // @match https://*/* // @run-at document-end // @grant none // @license MIT // ==/UserScript== /* v1.9.0 相对 1.8.3 的修复 * 1. 长按切「仅译文」失效:长按后浏览器仍会补发 click,导致刚开就被关掉。改为抑制合成 click。 * 2. 三个按钮重叠:图片按钮 bottom 与文字按钮 top 相撞(手机上重叠 17px)。改为统一按钮坞纵向排列。 * 3. 图片翻译主路径必失败:接口不接受远程 URL(30s 超时 504),原来总是掉到需登录的第三方 OCR。 * 改为 canvas 压缩 → base64 内联直传(实测 4s 出结果),跨域被污染时自动走 weserv 代理重取。 * 4. 还原不干净:原元素上残留 vtx-hide 类。现在一并清除。 * 5. 并发 3 + 间隔 150ms 会撞 503 风暴。改为全局限流闸门 + 指数退避 + 抖动。 * 6. 被用户停止后仍提示「翻译完成」。改为按实际状态提示。 * 7. 跳过导航/页头用 className 子串匹配,会误判(如 canvas 含 nav)。改为按类名分词精确匹配。 * 8. 杂项:重复注册 mouseup、图片拦截未阻断页面自身处理、识图建议用 35B。 */ (function () { 'use strict'; if (window.__vtxInjected) return; // 不在 iframe 里运行,避免多出一套按钮 if (window.top !== window.self) return; window.__vtxInjected = true; /* ================== 可调参数 ================== */ var CFG = { api: 'https://index-translate.bilibili.com/v1/chat/completions', model: 'Index-Translate-35B-A3B', batchChars: 900, // 单次请求携带的字符数(实测 >1000 容易超时/漏译) segMax: 1000, // 单段超长则按句子切开 gap: 180, // 批次之间的间隔(毫秒) concurrency: 3, // 并发批次数量(配合限流闸门,3 较稳) retry: 3, // 单请求最大尝试次数 maxBlocks: 600, maxChars: 60000, imgMaxSide: 1400, // 识图前把图片最长边压到这么多像素 imgQuality: 0.82 // 识图 JPEG 质量 }; var MODELS = [ { id: 'Index-Translate-2B', short: '2B', label: '快速(2B,文字够用,识图较差)' }, { id: 'Index-Translate-9B', short: '9B', label: '均衡(9B)' }, { id: 'Index-Translate-35B-A3B', short: '35B', label: '高质量(35B)' } ]; function loadModel() { try { var saved = localStorage.getItem('vtx_model'); if (saved) { for (var i = 0; i < MODELS.length; i++) { if (MODELS[i].id === saved) { CFG.model = saved; return i; } } } } catch (e) { } return 2; } function saveModel(idx) { CFG.model = MODELS[idx].id; try { localStorage.setItem('vtx_model', CFG.model); } catch (e) { } } var modelIdx = loadModel(); /* ================== 常量 ================== */ var BLOCK_TAGS = { P: 1, DIV: 1, LI: 1, UL: 1, OL: 1, DL: 1, DT: 1, DD: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1, SECTION: 1, ARTICLE: 1, ASIDE: 1, HEADER: 1, FOOTER: 1, MAIN: 1, NAV: 1, BLOCKQUOTE: 1, FIGURE: 1, FIGCAPTION: 1, TABLE: 1, THEAD: 1, TBODY: 1, TFOOT: 1, TR: 1, TD: 1, TH: 1, FORM: 1, FIELDSET: 1, ADDRESS: 1, DETAILS: 1, SUMMARY: 1, BODY: 1 }; var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, IFRAME: 1, SVG: 1, CANVAS: 1, VIDEO: 1, AUDIO: 1, TEXTAREA: 1, INPUT: 1, SELECT: 1, OPTION: 1, BUTTON: 1, CODE: 1, PRE: 1, KBD: 1, SAMP: 1, TEMPLATE: 1, HEAD: 1, META: 1, LINK: 1 }; // 这些容器里的文字属于站点导航/页脚,不翻译(按类名分词精确匹配,避免误伤) var CHROME_RE = /^(nav|navbar|navigation|navi|menu|menubar|submenu|topbar|header|masthead|footer|sidebar|breadcrumb|site-nav|main-nav|site-header|page-header|site-footer|page-footer)([-_]|$)/; var PROMPT_BATCH = [ 'Translate each numbered line into Simplified Chinese (简体中文).', 'Output rules:', '1. Output exactly one line per input line, in the same order, no blank lines.', '2. Keep the "N|||" prefix exactly as in the input, then put the translation after it.', '3. Output the translation only: no explanation, no notes, no quotes, no markdown.', '4. Keep numbers, URLs, emails, file paths and code identifiers unchanged.', '' ].join('\n'); var PROMPT_SINGLE = 'Translate the following text into Simplified Chinese. Output only the translation, without quotes or any explanation:\n\n'; // 注意:模板里绝对不能出现 <占位符>,模型会把尖括号里的占位符原样输出 var PROMPT_IMAGE = '识别图片中的文字并翻译成简体中文。用下面两个标记分段输出,标记各占一行:\n【原文】\n【译文】'; /* ================== 状态 ================== */ var S = { running: false, cancel: false, hasResult: false, onlyMode: false, total: 0, done: 0, unreadable: 0, failed: 0 }; var IMG = { mode: false, loading: false }; var GATE = { until: 0 }; var NET = { dead: false }; var CTRL = null; // AbortController,停止时取消在飞请求 var CREATED = { blocks: [], modified: [] }; // 记录脚本创建/修改的元素,避免误删网页原有内容 /* ================== 工具函数 ================== */ function sleep(ms) { return new Promise(function (res) { setTimeout(res, ms); }); } function norm(s) { return String(s || '').replace(/\s+/g, ' ').trim(); } var LETTER_RE = /[A-Za-z\u00C0-\u024F\u0370-\u03FF\u0400-\u04FF\u0590-\u05FF\u0600-\u06FF\u0E00-\u0E7F\u3040-\u30FF\uAC00-\uD7AF]/; var HAN_RE = /[\u3400-\u4DBF\u4E00-\u9FFF]/g; var KANA_RE = /[\u3040-\u30FF]/; function needTranslate(t) { if (!t || t.length < 2) return false; if (!LETTER_RE.test(t)) return false; if (KANA_RE.test(t)) return true; var han = (t.match(HAN_RE) || []).length; if (han > 0 && han / t.length > 0.3) return false; return true; } function splitSeg(text, max) { var out = []; var rest = String(text); while (rest.length > max) { var head = rest.slice(0, max + 1); var cut = -1; var re = /[.!?;:。!?;:]["'”’)\]]?\s/g; var m; while ((m = re.exec(head)) !== null) { if (m.index + 1 >= max * 0.35) cut = m.index + m[0].length; } if (cut <= 0) { var sp = rest.slice(0, max).lastIndexOf(' '); cut = sp > max * 0.4 ? sp : max; } out.push(rest.slice(0, cut).trim()); rest = rest.slice(cut).trim(); } if (rest) out.push(rest); return out.length ? out : [text]; } function parseNumbered(content, n) { var res = new Array(n); var lines = String(content).replace(/\r/g, '').split('\n'); for (var i = 0; i < lines.length; i++) { var m = lines[i].match(/^\s*(\d{1,3})\s*[||]{1,3}\s*([\s\S]*)$/); if (m) { var k = parseInt(m[1], 10) - 1; if (k >= 0 && k < n && !res[k]) { var v = m[2].trim(); if (v) res[k] = v; } } } for (var j = 0; j < n; j++) if (!res[j]) return null; return res; } function parseByLines(content, n) { var arr = String(content).replace(/\r/g, '').split('\n') .map(function (s) { return s.replace(/^\s*\d{1,3}\s*[||]{1,3}\s*/, '').trim(); }) .filter(function (s) { return s.length > 0; }); if (arr.length !== n) return null; return arr; } // 清洗单条译文;结果里没有汉字且原文含拉丁字母,视为「没翻译」丢弃 function cleanOne(c, src) { var t = String(c || '').trim(); t = t.replace(/^["'“”‘’]+|["'“”‘’]+$/g, '').trim(); t = t.replace(/^译文[::]\s*/, '').trim(); if (!t) return ''; if (!/[\u4e00-\u9fff]/.test(t)) { if (!src || /[A-Za-z]{3,}/.test(src)) return ''; return t; } return t; } /* ================== 网络请求 ================== */ function waitGate() { var d = GATE.until - Date.now(); return d > 0 ? sleep(d) : Promise.resolve(); } function coolDown(ms) { GATE.until = Math.max(GATE.until, Date.now() + ms); } function post(body, timeoutMs, ignoreGlobal) { var ac = new AbortController(); var timer = setTimeout(function () { ac.abort(); }, timeoutMs || 25000); var onGlobalAbort = function () { ac.abort(); }; if (!ignoreGlobal && CTRL && CTRL.signal) { if (CTRL.signal.aborted) ac.abort(); else CTRL.signal.addEventListener('abort', onGlobalAbort); } return fetch(CFG.api, { method: 'POST', mode: 'cors', credentials: 'omit', signal: ac.signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(function (r) { if (!r.ok) { var err = new Error('HTTP ' + r.status); err.status = r.status; throw err; } return r.json(); }).then(function (d) { var c = d && d.choices && d.choices[0] && d.choices[0].message && d.choices[0].message.content; if (!c || !String(c).trim()) throw new Error('empty'); return String(c); }).finally(function () { clearTimeout(timer); if (!ignoreGlobal && CTRL && CTRL.signal) CTRL.signal.removeEventListener('abort', onGlobalAbort); }); } function request(prompt, attempt) { attempt = attempt || 1; if (NET.dead) return Promise.reject(new Error('net-dead')); if (S.cancel) { var e = new Error('cancelled'); e.name = 'AbortError'; return Promise.reject(e); } return waitGate().then(function () { if (S.cancel) { var e2 = new Error('cancelled'); e2.name = 'AbortError'; throw e2; } return post({ model: CFG.model, temperature: 0.1, stream: false, messages: [ { role: 'system', content: '你是专业翻译引擎。把用户给的英文内容翻译成简体中文。只输出译文本身,不要解释。' }, { role: 'user', content: prompt } ] }); }).catch(function (err) { // 用户主动取消(CTRL.abort 或 S.cancel 置位),不重试 if (err.name === 'AbortError' && (S.cancel || (CTRL && CTRL.signal.aborted))) throw err; // 请求超时(ac.abort 但 CTRL 未取消),当作网络错误重试 // 页面 CSP 的 connect-src 或断网会让 fetch 直接抛 TypeError,重试没有意义 var isNetErr = !err.status && (err.name === 'TypeError' || err.name === 'AbortError' || /Failed to fetch|NetworkError|Load failed|net-dead/i.test(err.message || '')); if (isNetErr && !(CTRL && CTRL.signal.aborted)) { // 超时也重试,但不标记 NET.dead(只是这一次慢) if (err.name === 'AbortError') { if (attempt >= CFG.retry) throw err; return sleep(1000).then(function () { return request(prompt, attempt + 1); }); } NET.dead = true; throw err; } if (err.name === 'AbortError') throw err; // 限流类错误:整体冷却,且退避时间随次数递增 if (!err.status || err.status === 429 || err.status >= 500) { coolDown(900 * attempt + Math.round(Math.random() * 400)); } if (attempt >= CFG.retry) throw err; return sleep(700 * attempt + Math.round(Math.random() * 300)) .then(function () { return request(prompt, attempt + 1); }); }); } // 逐条翻译(批量失败时的兜底) function translateIndividually(items) { var out = new Array(items.length); var nextIdx = 0; function worker() { if (S.cancel || NET.dead) return Promise.resolve(); var i = nextIdx++; if (i >= items.length) return Promise.resolve(); var k = i; return request(PROMPT_SINGLE + items[k].chunk.src, 1).then(function (c) { out[k] = cleanOne(c, items[k].chunk.src); }).catch(function () { out[k] = ''; }) .then(function () { return sleep(160); }) .then(worker); } var starts = []; for (var w = 0; w < Math.min(CFG.concurrency, items.length); w++) starts.push(worker()); return Promise.all(starts).then(function () { return out; }); } function translateBatch(items) { var n = items.length; if (n === 1) { return request(PROMPT_SINGLE + items[0].chunk.src, 1) .then(function (c) { return [cleanOne(c, items[0].chunk.src)]; }) .catch(function () { return []; }); } var lines = items.map(function (it, i) { return (i + 1) + '|||' + String(it.chunk.src).replace(/\s*\n\s*/g, ' '); }); return request(PROMPT_BATCH + lines.join('\n'), 1).then(function (c) { var arr = parseNumbered(c, n) || parseByLines(c, n); if (!arr) return null; return arr.map(function (s, i) { return cleanOne(s, items[i] && items[i].chunk.src); }); }).catch(function (err) { if (S.cancel) return []; return null; }).then(function (res) { if (res || S.cancel || NET.dead) return res || []; return translateIndividually(items); }); } /* ================== 页面文本抽取 ================== */ function isVisible(el) { var r = el.getBoundingClientRect(); // 小于 2px 的块基本是屏读辅助元素(如 Skip to main content),不翻 if (r.width < 2 || r.height < 2) return false; var cs; try { cs = window.getComputedStyle(el); } catch (e) { return true; } if (!cs) return true; if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false; return true; } function isChrome(el) { var p = el; while (p && p !== document.body) { var pt = p.tagName; if (pt === 'NAV' || pt === 'HEADER' || pt === 'FOOTER' || pt === 'ASIDE') return true; var cls = (typeof p.className === 'string') ? p.className : ''; if (cls) { var toks = cls.toLowerCase().split(/\s+/); for (var i = 0; i < toks.length; i++) { if (toks[i] && CHROME_RE.test(toks[i])) return true; } } p = p.parentNode; } return false; } function collect() { var out = []; function walk(el) { if (!el || el.nodeType !== 1) return; if (SKIP_TAGS[el.tagName]) return; if (el.id && el.id.indexOf('vtx-') === 0) return; if (el.className && typeof el.className === 'string' && el.className.indexOf('vtx-') === 0) return; if (el.getAttribute && el.getAttribute('contenteditable') === 'true') return; if (el.getAttribute && el.getAttribute('aria-hidden') === 'true') return; if (el.classList && el.classList.contains('vtx-done')) return; if (isChrome(el)) return; var kids = el.children; var hasBlockChild = false; var hasImgChild = false; for (var i = 0; i < kids.length; i++) { var ct = kids[i].tagName; if (BLOCK_TAGS[ct]) { hasBlockChild = true; break; } if (ct === 'IMG') hasImgChild = true; } if (hasBlockChild) { for (var j = 0; j < kids.length; j++) walk(kids[j]); return; } // 含图片的卡片:继续往下找文字子节点,不把图片 alt 混进翻译文本 if (hasImgChild) { for (var k2 = 0; k2 < kids.length; k2++) { if (kids[k2].tagName !== 'IMG') walk(kids[k2]); } var directText = ''; for (var n = 0; n < el.childNodes.length; n++) { if (el.childNodes[n].nodeType === 3) directText += el.childNodes[n].nodeValue; } directText = norm(directText); if (needTranslate(directText) && isVisible(el)) out.push({ el: el, text: directText }); return; } var t = norm(el.textContent); if (!needTranslate(t)) return; if (!isVisible(el)) return; out.push({ el: el, text: t }); } if (document.body) walk(document.body); return out; } /* ================== 样式 ================== */ function injectStyle() { if (document.getElementById('vtx-style')) return; var st = document.createElement('style'); st.id = 'vtx-style'; st.textContent = // 按钮坞:三个按钮竖向排列,整体可拖动 '#vtx-dock{position:fixed;right:5px;top:50%;transform:translateY(-50%);z-index:2147483645;' + 'display:flex;flex-direction:column;align-items:flex-end;gap:8px;touch-action:none;}' + '#vtx-dock>div{width:40px;min-height:52px;padding:6px 0;box-sizing:border-box;' + 'display:flex;align-items:center;justify-content:center;' + 'color:#fff;font:600 15px/1.15 -apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;' + 'border-radius:14px 4px 4px 14px;box-shadow:0 2px 8px rgba(0,0,0,.22);cursor:pointer;' + 'opacity:.9;-webkit-tap-highlight-color:transparent;-webkit-user-select:none;user-select:none;' + 'letter-spacing:0;text-align:center;}' + '#vtx-dock.vtx-left{right:auto;left:5px;align-items:flex-start;}' + '#vtx-dock.vtx-left>div{border-radius:4px 14px 14px 4px;}' + '#vtx-fab{background:rgba(0,161,214,.92);}' + '#vtx-imgfab{background:rgba(230,126,34,.92);}' + '#vtx-modelfab{background:rgba(90,90,90,.92);font-size:11px;min-height:30px;width:36px;}' + '@media (min-width:768px){#vtx-dock>div{width:36px;min-height:46px;font-size:13px;}' + '#vtx-modelfab{width:32px;min-height:26px;font-size:10px;}}' + '#vtx-dock>div:active{opacity:1;}' + '#vtx-fab.vtx-run{background:rgba(251,114,153,.95);font-size:12px;}' + '#vtx-fab.vtx-done{background:rgba(70,150,95,.95);}' + '#vtx-fab.vtx-only{background:rgba(155,89,182,.95);}' + '#vtx-imgfab.vtx-on{background:rgba(230,126,34,1);box-shadow:0 0 0 3px rgba(230,126,34,.35);}' + // 译文块 '.vtx-block{display:block;margin:6px 0 8px;padding:6px 9px;border-left:3px solid #00a1d6;' + 'background:rgba(0,161,214,.09);border-radius:0 4px 4px 0;font-size:14px;line-height:1.65;' + 'min-height:1em;color:#222 !important;text-align:left;word-break:break-word;}' + 'body.vtx-only .vtx-hide{display:none !important;}' + 'body.vtx-only .vtx-block{margin:0 0 6px;padding:0;border-left:none;' + 'background:transparent;border-radius:0;font-size:16px;line-height:1.7;}' + 'body.vtx-imgmode img{outline:2px dashed rgba(230,126,34,.6);outline-offset:-2px;}' + // 图片翻译面板 '#vtx-panel{position:fixed;left:8px;right:8px;bottom:8px;z-index:2147483646;' + 'max-height:55vh;overflow-y:auto;background:#fff;color:#222;border-radius:12px;' + 'box-shadow:0 -4px 24px rgba(0,0,0,.25);padding:14px 14px 10px;box-sizing:border-box;' + 'font:14px/1.6 -apple-system,"PingFang SC",Arial,sans-serif;display:none;}' + '#vtx-panel.vtx-show{display:block;}' + '#vtx-panel .vtx-panel-title{font-weight:700;font-size:13px;color:#e67e22;margin-bottom:8px;}' + '#vtx-panel .vtx-panel-sec{margin:8px 0;}' + '#vtx-panel .vtx-panel-label{font-size:12px;color:#888;margin-bottom:2px;}' + '#vtx-panel .vtx-panel-body{white-space:pre-wrap;word-break:break-word;}' + '#vtx-panel .vtx-panel-close{position:absolute;top:8px;right:12px;font-size:20px;color:#999;' + 'cursor:pointer;line-height:1;padding:2px 4px;}' + '#vtx-toast{position:fixed;left:50%;bottom:16%;transform:translateX(-50%);z-index:2147483647;' + 'max-width:80%;padding:8px 14px;border-radius:18px;background:rgba(0,0,0,.78);color:#fff;' + 'font:14px/1.4 -apple-system,"PingFang SC",Arial,sans-serif;text-align:center;' + 'pointer-events:none;opacity:1;transition:opacity .35s;}'; (document.head || document.documentElement).appendChild(st); } function toast(msg, duration) { duration = duration || 2200; if (window.via && typeof window.via.toast === 'function') { try { window.via.toast(String(msg)); return; } catch (e) { } } var t = document.getElementById('vtx-toast'); if (!t) { t = document.createElement('div'); t.id = 'vtx-toast'; document.body.appendChild(t); } t.textContent = String(msg); t.style.opacity = '1'; clearTimeout(toast._t); toast._t = setTimeout(function () { t.style.opacity = '0'; setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 400); }, duration); } /* ================== 拖拽 ================== */ var dock = null; function makeDraggable(el) { var lpTimer = null, lpFiredAt = 0; var dragging = false, moved = false, pressed = false; var sx = 0, sy = 0; function clearLp() { if (lpTimer) { clearTimeout(lpTimer); lpTimer = null; } } function pt(e) { return (e.touches && e.touches.length) ? e.touches[0] : e; } function doMove(e) { if (!pressed) return; var p = pt(e); if (!p || typeof p.clientX !== 'number') return; var dx = p.clientX - sx, dy = p.clientY - sy; if (!dragging && !moved && Math.sqrt(dx * dx + dy * dy) > 12) { dragging = true; moved = true; clearLp(); var r = el.getBoundingClientRect(); el.style.right = 'auto'; el.style.left = r.left + 'px'; el.style.top = r.top + 'px'; el.style.transform = 'none'; } if (dragging) { if (e.cancelable) e.preventDefault(); // 每帧以当前指针为基准,避免累加误差 var r2 = el.getBoundingClientRect(); var nl = Math.max(0, Math.min(window.innerWidth - r2.width, r2.left + dx)); var nt = Math.max(0, Math.min(window.innerHeight - r2.height, r2.top + dy)); el.style.left = nl + 'px'; el.style.top = nt + 'px'; sx = p.clientX; sy = p.clientY; } } function finishDrag() { pressed = false; if (!dragging) return; dragging = false; var r = el.getBoundingClientRect(); var centerX = r.left + r.width / 2; el.style.top = Math.max(6, Math.min(window.innerHeight - r.height - 6, r.top)) + 'px'; if (centerX < window.innerWidth / 2) { el.classList.add('vtx-left'); el.style.left = '5px'; el.style.right = 'auto'; } else { el.classList.remove('vtx-left'); el.style.right = '5px'; el.style.left = 'auto'; } } document.addEventListener('mouseup', finishDrag, true); document.addEventListener('mousemove', doMove, true); document.addEventListener('touchmove', doMove, { passive: false }); document.addEventListener('touchend', function () { if (dragging) finishDrag(); }, true); return { onDown: function (e, longPressCb) { var p = pt(e); if (!p) return; sx = p.clientX; sy = p.clientY; pressed = true; dragging = false; moved = false; clearLp(); if (longPressCb) { lpTimer = setTimeout(function () { lpTimer = null; lpFiredAt = Date.now(); longPressCb(); }, 500); } }, onUp: function () { clearLp(); finishDrag(); }, // 长按结束后浏览器还会补发一次 click,必须吞掉 justLongPressed: function () { if (lpFiredAt && (Date.now() - lpFiredAt < 900)) { lpFiredAt = 0; return true; } return false; }, wasMoved: function () { return moved; }, resetMoved: function () { moved = false; } }; } function bindButton(btn, drag, onTap, longPressCb) { btn.addEventListener('touchstart', function (e) { drag.onDown(e, longPressCb); }, { passive: true }); btn.addEventListener('touchend', function () { drag.onUp(); }, false); btn.addEventListener('touchcancel', function () { drag.onUp(); }, false); btn.addEventListener('mousedown', function (e) { drag.onDown(e, longPressCb); }, false); btn.addEventListener('mouseup', function () { drag.onUp(); }, false); btn.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); if (drag.justLongPressed()) return; // 长按后补发的 click,忽略 if (drag.wasMoved()) { drag.resetMoved(); return; } onTap(); }, false); } /* ================== 文字翻译按钮 ================== */ var fab = null; function setFab(txt, mode) { if (!fab) return; fab.textContent = txt; fab.className = mode === 'run' ? 'vtx-run' : (mode === 'only' ? 'vtx-only' : (mode === 'done' ? 'vtx-done' : '')); } function refreshFab() { if (S.running) return; if (!S.hasResult) { setFab('译', ''); return; } setFab('原', S.onlyMode ? 'only' : 'done'); } function createDock() { var existing = document.getElementById('vtx-dock'); if (existing) { dock = existing; fab = document.getElementById('vtx-fab'); return; } dock = document.createElement('div'); dock.id = 'vtx-dock'; fab = document.createElement('div'); fab.id = 'vtx-fab'; fab.setAttribute('role', 'button'); fab.setAttribute('aria-label', '翻译本页'); fab.textContent = '译'; dock.appendChild(fab); (document.body || document.documentElement).appendChild(dock); } /* ================== 仅译文模式 ================== */ function toggleOnlyMode() { if (S.running) { toast('翻译中,请先停止'); return; } if (!S.hasResult) { toast('先翻译,再长按按钮切仅译文'); return; } S.onlyMode = !S.onlyMode; if (S.onlyMode) { document.body.classList.add('vtx-only'); toast('仅显示译文(长按切回双语)'); } else { document.body.classList.remove('vtx-only'); toast('已切回双语对照'); } refreshFab(); } /* ================== 图片翻译 ================== */ var imgFab = null; function resolveImgUrl(imgEl) { var src = imgEl.currentSrc || imgEl.src || imgEl.getAttribute('data-src') || imgEl.getAttribute('data-original') || imgEl.getAttribute('data-lazy-src') || ''; if (!src) return ''; if (src.indexOf(',') > -1 && src.indexOf(' ') > -1) src = src.trim().split(/\s+/)[0]; if (src.charAt(0) === '/' && src.charAt(1) === '/') src = location.protocol + src; else if (src.charAt(0) === '/') src = location.origin + src; return src; } function proxied(url) { if (!url || url.indexOf('data:') === 0) return ''; return 'https://images.weserv.nl/?url=' + encodeURIComponent(url.replace(/^https?:\/\//, '')); } function loadForCanvas(url) { return new Promise(function (resolve, reject) { var im = new Image(); im.crossOrigin = 'anonymous'; var timer = setTimeout(function () { im.onload = im.onerror = null; reject(new Error('图片加载超时')); }, 15000); im.onload = function () { clearTimeout(timer); resolve(im); }; im.onerror = function () { clearTimeout(timer); reject(new Error('load fail')); }; im.src = url; }); } // 把图片压成 base64(接口只吃内联图片,远程 URL 会 504) function toDataUrl(imgEl) { var first = resolveImgUrl(imgEl); if (!first) return Promise.reject(new Error('读不到图片地址')); var list = [first]; var p = proxied(first); if (p) list.push(p); function attempt(i) { if (i >= list.length) return Promise.reject(new Error('图片无法读取(跨域限制)')); return loadForCanvas(list[i]).then(function (im) { var w = im.naturalWidth || im.width, h = im.naturalHeight || im.height; if (!w || !h) throw new Error('bad size'); var scale = Math.min(1, CFG.imgMaxSide / Math.max(w, h)); var cw = Math.max(1, Math.round(w * scale)), ch = Math.max(1, Math.round(h * scale)); var cv = document.createElement('canvas'); cv.width = cw; cv.height = ch; var ctx = cv.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, cw, ch); ctx.drawImage(im, 0, 0, cw, ch); var data; try { data = cv.toDataURL('image/jpeg', CFG.imgQuality); } catch (e) { throw new Error('tainted'); } // 画布被跨域污染 if (!data || data.indexOf('data:image') !== 0) throw new Error('encode fail'); return data; }).catch(function () { return attempt(i + 1); }); } return attempt(0); } function biliImageTranslate(dataUrl, attempt) { attempt = attempt || 1; var model = (CFG.model === 'Index-Translate-2B') ? 'Index-Translate-35B-A3B' : CFG.model; return waitGate().then(function () { return post({ model: model, temperature: 0.1, stream: false, messages: [{ role: 'user', content: [ { type: 'text', text: PROMPT_IMAGE }, { type: 'image_url', image_url: { url: dataUrl } } ] }] }, 20000, true); }).catch(function (err) { if (attempt >= CFG.retry) throw err; if (!err.status || err.status === 429 || err.status >= 500) { coolDown(900 * attempt + Math.round(Math.random() * 400)); } return sleep(700 * attempt + Math.round(Math.random() * 300)) .then(function () { return biliImageTranslate(dataUrl, attempt + 1); }); }); } // 解析识图结果:兼容【原文】/【译文】与「原文:/译文:」两种写法,可重复出现多组 function parseImageResult(text) { var t = String(text || '').replace(/\r/g, ''); t = t.replace(/【\s*原文\s*】/g, '\n@@O@@\n') .replace(/【\s*译文\s*】/g, '\n@@T@@\n') .replace(/(^|\n)[ \t]*原文[ \t]*[::]/g, '$1\n@@O@@\n') .replace(/(^|\n)[ \t]*译文[ \t]*[::]/g, '$1\n@@T@@\n'); // 去掉模型照抄回来的模板占位符 function stripPH(s) { return String(s).split('\n').filter(function (ln) { var x = ln.trim(); if (!x) return false; if (/^[<<][^>>]*[>>]$/.test(x)) return false; if (/^(识别到的原文|识别到的文字|中文翻译|原文|译文|待翻译内容)$/.test(x)) return false; return true; }).join('\n').trim(); } var origs = [], trans = []; var parts = t.split(/@@([OT])@@/); for (var i = 1; i < parts.length; i += 2) { var tag = parts[i]; var body = stripPH(parts[i + 1] || ''); if (!body) continue; if (tag === 'O') origs.push(body); else trans.push(body); } var o = origs.join('\n').trim(); var r = trans.join('\n').trim(); if (!r && /[\u4e00-\u9fff]/.test(t)) r = t.trim(); // 模型只给了译文时的兜底 return { orig: o, trans: r }; } function panelSet(orig, trans) { var p = document.getElementById('vtx-panel'); if (!p) return; if (orig !== null) p.querySelector('[data-orig]').textContent = orig; if (trans !== null) p.querySelector('[data-trans]').textContent = trans; } function showPanel(orig, trans) { var p = document.getElementById('vtx-panel'); if (!p) { p = document.createElement('div'); p.id = 'vtx-panel'; p.innerHTML = '
×
' + '
图片翻译
' + '
识别原文
' + '
译文
'; document.body.appendChild(p); p.querySelector('.vtx-panel-close').addEventListener('click', function (e) { e.stopPropagation(); p.classList.remove('vtx-show'); }); } p.querySelector('[data-orig]').textContent = orig; p.querySelector('[data-trans]').textContent = trans; p.classList.add('vtx-show'); } function ocrAndTranslate(imgEl) { if (IMG.loading) { toast('正在处理上一张,请稍候'); return; } IMG.loading = true; showPanel('识别中…', '翻译中…'); toDataUrl(imgEl).then(function (dataUrl) { return biliImageTranslate(dataUrl); }).then(function (raw) { var r = parseImageResult(raw); panelSet(r.orig || '(未识别到文字)', r.trans || '(翻译失败)'); IMG.loading = false; }).catch(function (err) { var msg = (err && err.name === 'AbortError') ? '请求超时' : (err && err.message) ? err.message : String(err); panelSet(null, '翻译失败:' + msg.substring(0, 60)); IMG.loading = false; toast('图片翻译失败:' + msg.substring(0, 40), 4000); }); } function toggleImgMode() { IMG.mode = !IMG.mode; if (IMG.mode) { document.body.classList.add('vtx-imgmode'); imgFab.classList.add('vtx-on'); imgFab.textContent = '关'; toast('图片翻译:点页面上的图片'); } else { document.body.classList.remove('vtx-imgmode'); imgFab.classList.remove('vtx-on'); imgFab.textContent = '图'; var p = document.getElementById('vtx-panel'); if (p) p.classList.remove('vtx-show'); } } /* ================== 模型切换 ================== */ var modelFab = null; function cycleModel() { modelIdx = (modelIdx + 1) % MODELS.length; saveModel(modelIdx); modelFab.textContent = MODELS[modelIdx].short; toast('模型:' + MODELS[modelIdx].label, 2600); } /* ================== 渲染 ================== */ function renderNode(node) { if (node.block) return; var text = node.chunks.map(function (c) { return c.out || ''; }).join('').trim(); if (!text || text.length < 2) { S.unreadable++; node.el.classList.add('vtx-done'); CREATED.modified.push(node.el); return; } try { var el = node.el; var box = document.createElement('div'); box.className = 'vtx-block'; box.setAttribute('data-vtx', '1'); box.textContent = text; // 表格单元格内追加 div(单元格本身可以放 div) if (el.tagName === 'TD' || el.tagName === 'TH') { el.appendChild(box); } else if (el.tagName === 'TR') { // 整行:插到整个表格后面 var tbl = el.closest('table'); if (tbl && tbl.nextSibling) tbl.parentNode.insertBefore(box, tbl.nextSibling); else if (tbl) tbl.parentNode.appendChild(box); else if (el.nextSibling) el.parentNode.insertBefore(box, el.nextSibling); else el.parentNode.appendChild(box); } else if (el.nextSibling) { el.parentNode.insertBefore(box, el.nextSibling); } else { el.parentNode.appendChild(box); } el.classList.add('vtx-hide'); el.classList.add('vtx-done'); CREATED.blocks.push(box); CREATED.modified.push(el); node.block = box; } catch (e) { } } function restore() { for (var i = 0; i < CREATED.blocks.length; i++) { var b = CREATED.blocks[i]; if (b.parentNode) b.parentNode.removeChild(b); } for (var h = 0; h < CREATED.modified.length; h++) { var el = CREATED.modified[h]; el.classList.remove('vtx-hide'); el.classList.remove('vtx-done'); } CREATED.blocks = []; CREATED.modified = []; document.body.classList.remove('vtx-only'); document.body.classList.remove('vtx-imgmode'); IMG.mode = false; IMG.loading = false; if (imgFab) { imgFab.classList.remove('vtx-on'); imgFab.textContent = '图'; } var p = document.getElementById('vtx-panel'); if (p) p.classList.remove('vtx-show'); S.hasResult = false; S.onlyMode = false; refreshFab(); toast('已还原原文'); } /* ================== 主流程 ================== */ function onFab() { if (S.running) { S.cancel = true; if (CTRL) { try { CTRL.abort(); } catch (e) {} } setFab('…', 'run'); return; } if (S.onlyMode) { // 仅译文模式下短点 = 切回双语 S.onlyMode = false; document.body.classList.remove('vtx-only'); refreshFab(); return; } if (S.hasResult) { restore(); return; } start(); } function start() { if (typeof fetch !== 'function') { toast('当前浏览器内核过旧'); return; } var found = collect(); if (!found.length) { toast('没有需要翻译的外文内容'); return; } var nodes = [], queue = [], usedChars = 0; for (var i = 0; i < found.length && nodes.length < CFG.maxBlocks; i++) { var text = found[i].text; if (usedChars >= CFG.maxChars) break; var parts = splitSeg(text, CFG.segMax); var node = { el: found[i].el, chunks: [], block: null, pending: parts.length }; for (var p = 0; p < parts.length; p++) { var chunk = { src: parts[p], out: '' }; node.chunks.push(chunk); queue.push({ node: node, chunk: chunk }); usedChars += parts[p].length; } nodes.push(node); } // 第一批故意很小(≤160 字符),让第一条译文尽快出现 var batches = [], cur = [], curLen = 0; for (var q = 0; q < queue.length; q++) { var len = queue[q].chunk.src.length; var isFirst = (batches.length === 0); var limit = isFirst ? 160 : CFG.batchChars; if (cur.length && (curLen + len > limit || (isFirst && cur.length >= 2))) { batches.push(cur); cur = []; curLen = 0; } cur.push(queue[q]); curLen += len; } if (cur.length) batches.push(cur); S.running = true; S.cancel = false; S.total = queue.length; S.done = 0; S.unreadable = 0; S.failed = 0; NET.dead = false; CTRL = (typeof AbortController !== 'undefined') ? new AbortController() : null; setFab('…', 'run'); toast('翻译 ' + nodes.length + ' 段,点按钮停止'); var bi = 0; function stop(finished) { S.running = false; var any = false; for (var k = 0; k < nodes.length; k++) { if (nodes[k].block) { any = true; break; } } S.hasResult = any; S.onlyMode = false; refreshFab(); CTRL = null; if (NET.dead && !any) { // 全失败时清掉残留的 vtx-done,否则下次翻译会跳过这些元素 for (var m = 0; m < CREATED.modified.length; m++) { CREATED.modified[m].classList.remove('vtx-done'); CREATED.modified[m].classList.remove('vtx-hide'); } CREATED.modified = []; CREATED.blocks = []; toast('本页安全策略(CSP)或网络阻止了翻译请求', 5000); } else if (finished && any) { var okChunks = S.done - S.failed; var base = (NET.dead ? '网络受限,仅完成 ' : '完成 ') + okChunks + ' 段'; var tail = S.failed > 0 ? ',失败 ' + S.failed + ' 段' : '|长按切仅译文'; toast(base + tail, 2800); } else if (any) { toast('已停止,点按钮还原'); } else { for (var m2 = 0; m2 < CREATED.modified.length; m2++) { CREATED.modified[m2].classList.remove('vtx-done'); CREATED.modified[m2].classList.remove('vtx-hide'); } CREATED.modified = []; CREATED.blocks = []; toast('没有翻译出内容'); } } function worker() { if (S.cancel || NET.dead || bi >= batches.length) return Promise.resolve(); var batch = batches[bi++]; return translateBatch(batch).then(function (res) { if (S.cancel) return; for (var k = 0; k < batch.length; k++) { var item = batch[k]; item.chunk.out = (res && res[k]) ? String(res[k]).trim() : ''; if (!item.chunk.out) S.failed++; item.node.pending--; if (item.node.pending <= 0) renderNode(item.node); S.done++; } setFab(Math.round(S.done / S.total * 100) + '%', 'run'); return sleep(CFG.gap); }).catch(function () { return sleep(900); }) .then(function () { return worker(); }); } var workers = []; var nWorkers = Math.min(CFG.concurrency, batches.length); for (var w = 0; w < nWorkers; w++) workers.push(worker()); Promise.all(workers).then(function () { stop(!S.cancel); }); } /* ================== 启动 ================== */ function init() { injectStyle(); createDock(); var drag = makeDraggable(dock); bindButton(fab, drag, onFab, toggleOnlyMode); imgFab = document.createElement('div'); imgFab.id = 'vtx-imgfab'; imgFab.setAttribute('role', 'button'); imgFab.setAttribute('aria-label', '图片翻译'); imgFab.textContent = '图'; dock.appendChild(imgFab); modelFab = document.createElement('div'); modelFab.id = 'vtx-modelfab'; modelFab.setAttribute('role', 'button'); modelFab.title = '点击切换翻译模型'; modelFab.textContent = MODELS[modelIdx].short; dock.appendChild(modelFab); bindButton(imgFab, drag, toggleImgMode, null); bindButton(modelFab, drag, cycleModel, null); // 图片模式下拦截图片点击(捕获阶段,且阻止页面自身的点击处理) document.addEventListener('click', function (e) { if (!IMG.mode) return; var t = e.target; while (t && t !== document.body) { if (t.tagName === 'IMG') { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); ocrAndTranslate(t); return; } if (t.id && t.id.indexOf('vtx-') === 0) return; t = t.parentNode; } }, true); } if (document.body) init(); else document.addEventListener('DOMContentLoaded', init, false); })();