// ==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 = '