OpenImmersive Translator
+29
-3
@@ -88,4 +88,23 @@ 'use strict'; | ||
| } | ||
| // 设置页的账号区:查询官网登录状态(cookie 随请求自动带上,插件不存凭据) | ||
| if (message.type === 'oiAuthStatus') { | ||
| oiAuthStatus() | ||
| .then(sendResponse) | ||
| .catch(err => sendResponse({ success: false, error: err.message })); | ||
| return true; | ||
| } | ||
| }); | ||
| // 官网会员状态。404 = 新站尚未部署到该地址(AI 档即将上线的同一状态)。 | ||
| async function oiAuthStatus() { | ||
| const { oiSiteBase } = await storageGet(['oiSiteBase']); | ||
| const site = oiSiteBase || OI_SITE; | ||
| const resp = await fetch(`${site}/api/auth/me`, { credentials: 'include' }); | ||
| if (resp.status === 404) return { success: true, site, available: false, user: null }; | ||
| let data = {}; | ||
| try { data = await resp.json(); } catch (_) { /* 非 JSON 视作未登录 */ } | ||
| return { success: true, site, available: true, user: (data && data.user) || null }; | ||
| } | ||
| // ===== 翻译入口 ===== | ||
@@ -132,3 +151,6 @@ | ||
| async function translateViaSite(text, targetLang, sourceLang, tier) { | ||
| const resp = await fetch(`${OI_SITE}/api/translate`, { | ||
| // oiSiteBase:内部测试用的官网地址覆盖(正式发布不暴露 UI,storage 手工写入) | ||
| const { oiSiteBase } = await storageGet(['oiSiteBase']); | ||
| const site = oiSiteBase || OI_SITE; | ||
| const resp = await fetch(`${site}/api/translate`, { | ||
| method: 'POST', | ||
@@ -149,7 +171,11 @@ headers: { 'Content-Type': 'application/json' }, | ||
| if (resp.status === 401) { | ||
| throw new Error(`需要登录 ${OI_SITE} 才能使用 AI 翻译档(免费的 Google 档无需登录)`); | ||
| throw new Error(`需要登录 ${site} 才能使用 AI 翻译档(免费的 Google 档无需登录)`); | ||
| } | ||
| if (resp.status === 402) { | ||
| throw new Error(`额度不足,请到 ${OI_SITE} 充值或订阅`); | ||
| throw new Error(`额度不足,请到 ${site} 充值或订阅`); | ||
| } | ||
| if (resp.status === 404) { | ||
| // 官网新版(带账号/额度)尚未切到正式域名时,接口整个不存在 | ||
| throw new Error('AI 翻译档即将上线,暂请使用免费的 Google 档'); | ||
| } | ||
| if (!resp.ok) { | ||
@@ -156,0 +182,0 @@ throw new Error((data.error && data.error.message) || `官网接口返回 ${resp.status}`); |
+9
-0
@@ -27,2 +27,11 @@ /* 语灵灵 Content Styles */ | ||
| /* 仅显示译文模式:原文包裹后隐藏,译文完全继承原元素样式 */ | ||
| .yll-orig-hidden { | ||
| display: none !important; | ||
| } | ||
| .yll-tr-replace { | ||
| font: inherit; | ||
| color: inherit; | ||
| } | ||
| /* 悬浮球:点一下整页翻译/还原 */ | ||
@@ -29,0 +38,0 @@ #yll-ball-wrap { |
+83
-7
@@ -24,2 +24,3 @@ 'use strict'; | ||
| targetLang: 'zh-CN', | ||
| displayMode: 'bilingual', // 'bilingual' 双语对照 | 'replace' 仅显示译文 | ||
| translatedCount: 0, | ||
@@ -325,3 +326,3 @@ totalCount: 0, | ||
| // 注意:必须显式列出所有键——getSettings() 只取 targetLang/engine,读不到行为开关。 | ||
| storageGet(['targetLang', 'engine', 'hoverTranslate', 'hoverKey', | ||
| storageGet(['targetLang', 'engine', 'displayMode', 'hoverTranslate', 'hoverKey', | ||
| 'selectionTranslate', 'inputTranslate', 'inputTargetLang', 'autoTranslateSites', | ||
@@ -331,2 +332,3 @@ 'autoTranslateLangs', 'floatBall']).then(s => { | ||
| if (s.targetLang) state.targetLang = s.targetLang; | ||
| if (s.displayMode) state.displayMode = s.displayMode; | ||
| if (s.hoverTranslate === false) hoverEnabled = false; | ||
@@ -400,2 +402,3 @@ if (typeof s.hoverKey === 'string') hoverKey = s.hoverKey; | ||
| if (changes.targetLang) state.targetLang = changes.targetLang.newValue || state.targetLang; | ||
| if (changes.displayMode) state.displayMode = changes.displayMode.newValue || state.displayMode; | ||
| if (changes.hoverTranslate) hoverEnabled = changes.hoverTranslate.newValue !== false; | ||
@@ -486,2 +489,3 @@ if (changes.hoverKey) hoverKey = changes.hoverKey.newValue || hoverKey; | ||
| state.targetLang = settings.targetLang || 'zh-CN'; | ||
| state.displayMode = settings.displayMode || 'bilingual'; | ||
| state.isTranslating = true; | ||
@@ -548,3 +552,3 @@ showProgressBar(); | ||
| const text = extractText(el); | ||
| if (!text || text.trim().length < 5) return; | ||
| if (!text || text.trim().length < minTextLen(el, 5)) return; | ||
@@ -554,2 +558,7 @@ // 跳过已经是目标语言的内容 | ||
| // 「A • B • C」式专有名词名单(署名墙、合作伙伴列表):引擎会把一部分 | ||
| // 品牌名意译成怪词(Cognition→认知、Disruptive→颠覆性),剩下的保留, | ||
| // 观感像随机挑着翻。整段跳过比翻一半更不伤原文。 | ||
| if (looksLikeNameList(text)) return; | ||
| try { | ||
@@ -602,3 +611,4 @@ const resp = await api.runtime.sendMessage({ | ||
| const text = extractText(el); | ||
| if (!text || text.trim().length < 12) continue; | ||
| if (!text || text.trim().length < minTextLen(el, 12)) continue; | ||
| if (!/\p{L}/u.test(text)) continue; // 纯数字/符号(表格数值列)没有可翻内容 | ||
@@ -643,7 +653,10 @@ seen.add(el); | ||
| // 跳过导航、页眉、页脚等 | ||
| // 跳过导航、页眉、页脚等。header/footer 只在页面级(不在 article/main 里) | ||
| // 才算页头页脚——新闻站的头条区常包在文章内的 <header> 里,连坐会把 | ||
| // 正文标题整个漏掉(Reuters 频道页头条就是这样丢的)。 | ||
| const container = el.closest('nav, header, footer, aside, [role]'); | ||
| if (container) { | ||
| const tag = container.tagName; | ||
| if (['NAV', 'HEADER', 'FOOTER', 'ASIDE'].includes(tag)) return false; | ||
| if (['NAV', 'ASIDE'].includes(tag)) return false; | ||
| if (['HEADER', 'FOOTER'].includes(tag) && !container.closest('article, main')) return false; | ||
| const role = container.getAttribute('role'); | ||
@@ -662,2 +675,3 @@ if (role && SKIP_ROLES.has(role)) return false; | ||
| if (el.offsetParent === null && el.tagName !== 'BODY') return false; | ||
| if (isVisuallyHidden(el)) return false; | ||
@@ -667,2 +681,17 @@ return true; | ||
| // 视觉上不可见的节点:display/visibility 之外还要认出 sr-only 模式 | ||
| //(absolute + 1px 盒 + clip,无障碍专用文本)。NYT 图集把多张图的说明全 | ||
| // 渲染在 DOM 里只显示一张,不过滤会把隐藏的几条全翻出来叠在图上。 | ||
| function isVisuallyHidden(el) { | ||
| let cs; | ||
| try { cs = getComputedStyle(el); } catch (_) { return false; } | ||
| if (!cs) return false; | ||
| if (cs.display === 'none' || cs.visibility === 'hidden') return true; | ||
| if (cs.position === 'absolute' || cs.position === 'fixed') { | ||
| const r = el.getBoundingClientRect(); | ||
| if (r.width <= 1 && r.height <= 1) return true; | ||
| } | ||
| return false; | ||
| } | ||
| function extractText(el) { | ||
@@ -680,2 +709,5 @@ let text = ''; | ||
| if (SKIP_TAGS.has(node.tagName)) continue; | ||
| // 视觉隐藏的子节点(sr-only 辅助文本等)不算正文:新闻卡片里的 | ||
| // 隐藏「category」标签混进标题,译文就成了一句拼接怪话。 | ||
| if (isVisuallyHidden(node)) continue; | ||
| text += extractText(node); | ||
@@ -687,2 +719,18 @@ } | ||
| // 表格单元格里合法的内容天然就短("Chips"、"Funding"),统一用 12 字符 | ||
| // 门槛会把排行榜的词条列整列漏掉、只剩长词条被翻的"选择性翻译"观感。 | ||
| function minTextLen(el, normal) { | ||
| return (el.tagName === 'TD' || el.tagName === 'TH') ? 3 : normal; | ||
| } | ||
| // 「A • B • C」式名单:≥8 段、绝大多数段短且以大写/数字开头 | ||
| function looksLikeNameList(text) { | ||
| const segs = text.split(/[•·]/).map(s => s.trim()).filter(Boolean); | ||
| if (segs.length < 8) return false; | ||
| const nameLike = segs.filter(s => | ||
| s.length <= 40 && /^[("'\d]*[A-Z0-9]/.test(s) && s.split(/\s+/).length <= 5 | ||
| ); | ||
| return nameLike.length / segs.length >= 0.8; | ||
| } | ||
| // 忽略空白与标点差异后是否是同一段文字(翻译服务常只改动标点/空格) | ||
@@ -716,2 +764,18 @@ function sameText(a, b) { | ||
| // 仅显示译文:把原文子节点整体挪进一个隐藏的包裹节点(保留 DOM,可还原), | ||
| // 译文放回元素内部——继承元素级样式,grid/表格里也不产生新兄弟节点。 | ||
| if (state.displayMode === 'replace') { | ||
| const orig = document.createElement('span'); | ||
| orig.className = 'yll-orig-hidden'; | ||
| orig.setAttribute('data-yll-orig', '1'); | ||
| while (el.firstChild) orig.appendChild(el.firstChild); | ||
| const tr = document.createElement('span'); | ||
| tr.className = 'yll-tr-replace'; | ||
| tr.setAttribute('data-yll-inject', '1'); | ||
| tr.setAttribute('data-yll-ui', 'true'); | ||
| tr.textContent = translation; | ||
| el.append(orig, tr); | ||
| return; | ||
| } | ||
| if (INLINE_APPEND_TAGS.has(el.tagName)) { | ||
@@ -775,2 +839,10 @@ const span = document.createElement('span'); | ||
| document.querySelectorAll('[data-yll-inject]').forEach(el => el.remove()); | ||
| // 仅译文模式藏起来的原文:把子节点挪回原位再删掉包裹节点 | ||
| document.querySelectorAll('[data-yll-orig]').forEach(wrap => { | ||
| const parent = wrap.parentNode; | ||
| if (parent) { | ||
| while (wrap.firstChild) parent.insertBefore(wrap.firstChild, wrap); | ||
| } | ||
| wrap.remove(); | ||
| }); | ||
| document.querySelectorAll('[data-yll-done]').forEach(el => delete el.dataset.yllDone); | ||
@@ -818,3 +890,3 @@ state.isTranslated = false; | ||
| function getSettings() { | ||
| return storageGet(['targetLang', 'engine']); | ||
| return storageGet(['targetLang', 'engine', 'displayMode']); | ||
| } | ||
@@ -845,3 +917,7 @@ | ||
| detectPageLang, | ||
| sameText | ||
| sameText, | ||
| isVisuallyHidden, | ||
| looksLikeNameList, | ||
| minTextLen, | ||
| setDisplayMode: (m) => { state.displayMode = m; } | ||
| }; | ||
@@ -848,0 +924,0 @@ } |
+49
-13
@@ -128,21 +128,57 @@ 'use strict'; | ||
| // 逐片段翻译。translateFn(text) -> Promise<string>;失败的片段保留原文, | ||
| // 批量翻译。translateFn(text) -> Promise<string>;失败的片段保留原文, | ||
| // 让用户至少拿到一个结构完整、部分翻译的文件,而不是整份失败。 | ||
| async function translateFile({ format, text, translateFn, concurrency = 3, onProgress }) { | ||
| // | ||
| // 多个片段拼成一个请求(按 \n 对齐拆回):字幕文件动辄上千行,逐行请求 | ||
| // 一部 90 分钟纪录片要 6 分多钟;50 行一批直接压到半分钟内。引擎返回的 | ||
| // 行数对不上这一批就落回逐行,正确性不赌批量。 | ||
| async function translateFile({ format, text, translateFn, concurrency = 3, onProgress, | ||
| batchLines = 50, batchChars = 1800 }) { | ||
| const { parts, join } = parse(format, text); | ||
| const out = parts.map(p => p.text); | ||
| const todo = []; | ||
| parts.forEach((p, i) => { if (p.translatable) todo.push(i); }); | ||
| const idxs = []; | ||
| parts.forEach((p, i) => { if (p.translatable) idxs.push(i); }); | ||
| // 组批:自带换行的片段会破坏按行对齐,单独成批 | ||
| const batches = []; | ||
| let cur = [], curLen = 0; | ||
| const flush = () => { if (cur.length) { batches.push(cur); cur = []; curLen = 0; } }; | ||
| for (const i of idxs) { | ||
| const t = parts[i].text; | ||
| if (t.includes('\n')) { flush(); batches.push([i]); continue; } | ||
| if (cur.length >= batchLines || curLen + t.length > batchChars) flush(); | ||
| cur.push(i); curLen += t.length; | ||
| } | ||
| flush(); | ||
| let done = 0; | ||
| const workers = Array.from({ length: Math.min(concurrency, todo.length || 1) }, async () => { | ||
| const total = idxs.length; | ||
| const queue = batches.slice(); | ||
| const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => { | ||
| for (;;) { | ||
| const i = todo.shift(); | ||
| if (i === undefined) break; | ||
| try { | ||
| const t = await translateFn(parts[i].text); | ||
| if (t) out[i] = keepEdgeSpace(parts[i].text, t); | ||
| } catch (_) { /* 保留原文 */ } | ||
| done++; | ||
| if (onProgress) onProgress(done, done + todo.length); | ||
| const batch = queue.shift(); | ||
| if (!batch) break; | ||
| let ok = false; | ||
| if (batch.length > 1) { | ||
| try { | ||
| const joined = batch.map(i => parts[i].text).join('\n'); | ||
| const lines = String((await translateFn(joined)) || '').split('\n'); | ||
| if (lines.length === batch.length) { | ||
| batch.forEach((i, k) => { | ||
| if (lines[k] && lines[k].trim()) out[i] = keepEdgeSpace(parts[i].text, lines[k]); | ||
| }); | ||
| ok = true; | ||
| } | ||
| } catch (_) { /* 落回逐行 */ } | ||
| } | ||
| if (!ok) { | ||
| for (const i of batch) { | ||
| try { | ||
| const t = await translateFn(parts[i].text); | ||
| if (t) out[i] = keepEdgeSpace(parts[i].text, t); | ||
| } catch (_) { /* 保留原文 */ } | ||
| } | ||
| } | ||
| done += batch.length; | ||
| if (onProgress) onProgress(done, total); | ||
| } | ||
@@ -149,0 +185,0 @@ }); |
+1
-1
| { | ||
| "manifest_version": 3, | ||
| "name": "OpenImmersive Translator", | ||
| "version": "1.11.0", | ||
| "version": "1.13.0", | ||
| "description": "\u5f00\u6e90\u3001\u9690\u79c1\u4f18\u5148\u7684\u53cc\u8bed\u7ffb\u8bd1\u63d2\u4ef6\uff1a\u6574\u9875\u53cc\u8bed\u5bf9\u7167\u3001\u5212\u8bcd/\u60ac\u505c/\u8f93\u5165\u6846\u7ffb\u8bd1\u3001199 \u79cd\u8bed\u8a00\u3002\u7ffb\u8bd1\u8bf7\u6c42\u76f4\u8fde\u6240\u9009\u5f15\u64ce\uff0c\u65e0\u4e2d\u95f4\u670d\u52a1\u5668\u3001\u96f6\u6570\u636e\u6536\u96c6\uff1b\u53ef\u9009\u654f\u611f\u4fe1\u606f\u672c\u5730\u8131\u654f\u3002", | ||
@@ -6,0 +6,0 @@ "author": "OpenImmersive", |
+24
-0
@@ -132,2 +132,17 @@ <!DOCTYPE html> | ||
| <!-- OpenImmersive 账号 --> | ||
| <div class="card"> | ||
| <div class="card-header">OpenImmersive 账号</div> | ||
| <div class="card-body"> | ||
| <div class="form-group"> | ||
| <div id="oiAcctStatus" class="hint" style="font-size:14px;color:#333">查询中…</div> | ||
| <div style="margin-top:10px;display:flex;gap:10px"> | ||
| <button type="button" id="btnOiLogin" class="btn-show">登录 / 注册</button> | ||
| <button type="button" id="btnOiRefresh" class="btn-show">刷新状态</button> | ||
| </div> | ||
| <p class="hint">AI 翻译档(Standard / Premium)使用官网账号的额度;免费 Google 档无需账号。在官网登录或登出后点「刷新状态」即可生效——插件本身不保存你的密码。</p> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <!-- 常用设置 --> | ||
@@ -143,2 +158,11 @@ <div class="card"> | ||
| <div class="form-group"> | ||
| <label>译文显示方式</label> | ||
| <select id="selDisplayMode"> | ||
| <option value="bilingual">双语对照(原文 + 译文)</option> | ||
| <option value="replace">仅显示译文</option> | ||
| </select> | ||
| <p class="hint">仅显示译文时原文只是被隐藏,「恢复原文」可完整还原。</p> | ||
| </div> | ||
| <div class="toggle-row"> | ||
@@ -145,0 +169,0 @@ <div> |
+35
-1
@@ -26,3 +26,3 @@ 'use strict'; | ||
| const data = await getStorage([ | ||
| 'targetLang', 'engine', 'apiKeys', 'sensitiveMask', | ||
| 'targetLang', 'engine', 'displayMode', 'apiKeys', 'sensitiveMask', | ||
| 'hoverTranslate', 'hoverKey', 'selectionTranslate', | ||
@@ -34,2 +34,3 @@ 'inputTranslate', 'inputTargetLang' | ||
| document.getElementById('defLang').value = data.targetLang || 'zh-CN'; | ||
| document.getElementById('selDisplayMode').value = data.displayMode || 'bilingual'; | ||
| document.getElementById('swHover').checked = data.hoverTranslate !== false; // 默认开 | ||
@@ -56,2 +57,3 @@ document.getElementById('selHoverKey').value = data.hoverKey || 'Alt'; | ||
| targetLang: document.getElementById('defLang').value, | ||
| displayMode: document.getElementById('selDisplayMode').value, | ||
| hoverTranslate: document.getElementById('swHover').checked, | ||
@@ -105,4 +107,36 @@ hoverKey: document.getElementById('selHoverKey').value, | ||
| // ===== OpenImmersive 账号区 ===== | ||
| let oiSiteUrl = 'https://openimmersive.ai'; | ||
| async function refreshOiAccount() { | ||
| const el = document.getElementById('oiAcctStatus'); | ||
| el.textContent = '查询中…'; | ||
| try { | ||
| const resp = await api.runtime.sendMessage({ type: 'oiAuthStatus' }); | ||
| if (!resp || !resp.success) throw new Error(resp ? resp.error : '通信错误'); | ||
| if (resp.site) oiSiteUrl = resp.site; | ||
| if (!resp.available) { | ||
| el.textContent = 'AI 翻译档即将上线,敬请期待(免费 Google 档不受影响)'; | ||
| return; | ||
| } | ||
| if (!resp.user) { | ||
| el.textContent = '未登录 —— 点「登录 / 注册」在官网完成登录后回来刷新'; | ||
| return; | ||
| } | ||
| const u = resp.user; | ||
| el.textContent = `已登录:${u.email} · 套餐 ${u.plan || 'free'} · 余额 ${u.credits ?? 0} credits`; | ||
| } catch (err) { | ||
| el.textContent = `状态查询失败:${err.message}`; | ||
| } | ||
| } | ||
| document.getElementById('btnOiLogin').addEventListener('click', () => { | ||
| window.open(oiSiteUrl, '_blank'); | ||
| }); | ||
| document.getElementById('btnOiRefresh').addEventListener('click', refreshOiAccount); | ||
| // ===== 启动 ===== | ||
| loadSettings().catch(console.error); | ||
| refreshOiAccount().catch(() => {}); | ||
@@ -109,0 +143,0 @@ // ===== 文件翻译:本地解析简单文本格式,逐段送翻译,原格式下载 ===== |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet