// ==UserScript== // @name 片假名终结者(2026修复版) // @description 在网页中的日语外来语上方标注英文原词,且基于原作者Arnie97旧代码修复部分性能问题和bug问题 // @author Arnie97 (fixed by Marina) // @license MIT // @match *://*/* // @exclude *://*.bilibili.com/video/* // @grant GM.xmlHttpRequest // @grant GM_xmlhttpRequest // @grant GM_addStyle // @connect translate.googleapis.com // @version 2026.04.24 // @name:ja-JP カタカナターミネーター(2026fixed) // @name:zh-CN 片假名终结者(2026修复版) // @name:en Katakana Terminator (2026 fixed) // @description:zh-CN 在网页中的日语外来语上方标注英文原词 // @description:ja-JP ウェブページ上の外来語(カタカナ語)の上に英単語の原語を表示します。 // @description:en Annotate Japanese gairaigo with their English originals on web pages. // @namespace https://greasyfork.org/users/1594580 // @downloadURL https://update.greasyfork.icu/scripts/575179/%E7%89%87%E5%81%87%E5%90%8D%E7%BB%88%E7%BB%93%E8%80%85%282026%E4%BF%AE%E5%A4%8D%E7%89%88%29.user.js // @updateURL https://update.greasyfork.icu/scripts/575179/%E7%89%87%E5%81%87%E5%90%8D%E7%BB%88%E7%BB%93%E8%80%85%282026%E4%BF%AE%E5%A4%8D%E7%89%88%29.meta.js // ==/UserScript== (function() { 'use strict'; // ---------- 常量 ---------- const KATAKANA_RE = /[\u30A1-\u30FA\u30FD-\u30FF][\u3099\u309A\u30A1-\u30FF]*[\u3099\u309A\u30A1-\u30FA\u30FC-\u30FF]|[\uFF66-\uFF6F\uFF71-\uFF9D][\uFF65-\uFF9F]*[\uFF66-\uFF9F]/g; const EXCLUDE_TAGS = { ruby:1, script:1, select:1, textarea:1, input:1 }; // ---------- 状态 ---------- const pendingNodes = []; // 待扫描的节点队列 const phraseToRTs = new Map(); // 片假名 -> [rt元素] const translationCache = new Map(); // 片假名 -> 英文 let pendingPhrases = new Set(); // 等待翻译的短语 let inflightPhrases = new Set(); // 正在请求中的短语 const processedTextNodes = new WeakSet(); // 已处理过的文本节点,防止重复扫描 let processScheduled = false; // ---------- 样式 ---------- GM_addStyle("rt.katakana-terminator-rt::before { content: attr(data-rt); }"); // ---------- 工具 ---------- const sleep = ms => new Promise(r => setTimeout(r, ms)); // ---------- 文本扫描 ---------- function scanNode(node) { if (!node || !document.body.contains(node)) return; if (node.nodeType === Node.TEXT_NODE) { processTextNode(node); return; } if (node.nodeType !== Node.ELEMENT_NODE) return; if (node.dataset?.ktGenerated === 'true') return; // 跳过脚本生成的 ruby const tag = node.tagName.toLowerCase(); if (tag in EXCLUDE_TAGS || node.isContentEditable) return; const walker = document.createTreeWalker( node, NodeFilter.SHOW_TEXT, { acceptNode: textNode => { let parent = textNode.parentNode; while (parent && parent !== node) { if (parent.dataset?.ktGenerated === 'true') return NodeFilter.FILTER_REJECT; const t = parent.tagName.toLowerCase(); if (t in EXCLUDE_TAGS || parent.isContentEditable) return NodeFilter.FILTER_REJECT; parent = parent.parentNode; } return NodeFilter.FILTER_ACCEPT; } } ); let textNode; while ((textNode = walker.nextNode())) { processTextNode(textNode); } } function processTextNode(node) { if (processedTextNodes.has(node)) return; // 已处理过,直接跳过 processedTextNodes.add(node); // ★ 重置正则状态,防止跨节点污染 KATAKANA_RE.lastIndex = 0; let current = node; while (current) { const match = KATAKANA_RE.exec(current.nodeValue); if (!match) break; current = insertRuby(current, match); } } // ★ 修复点:按照原版算法,直接修改 after 节点的 textContent,切割掉片假名 function insertRuby(textNode, match) { const katakana = match[0]; // 分割文本 const after = textNode.splitText(match.index); // after 包含从匹配位置开始的全部内容 const remainingText = after.nodeValue.substring(katakana.length); after.nodeValue = remainingText; // ★ 关键:删除前面的片假名 // 创建 ruby 并标记为脚本生成 const ruby = document.createElement('ruby'); ruby.dataset.ktGenerated = 'true'; ruby.appendChild(document.createTextNode(katakana)); const rt = document.createElement('rt'); rt.classList.add('katakana-terminator-rt'); ruby.appendChild(rt); // 将 ruby 插入到 after 之前 textNode.parentNode.insertBefore(ruby, after); // 收集 rt 到待翻译列表 if (!phraseToRTs.has(katakana)) phraseToRTs.set(katakana, []); phraseToRTs.get(katakana).push(rt); // 排入翻译(智能去重) if (!translationCache.has(katakana) && !inflightPhrases.has(katakana)) { pendingPhrases.add(katakana); } return after; // 返回已缩短的 after 节点继续扫描 } // ---------- 队列管理 ---------- function flushQueue() { if (pendingNodes.length === 0) { processScheduled = false; return; } // 清空当前队列,防止新增内容干扰(同时保持原子) const nodesToProcess = pendingNodes.splice(0, pendingNodes.length); nodesToProcess.forEach(scanNode); // 处理完队列后触发翻译请求 if (pendingPhrases.size > 0) { scheduleTranslation(); } // 若在处理期间又有新节点推入,继续处理 if (pendingNodes.length > 0) { setTimeout(flushQueue, 10); } else { processScheduled = false; } } function enqueueNode(node) { pendingNodes.push(node); if (!processScheduled) { processScheduled = true; setTimeout(flushQueue, 10); // 异步启动,避免阻塞当前任务 } } // ---------- 翻译 API ---------- function scheduleTranslation() { // 简单的防抖:直接发送当前积累的短语 const phrases = Array.from(pendingPhrases); pendingPhrases.clear(); if (phrases.length === 0) return; phrases.forEach(p => inflightPhrases.add(p)); batchTranslate(phrases) .finally(() => phrases.forEach(p => inflightPhrases.delete(p))); } async function batchTranslate(phrases) { const CHUNK = 100; for (let i = 0; i < phrases.length; i += CHUNK) { const chunk = phrases.slice(i, i + CHUNK); await translateChunk(chunk); if (i + CHUNK < phrases.length) await sleep(200); } } function translateChunk(phrases) { return new Promise(resolve => { const joined = phrases.join('\n'); const url = `https://translate.googleapis.com/translate_a/single?client=gtx&dt=t&sl=ja&tl=en&q=${encodeURIComponent(joined)}`; const requester = typeof GM_xmlhttpRequest !== 'undefined' ? GM_xmlhttpRequest : GM.xmlHttpRequest; requester({ method: 'GET', url, onload: resp => { try { const data = JSON.parse(resp.responseText.replace(/'/g, '\u2019')); (data?.[0] || []).forEach(item => { const original = (item[1] || '').trim(); const translated = (item[0] || '').trim(); if (original && translated) { translationCache.set(original, translated); const rtList = phraseToRTs.get(original); if (rtList) { rtList.forEach(rt => rt.dataset.rt = translated); phraseToRTs.delete(original); } } }); } catch (e) { console.error('Katakana Terminator: 翻译解析失败', e); } resolve(); }, onerror: () => resolve() }); }); } // ---------- MutationObserver (始终连接) ---------- const observer = new MutationObserver(mutations => { for (const m of mutations) { for (const node of m.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE) { // 跳过脚本自己生成的 ruby 及其内部节点 if (node.nodeType === Node.ELEMENT_NODE && node.dataset?.ktGenerated === 'true') continue; enqueueNode(node); } } } }); // ---------- 启动 ---------- function init() { enqueueNode(document.body); observer.observe(document.body, { childList: true, subtree: true }); } // 兼容 Greasemonkey 4 if (typeof GM_xmlhttpRequest === 'undefined' && typeof GM === 'object' && GM.xmlHttpRequest) { GM_xmlhttpRequest = GM.xmlHttpRequest; } if (typeof GM_addStyle === 'undefined') { GM_addStyle = css => { const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); }; } init(); })();